Skip to main content

bougie_cli/
lib.rs

1use clap::builder::styling::{AnsiColor, Effects, Styles};
2use clap::{Args, Parser, Subcommand};
3use std::ffi::OsString;
4
5/// Full version string, uv-style: `0.6.4 (63c5f57d3 2026-05-08 <target>)`.
6///
7/// Built by `build.rs`; degrades to the bare crate version when git metadata is
8/// unavailable. clap prefixes the binary name, so `--version` prints
9/// `bougie 0.6.4 (...)`.
10pub const LONG_VERSION: &str = env!("BOUGIE_LONG_VERSION");
11
12const HELP_STYLES: Styles = Styles::styled()
13    .header(AnsiColor::Blue.on_default().effects(Effects::BOLD))
14    .usage(AnsiColor::Magenta.on_default().effects(Effects::BOLD))
15    .literal(AnsiColor::BrightMagenta.on_default().effects(Effects::BOLD))
16    .placeholder(AnsiColor::BrightMagenta.on_default())
17    .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
18    .valid(AnsiColor::Green.on_default())
19    .invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
20
21/// Grouped quick-reference appended to `bougie --help`. clap renders all
22/// subcommands in one flat "Commands:" list (it has no headed-group
23/// support), and `display_order` clusters that list into these same
24/// groups — this cheat-sheet just names the groups so the core verbs are
25/// findable at a glance.
26const COMMAND_GROUPS: &str = "\
27Command groups:
28  Project      init, new, start, stop, run, sync, make, format
29  Dependencies add, remove, lock, tree, outdated, ext, composer
30  Toolchain    php, node, tool
31  Services     server, services, projects
32  Admin        cache, self
33
34Run `bougie help <command>` for details on any command.";
35
36#[derive(Parser, Debug)]
37#[command(
38    name = "bougie",
39    version = LONG_VERSION,
40    about,
41    long_about = None,
42    styles = HELP_STYLES,
43    after_long_help = COMMAND_GROUPS,
44)]
45pub struct Cli {
46    #[command(subcommand)]
47    pub command: Command,
48
49    /// Suppress non-error output.
50    #[arg(short, long, global = true)]
51    pub quiet: bool,
52
53    /// Verbose output.
54    #[arg(short, long, global = true)]
55    pub verbose: bool,
56
57    /// Output format.
58    #[arg(long, global = true, default_value = "text")]
59    pub format: OutputFormat,
60}
61
62/// Shared PHP-source preference flags (uv's system-Python model adapted
63/// to PHP). Flattened into `sync` / `run`; `--managed-php` and
64/// `--no-managed-php` are mutually exclusive. With none set, bougie's
65/// default applies: prefer an installed managed PHP, then a qualifying
66/// system PHP, then download a managed one.
67#[derive(Args, Debug, Clone, Copy, Default)]
68pub struct PhpPrefArgs {
69    /// Only use a bougie-managed PHP; never a system PHP.
70    #[arg(long, conflicts_with = "no_managed_php")]
71    pub managed_php: bool,
72    /// Only use a system PHP already on this machine; never a managed one.
73    #[arg(long)]
74    pub no_managed_php: bool,
75    /// Never download a managed PHP — use an installed managed PHP or a
76    /// system one. Errors if neither is present.
77    #[arg(long)]
78    pub no_php_downloads: bool,
79}
80
81#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
82pub enum OutputFormat {
83    Text,
84    /// `json-v1` is bougie's structured envelope; `json` is accepted as
85    /// an alias so Composer-compatible subcommands (`composer show
86    /// --format json`, etc.) work with the same global flag.
87    #[value(name = "json-v1", alias = "json")]
88    JsonV1,
89}
90
91/// Version-preference policy for a resolve, mirroring uv's
92/// `--resolution`. Maps onto `bougie-composer-resolver`'s
93/// `ResolutionStrategy` in the dispatch layer.
94#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub enum ResolutionStrategy {
96    /// Prefer the newest compatible version of every package (the default).
97    #[default]
98    Highest,
99    /// Prefer the oldest compatible version of every package, including
100    /// transitive dependencies (Composer's `--prefer-lowest`).
101    Lowest,
102    /// Prefer the oldest compatible version of the project's direct
103    /// requires, but the newest for everything they pull in transitively.
104    #[value(name = "lowest-direct")]
105    LowestDirect,
106}
107
108#[derive(Subcommand, Debug)]
109pub enum Command {
110    /// Create a new project.
111    #[command(display_order = 1)]
112    Init {
113        /// Place bougie configuration in a bougie.toml file.
114        #[arg(long)]
115        toml: bool,
116        /// Set the package name (`vendor/package`) of the generated
117        /// composer.json. Overrides the name from a `--starter` manifest.
118        #[arg(long, value_name = "VENDOR/PACKAGE")]
119        name: Option<String>,
120        /// Scaffold from a starter pack: a built-in alias (e.g. `mageos`)
121        /// or an https URL serving a starter manifest. Writes the
122        /// starter's composer.json instead of the empty default.
123        #[arg(long, value_name = "URL_OR_ALIAS")]
124        starter: Option<String>,
125        /// After scaffolding, bring the project up — equivalent to
126        /// `bougie start` (sync the toolchain + vendor, then run the
127        /// project recipe). Unix-only.
128        #[arg(long)]
129        start: bool,
130    },
131
132    /// Create a new project in a new directory.
133    #[command(display_order = 2)]
134    New {
135        /// Directory to create under the current directory and scaffold
136        /// the project into.
137        #[arg(value_name = "DIRECTORY")]
138        directory: String,
139        /// Place bougie configuration in a bougie.toml file.
140        #[arg(long)]
141        toml: bool,
142        /// Set the package name (`vendor/package`) of the generated
143        /// composer.json. Overrides the name from a `--starter` manifest.
144        #[arg(long, value_name = "VENDOR/PACKAGE")]
145        name: Option<String>,
146        /// Scaffold from a starter pack: a built-in alias (e.g. `mageos`)
147        /// or an https URL serving a starter manifest.
148        #[arg(long, value_name = "URL_OR_ALIAS")]
149        starter: Option<String>,
150        /// After scaffolding, bring the project up — equivalent to
151        /// `bougie start`. Unix-only.
152        #[arg(long)]
153        start: bool,
154    },
155
156    /// Manage PHP extensions.
157    #[command(subcommand, display_order = 15)]
158    Ext(ExtCommand),
159
160    /// Manage native (cweagans-style) patches applied to installed packages.
161    #[command(subcommand, display_order = 16)]
162    Patches(PatchesCommand),
163
164    /// Add one or more packages to the project and sync. The uv-flavored
165    /// twin of `composer require`: a bare `vendor/pkg` writes a `>=X.Y`
166    /// lower bound (vs `composer require`'s caret `^X.Y`), and an
167    /// explicit constraint uses the `@` syntax (`vendor/pkg@^1.0`), as in
168    /// `bougie tool install` / `bougie ext add`. Edits `composer.json`,
169    /// re-resolves `composer.lock`, and installs into `vendor/`.
170    #[command(display_order = 10)]
171    Add {
172        /// Packages to add, `vendor/pkg` or `vendor/pkg@<constraint>`.
173        #[arg(value_name = "PACKAGES", required = true)]
174        packages: Vec<String>,
175        /// Add to `require-dev` instead of `require`.
176        #[arg(long = "dev")]
177        dev: bool,
178        /// Also update the new packages' dependencies (`-w`).
179        #[arg(short = 'w', long = "with-dependencies")]
180        with_dependencies: bool,
181        /// Also update all dependencies, including shared ones (`-W`).
182        #[arg(short = 'W', long = "with-all-dependencies")]
183        with_all_dependencies: bool,
184        /// Update `composer.json` + `composer.lock` but don't install
185        /// into `vendor/`.
186        #[arg(long = "no-sync")]
187        no_sync: bool,
188        /// Edit `composer.json` only — don't touch the lock or `vendor/`.
189        #[arg(long = "frozen")]
190        frozen: bool,
191        /// Version-preference policy when resolving (uv's `--resolution`).
192        #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
193        resolution: ResolutionStrategy,
194        /// Run in this directory instead of CWD (`-d`).
195        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
196        working_dir: Option<std::path::PathBuf>,
197        /// Resolve and report what would change without writing anything.
198        #[arg(long = "dry-run")]
199        dry_run: bool,
200    },
201
202    /// Remove one or more packages from the project and sync. The
203    /// uv-flavored twin of `composer remove`.
204    #[command(display_order = 11)]
205    Remove {
206        /// Packages to remove (`vendor/name`).
207        #[arg(value_name = "PACKAGES", required = true)]
208        packages: Vec<String>,
209        /// Remove from `require-dev` instead of `require`.
210        #[arg(long = "dev")]
211        dev: bool,
212        /// Re-resolve `composer.lock` but don't touch `vendor/`.
213        #[arg(long = "no-sync")]
214        no_sync: bool,
215        /// Edit `composer.json` only — don't touch the lock or `vendor/`.
216        #[arg(long = "frozen")]
217        frozen: bool,
218        /// Run in this directory instead of CWD (`-d`).
219        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
220        working_dir: Option<std::path::PathBuf>,
221        /// Resolve and report what would change without writing anything.
222        #[arg(long = "dry-run")]
223        dry_run: bool,
224    },
225
226    /// Refresh `composer.lock` to match `composer.json` (native; uv's
227    /// `uv lock`). Minimal: keeps every package at its locked version
228    /// where still valid, re-resolving only what changed. Never bumps
229    /// versions and never installs — use `bougie composer update` to pull
230    /// newer versions.
231    #[command(display_order = 12)]
232    Lock {
233        /// Version-preference policy when re-resolving changed requires
234        /// (uv's `--resolution`).
235        #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
236        resolution: ResolutionStrategy,
237        /// Run in this directory instead of CWD (`-d`).
238        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
239        working_dir: Option<std::path::PathBuf>,
240        /// Resolve and report what would change without writing the lock.
241        #[arg(long = "dry-run")]
242        dry_run: bool,
243    },
244
245    /// Print the project's dependency tree (native; uv's `uv tree`).
246    /// Reads `composer.lock`.
247    #[command(display_order = 13)]
248    Tree {
249        /// Root the tree at this package instead of the project.
250        #[arg(value_name = "PACKAGE")]
251        package: Option<String>,
252        /// Skip dev dependencies.
253        #[arg(long = "no-dev")]
254        no_dev: bool,
255        /// Run in this directory instead of CWD (`-d`).
256        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
257        working_dir: Option<std::path::PathBuf>,
258    },
259
260    /// List installed packages with a newer version available (native;
261    /// like `uv`/`pnpm outdated`). Reads `composer.lock` and queries the
262    /// configured repositories.
263    #[command(display_order = 14)]
264    Outdated {
265        /// Optional `vendor/name` filters; with none, all are considered.
266        #[arg(value_name = "PACKAGES")]
267        packages: Vec<String>,
268        /// Only the project's direct dependencies (`--direct` / `-D`).
269        #[arg(short = 'D', long = "direct")]
270        direct: bool,
271        /// Only packages with a new major version.
272        #[arg(long = "major-only")]
273        major_only: bool,
274        /// Only packages with a new minor version.
275        #[arg(long = "minor-only")]
276        minor_only: bool,
277        /// Only packages with a new patch version.
278        #[arg(long = "patch-only")]
279        patch_only: bool,
280        /// Skip dev dependencies.
281        #[arg(long = "no-dev")]
282        no_dev: bool,
283        /// Exit non-zero if any package is outdated.
284        #[arg(long = "strict")]
285        strict: bool,
286        /// Run in this directory instead of CWD (`-d`).
287        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
288        working_dir: Option<std::path::PathBuf>,
289    },
290
291    /// Install everything the project requires.
292    #[command(display_order = 6)]
293    Sync {
294        /// Don't try to download anything, this will fail if there are uncached packages.
295        #[arg(long)]
296        offline: bool,
297        /// Show the plan, change nothing on disk.
298        #[arg(long)]
299        dry_run: bool,
300        /// Run composer.json root scripts for this sync, overriding
301        /// `[scripts] run` in bougie.toml. Off by default (opt-in).
302        #[arg(long, conflicts_with = "no_scripts")]
303        scripts: bool,
304        /// Skip composer.json root scripts for this sync, overriding
305        /// `[scripts] run = true` in bougie.toml.
306        #[arg(long = "no-scripts")]
307        no_scripts: bool,
308        /// Version-preference policy when a fresh lock must be resolved
309        /// (uv's `--resolution`). No effect when a `composer.lock` already
310        /// exists.
311        #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
312        resolution: ResolutionStrategy,
313        /// Apply native cweagans-style patches for this sync, overriding
314        /// `[patches] enable` / `enable-patching`. On by default when
315        /// patches are declared.
316        #[arg(long, conflicts_with = "no_patches")]
317        patches: bool,
318        /// Skip native patch application for this sync.
319        #[arg(long = "no-patches")]
320        no_patches: bool,
321        #[command(flatten)]
322        php: PhpPrefArgs,
323    },
324
325    /// Run a command in the project environment.
326    #[command(display_order = 5)]
327    Run {
328        /// Add a temporary extension for this invocation.
329        #[arg(long, value_name = "EXT=VER")]
330        with: Vec<String>,
331        /// Skip the implicit `bougie sync` before running.
332        #[arg(long)]
333        no_sync: bool,
334        /// Layer the server's debug overlay (`vendor/bougie/conf.d-debug/`)
335        /// into `PHP_INI_SCAN_DIR` and set `XDEBUG_SESSION=1` for the
336        /// child. Installs xdebug on first use if not already present.
337        #[arg(long)]
338        xdebug: bool,
339        /// Run with a specific PHP interpreter. Accepts a version
340        /// (`8.3`, `8.3.12`), a constraint (`~8.3`, `>=8.2,<8.4`), or a
341        /// path to a `php` binary. Forces a sync to that interpreter,
342        /// so it can't be combined with `--no-sync`. Mirrors
343        /// `uv run --python`.
344        #[arg(long = "php", value_name = "VER|PATH", conflicts_with = "no_sync")]
345        php_request: Option<String>,
346        #[command(flatten)]
347        php: PhpPrefArgs,
348        /// Command and arguments. `--` separator is optional.
349        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
350        argv: Vec<String>,
351    },
352
353    /// Manage PHP interpreters.
354    #[command(subcommand, display_order = 20)]
355    Php(PhpCommand),
356
357    /// Manage Node.js interpreters (for projects that build frontend
358    /// assets — Vite, Laravel Mix, Magento static-content deploy).
359    #[command(subcommand, display_order = 21)]
360    Node(NodeCommand),
361
362    /// Run Composer, reimplemented natively. bougie does not bundle or
363    /// execute the Composer phar; the common Composer surface
364    /// (install/update/require/remove/show/why/why-not/outdated/audit/
365    /// licenses/fund/status/validate/dump-autoload) runs natively, and an
366    /// unrecognized subcommand errors with a pointer to
367    /// `bougie tool install composer/composer` for the full upstream
368    /// Composer.
369    #[command(subcommand, display_order = 16)]
370    Composer(ComposerCommand),
371
372    /// Manage globally-installed, isolated PHP CLI tools. See
373    /// `TOOL_PLAN.md` for the design.
374    #[command(subcommand, display_order = 22)]
375    Tool(ToolCommand),
376
377    /// Runtime shim invoked by tool wrappers (`#!.../bougie tool-exec`).
378    /// Not for direct CLI use; hidden from `--help`.
379    #[command(hide = true, name = "tool-exec")]
380    ToolExec {
381        /// Path to the tool wrapper script the kernel handed us as
382        /// argv[1] via the shebang.
383        wrapper: std::path::PathBuf,
384        /// User-supplied arguments to the tool, passed through to PHP.
385        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
386        args: Vec<std::ffi::OsString>,
387    },
388
389    /// Manage bougie's cache.
390    #[command(subcommand, display_order = 40)]
391    Cache(CacheCommand),
392
393    /// Manage the bougie binary itself.
394    #[command(subcommand)]
395    #[command(name = "self", display_order = 41)]
396    SelfCmd(SelfCommand),
397
398    /// Run the bougie development HTTP server for the current project.
399    /// With no subcommand, registers the project with the shared dev
400    /// server, prints its URL, and streams its log (Ctrl-C detaches).
401    /// See SERVER.md.
402    #[command(display_order = 30)]
403    Server(ServerArgs),
404
405    /// Manage project-scoped dev services (mariadb, redis, …). See
406    /// SERVICES.md and CLI.md §3.8.
407    #[command(subcommand, display_order = 31)]
408    Services(ServicesCommand),
409
410    /// Inspect and manage provisioned tenants across the shared dev
411    /// services and the project each belongs to. Reads the on-disk
412    /// tenant ledgers; no daemon required.
413    #[command(subcommand, display_order = 32)]
414    Projects(ProjectsCommand),
415
416    /// Bring the whole project up: run the detected recipe's `start`
417    /// task, whose DAG syncs the toolchain + vendor, provisions and
418    /// starts the project's services, runs any setup, and starts the
419    /// dev server. The project lifecycle umbrella (ddev's `start`).
420    /// For an individual task use `bougie make <task>`. Unix-only.
421    #[command(display_order = 3)]
422    Start {
423        /// Skip the implicit `bougie sync` prologue.
424        #[arg(long)]
425        no_sync: bool,
426        /// Show what would run, but don't execute.
427        #[arg(long)]
428        dry_run: bool,
429        /// Explain why each step runs or skips.
430        #[arg(long)]
431        explain: bool,
432        /// Ignore the builtin recipe; use only `bougie.toml`.
433        #[arg(long)]
434        no_builtin: bool,
435        /// Force a specific builtin (e.g. `magento`).
436        #[arg(long, value_name = "NAME")]
437        recipe: Option<String>,
438    },
439
440    /// Bring the project down: stop its declared services (or every
441    /// service in `names`), including the dev-server tenant when the
442    /// `server` service is declared. The shared daemon and any other
443    /// project's tenants stay up. The teardown twin of `bougie start`.
444    /// Unix-only.
445    #[command(display_order = 4)]
446    Stop {
447        /// Service names to stop. Empty = every declared service.
448        names: Vec<String>,
449        /// Destroy persisted tenant data (e.g. FLUSHDB on redis). Off
450        /// by default — `bougie start` should restore state.
451        #[arg(long)]
452        purge: bool,
453    },
454
455    /// Walk a project recipe's DAG, running tasks whose freshness
456    /// check fails. With no task, lists the available tasks; use
457    /// `bougie start` to bring the whole project up. See RECIPES.md.
458    #[command(display_order = 7)]
459    Make {
460        /// Task to run. With none, the available tasks are listed.
461        task: Option<String>,
462        /// List available tasks instead of running.
463        #[arg(long, conflicts_with_all = ["dry_run", "explain", "print"])]
464        list: bool,
465        /// Show what would run, but don't execute.
466        #[arg(long)]
467        dry_run: bool,
468        /// Explain why each step runs or skips.
469        #[arg(long)]
470        explain: bool,
471        /// Skip the implicit `bougie sync` prologue.
472        #[arg(long)]
473        no_sync: bool,
474        /// Ignore the builtin recipe; use only `bougie.toml`.
475        #[arg(long)]
476        no_builtin: bool,
477        /// Force a specific builtin (e.g. `magento`).
478        #[arg(long, value_name = "NAME")]
479        recipe: Option<String>,
480        /// Print the merged recipe to stdout instead of running.
481        #[arg(long)]
482        print: bool,
483    },
484
485    /// Format the project's PHP, the way `uv format` runs ruff.
486    ///
487    /// bougie does not bundle a formatter: on first use it downloads a
488    /// pinned `wick` binary (cresset-tools/wick — an unconfigurable,
489    /// Laravel Pint-style formatter), caches it, and execs it. Every
490    /// argument is forwarded verbatim to `wick`, so `bougie format`,
491    /// `bougie format --check`, `bougie format src/ --diff`, and
492    /// `… | bougie format -` behave exactly like the matching `wick`
493    /// invocation. Pin a specific wick with `BOUGIE_WICK_VERSION`.
494    #[command(display_order = 8)]
495    Format {
496        /// Arguments forwarded verbatim to `wick` (paths, `--check`,
497        /// `--diff`, `-` for stdin, …).
498        #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS")]
499        args: Vec<std::ffi::OsString>,
500    },
501}
502
503#[derive(Subcommand, Debug)]
504pub enum ServicesCommand {
505    /// Start the project's declared services (or every service in
506    /// `names`) and provision the project's tenant in each. For the
507    /// whole-project bring-up use `bougie start`.
508    Up {
509        /// Service names to bring up. Empty = every declared service.
510        names: Vec<String>,
511        /// Start the services and return immediately instead of
512        /// attaching to their combined log stream. Attaching is the
513        /// default for an interactive (TTY) text-mode invocation;
514        /// non-interactive runs and `--format json-v1` always detach.
515        #[arg(short = 'd', long)]
516        detach: bool,
517    },
518    /// Stop the project's declared services (or every service in
519    /// `names`). The shared global process stays up while any other
520    /// project's tenant remains. For the whole-project teardown use
521    /// `bougie stop`.
522    Down {
523        names: Vec<String>,
524        /// Destroy persisted tenant data (e.g. FLUSHDB on redis). Off
525        /// by default — re-adding the service should restore state.
526        #[arg(long)]
527        purge: bool,
528    },
529    /// Declare a service in the project. Errors if the name isn't in
530    /// the catalog. Use `bougie services catalog` to discover names.
531    Add {
532        /// One or more service names, each optionally `@<version>`.
533        names: Vec<String>,
534    },
535    /// Remove a service declaration from the project. Tenant data is
536    /// kept by default (re-adding restores it); pass `--purge` to also
537    /// destroy it.
538    Remove {
539        /// Service names to remove.
540        names: Vec<String>,
541        /// Also destroy the project's tenant data for each service
542        /// (same as `bougie services down --purge`) before undeclaring.
543        #[arg(long)]
544        purge: bool,
545    },
546    /// List the services declared in the current project.
547    List {
548        /// Reserved for cross-project listing in Phase 3+. Today this
549        /// degrades silently to per-project output.
550        #[arg(long)]
551        all: bool,
552    },
553    /// Print the built-in service catalog (no daemon required).
554    Catalog,
555    /// Restart the named services (or every declared service). Stops
556    /// then starts the underlying global process; the tenant ledger
557    /// is preserved, so generated passwords / DB numbers survive.
558    /// Affects every project sharing the same service.
559    Restart {
560        names: Vec<String>,
561    },
562    /// Per-service status for the current project.
563    Status {
564        /// Limit to a single service.
565        name: Option<String>,
566    },
567    /// Tail (and optionally follow) service logs. With no name, shows
568    /// the combined ("multilog") stream of every service declared in the
569    /// project, each line prefixed with its (colorized) service name —
570    /// the same view `bougie services up` attaches to.
571    Logs {
572        /// Service name. Omit to tail every declared service at once.
573        name: Option<String>,
574        /// Follow the log; runs until interrupted (Ctrl-C).
575        #[arg(short = 'f', long)]
576        follow: bool,
577        /// Number of trailing lines to print before any follow.
578        #[arg(short = 'n', long, default_value_t = 50)]
579        lines: usize,
580    },
581    /// Inspect and control the `bougied` daemon.
582    #[command(subcommand)]
583    Daemon(ServicesDaemonCommand),
584}
585
586#[derive(Subcommand, Debug)]
587pub enum ProjectsCommand {
588    /// List every provisioned tenant across the shared services and the
589    /// project each belongs to. Reads the on-disk tenant ledgers; no
590    /// daemon required.
591    List {
592        /// Show the per-service allocation (redis db number, rabbitmq
593        /// vhost, server hostname, …) as an extra column.
594        #[arg(long)]
595        alloc: bool,
596    },
597    /// Deprovision tenants and remove them from the service ledgers.
598    /// With no flags, targets *orphaned* tenants whose project directory
599    /// no longer exists. Destructive: when the service is running this
600    /// drops the tenant's data (database, vhost, redis db, …); when it's
601    /// stopped, only the ledger entry is removed.
602    Purge {
603        /// Purge a specific project's tenants by path (it may already be
604        /// deleted) instead of the orphaned set.
605        #[arg(long)]
606        project: Option<String>,
607        /// Purge every tenant of every project. Use with care.
608        #[arg(long)]
609        all: bool,
610        /// Print what would be purged and exit without changing anything.
611        #[arg(long)]
612        dry_run: bool,
613        /// Skip the confirmation prompt (required for non-interactive use).
614        #[arg(short = 'y', long)]
615        yes: bool,
616    },
617}
618
619#[derive(Subcommand, Debug)]
620pub enum ServicesDaemonCommand {
621    /// Print daemon PID, socket path, and managed-service count. The
622    /// daemon is auto-spawned if not already running.
623    Status,
624    /// Send a graceful shutdown to the running daemon.
625    Stop,
626    /// Print the daemon's reported version (used by the CLI to detect
627    /// post-`self update` daemon-binary mismatches).
628    Version,
629}
630
631/// `bougie server` — the project verb plus its management subcommands.
632/// With no subcommand, the flattened [`ServeArgs`] drive the default
633/// "serve the current project" action; otherwise a [`ServerCommand`]
634/// runs.
635#[derive(Args, Debug)]
636#[command(args_conflicts_with_subcommands = true)]
637pub struct ServerArgs {
638    #[command(subcommand)]
639    pub command: Option<ServerCommand>,
640    #[command(flatten)]
641    pub serve: ServeArgs,
642}
643
644/// Default-action arguments for `bougie server` (no subcommand):
645/// register the current project with the shared dev server, print its
646/// URL, and stream its log.
647#[derive(Args, Debug)]
648#[allow(clippy::struct_excessive_bools)] // each bool is a distinct CLI flag
649pub struct ServeArgs {
650    /// Hostname label override — the `<name>` in `<name>.bougie.run`.
651    /// Defaults to a name derived from the project.
652    #[arg(value_name = "NAME")]
653    pub name: Option<String>,
654    /// Open the project URL in a browser once the server is ready.
655    #[arg(long)]
656    pub open: bool,
657    /// Serve over HTTPS (requires `bougie server tls install`).
658    #[arg(long)]
659    pub tls: bool,
660    /// Print the URL and return immediately instead of attaching to the
661    /// log stream. Matches `services up`'s `-d`.
662    #[arg(short = 'd', long = "detach")]
663    pub detach: bool,
664    /// Skip the implicit `bougie sync` before serving.
665    #[arg(long)]
666    pub no_sync: bool,
667}
668
669#[derive(Subcommand, Debug)]
670pub enum ServerCommand {
671    /// Low-level primitive: run the server process against an explicit
672    /// multi-host `server.toml`, foreground, with no daemon. This is
673    /// what `bougied` spawns and what CI / power users invoke directly;
674    /// `--config` is required because a multi-host server has no single
675    /// project to default to. The bougied-managed path (`bougie services
676    /// up server`) supplies its own service-scoped `server.toml`.
677    Run {
678        /// `server.toml` path. Required.
679        #[arg(long, value_name = "PATH")]
680        config: std::path::PathBuf,
681        /// CLI override of `[server].listen` (e.g. `127.0.0.1:7080`).
682        #[arg(long, value_name = "ADDR")]
683        listen: Option<String>,
684        /// CLI override of `[server].log_format`.
685        #[arg(long, value_name = "FMT")]
686        log_format: Option<String>,
687    },
688    /// Show the dev server's hosts and live pool state. Reads the
689    /// running server's control socket when available, falling back to
690    /// the configured hosts otherwise. Replaces the old `list`, which
691    /// remains as a hidden alias.
692    #[command(alias = "list")]
693    Status {
694        /// `server.toml` to inspect. Defaults to the bougied-managed
695        /// config.
696        #[arg(long, value_name = "PATH")]
697        config: Option<std::path::PathBuf>,
698    },
699    /// Open the current project's (or NAME's) dev URL in a browser.
700    Open {
701        /// Hostname label to open. Defaults to the current project.
702        #[arg(value_name = "NAME")]
703        name: Option<String>,
704    },
705    /// Stop the shared dev server. Equivalent to `bougie services down
706    /// server`; stops hosting for every project, since the server is shared.
707    Stop,
708    /// Tail the dev server's request log. In a project, defaults to
709    /// this project's host.
710    Logs {
711        /// Follow the log; runs until interrupted (Ctrl-C).
712        #[arg(short = 'f', long)]
713        follow: bool,
714        /// Number of trailing lines to print before any follow.
715        #[arg(short = 'n', long, default_value_t = 50)]
716        lines: usize,
717    },
718    /// Manage local TLS via mkcert.
719    #[command(subcommand)]
720    Tls(ServerTlsCommand),
721    /// Manage `/etc/hosts` overrides.
722    #[command(subcommand)]
723    Hosts(ServerHostsCommand),
724}
725
726#[derive(Subcommand, Debug)]
727pub enum ServerHostsCommand {
728    /// Rewrite the bougie sentinel block in /etc/hosts to match
729    /// server.toml. Requires root — runs via sudo.
730    Apply {
731        /// `server.toml` to read the host list from. Defaults to the
732        /// bougied-managed config.
733        #[arg(long, value_name = "PATH")]
734        config: Option<std::path::PathBuf>,
735    },
736}
737
738#[derive(Subcommand, Debug)]
739pub enum ServerTlsCommand {
740    /// Fetch mkcert and install bougie's local CA.
741    Install,
742    /// Uninstall bougie's local CA.
743    Uninstall,
744}
745
746#[derive(Subcommand, Debug)]
747pub enum ExtCommand {
748    /// Add an extension dependency. Each `<arg>` is either an
749    /// extension name (e.g. `redis`, `xdebug@3.5.1`) — fetched from
750    /// the index and recorded in composer.json — or a path to a local
751    /// `.so` file (e.g. `/opt/tideways/tideways-php-8.5.so`), in which
752    /// case bougie copies it into the store, auto-detects the
753    /// extension name and Zend-ness from the binary, and writes a
754    /// fragment to the durable, machine-local `conf.d-local/` (under
755    /// `$BOUGIE_HOME`) without touching composer.json. Mix and match in
756    /// one invocation.
757    Add {
758        /// Extension names or `.so` paths (anything ending in `.so` is
759        /// treated as a local file).
760        args: Vec<String>,
761        /// Skip the implicit `bougie sync` after the composer call.
762        #[arg(long)]
763        no_sync: bool,
764        #[command(flatten)]
765        php: PhpPrefArgs,
766    },
767    /// Remove an extension dependency.
768    Remove {
769        /// The extension(s) to remove.
770        names: Vec<String>,
771        /// Skip the implicit `bougie sync` after the composer call.
772        #[arg(long)]
773        no_sync: bool,
774    },
775    /// List available extensions.
776    List {
777        /// Only show installed extensions.
778        #[arg(long)]
779        only_installed: bool,
780        /// Only show extensions advertised by the index.
781        #[arg(long)]
782        only_available: bool,
783        /// List all extension versions, including older releases.
784        #[arg(long)]
785        all_versions: bool,
786        /// List extensions for all platforms, not just the host's.
787        #[arg(long)]
788        all_platforms: bool,
789        /// Show the URLs of available extension downloads.
790        #[arg(long)]
791        show_urls: bool,
792    },
793}
794
795#[derive(Subcommand, Debug)]
796pub enum PatchesCommand {
797    /// Add a root patch rule from a URL or local file (the `composer
798    /// require` of patches). The target package is inferred from the diff
799    /// headers unless `--package` is given.
800    Add {
801        /// An `http(s)://` URL or a local patch file path.
802        source: String,
803        /// Target package (`vendor/pkg`); inferred from the diff if omitted.
804        #[arg(long, value_name = "VENDOR/PKG")]
805        package: Option<String>,
806        /// Human description (defaults to the URL basename / filename).
807        #[arg(long)]
808        description: Option<String>,
809        /// Explicit `-pN` strip depth.
810        #[arg(long, value_name = "N")]
811        depth: Option<usize>,
812        /// Write into the external patches file rather than `extra.patches`.
813        #[arg(long = "to-file")]
814        to_file: bool,
815        /// Don't run `bougie sync` afterward.
816        #[arg(long = "no-sync")]
817        no_sync: bool,
818    },
819    /// Show the resolved patch set, plus any unadopted dependency-declared
820    /// patches (which bougie never applies automatically).
821    List,
822    /// Adopt dependency-declared patches into the root `composer.json`
823    /// (the only way a dependency's patches ever apply).
824    Import {
825        /// Dependencies to import from (default: all that declare patches).
826        packages: Vec<String>,
827        /// Import from every dependency that declares patches.
828        #[arg(long)]
829        all: bool,
830        /// Write into the external patches file rather than `extra.patches`.
831        #[arg(long = "to-file")]
832        to_file: bool,
833    },
834    /// Force a clean re-extract + re-apply for the named packages (or all):
835    /// drops their recorded fingerprints, then syncs.
836    Repatch {
837        /// Packages to repatch (default: all patched packages).
838        packages: Vec<String>,
839    },
840    /// Rebuild `patches.lock.json` from current config: re-download remote
841    /// patches and re-apply everything from pristine.
842    Relock,
843    /// Diagnose patch configuration: unresolvable `patches/` files,
844    /// `http://` URLs, missing checksums, unadopted dependency patches.
845    Doctor,
846}
847
848#[derive(Subcommand, Debug)]
849pub enum PhpCommand {
850    /// Install a new PHP version.
851    Install {
852        /// The PHP version(s) to install (e.g. `8.3`, `8.3.12`, `8.3+zts`).
853        requests: Vec<String>,
854        /// Build flavor to install [possible values: nts, nts-debug, zts, zts-debug].
855        #[arg(long)]
856        flavor: Option<String>,
857        /// Skip the entire baseline extension set; install only the bare
858        /// Debian-aligned interpreter (`REFACTOR_DEBIAN_ALIGNED.md`).
859        #[arg(long, conflicts_with = "without")]
860        bare: bool,
861        /// Skip a specific baseline extension. Repeatable: `--without opcache
862        /// --without readline`. The named extensions must already be in the
863        /// baseline set; use `bougie ext remove` after install for anything else.
864        #[arg(long, value_name = "EXT", action = clap::ArgAction::Append)]
865        without: Vec<String>,
866    },
867    /// Remove a PHP version.
868    Uninstall {
869        /// The PHP version(s) to uninstall.
870        #[arg(required = true)]
871        requests: Vec<String>,
872        /// Build flavor to uninstall [possible values: nts, nts-debug, zts, zts-debug].
873        #[arg(long)]
874        flavor: Option<String>,
875    },
876    /// List available PHP interpreters.
877    List {
878        /// A PHP request to filter by.
879        request: Option<String>,
880        /// Only show installed PHP versions.
881        #[arg(long)]
882        only_installed: bool,
883        /// Only show PHP versions available for download.
884        #[arg(long)]
885        only_available: bool,
886        /// List all PHP versions, including older patch versions.
887        #[arg(long)]
888        all_versions: bool,
889        /// List PHP downloads for all platforms.
890        #[arg(long)]
891        all_platforms: bool,
892        /// List PHP downloads for all architectures.
893        #[arg(long)]
894        all_arches: bool,
895        /// Show the URLs of available PHP downloads.
896        #[arg(long)]
897        show_urls: bool,
898    },
899    /// Search for a PHP interpreter.
900    Find {
901        /// A PHP request to search for.
902        request: Option<String>,
903    },
904    /// Pin the project's PHP version.
905    Pin {
906        /// The PHP version to pin.
907        request: String,
908        /// Write the pin to `bougie.toml` (creating it if needed).
909        #[arg(long, conflicts_with = "composer")]
910        toml: bool,
911        /// Write the pin to `composer.json`'s `require.php`.
912        #[arg(long, conflicts_with = "toml")]
913        composer: bool,
914    },
915    /// Refresh installed interpreters to the latest published patch.
916    Upgrade {
917        /// The PHP minor version(s) to upgrade (e.g. `8.3`).
918        minor: Option<String>,
919    },
920    /// Show the PHP interpreter installation directory.
921    Dir,
922}
923
924#[derive(Subcommand, Debug)]
925pub enum NodeCommand {
926    /// Install a Node.js version from nodejs.org.
927    Install {
928        /// The Node version(s) to install (e.g. `latest`, `lts`, `20`,
929        /// `20.11`, `20.11.0`). Defaults to `latest`.
930        requests: Vec<String>,
931    },
932    /// Remove an installed Node.js version.
933    Uninstall {
934        /// The Node version(s) to uninstall (exact `20.11.0`).
935        #[arg(required = true)]
936        requests: Vec<String>,
937    },
938    /// List installed Node.js versions.
939    List,
940    /// Resolve a request and show the version + download URL it maps to,
941    /// without installing.
942    Find {
943        /// A Node request to resolve (e.g. `lts`, `20`). Defaults to `latest`.
944        request: Option<String>,
945    },
946    /// Show the Node.js installation directory.
947    Dir,
948}
949
950#[derive(Subcommand, Debug)]
951pub enum ComposerCommand {
952    /// Install a project's `vendor/` from `composer.lock`. Reads
953    /// `composer.json` + `composer.lock` in the working directory,
954    /// content-hash-verifies the lock, parallel-downloads dists into
955    /// `vendor/`, and emits `vendor/autoload.php`.
956    Install {
957        /// Run the install in this directory instead of CWD.
958        /// Mirrors Composer's `--working-dir` / `-d`.
959        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
960        working_dir: Option<std::path::PathBuf>,
961        /// Skip dev-only packages and dev autoload entries.
962        #[arg(long = "no-dev")]
963        no_dev: bool,
964        /// Fail if composer.lock is out of sync with composer.json.
965        /// Currently a no-op — the install already errors on
966        /// content-hash mismatch by default. Accepted for parity
967        /// with Composer's CI usage.
968        #[arg(long = "frozen")]
969        frozen: bool,
970        /// Verify the lock is internally consistent (content-hash,
971        /// requires, transitives) and exit. Doesn't touch `vendor/`
972        /// or run the autoloader. CI-friendly read-only check.
973        #[arg(long = "lock-verify")]
974        lock_verify: bool,
975        /// Ignore all platform requirements (php, ext-*, lib-*).
976        /// Accepted for Composer parity; bougie does not enforce
977        /// platform requirements yet.
978        #[arg(long = "ignore-platform-reqs")]
979        ignore_platform_reqs: bool,
980        /// Ignore a specific platform requirement.
981        #[arg(long = "ignore-platform-req", value_name = "REQ")]
982        ignore_platform_req: Vec<String>,
983        /// Run composer.json root scripts, overriding `[scripts] run`
984        /// in bougie.toml. Off by default (opt-in).
985        #[arg(long, conflicts_with = "no_scripts")]
986        scripts: bool,
987        /// Skip composer.json root scripts, overriding `[scripts] run
988        /// = true` in bougie.toml (Composer-compatible `--no-scripts`).
989        #[arg(long = "no-scripts")]
990        no_scripts: bool,
991        /// Apply native cweagans-style patches, overriding `[patches]
992        /// enable` / `enable-patching`. On by default when patches are
993        /// declared.
994        #[arg(long, conflicts_with = "no_patches")]
995        patches: bool,
996        /// Skip native patch application for this install.
997        #[arg(long = "no-patches")]
998        no_patches: bool,
999    },
1000    /// Resolve the project's dependency graph, write a fresh
1001    /// `composer.lock`, and install the result into `vendor/` (matching
1002    /// Composer's `update`). With no package arguments this re-resolves
1003    /// from scratch; naming one or more packages does a partial update —
1004    /// only those re-resolve while every other locked package stays
1005    /// pinned. `--no-install` stops after writing the lock; `--dry-run`
1006    /// previews the solution without writing anything. Aliased to
1007    /// `upgrade` / `u`, like Composer.
1008    #[command(visible_alias = "upgrade", alias = "u")]
1009    Update {
1010        /// Packages to update (`vendor/name`). When given, only these
1011        /// packages re-resolve; every other package stays pinned to its
1012        /// `composer.lock` version (Composer's partial update). With no
1013        /// packages, the whole graph re-resolves from scratch.
1014        #[arg(value_name = "PACKAGES")]
1015        packages: Vec<String>,
1016        /// Write the lock but don't install into `vendor/` (Composer's
1017        /// `--no-install`).
1018        #[arg(long = "no-install")]
1019        no_install: bool,
1020        /// Also update the named packages' dependencies (Composer's
1021        /// `--with-dependencies` / `-w`).
1022        #[arg(short = 'w', long = "with-dependencies")]
1023        with_dependencies: bool,
1024        /// Also update all of the named packages' dependencies, including
1025        /// ones shared with other packages (Composer's
1026        /// `--with-all-dependencies` / `-W`).
1027        #[arg(short = 'W', long = "with-all-dependencies")]
1028        with_all_dependencies: bool,
1029        /// Run the update in this directory instead of CWD.
1030        /// Mirrors Composer's `--working-dir` / `-d`.
1031        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1032        working_dir: Option<std::path::PathBuf>,
1033        /// Skip dev-only root requires when resolving.
1034        #[arg(long = "no-dev")]
1035        no_dev: bool,
1036        /// Version-preference policy when resolving (uv's `--resolution`).
1037        #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
1038        resolution: ResolutionStrategy,
1039        /// Prefer the lowest matching versions (Composer's
1040        /// `--prefer-lowest`). Equivalent to `--resolution lowest`; when
1041        /// set it overrides `--resolution`.
1042        #[arg(long = "prefer-lowest")]
1043        prefer_lowest: bool,
1044        /// Resolve and print the solution without writing
1045        /// `composer.lock` or touching `vendor/`. Without this flag,
1046        /// `update` writes a fresh `composer.lock`.
1047        #[arg(long = "dry-run")]
1048        dry_run: bool,
1049        /// Ignore all platform requirements (php, ext-*, lib-*).
1050        /// Accepted for Composer parity; bougie does not enforce
1051        /// platform requirements yet.
1052        #[arg(long = "ignore-platform-reqs")]
1053        ignore_platform_reqs: bool,
1054        /// Ignore a specific platform requirement.
1055        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1056        ignore_platform_req: Vec<String>,
1057    },
1058    /// Validate composer.json structure and contents.
1059    Validate {
1060        /// Run in this directory instead of CWD.
1061        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1062        working_dir: Option<std::path::PathBuf>,
1063        /// Return non-zero exit code for warnings too.
1064        #[arg(long)]
1065        strict: bool,
1066        /// Skip lock file freshness check.
1067        #[arg(long = "no-check-lock")]
1068        no_check_lock: bool,
1069        /// Skip publish-only checks (name casing, required fields).
1070        #[arg(long = "no-check-publish")]
1071        no_check_publish: bool,
1072        /// Skip unbound/exact version constraint warnings.
1073        #[arg(long = "no-check-all")]
1074        no_check_all: bool,
1075        /// Also validate installed dependencies' composer.json files.
1076        #[arg(long = "with-dependencies")]
1077        with_dependencies: bool,
1078        /// Force lock file checking even when `config.lock` is false.
1079        #[arg(long = "check-lock")]
1080        check_lock: bool,
1081    },
1082    /// Regenerate `vendor/composer/autoload_*.php` against the current
1083    /// `composer.lock`. Drop-in for `composer dump-autoload`; output
1084    /// is byte-equivalent to Composer 2.8.12 with the same flags. Aliased
1085    /// to `dump-autoload` for users coming from Composer muscle-memory.
1086    #[command(alias = "dump-autoload")]
1087    DumpAutoloader {
1088        /// Optimize the classmap (`--optimize` / `-o`).
1089        #[arg(short = 'o', long = "optimize", alias = "optimize-autoloader")]
1090        optimize: bool,
1091        /// Emit the classmap-authoritative static loader
1092        /// (`--classmap-authoritative` / `-a`). Implies `--optimize`.
1093        #[arg(short = 'a', long = "classmap-authoritative")]
1094        classmap_authoritative: bool,
1095        /// Skip dev autoload entries (`--no-dev`).
1096        #[arg(long = "no-dev")]
1097        no_dev: bool,
1098        /// Emit the `APCu` loader bootstrap (`--apcu-autoloader`).
1099        #[arg(long = "apcu-autoloader")]
1100        apcu_autoloader: bool,
1101        /// Explicit `APCu` prefix; implies `--apcu-autoloader`.
1102        #[arg(long = "apcu-autoloader-prefix", value_name = "PREFIX")]
1103        apcu_prefix: Option<String>,
1104        /// Override the `ComposerAutoloaderInit<X>` class suffix —
1105        /// otherwise the value from `composer.json`'s
1106        /// `config.autoloader-suffix`, or the `composer.lock`
1107        /// content-hash.
1108        #[arg(long = "autoloader-suffix", value_name = "SUFFIX")]
1109        autoloader_suffix: Option<String>,
1110        /// Run the dump in this directory instead of the current one.
1111        /// Mirrors Composer's `--working-dir` / `-d`.
1112        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1113        working_dir: Option<std::path::PathBuf>,
1114    },
1115    /// Add one or more packages to `composer.json` `require` (or
1116    /// `require-dev`), re-resolve `composer.lock`, and install them.
1117    /// Fully Composer-compatible: a bare `vendor/pkg` resolves the
1118    /// latest stable and writes a caret (`^X.Y`) constraint; supply an
1119    /// explicit constraint with `vendor/pkg:^1.0`, `vendor/pkg=^1.0`, or
1120    /// a trailing argument (`vendor/pkg ^1.0`) — Composer's separators
1121    /// are `:`, `=`, or a space (the `@` separator is *not* accepted, as
1122    /// in Composer). For bougie's `>=`-default + `@`-syntax house style,
1123    /// use the top-level `bougie add` instead.
1124    Require {
1125        /// Packages to require, as Composer name↔version pairs.
1126        #[arg(value_name = "PACKAGES", required = true)]
1127        packages: Vec<String>,
1128        /// Add to `require-dev` instead of `require`.
1129        #[arg(long = "dev")]
1130        dev: bool,
1131        /// Edit `composer.json` only — don't re-resolve `composer.lock`
1132        /// or touch `vendor/` (Composer's `--no-update`).
1133        #[arg(long = "no-update")]
1134        no_update: bool,
1135        /// Re-resolve and write `composer.lock` but don't install into
1136        /// `vendor/` (Composer's `--no-install`).
1137        #[arg(long = "no-install")]
1138        no_install: bool,
1139        /// Also update the new packages' dependencies (`-w`).
1140        #[arg(short = 'w', long = "with-dependencies")]
1141        with_dependencies: bool,
1142        /// Also update all dependencies, including shared ones (`-W`).
1143        #[arg(short = 'W', long = "with-all-dependencies")]
1144        with_all_dependencies: bool,
1145        /// Prefer the lowest matching versions when resolving.
1146        #[arg(long = "prefer-lowest")]
1147        prefer_lowest: bool,
1148        /// Ignore all platform requirements (php, ext-*, lib-*).
1149        #[arg(long = "ignore-platform-reqs")]
1150        ignore_platform_reqs: bool,
1151        /// Ignore a specific platform requirement.
1152        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1153        ignore_platform_req: Vec<String>,
1154        /// Run in this directory instead of CWD (`-d`).
1155        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1156        working_dir: Option<std::path::PathBuf>,
1157        /// Resolve and report what would change without writing
1158        /// `composer.json`, `composer.lock`, or `vendor/`.
1159        #[arg(long = "dry-run")]
1160        dry_run: bool,
1161    },
1162    /// Remove one or more packages from `composer.json`, re-resolve
1163    /// `composer.lock`, and uninstall them from `vendor/`. Drop-in for
1164    /// `composer remove`.
1165    Remove {
1166        /// Packages to remove (`vendor/name`).
1167        #[arg(value_name = "PACKAGES", required = true)]
1168        packages: Vec<String>,
1169        /// Remove from `require-dev` instead of `require`.
1170        #[arg(long = "dev")]
1171        dev: bool,
1172        /// Edit `composer.json` only — don't re-resolve or touch
1173        /// `vendor/` (Composer's `--no-update`).
1174        #[arg(long = "no-update")]
1175        no_update: bool,
1176        /// Re-resolve and write `composer.lock` but don't touch
1177        /// `vendor/` (Composer's `--no-install`).
1178        #[arg(long = "no-install")]
1179        no_install: bool,
1180        /// Skip dev-only packages when resolving.
1181        #[arg(long = "no-dev")]
1182        no_dev: bool,
1183        /// Ignore all platform requirements (php, ext-*, lib-*).
1184        #[arg(long = "ignore-platform-reqs")]
1185        ignore_platform_reqs: bool,
1186        /// Ignore a specific platform requirement.
1187        #[arg(long = "ignore-platform-req", value_name = "REQ")]
1188        ignore_platform_req: Vec<String>,
1189        /// Run in this directory instead of CWD (`-d`).
1190        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1191        working_dir: Option<std::path::PathBuf>,
1192        /// Resolve and report what would change without writing
1193        /// `composer.json`, `composer.lock`, or `vendor/`.
1194        #[arg(long = "dry-run")]
1195        dry_run: bool,
1196    },
1197    /// List installed packages, or show details for one. Reads the
1198    /// project's `composer.lock`. Drop-in for `composer show` (aliases
1199    /// `info`, `list`).
1200    #[command(alias = "info", alias = "list")]
1201    Show {
1202        /// A single `vendor/name` to show details for. With no argument,
1203        /// every installed package is listed.
1204        #[arg(value_name = "PACKAGE")]
1205        package: Option<String>,
1206        /// Render the dependency tree (`--tree` / `-t`).
1207        #[arg(short = 't', long = "tree")]
1208        tree: bool,
1209        /// Only the project's direct dependencies (`--direct` / `-D`).
1210        #[arg(short = 'D', long = "direct")]
1211        direct: bool,
1212        /// Only platform packages — php, ext-*, lib-* (`--platform` / `-p`).
1213        #[arg(short = 'p', long = "platform")]
1214        platform: bool,
1215        /// Show the root package's own info (`--self` / `-s`).
1216        #[arg(short = 's', long = "self")]
1217        self_: bool,
1218        /// Print package names only (`--name-only` / `-N`).
1219        #[arg(short = 'N', long = "name-only")]
1220        name_only: bool,
1221        /// Show each package's install path (`--path` / `-P`).
1222        #[arg(short = 'P', long = "path")]
1223        path: bool,
1224        /// Also fetch and show the latest available version
1225        /// (`--latest` / `-l`).
1226        #[arg(short = 'l', long = "latest")]
1227        latest: bool,
1228        /// Only packages with a newer version available
1229        /// (`--outdated` / `-o`). Implies `--latest`.
1230        #[arg(short = 'o', long = "outdated")]
1231        outdated: bool,
1232        /// Skip dev dependencies (`--no-dev`).
1233        #[arg(long = "no-dev")]
1234        no_dev: bool,
1235        /// Run in this directory instead of CWD (`-d`).
1236        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1237        working_dir: Option<std::path::PathBuf>,
1238    },
1239    /// Show which packages depend on a given package — i.e. why it's
1240    /// installed. Drop-in for `composer why` (alias `depends`).
1241    #[command(alias = "depends")]
1242    Why {
1243        /// The package to explain.
1244        #[arg(value_name = "PACKAGE", required = true)]
1245        package: String,
1246        /// Recurse through the dependency chain (`--recursive` / `-r`).
1247        #[arg(short = 'r', long = "recursive")]
1248        recursive: bool,
1249        /// Render the full dependency-of tree (`--tree` / `-t`).
1250        #[arg(short = 't', long = "tree")]
1251        tree: bool,
1252        /// Run in this directory instead of CWD (`-d`).
1253        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1254        working_dir: Option<std::path::PathBuf>,
1255    },
1256    /// Show what prevents a package (optionally at a version) from being
1257    /// installed — conflicting requirements. Drop-in for
1258    /// `composer why-not` (alias `prohibits`).
1259    #[command(name = "why-not", alias = "prohibits")]
1260    WhyNot {
1261        /// The package to test.
1262        #[arg(value_name = "PACKAGE", required = true)]
1263        package: String,
1264        /// The version (or constraint) to test against. Defaults to `*`.
1265        #[arg(value_name = "VERSION")]
1266        version: Option<String>,
1267        /// Recurse through the dependency chain (`--recursive` / `-r`).
1268        #[arg(short = 'r', long = "recursive")]
1269        recursive: bool,
1270        /// Render the full tree (`--tree` / `-t`).
1271        #[arg(short = 't', long = "tree")]
1272        tree: bool,
1273        /// Run in this directory instead of CWD (`-d`).
1274        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1275        working_dir: Option<std::path::PathBuf>,
1276    },
1277    /// List installed packages that have a newer version available.
1278    /// Drop-in for `composer outdated` (a focused `show --latest
1279    /// --outdated`). Use the global `--format json` for JSON output.
1280    Outdated {
1281        /// Optional `vendor/name` filters; with none, all packages are
1282        /// considered.
1283        #[arg(value_name = "PACKAGES")]
1284        packages: Vec<String>,
1285        /// Only the project's direct dependencies (`--direct` / `-D`).
1286        #[arg(short = 'D', long = "direct")]
1287        direct: bool,
1288        /// Only show packages with a new major version (`--major-only`).
1289        #[arg(long = "major-only")]
1290        major_only: bool,
1291        /// Only show packages with a new minor version (`--minor-only`).
1292        #[arg(long = "minor-only")]
1293        minor_only: bool,
1294        /// Only show packages with a new patch version (`--patch-only`).
1295        #[arg(long = "patch-only")]
1296        patch_only: bool,
1297        /// Skip dev dependencies (`--no-dev`).
1298        #[arg(long = "no-dev")]
1299        no_dev: bool,
1300        /// Exit non-zero if any package is outdated (`--strict`).
1301        #[arg(long = "strict")]
1302        strict: bool,
1303        /// Run in this directory instead of CWD (`-d`).
1304        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1305        working_dir: Option<std::path::PathBuf>,
1306    },
1307    /// Check installed packages against the Packagist security-advisories
1308    /// database. Drop-in for `composer audit`. Exits non-zero when
1309    /// advisories are found. Use the global `--format json` for JSON.
1310    Audit {
1311        /// Skip dev dependencies (`--no-dev`).
1312        #[arg(long = "no-dev")]
1313        no_dev: bool,
1314        /// How to treat abandoned packages (`--abandoned`). Currently
1315        /// accepted for parity; abandoned detection is not yet wired.
1316        #[arg(long = "abandoned", value_enum, default_value = "report")]
1317        abandoned: AbandonedHandling,
1318        /// Audit the locked set (`--locked`). bougie always reads
1319        /// `composer.lock`, so this is the default behavior; accepted
1320        /// for parity.
1321        #[arg(long = "locked")]
1322        locked: bool,
1323        /// Run in this directory instead of CWD (`-d`).
1324        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1325        working_dir: Option<std::path::PathBuf>,
1326    },
1327    /// List the license of every installed package. Drop-in for
1328    /// `composer licenses`. Use the global `--format json` for JSON.
1329    Licenses {
1330        /// Skip dev dependencies (`--no-dev`).
1331        #[arg(long = "no-dev")]
1332        no_dev: bool,
1333        /// Run in this directory instead of CWD (`-d`).
1334        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1335        working_dir: Option<std::path::PathBuf>,
1336    },
1337    /// Report packages that look locally modified. Drop-in for
1338    /// `composer status`. bougie installs from dist archives, so for the
1339    /// common case this reports "no local changes".
1340    Status {
1341        /// Run in this directory instead of CWD (`-d`).
1342        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1343        working_dir: Option<std::path::PathBuf>,
1344    },
1345    /// Show funding information for installed packages, grouped by
1346    /// vendor. Drop-in for `composer fund`. Use `--format json` for JSON.
1347    Fund {
1348        /// Skip dev dependencies (`--no-dev`).
1349        #[arg(long = "no-dev")]
1350        no_dev: bool,
1351        /// Run in this directory instead of CWD (`-d`).
1352        #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1353        working_dir: Option<std::path::PathBuf>,
1354    },
1355    /// Catch-all for any composer subcommand bougie does not implement
1356    /// natively (`create-project`, `archive`, `bump`, `global`, …).
1357    /// bougie does not bundle the Composer phar, so these no longer run;
1358    /// the dispatch returns an error pointing at
1359    /// `bougie tool install composer/composer` for the full upstream
1360    /// Composer.
1361    #[command(external_subcommand)]
1362    External(Vec<OsString>),
1363}
1364
1365/// How `composer audit` treats abandoned packages.
1366#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
1367pub enum AbandonedHandling {
1368    /// Ignore abandoned packages entirely.
1369    Ignore,
1370    /// Report abandoned packages but don't fail on them.
1371    Report,
1372    /// Treat abandoned packages as an audit failure.
1373    Fail,
1374}
1375
1376#[derive(Subcommand, Debug)]
1377pub enum CacheCommand {
1378    /// Wipe the full cache.
1379    Clean,
1380    /// Remove unneeded library files.
1381    Prune {
1382        /// Show what would be pruned without removing anything.
1383        #[arg(long)]
1384        dry_run: bool,
1385        /// Also remove tracked projects that no longer exist on disk.
1386        #[arg(long)]
1387        prune_projects: bool,
1388    },
1389    /// Show the location of the cache directory.
1390    Dir,
1391    /// Show the cache size.
1392    Size,
1393}
1394
1395#[derive(Subcommand, Debug)]
1396pub enum ToolCommand {
1397    /// Install a tool. Pass `<vendor>/<name>` optionally followed by
1398    /// `@<constraint>` (e.g. `phpstan/phpstan@^1.10`).
1399    Install {
1400        /// Composer package identifier, optionally with `@<constraint>`.
1401        package: String,
1402        /// Pin the tool to a specific PHP. Accepts a version (`8.3`,
1403        /// `8.3.12`) or a constraint (`~8.3`, `>=8.2,<8.4`). When the
1404        /// requested PHP isn't installed, bougie installs it
1405        /// automatically. Defaults to the highest installed NTS PHP.
1406        #[arg(long, value_name = "VER")]
1407        php: Option<String>,
1408        /// Additional Composer package (`vendor/name[@<constraint>]`)
1409        /// or PHP extension (`intl`, `redis`) to install alongside the
1410        /// tool. May be passed multiple times.
1411        #[arg(long, value_name = "PKG_OR_EXT")]
1412        with: Vec<String>,
1413        /// Overwrite an existing executable at the bin-dir path.
1414        #[arg(long)]
1415        force: bool,
1416    },
1417    /// Remove an installed tool by its `<vendor>/<name>` identifier.
1418    Uninstall {
1419        /// Composer package identifier.
1420        package: String,
1421    },
1422    /// Add an extra composer package or PHP extension to an
1423    /// installed tool. Re-resolves the tool's lock and updates the
1424    /// vendor tree in place.
1425    Inject {
1426        /// Composer package identifier of the tool.
1427        package: String,
1428        /// Extra to add (`vendor/name[@<constraint>]` for composer
1429        /// packages, bare name for PHP extensions). Repeatable.
1430        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1431        with: Vec<String>,
1432    },
1433    /// Remove an extra previously added via `--with` / `inject`.
1434    Uninject {
1435        /// Composer package identifier of the tool.
1436        package: String,
1437        /// Extra to remove. Repeatable.
1438        #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1439        with: Vec<String>,
1440    },
1441    /// List installed tools.
1442    List,
1443    /// Print a tool's install directory, or the tools root if no
1444    /// package is given.
1445    Dir {
1446        /// Composer package identifier; omit to print the tools root.
1447        package: Option<String>,
1448    },
1449    /// Run an installed-or-cached tool one-off. Reuses an existing
1450    /// persistent install if `(package, constraint, php, with)` match
1451    /// exactly; otherwise materialises into the ephemeral cache.
1452    ///
1453    /// `bgx` is provided as a convenient alias for `bougie tool run`;
1454    /// their behavior is identical.
1455    #[command(
1456        override_usage = "bougie tool run [OPTIONS] <PACKAGE> [ARGS]...",
1457        after_help = "Use `bgx` as a shortcut for `bougie tool run`.\n\n\
1458                      Use `bougie help tool run` for more details.",
1459        after_long_help = ""
1460    )]
1461    Run(ToolRunArgs),
1462    // Hidden alias for `bougie tool run` for the `bgx` command. The
1463    // variant is reached only via the `bgx` binary exec'ing into it;
1464    // it doesn't surface under `bougie tool --help`. Carrying it as
1465    // a separate variant (with `display_name`, `override_usage`)
1466    // lets clap render `bgx --help` and clap-level error messages
1467    // with `bgx` as the program name rather than leaking
1468    // `bougie tool run`.
1469    #[command(
1470        hide = true,
1471        override_usage = "bgx [OPTIONS] <PACKAGE> [ARGS]...",
1472        about = "Run a tool from a Composer package.",
1473        long_about = None,
1474        after_help = "Use `bougie help tool run` for more details.",
1475        after_long_help = "",
1476        display_name = "bgx",
1477        // `bgx --version` / `bgx -V` exec into `bougie tool bgx`; give
1478        // this variant its own version flag so it short-circuits before
1479        // the required `<PACKAGE>` positional and prints `bgx <version>`.
1480        version = LONG_VERSION
1481    )]
1482    Bgx(BgxArgs),
1483    /// Re-resolve a tool's lock and bring its vendor tree up to date.
1484    /// Pass `--all` to walk every installed tool, or `--reinstall` to
1485    /// wipe and rebuild from scratch (recovery for broken state).
1486    Upgrade {
1487        /// Composer package identifier. Required unless `--all`.
1488        #[arg(required_unless_present = "all", conflicts_with = "all")]
1489        package: Option<String>,
1490        /// Upgrade every installed tool.
1491        #[arg(long)]
1492        all: bool,
1493        /// Wipe the tool dir + every entrypoint symlink and reinstall
1494        /// from scratch using the receipt's pinned `(package,
1495        /// constraint, php_version, with, extensions)` tuple.
1496        #[arg(long)]
1497        reinstall: bool,
1498    },
1499}
1500
1501#[derive(Subcommand, Debug)]
1502pub enum SelfCommand {
1503    /// Update bougie.
1504    Update {
1505        /// Update even when bougie can't confirm it installed this
1506        /// binary. By default `self update` only touches a binary that
1507        /// bougie's own installer placed (per the install receipt);
1508        /// copies from a package manager, cargo, or nix are left for
1509        /// that tool to update. Pass `--force` only if you know this
1510        /// copy came from bougie's installer.
1511        #[arg(long)]
1512        force: bool,
1513    },
1514    /// Show bougie's version.
1515    Version {
1516        /// Only show the version.
1517        #[arg(long)]
1518        short: bool,
1519    },
1520}
1521
1522#[derive(Args, Debug)]
1523pub struct ToolRunArgs {
1524    /// Pin the tool to a specific PHP for this run.
1525    #[arg(long, value_name = "VER")]
1526    pub php: Option<String>,
1527    /// Extra composer package or PHP extension, same shape as
1528    /// `tool install --with`. Repeatable.
1529    #[arg(long, value_name = "PKG_OR_EXT")]
1530    pub with: Vec<String>,
1531    /// The tool's Composer package (optionally `@<constraint>`) followed
1532    /// by the arguments to forward to it. bougie's own options must come
1533    /// *before* the package; everything from the package onward is passed
1534    /// to the tool verbatim, so no `--` separator is needed.
1535    #[arg(
1536        trailing_var_arg = true,
1537        allow_hyphen_values = true,
1538        required = true,
1539        value_name = "PACKAGE"
1540    )]
1541    pub command: Vec<std::ffi::OsString>,
1542}
1543
1544/// Args for the hidden `bgx` alias. Wraps [`ToolRunArgs`] verbatim so
1545/// the two variants share their entire surface; the wrapper exists
1546/// only so clap renders help / errors with `bgx` as the program name.
1547#[derive(Args, Debug)]
1548pub struct BgxArgs {
1549    #[command(flatten)]
1550    pub tool_run: ToolRunArgs,
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556    use clap::Parser;
1557
1558    fn cmd(argv: &[&str]) -> Command {
1559        Cli::try_parse_from(argv).expect("parse").command
1560    }
1561
1562    #[test]
1563    fn start_is_its_own_verb() {
1564        assert!(matches!(cmd(&["bougie", "start"]), Command::Start { .. }));
1565        assert!(matches!(
1566            cmd(&["bougie", "start", "--no-sync", "--dry-run"]),
1567            Command::Start { no_sync: true, dry_run: true, .. }
1568        ));
1569    }
1570
1571    #[test]
1572    fn stop_takes_names_and_purge() {
1573        let Command::Stop { names, purge } = cmd(&["bougie", "stop", "redis", "--purge"]) else {
1574            panic!("expected stop");
1575        };
1576        assert_eq!(names, ["redis"]);
1577        assert!(purge);
1578    }
1579
1580    #[test]
1581    fn up_down_live_under_services() {
1582        assert!(matches!(
1583            cmd(&["bougie", "services", "up", "redis", "-d"]),
1584            Command::Services(ServicesCommand::Up { detach: true, .. })
1585        ));
1586        assert!(matches!(
1587            cmd(&["bougie", "services", "down", "--purge"]),
1588            Command::Services(ServicesCommand::Down { purge: true, .. })
1589        ));
1590    }
1591
1592    #[test]
1593    fn top_level_up_down_are_gone() {
1594        // The deprecated top-level aliases were removed; `up`/`down` only
1595        // exist under `services` now.
1596        assert!(Cli::try_parse_from(["bougie", "up"]).is_err());
1597        assert!(Cli::try_parse_from(["bougie", "down"]).is_err());
1598    }
1599
1600    #[test]
1601    fn server_detach_flag() {
1602        for argv in [
1603            &["bougie", "server", "-d"][..],
1604            &["bougie", "server", "--detach"][..],
1605        ] {
1606            let Command::Server(args) = cmd(argv) else {
1607                panic!("expected server for {argv:?}");
1608            };
1609            assert!(args.serve.detach, "detach should be set for {argv:?}");
1610        }
1611        // The old `--no-attach` spelling is gone.
1612        assert!(Cli::try_parse_from(["bougie", "server", "--no-attach"]).is_err());
1613    }
1614
1615    #[test]
1616    fn make_no_longer_aliases_start() {
1617        // `start` is no longer a clap alias of `make`; it's the
1618        // first-class verb above. `bougie make start` is just `make`
1619        // with the literal task `start`.
1620        let Command::Make { task, .. } = cmd(&["bougie", "make", "start"]) else {
1621            panic!("expected make");
1622        };
1623        assert_eq!(task.as_deref(), Some("start"));
1624
1625        // Bare `bougie make` parses with no task; the dispatcher turns
1626        // that into a task listing.
1627        let Command::Make { task, .. } = cmd(&["bougie", "make"]) else {
1628            panic!("expected make");
1629        };
1630        assert_eq!(task, None);
1631    }
1632}