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