1use crate::models::common::enums::{Channel, Filetype, Provider, TrustMode};
2use chrono::NaiveDate;
3use clap::{Parser, Subcommand};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
6pub enum BuildProfile {
7 Rust,
8 Dotnet,
9 Go,
10 Zig,
11 Cmake,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
15pub enum ImportAs {
16 Keys,
17 Manifest,
18 Snapshot,
19}
20
21#[derive(Parser)]
22#[command(name = "upstream")]
23#[command(about = "A package manager for everything else.")]
24#[command(
25 long_about = "Upstream is a lightweight package manager that installs and manages \
26 applications from most software sources that do not have their own package manager.\n\n\
27 Install binaries, AppImages, and other artifacts with automatic updates, \
28 version pinning, rollback support, and minimal configuration.\n\n\
29 EXAMPLES:\n \
30 upstream install neovim/neovim nvim --desktop\n \
31 upstream install BurntSushi/ripgrep # name inferred as ripgrep\n \
32 upstream upgrade # Upgrade all packages\n \
33 upstream list # Show installed packages\n \
34 upstream config set github.api_token=ghp_xxx"
35)]
36#[command(
37 version,
38 long_version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")")
39)]
40pub struct Cli {
41 #[arg(short = 'y', long, global = true, default_value_t = false)]
43 pub yes: bool,
44
45 #[command(subcommand)]
46 pub command: Commands,
47}
48
49#[derive(Subcommand)]
50pub enum Commands {
51 #[command(long_about = "Install a new package from a download source.\n\n\
53 Downloads the specified file type from the latest release (or specified channel) \
54 and registers it under the given name for future updates. If the name is omitted \
55 for a git repository, upstream falls back to the repository name. \
56 Direct HTTP sources may still require an explicit name.\n\n\
57 EXAMPLES:\n \
58 upstream install BurntSushi/ripgrep rg -k binary\n \
59 upstream install bootandy/dust # name inferred as dust\n \
60 upstream install sharkdp/bat bat")]
61 Install {
62 repo_slug: String,
64
65 name: Option<String>,
67
68 #[arg(short, long)]
70 tag: Option<String>,
71
72 #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
74 kind: Filetype,
75
76 #[arg(short = 'p', long)]
78 provider: Option<Provider>,
79
80 #[arg(long, requires = "provider")]
82 base_url: Option<String>,
83
84 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
86 channel: Channel,
87
88 #[arg(short = 'm', long, name = "match")]
90 match_pattern: Option<String>,
91
92 #[arg(short = 'e', long, name = "exclude")]
94 exclude_pattern: Option<String>,
95
96 #[arg(short, long, default_value_t = false)]
98 desktop: bool,
99
100 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
102 trust_mode: TrustMode,
103
104 #[arg(long, default_value_t = false)]
106 dry_run: bool,
107 },
108
109 #[command(long_about = "Build and install a package from source.\n\n\
111 Use this command when release tags exist but prebuilt artifacts are missing \
112 or unsuitable for your system.\n\n\
113 Mirrors install-style source resolution, with optional automatic profile detection. \
114 Git workspaces are cached under upstream's cache directory so upgrades can reuse \
115 prior build outputs when the project build system supports incremental rebuilds. \
116 If the name is omitted for a git repository, upstream falls back to the repository name.\n\n\
117 EXAMPLES:\n \
118 upstream build BurntSushi/ripgrep rg\n \
119 upstream build BurntSushi/ripgrep # name inferred as ripgrep\n \
120 upstream build BurntSushi/ripgrep rg --branch main\n \
121 upstream build BurntSushi/ripgrep rg --build-profile rust\n \
122 upstream build owner/repo app --build-profile dotnet --tag v1.2.3")]
123 Build {
124 repo_slug: String,
126
127 name: Option<String>,
129
130 #[arg(short, long, conflicts_with = "branch")]
132 tag: Option<String>,
133
134 #[arg(long, conflicts_with = "tag")]
136 branch: Option<String>,
137
138 #[arg(short = 'p', long)]
140 provider: Option<Provider>,
141
142 #[arg(long, requires = "provider")]
144 base_url: Option<String>,
145
146 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
148 channel: Channel,
149
150 #[arg(short, long, default_value_t = false)]
152 desktop: bool,
153
154 #[arg(long, value_enum)]
156 build_profile: Option<BuildProfile>,
157
158 #[arg(long, default_value_t = false)]
160 dry_run: bool,
161 },
162
163 #[command(
165 long_about = "Uninstall packages and optionally remove cached data.\n\n\
166 By default, removes the package binary/files but preserves cached release data. \
167 Use --purge to remove everything. Use --force to ignore uninstall errors \
168 (for example, missing files) and still remove package metadata.\n\n\
169 EXAMPLES:\n \
170 upstream remove nvim\n \
171 upstream remove rg fd bat --purge\n \
172 upstream remove rg --force\n \
173 upstream remove rg --dry-run"
174 )]
175 Remove {
176 names: Vec<String>,
178
179 #[arg(long, default_value_t = false)]
181 purge: bool,
182
183 #[arg(long, default_value_t = false)]
185 force: bool,
186
187 #[arg(long, default_value_t = false)]
189 dry_run: bool,
190 },
191
192 #[command(long_about = "Manage package rollback points.\n\n\
194 Use restore without package names to restore the latest reversible transaction \
195 recorded in upstream's transaction history. Use explicit package names to restore \
196 selected packages, list rollback artifacts, or prune stored rollback data.\n\n\
197 EXAMPLES:\n \
198 upstream rollback restore\n \
199 upstream rollback restore rg\n \
200 upstream rollback restore rg fd --dry-run\n \
201 upstream rollback list\n \
202 upstream rollback prune\n \
203 upstream rollback prune rg")]
204 Rollback {
205 #[command(subcommand)]
206 action: RollbackAction,
207 },
208
209 #[command(
211 long_about = "Reinstall packages by uninstalling and then installing them again.\n\n\
212 Reinstall uses each package's stored source metadata. Release installs attempt \
213 the currently recorded version tag; build installs rebuild from source.\n\n\
214 EXAMPLES:\n \
215 upstream reinstall nvim\n \
216 upstream reinstall rg fd\n \
217 upstream reinstall rg --force\n \
218 upstream reinstall rg --trust none"
219 )]
220 Reinstall {
221 names: Vec<String>,
223
224 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
226 trust_mode: TrustMode,
227
228 #[arg(long, default_value_t = false)]
230 force: bool,
231
232 #[arg(long, default_value_t = false)]
234 dry_run: bool,
235 },
236
237 #[command(long_about = "Check for and install updates to packages.\n\n\
239 Without arguments, upgrades all packages. Specify package names to upgrade \
240 only those packages. Use --check to preview available updates. At the \
241 confirmation prompt, enter c to view release notes before deciding.\n\n\
242 EXAMPLES:\n \
243 upstream upgrade # Upgrade all\n \
244 upstream upgrade nvim rg # Upgrade specific packages\n \
245 upstream upgrade --check # Check for updates\n \
246 upstream upgrade --check --json # Check for updates as JSON\n \
247 upstream upgrade --check --machine-readable # Script-friendly output\n \
248 upstream upgrade nvim --force # Force reinstall\n \
249 upstream upgrade --trust none")]
250 Upgrade {
251 names: Option<Vec<String>>,
253
254 #[arg(long, default_value_t = false)]
256 force: bool,
257
258 #[arg(long, default_value_t = false)]
260 check: bool,
261
262 #[arg(long, default_value_t = false, requires = "check")]
264 machine_readable: bool,
265
266 #[arg(
268 long,
269 default_value_t = false,
270 requires = "check",
271 conflicts_with = "machine_readable"
272 )]
273 json: bool,
274
275 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
277 trust_mode: TrustMode,
278
279 #[arg(long, default_value_t = false)]
281 dry_run: bool,
282 },
283
284 #[command(long_about = "Display information about installed packages.\n\n\
286 Without arguments, shows a summary of all installed packages. \
287 Provide a package name to see detailed information.\n\n\
288 EXAMPLES:\n \
289 upstream list # List all packages\n \
290 upstream list nvim # Show details for nvim")]
291 List {
292 name: Option<String>,
294
295 #[arg(long, default_value_t = false)]
297 json: bool,
298 },
299
300 #[command(long_about = "Show release notes for an installed package.\n\n\
302 By default, prints release bodies newer than the installed version up to \
303 the latest release for the package's tracked channel. Use --from and --to \
304 to override the range endpoints by release tag, or use current/latest for \
305 the installed or tracked latest release.\n\n\
306 EXAMPLES:\n \
307 upstream changelog nvim\n \
308 upstream changelog nvim --from current --to latest\n \
309 upstream changelog nvim --from v0.10.0\n \
310 upstream changelog nvim --from v0.10.0 --to v0.11.0")]
311 Changelog {
312 name: String,
314
315 #[arg(long = "from")]
317 from_tag: Option<String>,
318
319 #[arg(long = "to")]
321 to_tag: Option<String>,
322 },
323
324 #[command(
326 long_about = "Probe a repository/source, choose a release asset interactively, \
327 and install it. Use --dry-run to show parsed releases without installing.\n\n\
328 EXAMPLES:\n \
329 upstream probe neovim/neovim\n \
330 upstream probe https://ziglang.org/download/ -p scraper --limit 20\n \
331 upstream probe owner/repo tool --desktop\n \
332 upstream probe owner/repo --include-incompatible\n \
333 upstream probe owner/repo --limit 20\n \
334 upstream probe owner/repo --tag v1.2.3\n \
335 upstream probe owner/repo --channel nightly --verbose\n \
336 upstream probe owner/repo --dry-run\n \
337 upstream probe owner/repo --json"
338 )]
339 Probe {
340 repo_slug: String,
342
343 name: Option<String>,
345
346 #[arg(short = 'p', long)]
348 provider: Option<Provider>,
349
350 #[arg(long)]
352 base_url: Option<String>,
353
354 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
356 channel: Channel,
357
358 #[arg(long)]
360 limit: Option<u32>,
361
362 #[arg(long, value_name = "TAG")]
364 tag: Option<String>,
365
366 #[arg(long, default_value_t = false)]
368 verbose: bool,
369
370 #[arg(long, default_value_t = false)]
372 include_incompatible: bool,
373
374 #[arg(long, default_value_t = false)]
376 json: bool,
377
378 #[arg(short, long, default_value_t = false)]
380 desktop: bool,
381
382 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
384 trust_mode: TrustMode,
385
386 #[arg(long, default_value_t = false)]
388 dry_run: bool,
389 },
390
391 #[command(long_about = "Search for repositories on a provider.\n\n\
393 Defaults to GitHub when provider is omitted.\n\n\
394 EXAMPLES:\n \
395 upstream search\n \
396 upstream search ripgrep\n \
397 upstream search editor --language Rust --min-stars 100 --max-stars 50000\n \
398 upstream search rip grep --limit 5\n \
399 upstream search my tool -p github\n \
400 upstream search cli --topic terminal\n \
401 upstream search ripgrep --json")]
402 Search {
403 #[arg(num_args(0..), value_delimiter = ' ')]
405 query_words: Vec<String>,
406
407 #[arg(short = 'p', long)]
409 provider: Option<Provider>,
410
411 #[arg(long, requires = "provider")]
413 base_url: Option<String>,
414
415 #[arg(long, default_value_t = 10)]
417 limit: u32,
418
419 #[arg(long)]
421 language: Option<String>,
422
423 #[arg(long)]
425 topic: Option<String>,
426
427 #[arg(long, value_name = "N")]
429 min_stars: Option<u64>,
430
431 #[arg(long, value_name = "N")]
433 max_stars: Option<u64>,
434
435 #[arg(long, value_name = "YYYY-MM-DD", value_parser = parse_search_date)]
437 pushed_after: Option<NaiveDate>,
438
439 #[arg(long, default_value_t = false)]
441 include_forks: bool,
442
443 #[arg(long, default_value_t = false)]
445 include_archived: bool,
446
447 #[arg(long, default_value_t = false)]
449 json: bool,
450 },
451
452 #[command(
454 long_about = "Search for repositories on a provider, choose a result interactively, \
455 and install the selected repository.\n\n\
456 Defaults to GitHub when provider is omitted. After selection, prompts for the package \
457 name with the selected repository name as the default; submitting an empty name uses \
458 that inferred default. Use --name to skip the prompt.\n\n\
459 EXAMPLES:\n \
460 upstream find ripgrep\n \
461 upstream find terminal emulator --limit 20\n \
462 upstream find cli --language Rust --topic cli\n \
463 upstream find ripgrep --name rg -k binary\n \
464 upstream find app -p github --desktop --trust none"
465 )]
466 Find {
467 #[arg(required = true, num_args(1..), value_delimiter = ' ')]
469 query_words: Vec<String>,
470
471 #[arg(short = 'p', long)]
473 provider: Option<Provider>,
474
475 #[arg(long, requires = "provider")]
477 base_url: Option<String>,
478
479 #[arg(long, default_value_t = 10)]
481 limit: u32,
482
483 #[arg(long)]
485 language: Option<String>,
486
487 #[arg(long)]
489 topic: Option<String>,
490
491 #[arg(long, value_name = "N")]
493 min_stars: Option<u64>,
494
495 #[arg(long, value_name = "N")]
497 max_stars: Option<u64>,
498
499 #[arg(long, value_name = "YYYY-MM-DD", value_parser = parse_search_date)]
501 pushed_after: Option<NaiveDate>,
502
503 #[arg(long, default_value_t = false)]
505 include_forks: bool,
506
507 #[arg(long, default_value_t = false)]
509 include_archived: bool,
510
511 #[arg(long)]
513 name: Option<String>,
514
515 #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
517 kind: Filetype,
518
519 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
521 channel: Channel,
522
523 #[arg(short = 'm', long, name = "match")]
525 match_pattern: Option<String>,
526
527 #[arg(short = 'e', long, name = "exclude")]
529 exclude_pattern: Option<String>,
530
531 #[arg(short, long, default_value_t = false)]
533 desktop: bool,
534
535 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
537 trust_mode: TrustMode,
538
539 #[arg(long, default_value_t = false)]
541 dry_run: bool,
542 },
543
544 #[command(long_about = "View and modify upstream's configuration.\n\n\
546 Configuration is stored in TOML format and includes settings like \
547 API tokens, default providers, and installation preferences.\n\n\
548 EXAMPLES:\n \
549 upstream config set github.api_token=ghp_xxx\n \
550 upstream config get download.high_threads\n \
551 upstream config list\n \
552 upstream config edit")]
553 Config {
554 #[command(subcommand)]
555 action: ConfigAction,
556 },
557
558 #[command(long_about = "Control package behavior.\n\n\
560 Pin packages to prevent upgrades or rename installed package aliases.\n\n\
561 EXAMPLES:\n \
562 upstream package pin nvim\n \
563 upstream package unpin nvim\n \
564 upstream package rename nvim neovim")]
565 Package {
566 #[command(subcommand)]
567 action: PackageAction,
568 },
569
570 #[command(long_about = "Manage upstream shell integration hooks.\n\n\
572 Use these commands to add, verify, or remove shell PATH hooks. \
573 Purge removes shell hooks and deletes the local upstream data directory.\n\n\
574 EXAMPLES:\n \
575 upstream hooks init\n \
576 upstream hooks check\n \
577 upstream hooks clean\n \
578 upstream --yes hooks purge")]
579 Hooks {
580 #[command(subcommand)]
581 action: HooksAction,
582 },
583
584 #[command(
586 long_about = "Import trusted keys, package metadata manifests, or full snapshots.\n\n\
587 Autodetects the input type by content/extension, prompts for confirmation by default, \
588 and then performs the selected import operation.\n\n\
589 EXAMPLES:\n \
590 upstream import ./minisign.pub # Import trusted minisign keys\n \
591 upstream import ./cosign.pub # Import trusted cosign PEM keys\n \
592 upstream import ./packages.json # Import package metadata manifest\n \
593 upstream import ./backup.tar.gz # Restore full snapshot\n \
594 upstream --yes import ./input.bin --as keys"
595 )]
596 Import {
597 path: std::path::PathBuf,
599
600 #[arg(long, default_value_t = false)]
602 skip_failed: bool,
603
604 #[arg(long = "as", value_enum)]
606 import_as: Option<ImportAs>,
607 },
608
609 #[command(long_about = "Export installed packages for backup or transfer.\n\n\
611 By default, writes a lightweight manifest containing just enough info to \
612 reinstall each package. Use --full to instead create a tarball of the entire \
613 upstream directory (a full snapshot).\n\n\
614 EXAMPLES:\n \
615 upstream export ./packages.json # Export manifest\n \
616 upstream export ./backup.tar.gz --full # Full snapshot")]
617 Export {
618 path: std::path::PathBuf,
620 #[arg(long, default_value_t = false)]
622 full: bool,
623 },
624
625 #[command(
627 long_about = "Migrate existing upstream data to the current application format.\n\n\
628 Use this after upgrading across breaking changes that affect local data, \
629 metadata, package paths, or integration files. The migration is designed to \
630 be run manually when release notes or diagnostics ask for it. The current \
631 migration moves legacy package directories into the packages layout and rewrites \
632 affected metadata paths.\n\n\
633 EXAMPLE:\n \
634 upstream migrate"
635 )]
636 Migrate,
637
638 #[command(
640 long_about = "Inspect upstream installation health and package state.\n\n\
641 Checks package paths, symlinks, shell PATH integration, cached completions, \
642 desktop/icon files, and metadata. \
643 Reports a compact summary by default and includes actionable hints. \
644 Use --verbose to print each individual check result. Use --fix to repair \
645 supported issues such as PATH hooks, missing symlinks, executable bits, \
646 executable metadata, and cached completion drift.\n\n\
647 EXAMPLES:\n \
648 upstream doctor\n \
649 upstream doctor --verbose\n \
650 upstream doctor --fix\n \
651 upstream doctor nvim ripgrep\n \
652 upstream doctor --json"
653 )]
654 Doctor {
655 names: Vec<String>,
657
658 #[arg(long, default_value_t = false)]
660 verbose: bool,
661
662 #[arg(long, default_value_t = false)]
664 fix: bool,
665
666 #[arg(long, default_value_t = false)]
668 json: bool,
669 },
670}
671
672fn parse_search_date(raw: &str) -> Result<NaiveDate, String> {
673 NaiveDate::parse_from_str(raw, "%Y-%m-%d")
674 .map_err(|_| format!("expected date in YYYY-MM-DD format, got '{raw}'"))
675}
676
677#[derive(Subcommand)]
678pub enum RollbackAction {
679 #[command(long_about = "Restore stored rollback artifacts.\n\n\
681 Without package names, restores the latest reversible transaction recorded \
682 in upstream's transaction history.\n\n\
683 EXAMPLES:\n \
684 upstream rollback restore\n \
685 upstream rollback restore rg\n \
686 upstream rollback restore rg fd\n \
687 upstream rollback restore rg --dry-run")]
688 Restore {
689 #[arg(num_args(0..))]
691 names: Vec<String>,
692
693 #[arg(long, default_value_t = false)]
695 dry_run: bool,
696 },
697
698 #[command(long_about = "Delete stored rollback artifacts.\n\n\
700 Without package names, prunes all stored rollback artifacts.\n\n\
701 EXAMPLES:\n \
702 upstream rollback prune\n \
703 upstream rollback prune rg\n \
704 upstream rollback prune rg fd --dry-run")]
705 Prune {
706 names: Vec<String>,
708
709 #[arg(long, default_value_t = false)]
711 dry_run: bool,
712 },
713
714 #[command(long_about = "List packages with stored rollback artifacts.\n\n\
716 EXAMPLE:\n \
717 upstream rollback list")]
718 List,
719}
720
721impl Commands {
722 pub fn requires_lock(&self) -> bool {
723 match self {
724 Commands::List { .. } => false,
725 Commands::Changelog { .. } => false,
726 Commands::Doctor { fix, .. } => *fix,
727 Commands::Search { .. } => false,
728 Commands::Find { .. } => true,
729 Commands::Rollback {
730 action: RollbackAction::List,
731 } => false,
732 Commands::Hooks { action } => !matches!(action, HooksAction::Check),
733 Commands::Package { .. } => true,
734 Commands::Config { action } => {
735 !matches!(action, ConfigAction::Get { .. } | ConfigAction::List)
736 }
737 Commands::Install { .. }
738 | Commands::Build { .. }
739 | Commands::Remove { .. }
740 | Commands::Rollback { .. }
741 | Commands::Reinstall { .. }
742 | Commands::Upgrade { .. }
743 | Commands::Probe { .. }
744 | Commands::Import { .. }
745 | Commands::Export { .. }
746 | Commands::Migrate => true,
747 }
748 }
749}
750
751#[derive(Subcommand)]
752pub enum HooksAction {
753 #[command(
755 long_about = "Add upstream shell integration hooks and create required local directories.\n\n\
756 EXAMPLE:\n \
757 upstream hooks init"
758 )]
759 Init,
760
761 #[command(
763 long_about = "Check upstream shell integration hooks and required local directories.\n\n\
764 EXAMPLE:\n \
765 upstream hooks check"
766 )]
767 Check,
768
769 #[command(
771 long_about = "Remove upstream shell integration hooks without deleting installed package data.\n\n\
772 EXAMPLE:\n \
773 upstream hooks clean"
774 )]
775 Clean,
776
777 #[command(
779 long_about = "Remove upstream shell integration hooks and delete the local upstream data directory.\n\n\
780 This deletes installed package files and metadata under ~/.upstream. \
781 Pass global --yes to skip the confirmation prompt.\n\n\
782 EXAMPLE:\n \
783 upstream --yes hooks purge"
784 )]
785 Purge,
786}
787
788#[derive(Subcommand)]
789pub enum ConfigAction {
790 #[command(long_about = "Set one or more configuration values.\n\n\
792 Use dot notation for nested keys. Multiple key=value pairs can be set at once.\n\n\
793 EXAMPLES:\n \
794 upstream config set github.api_token=ghp_xxx\n \
795 upstream config set gitlab.api_token=glpat_xxx")]
796 Set {
797 #[arg(required = true)]
799 keys: Vec<String>,
800 },
801
802 #[command(long_about = "Retrieve one or more configuration values.\n\n\
804 Use dot notation to access nested keys.\n\n\
805 EXAMPLES:\n \
806 upstream config get download.high_threads\n \
807 upstream config get github.api_token gitlab.api_token")]
808 Get {
809 #[arg(required = true)]
811 keys: Vec<String>,
812 },
813
814 List,
816
817 Edit,
819
820 Reset,
822}
823
824#[derive(Subcommand)]
825pub enum PackageAction {
826 #[command(long_about = "Prevent a package from being upgraded.\n\n\
828 Pinned packages are skipped during 'upstream upgrade' operations.\n\n\
829 EXAMPLE:\n \
830 upstream package pin nvim")]
831 Pin {
832 name: String,
834
835 #[arg(long)]
837 reason: Option<String>,
838 },
839
840 #[command(long_about = "Remove version pin from a package.\n\n\
842 Unpinned packages will be included in future upgrade operations.\n\n\
843 EXAMPLE:\n \
844 upstream package unpin nvim")]
845 Unpin {
846 name: String,
848 },
849
850 #[command(long_about = "Rename the local alias of an installed package.\n\n\
852 This changes how upstream tracks the package and updates integration aliases \
853 (symlink/desktop entry) when possible.\n\n\
854 EXAMPLE:\n \
855 upstream package rename nvim neovim")]
856 Rename {
857 old_name: String,
859
860 new_name: String,
862 },
863}