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 ripgrep\n \
60 upstream install sharkdp/bat rg")]
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 Restore previously captured installs, or prune stored rollback artifacts. \
195 When no package names are provided, rollback restores the latest reversible \
196 transaction recorded in upstream's transaction history.\n\n\
197 EXAMPLES:\n \
198 upstream rollback\n \
199 upstream rollback rg\n \
200 upstream rollback rg fd --dry-run\n \
201 upstream rollback --prune\n \
202 upstream rollback --prune rg")]
203 Rollback {
204 names: Vec<String>,
206
207 #[arg(long, default_value_t = false)]
209 prune: bool,
210
211 #[arg(long, default_value_t = false)]
213 dry_run: bool,
214 },
215
216 #[command(
218 long_about = "Reinstall packages by uninstalling and then installing them again.\n\n\
219 Reinstall uses each package's stored source metadata. Release installs attempt \
220 the currently recorded version tag; build installs rebuild from source.\n\n\
221 EXAMPLES:\n \
222 upstream reinstall nvim\n \
223 upstream reinstall rg fd\n \
224 upstream reinstall rg --force\n \
225 upstream reinstall rg --trust none"
226 )]
227 Reinstall {
228 names: Vec<String>,
230
231 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
233 trust_mode: TrustMode,
234
235 #[arg(long, default_value_t = false)]
237 force: bool,
238
239 #[arg(long, default_value_t = false)]
241 dry_run: bool,
242 },
243
244 #[command(long_about = "Check for and install updates to packages.\n\n\
246 Without arguments, upgrades all packages. Specify package names to upgrade \
247 only those packages. Use --check to preview available updates.\n\n\
248 EXAMPLES:\n \
249 upstream upgrade # Upgrade all\n \
250 upstream upgrade nvim rg # Upgrade specific packages\n \
251 upstream upgrade --check # Check for updates\n \
252 upstream upgrade --check --json # Check for updates as JSON\n \
253 upstream upgrade --check --machine-readable # Script-friendly output\n \
254 upstream upgrade nvim --force # Force reinstall\n \
255 upstream upgrade --trust none")]
256 Upgrade {
257 names: Option<Vec<String>>,
259
260 #[arg(long, default_value_t = false)]
262 force: bool,
263
264 #[arg(long, default_value_t = false)]
266 check: bool,
267
268 #[arg(long, default_value_t = false, requires = "check")]
270 machine_readable: bool,
271
272 #[arg(
274 long,
275 default_value_t = false,
276 requires = "check",
277 conflicts_with = "machine_readable"
278 )]
279 json: bool,
280
281 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
283 trust_mode: TrustMode,
284
285 #[arg(long, default_value_t = false)]
287 dry_run: bool,
288 },
289
290 #[command(long_about = "Display information about installed packages.\n\n\
292 Without arguments, shows a summary of all installed packages. \
293 Provide a package name to see detailed information.\n\n\
294 EXAMPLES:\n \
295 upstream list # List all packages\n \
296 upstream list nvim # Show details for nvim")]
297 List {
298 name: Option<String>,
300
301 #[arg(long, default_value_t = false)]
303 json: bool,
304 },
305
306 #[command(long_about = "Show release notes for an installed package.\n\n\
308 By default, prints release bodies newer than the installed version up to \
309 the latest release for the package's tracked channel. Use --from and --to \
310 to override the range endpoints by release tag.\n\n\
311 EXAMPLES:\n \
312 upstream changelog nvim\n \
313 upstream changelog nvim --from v0.10.0\n \
314 upstream changelog nvim --from v0.10.0 --to v0.11.0")]
315 Changelog {
316 name: String,
318
319 #[arg(long = "from")]
321 from_tag: Option<String>,
322
323 #[arg(long = "to")]
325 to_tag: Option<String>,
326 },
327
328 #[command(long_about = "Probe a repository/source and show parsed releases.\n\n\
330 Useful for validating what upstream can see before installation.\n\n\
331 EXAMPLES:\n \
332 upstream probe neovim/neovim\n \
333 upstream probe https://ziglang.org/download/ -p scraper --limit 20\n \
334 upstream probe owner/repo --channel nightly --verbose\n \
335 upstream probe owner/repo --json")]
336 Probe {
337 repo_slug: String,
339
340 #[arg(short = 'p', long)]
342 provider: Option<Provider>,
343
344 #[arg(long)]
346 base_url: Option<String>,
347
348 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
350 channel: Channel,
351
352 #[arg(long, default_value_t = 10)]
354 limit: u32,
355
356 #[arg(long, default_value_t = false)]
358 verbose: bool,
359
360 #[arg(long, default_value_t = false)]
362 json: bool,
363 },
364
365 #[command(long_about = "Search for repositories on a provider.\n\n\
367 Defaults to GitHub when provider is omitted.\n\n\
368 EXAMPLES:\n \
369 upstream search\n \
370 upstream search ripgrep\n \
371 upstream search editor --language Rust --min-stars 100\n \
372 upstream search rip grep --limit 5\n \
373 upstream search my tool -p github\n \
374 upstream search widget -p gitlab --base-url https://gitlab.example.com\n \
375 upstream search ripgrep --json")]
376 Search {
377 #[arg(num_args(0..), value_delimiter = ' ')]
379 query_words: Vec<String>,
380
381 #[arg(short = 'p', long)]
383 provider: Option<Provider>,
384
385 #[arg(long, requires = "provider")]
387 base_url: Option<String>,
388
389 #[arg(long, default_value_t = 10)]
391 limit: u32,
392
393 #[arg(long)]
395 language: Option<String>,
396
397 #[arg(long)]
399 topic: Option<String>,
400
401 #[arg(long, value_name = "N")]
403 min_stars: Option<u64>,
404
405 #[arg(long, value_name = "N")]
407 max_stars: Option<u64>,
408
409 #[arg(long, value_name = "YYYY-MM-DD", value_parser = parse_search_date)]
411 pushed_after: Option<NaiveDate>,
412
413 #[arg(long, default_value_t = false)]
415 include_forks: bool,
416
417 #[arg(long, default_value_t = false)]
419 include_archived: bool,
420
421 #[arg(long, default_value_t = false)]
423 json: bool,
424 },
425
426 #[command(
428 long_about = "Search for repositories on a provider, choose a result interactively, \
429 and install the selected repository.\n\n\
430 Defaults to GitHub when provider is omitted. After selection, prompts for the package \
431 name with the selected repository name as the default; submitting an empty name uses \
432 that inferred default. Use --name to skip the prompt.\n\n\
433 EXAMPLES:\n \
434 upstream find ripgrep\n \
435 upstream find terminal emulator --limit 20\n \
436 upstream find ripgrep --name rg -k binary\n \
437 upstream find app -p github --desktop --trust none"
438 )]
439 Find {
440 #[arg(required = true, num_args(1..), value_delimiter = ' ')]
442 query_words: Vec<String>,
443
444 #[arg(short = 'p', long)]
446 provider: Option<Provider>,
447
448 #[arg(long, requires = "provider")]
450 base_url: Option<String>,
451
452 #[arg(long, default_value_t = 10)]
454 limit: u32,
455
456 #[arg(long)]
458 name: Option<String>,
459
460 #[arg(short, long, value_enum, default_value_t = Filetype::Auto)]
462 kind: Filetype,
463
464 #[arg(short, long, value_enum, default_value_t = Channel::Stable)]
466 channel: Channel,
467
468 #[arg(short = 'm', long, name = "match")]
470 match_pattern: Option<String>,
471
472 #[arg(short = 'e', long, name = "exclude")]
474 exclude_pattern: Option<String>,
475
476 #[arg(short, long, default_value_t = false)]
478 desktop: bool,
479
480 #[arg(long = "trust", value_enum, default_value_t = TrustMode::BestEffort)]
482 trust_mode: TrustMode,
483
484 #[arg(long, default_value_t = false)]
486 dry_run: bool,
487 },
488
489 #[command(long_about = "View and modify upstream's configuration.\n\n\
491 Configuration is stored in TOML format and includes settings like \
492 API tokens, default providers, and installation preferences.\n\n\
493 EXAMPLES:\n \
494 upstream config set github.api_token=ghp_xxx\n \
495 upstream config get trust\n \
496 upstream config list\n \
497 upstream config edit")]
498 Config {
499 #[command(subcommand)]
500 action: ConfigAction,
501 },
502
503 #[command(long_about = "Control package behavior.\n\n\
505 Pin packages to prevent upgrades or rename installed package aliases.\n\n\
506 EXAMPLES:\n \
507 upstream package pin nvim\n \
508 upstream package unpin nvim\n \
509 upstream package rename nvim neovim")]
510 Package {
511 #[command(subcommand)]
512 action: PackageAction,
513 },
514
515 #[command(long_about = "Manage upstream shell integration hooks.\n\n\
517 Use these commands to add, verify, or remove shell PATH hooks. \
518 Purge removes shell hooks and deletes the local upstream data directory.\n\n\
519 EXAMPLES:\n \
520 upstream hooks init\n \
521 upstream hooks check\n \
522 upstream hooks clean\n \
523 upstream --yes hooks purge")]
524 Hooks {
525 #[command(subcommand)]
526 action: HooksAction,
527 },
528
529 #[command(
531 long_about = "Import trusted keys, package metadata manifests, or full snapshots.\n\n\
532 Autodetects the input type by content/extension, prompts for confirmation by default, \
533 and then performs the selected import operation.\n\n\
534 EXAMPLES:\n \
535 upstream import ./minisign.pub # Import trusted minisign keys\n \
536 upstream import ./cosign.pub # Import trusted cosign PEM keys\n \
537 upstream import ./packages.json # Import package metadata manifest\n \
538 upstream import ./backup.tar.gz # Restore full snapshot\n \
539 upstream --yes import ./input.bin --as keys"
540 )]
541 Import {
542 path: std::path::PathBuf,
544
545 #[arg(long, default_value_t = false)]
547 skip_failed: bool,
548
549 #[arg(long = "as", value_enum)]
551 import_as: Option<ImportAs>,
552 },
553
554 #[command(long_about = "Export installed packages for backup or transfer.\n\n\
556 By default, writes a lightweight manifest containing just enough info to \
557 reinstall each package. Use --full to instead create a tarball of the entire \
558 upstream directory (a full snapshot).\n\n\
559 EXAMPLES:\n \
560 upstream export ./packages.json # Export manifest\n \
561 upstream export ./backup.tar.gz --full # Full snapshot")]
562 Export {
563 path: std::path::PathBuf,
565 #[arg(long, default_value_t = false)]
567 full: bool,
568 },
569
570 #[command(
572 long_about = "Migrate existing upstream data to the current application format.\n\n\
573 Use this after upgrading across breaking changes that affect local data, \
574 metadata, package paths, or integration files. The migration is designed to \
575 be run manually when release notes or diagnostics ask for it. The current \
576 migration moves legacy package directories into the packages layout and rewrites \
577 affected metadata paths.\n\n\
578 EXAMPLE:\n \
579 upstream migrate"
580 )]
581 Migrate,
582
583 #[command(
585 long_about = "Inspect upstream installation health and package state.\n\n\
586 Checks package paths, symlinks, shell PATH integration, cached completions, \
587 desktop/icon files, and metadata. \
588 Reports a compact summary by default and includes actionable hints. \
589 Use --verbose to print each individual check result. Use --fix to repair \
590 supported issues such as PATH hooks, missing symlinks, executable bits, \
591 executable metadata, and cached completion drift.\n\n\
592 EXAMPLES:\n \
593 upstream doctor\n \
594 upstream doctor --verbose\n \
595 upstream doctor --fix\n \
596 upstream doctor nvim ripgrep\n \
597 upstream doctor --json"
598 )]
599 Doctor {
600 names: Vec<String>,
602
603 #[arg(long, default_value_t = false)]
605 verbose: bool,
606
607 #[arg(long, default_value_t = false)]
609 fix: bool,
610
611 #[arg(long, default_value_t = false)]
613 json: bool,
614 },
615}
616
617fn parse_search_date(raw: &str) -> Result<NaiveDate, String> {
618 NaiveDate::parse_from_str(raw, "%Y-%m-%d")
619 .map_err(|_| format!("expected date in YYYY-MM-DD format, got '{raw}'"))
620}
621
622impl Commands {
623 pub fn requires_lock(&self) -> bool {
624 match self {
625 Commands::List { .. } => false,
626 Commands::Changelog { .. } => false,
627 Commands::Doctor { fix, .. } => *fix,
628 Commands::Search { .. } => false,
629 Commands::Find { .. } => true,
630 Commands::Hooks { action } => !matches!(action, HooksAction::Check),
631 Commands::Package { .. } => true,
632 Commands::Config { action } => {
633 !matches!(action, ConfigAction::Get { .. } | ConfigAction::List)
634 }
635 Commands::Install { .. }
636 | Commands::Build { .. }
637 | Commands::Remove { .. }
638 | Commands::Rollback { .. }
639 | Commands::Reinstall { .. }
640 | Commands::Upgrade { .. }
641 | Commands::Probe { .. }
642 | Commands::Import { .. }
643 | Commands::Export { .. }
644 | Commands::Migrate => true,
645 }
646 }
647}
648
649#[derive(Subcommand)]
650pub enum HooksAction {
651 #[command(
653 long_about = "Add upstream shell integration hooks and create required local directories.\n\n\
654 EXAMPLE:\n \
655 upstream hooks init"
656 )]
657 Init,
658
659 #[command(
661 long_about = "Check upstream shell integration hooks and required local directories.\n\n\
662 EXAMPLE:\n \
663 upstream hooks check"
664 )]
665 Check,
666
667 #[command(
669 long_about = "Remove upstream shell integration hooks without deleting installed package data.\n\n\
670 EXAMPLE:\n \
671 upstream hooks clean"
672 )]
673 Clean,
674
675 #[command(
677 long_about = "Remove upstream shell integration hooks and delete the local upstream data directory.\n\n\
678 This deletes installed package files and metadata under ~/.upstream. \
679 Pass global --yes to skip the confirmation prompt.\n\n\
680 EXAMPLE:\n \
681 upstream --yes hooks purge"
682 )]
683 Purge,
684}
685
686#[derive(Subcommand)]
687pub enum ConfigAction {
688 #[command(long_about = "Set one or more configuration values.\n\n\
690 Use dot notation for nested keys. Multiple key=value pairs can be set at once.\n\n\
691 EXAMPLES:\n \
692 upstream config set github.api_token=ghp_xxx\n \
693 upstream config set gitlab.api_token=glpat_xxx")]
694 Set {
695 #[arg(required = true)]
697 keys: Vec<String>,
698 },
699
700 #[command(long_about = "Retrieve one or more configuration values.\n\n\
702 Use dot notation to access nested keys.\n\n\
703 EXAMPLES:\n \
704 upstream config get trust\n \
705 upstream config get github.api_token gitlab.api_token")]
706 Get {
707 #[arg(required = true)]
709 keys: Vec<String>,
710 },
711
712 List,
714
715 Edit,
717
718 Reset,
720}
721
722#[derive(Subcommand)]
723pub enum PackageAction {
724 #[command(long_about = "Prevent a package from being upgraded.\n\n\
726 Pinned packages are skipped during 'upstream upgrade' operations.\n\n\
727 EXAMPLE:\n \
728 upstream package pin nvim")]
729 Pin {
730 name: String,
732
733 #[arg(long)]
735 reason: Option<String>,
736 },
737
738 #[command(long_about = "Remove version pin from a package.\n\n\
740 Unpinned packages will be included in future upgrade operations.\n\n\
741 EXAMPLE:\n \
742 upstream package unpin nvim")]
743 Unpin {
744 name: String,
746 },
747
748 #[command(long_about = "Rename the local alias of an installed package.\n\n\
750 This changes how upstream tracks the package and updates integration aliases \
751 (symlink/desktop entry) when possible.\n\n\
752 EXAMPLE:\n \
753 upstream package rename nvim neovim")]
754 Rename {
755 old_name: String,
757
758 new_name: String,
760 },
761}