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(long_about = "Probe a repository/source and show parsed releases.\n\n\
326 Useful for validating what upstream can see before installation.\n\n\
327 EXAMPLES:\n \
328 upstream probe neovim/neovim\n \
329 upstream probe https://ziglang.org/download/ -p scraper --limit 20\n \
330 upstream probe owner/repo --channel nightly --verbose\n \
331 upstream probe owner/repo --json")]
332 Probe {
333 repo_slug: String,
335
336 #[arg(short = 'p', long)]
338 provider: Option<Provider>,
339
340 #[arg(long)]
342 base_url: Option<String>,
343
344 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
346 channel: Channel,
347
348 #[arg(long, default_value_t = 10)]
350 limit: u32,
351
352 #[arg(long, default_value_t = false)]
354 verbose: bool,
355
356 #[arg(long, default_value_t = false)]
358 json: bool,
359 },
360
361 #[command(long_about = "Search for repositories on a provider.\n\n\
363 Defaults to GitHub when provider is omitted.\n\n\
364 EXAMPLES:\n \
365 upstream search\n \
366 upstream search ripgrep\n \
367 upstream search editor --language Rust --min-stars 100 --max-stars 50000\n \
368 upstream search rip grep --limit 5\n \
369 upstream search my tool -p github\n \
370 upstream search cli --topic terminal\n \
371 upstream search ripgrep --json")]
372 Search {
373 #[arg(num_args(0..), value_delimiter = ' ')]
375 query_words: Vec<String>,
376
377 #[arg(short = 'p', long)]
379 provider: Option<Provider>,
380
381 #[arg(long, requires = "provider")]
383 base_url: Option<String>,
384
385 #[arg(long, default_value_t = 10)]
387 limit: u32,
388
389 #[arg(long)]
391 language: Option<String>,
392
393 #[arg(long)]
395 topic: Option<String>,
396
397 #[arg(long, value_name = "N")]
399 min_stars: Option<u64>,
400
401 #[arg(long, value_name = "N")]
403 max_stars: Option<u64>,
404
405 #[arg(long, value_name = "YYYY-MM-DD", value_parser = parse_search_date)]
407 pushed_after: Option<NaiveDate>,
408
409 #[arg(long, default_value_t = false)]
411 include_forks: bool,
412
413 #[arg(long, default_value_t = false)]
415 include_archived: bool,
416
417 #[arg(long, default_value_t = false)]
419 json: bool,
420 },
421
422 #[command(
424 long_about = "Search for repositories on a provider, choose a result interactively, \
425 and install the selected repository.\n\n\
426 Defaults to GitHub when provider is omitted. After selection, prompts for the package \
427 name with the selected repository name as the default; submitting an empty name uses \
428 that inferred default. Use --name to skip the prompt.\n\n\
429 EXAMPLES:\n \
430 upstream find ripgrep\n \
431 upstream find terminal emulator --limit 20\n \
432 upstream find cli --language Rust --topic cli\n \
433 upstream find ripgrep --name rg -k binary\n \
434 upstream find app -p github --desktop --trust none"
435 )]
436 Find {
437 #[arg(required = true, num_args(1..), value_delimiter = ' ')]
439 query_words: Vec<String>,
440
441 #[arg(short = 'p', long)]
443 provider: Option<Provider>,
444
445 #[arg(long, requires = "provider")]
447 base_url: Option<String>,
448
449 #[arg(long, default_value_t = 10)]
451 limit: u32,
452
453 #[arg(long)]
455 language: Option<String>,
456
457 #[arg(long)]
459 topic: Option<String>,
460
461 #[arg(long, value_name = "N")]
463 min_stars: Option<u64>,
464
465 #[arg(long, value_name = "N")]
467 max_stars: Option<u64>,
468
469 #[arg(long, value_name = "YYYY-MM-DD", value_parser = parse_search_date)]
471 pushed_after: Option<NaiveDate>,
472
473 #[arg(long, default_value_t = false)]
475 include_forks: bool,
476
477 #[arg(long, default_value_t = false)]
479 include_archived: bool,
480
481 #[arg(long)]
483 name: Option<String>,
484
485 #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
487 kind: Filetype,
488
489 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
491 channel: Channel,
492
493 #[arg(short = 'm', long, name = "match")]
495 match_pattern: Option<String>,
496
497 #[arg(short = 'e', long, name = "exclude")]
499 exclude_pattern: Option<String>,
500
501 #[arg(short, long, default_value_t = false)]
503 desktop: bool,
504
505 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
507 trust_mode: TrustMode,
508
509 #[arg(long, default_value_t = false)]
511 dry_run: bool,
512 },
513
514 #[command(long_about = "View and modify upstream's configuration.\n\n\
516 Configuration is stored in TOML format and includes settings like \
517 API tokens, default providers, and installation preferences.\n\n\
518 EXAMPLES:\n \
519 upstream config set github.api_token=ghp_xxx\n \
520 upstream config get download.high_threads\n \
521 upstream config list\n \
522 upstream config edit")]
523 Config {
524 #[command(subcommand)]
525 action: ConfigAction,
526 },
527
528 #[command(long_about = "Control package behavior.\n\n\
530 Pin packages to prevent upgrades or rename installed package aliases.\n\n\
531 EXAMPLES:\n \
532 upstream package pin nvim\n \
533 upstream package unpin nvim\n \
534 upstream package rename nvim neovim")]
535 Package {
536 #[command(subcommand)]
537 action: PackageAction,
538 },
539
540 #[command(long_about = "Manage upstream shell integration hooks.\n\n\
542 Use these commands to add, verify, or remove shell PATH hooks. \
543 Purge removes shell hooks and deletes the local upstream data directory.\n\n\
544 EXAMPLES:\n \
545 upstream hooks init\n \
546 upstream hooks check\n \
547 upstream hooks clean\n \
548 upstream --yes hooks purge")]
549 Hooks {
550 #[command(subcommand)]
551 action: HooksAction,
552 },
553
554 #[command(
556 long_about = "Import trusted keys, package metadata manifests, or full snapshots.\n\n\
557 Autodetects the input type by content/extension, prompts for confirmation by default, \
558 and then performs the selected import operation.\n\n\
559 EXAMPLES:\n \
560 upstream import ./minisign.pub # Import trusted minisign keys\n \
561 upstream import ./cosign.pub # Import trusted cosign PEM keys\n \
562 upstream import ./packages.json # Import package metadata manifest\n \
563 upstream import ./backup.tar.gz # Restore full snapshot\n \
564 upstream --yes import ./input.bin --as keys"
565 )]
566 Import {
567 path: std::path::PathBuf,
569
570 #[arg(long, default_value_t = false)]
572 skip_failed: bool,
573
574 #[arg(long = "as", value_enum)]
576 import_as: Option<ImportAs>,
577 },
578
579 #[command(long_about = "Export installed packages for backup or transfer.\n\n\
581 By default, writes a lightweight manifest containing just enough info to \
582 reinstall each package. Use --full to instead create a tarball of the entire \
583 upstream directory (a full snapshot).\n\n\
584 EXAMPLES:\n \
585 upstream export ./packages.json # Export manifest\n \
586 upstream export ./backup.tar.gz --full # Full snapshot")]
587 Export {
588 path: std::path::PathBuf,
590 #[arg(long, default_value_t = false)]
592 full: bool,
593 },
594
595 #[command(
597 long_about = "Migrate existing upstream data to the current application format.\n\n\
598 Use this after upgrading across breaking changes that affect local data, \
599 metadata, package paths, or integration files. The migration is designed to \
600 be run manually when release notes or diagnostics ask for it. The current \
601 migration moves legacy package directories into the packages layout and rewrites \
602 affected metadata paths.\n\n\
603 EXAMPLE:\n \
604 upstream migrate"
605 )]
606 Migrate,
607
608 #[command(
610 long_about = "Inspect upstream installation health and package state.\n\n\
611 Checks package paths, symlinks, shell PATH integration, cached completions, \
612 desktop/icon files, and metadata. \
613 Reports a compact summary by default and includes actionable hints. \
614 Use --verbose to print each individual check result. Use --fix to repair \
615 supported issues such as PATH hooks, missing symlinks, executable bits, \
616 executable metadata, and cached completion drift.\n\n\
617 EXAMPLES:\n \
618 upstream doctor\n \
619 upstream doctor --verbose\n \
620 upstream doctor --fix\n \
621 upstream doctor nvim ripgrep\n \
622 upstream doctor --json"
623 )]
624 Doctor {
625 names: Vec<String>,
627
628 #[arg(long, default_value_t = false)]
630 verbose: bool,
631
632 #[arg(long, default_value_t = false)]
634 fix: bool,
635
636 #[arg(long, default_value_t = false)]
638 json: bool,
639 },
640}
641
642fn parse_search_date(raw: &str) -> Result<NaiveDate, String> {
643 NaiveDate::parse_from_str(raw, "%Y-%m-%d")
644 .map_err(|_| format!("expected date in YYYY-MM-DD format, got '{raw}'"))
645}
646
647#[derive(Subcommand)]
648pub enum RollbackAction {
649 #[command(long_about = "Restore stored rollback artifacts.\n\n\
651 Without package names, restores the latest reversible transaction recorded \
652 in upstream's transaction history.\n\n\
653 EXAMPLES:\n \
654 upstream rollback restore\n \
655 upstream rollback restore rg\n \
656 upstream rollback restore rg fd\n \
657 upstream rollback restore rg --dry-run")]
658 Restore {
659 #[arg(num_args(0..))]
661 names: Vec<String>,
662
663 #[arg(long, default_value_t = false)]
665 dry_run: bool,
666 },
667
668 #[command(long_about = "Delete stored rollback artifacts.\n\n\
670 Without package names, prunes all stored rollback artifacts.\n\n\
671 EXAMPLES:\n \
672 upstream rollback prune\n \
673 upstream rollback prune rg\n \
674 upstream rollback prune rg fd --dry-run")]
675 Prune {
676 names: Vec<String>,
678
679 #[arg(long, default_value_t = false)]
681 dry_run: bool,
682 },
683
684 #[command(long_about = "List packages with stored rollback artifacts.\n\n\
686 EXAMPLE:\n \
687 upstream rollback list")]
688 List,
689}
690
691impl Commands {
692 pub fn requires_lock(&self) -> bool {
693 match self {
694 Commands::List { .. } => false,
695 Commands::Changelog { .. } => false,
696 Commands::Doctor { fix, .. } => *fix,
697 Commands::Search { .. } => false,
698 Commands::Find { .. } => true,
699 Commands::Rollback {
700 action: RollbackAction::List,
701 } => false,
702 Commands::Hooks { action } => !matches!(action, HooksAction::Check),
703 Commands::Package { .. } => true,
704 Commands::Config { action } => {
705 !matches!(action, ConfigAction::Get { .. } | ConfigAction::List)
706 }
707 Commands::Install { .. }
708 | Commands::Build { .. }
709 | Commands::Remove { .. }
710 | Commands::Rollback { .. }
711 | Commands::Reinstall { .. }
712 | Commands::Upgrade { .. }
713 | Commands::Probe { .. }
714 | Commands::Import { .. }
715 | Commands::Export { .. }
716 | Commands::Migrate => true,
717 }
718 }
719}
720
721#[derive(Subcommand)]
722pub enum HooksAction {
723 #[command(
725 long_about = "Add upstream shell integration hooks and create required local directories.\n\n\
726 EXAMPLE:\n \
727 upstream hooks init"
728 )]
729 Init,
730
731 #[command(
733 long_about = "Check upstream shell integration hooks and required local directories.\n\n\
734 EXAMPLE:\n \
735 upstream hooks check"
736 )]
737 Check,
738
739 #[command(
741 long_about = "Remove upstream shell integration hooks without deleting installed package data.\n\n\
742 EXAMPLE:\n \
743 upstream hooks clean"
744 )]
745 Clean,
746
747 #[command(
749 long_about = "Remove upstream shell integration hooks and delete the local upstream data directory.\n\n\
750 This deletes installed package files and metadata under ~/.upstream. \
751 Pass global --yes to skip the confirmation prompt.\n\n\
752 EXAMPLE:\n \
753 upstream --yes hooks purge"
754 )]
755 Purge,
756}
757
758#[derive(Subcommand)]
759pub enum ConfigAction {
760 #[command(long_about = "Set one or more configuration values.\n\n\
762 Use dot notation for nested keys. Multiple key=value pairs can be set at once.\n\n\
763 EXAMPLES:\n \
764 upstream config set github.api_token=ghp_xxx\n \
765 upstream config set gitlab.api_token=glpat_xxx")]
766 Set {
767 #[arg(required = true)]
769 keys: Vec<String>,
770 },
771
772 #[command(long_about = "Retrieve one or more configuration values.\n\n\
774 Use dot notation to access nested keys.\n\n\
775 EXAMPLES:\n \
776 upstream config get download.high_threads\n \
777 upstream config get github.api_token gitlab.api_token")]
778 Get {
779 #[arg(required = true)]
781 keys: Vec<String>,
782 },
783
784 List,
786
787 Edit,
789
790 Reset,
792}
793
794#[derive(Subcommand)]
795pub enum PackageAction {
796 #[command(long_about = "Prevent a package from being upgraded.\n\n\
798 Pinned packages are skipped during 'upstream upgrade' operations.\n\n\
799 EXAMPLE:\n \
800 upstream package pin nvim")]
801 Pin {
802 name: String,
804
805 #[arg(long)]
807 reason: Option<String>,
808 },
809
810 #[command(long_about = "Remove version pin from a package.\n\n\
812 Unpinned packages will be included in future upgrade operations.\n\n\
813 EXAMPLE:\n \
814 upstream package unpin nvim")]
815 Unpin {
816 name: String,
818 },
819
820 #[command(long_about = "Rename the local alias of an installed package.\n\n\
822 This changes how upstream tracks the package and updates integration aliases \
823 (symlink/desktop entry) when possible.\n\n\
824 EXAMPLE:\n \
825 upstream package rename nvim neovim")]
826 Rename {
827 old_name: String,
829
830 new_name: String,
832 },
833}