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 /// Author a patch from hand-edited `vendor/` files: diff an installed
770 /// package against its originally-installed (pristine) contents and write
771 /// a clean patch into the `patches/` directory, where bougie
772 /// auto-discovers and re-applies it on the next `sync`
773 Create {
774 /// The installed package to diff (`vendor/package`)
775 #[arg(value_name = "VENDOR/PACKAGE")]
776 package: String,
777 /// Where to write the patch (default:
778 /// `<patches-dir>/<vendor>-<pkg>.patch`)
779 #[arg(long, value_name = "PATH")]
780 output: Option<String>,
781 /// Print the diff to stdout instead of writing a file
782 #[arg(long)]
783 stdout: bool,
784 },
785 /// Show the resolved patch set, plus any unadopted dependency-declared
786 /// patches (which bougie never applies automatically)
787 List,
788 /// Adopt dependency-declared patches into the root `composer.json`
789 /// (the only way a dependency's patches ever apply)
790 Import {
791 /// Dependencies to import from (default: all that declare patches)
792 packages: Vec<String>,
793 /// Import from every dependency that declares patches
794 #[arg(long)]
795 all: bool,
796 /// Write into the external patches file rather than `extra.patches`
797 #[arg(long = "to-file")]
798 to_file: bool,
799 },
800 /// Force a clean re-extract + re-apply for the named packages (or all):
801 /// drops their recorded fingerprints, then syncs
802 Repatch {
803 /// Packages to repatch (default: all patched packages)
804 packages: Vec<String>,
805 },
806 /// Rebuild `patches.lock.json` from current config: re-download remote
807 /// patches and re-apply everything from pristine
808 Relock,
809 /// Diagnose patch configuration: unresolvable `patches/` files,
810 /// `http://` URLs, missing checksums, unadopted dependency patches
811 Doctor,
812}
813
814#[derive(Subcommand, Debug)]
815pub enum PhpCommand {
816 /// Install a new PHP version
817 Install {
818 /// The PHP version(s) to install (e.g. `8.3`, `8.3.12`, `8.3+zts`)
819 requests: Vec<String>,
820 /// Build flavor to install [possible values: nts, nts-debug, zts, zts-debug]
821 #[arg(long)]
822 flavor: Option<String>,
823 /// Skip the entire baseline extension set; install only the bare
824 /// Debian-aligned interpreter
825 #[arg(long, conflicts_with = "without")]
826 bare: bool,
827 /// Skip a specific baseline extension. Repeatable: `--without opcache
828 /// --without readline`. The named extensions must already be in the
829 /// baseline set; use `bougie ext remove` after install for anything else
830 #[arg(long, value_name = "EXT", action = clap::ArgAction::Append)]
831 without: Vec<String>,
832 },
833 /// Remove a PHP version
834 Uninstall {
835 /// The PHP version(s) to uninstall
836 #[arg(required = true)]
837 requests: Vec<String>,
838 /// Build flavor to uninstall [possible values: nts, nts-debug, zts, zts-debug]
839 #[arg(long)]
840 flavor: Option<String>,
841 },
842 /// List available PHP interpreters
843 List {
844 /// A PHP request to filter by
845 request: Option<String>,
846 /// Only show installed PHP versions
847 #[arg(long)]
848 only_installed: bool,
849 /// Only show PHP versions available for download
850 #[arg(long)]
851 only_available: bool,
852 /// List all PHP versions, including older patch versions
853 #[arg(long)]
854 all_versions: bool,
855 /// List PHP downloads for all platforms
856 #[arg(long)]
857 all_platforms: bool,
858 /// List PHP downloads for all architectures
859 #[arg(long)]
860 all_arches: bool,
861 /// Show the URLs of available PHP downloads
862 #[arg(long)]
863 show_urls: bool,
864 },
865 /// Search for a PHP interpreter
866 Find {
867 /// A PHP request to search for
868 request: Option<String>,
869 },
870 /// Pin the project's PHP version
871 Pin {
872 /// The PHP version to pin
873 request: String,
874 /// Write the pin to `bougie.toml` (creating it if needed)
875 #[arg(long, conflicts_with = "composer")]
876 toml: bool,
877 /// Write the pin to `composer.json`'s `require.php`
878 #[arg(long, conflicts_with = "toml")]
879 composer: bool,
880 },
881 /// Refresh installed interpreters to the latest published patch
882 Upgrade {
883 /// The PHP minor version(s) to upgrade (e.g. `8.3`)
884 minor: Option<String>,
885 },
886 /// Show the PHP interpreter installation directory
887 Dir,
888}
889
890#[derive(Subcommand, Debug)]
891pub enum NodeCommand {
892 /// Install a Node.js version from nodejs.org
893 Install {
894 /// The Node version(s) to install (e.g. `latest`, `lts`, `20`,
895 /// `20.11`, `20.11.0`). Defaults to `latest`
896 requests: Vec<String>,
897 },
898 /// Remove an installed Node.js version
899 Uninstall {
900 /// The Node version(s) to uninstall (exact `20.11.0`)
901 #[arg(required = true)]
902 requests: Vec<String>,
903 },
904 /// List installed Node.js versions
905 List,
906 /// Resolve a request and show the version + download URL it maps to,
907 /// without installing
908 Find {
909 /// A Node request to resolve (e.g. `lts`, `20`). Defaults to `latest`
910 request: Option<String>,
911 },
912 /// Show the Node.js installation directory
913 Dir,
914}
915
916#[derive(Subcommand, Debug)]
917pub enum ComposerCommand {
918 /// Install `vendor/` from `composer.lock`
919 ///
920 /// Reads `composer.json` + `composer.lock` in the working directory,
921 /// content-hash-verifies the lock, parallel-downloads dists into
922 /// `vendor/`, and emits `vendor/autoload.php`
923 Install {
924 /// Run the install in this directory instead of CWD
925 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
926 working_dir: Option<std::path::PathBuf>,
927 /// Skip dev-only packages and dev autoload entries
928 #[arg(long = "no-dev")]
929 no_dev: bool,
930 /// Fail if composer.lock is out of sync with composer.json.
931 /// Currently a no-op — the install already errors on
932 /// content-hash mismatch by default
933 #[arg(long = "frozen")]
934 frozen: bool,
935 /// Verify the lock is internally consistent (content-hash,
936 /// requires, transitives) and exit. Doesn't touch `vendor/`
937 /// or run the autoloader. CI-friendly read-only check
938 #[arg(long = "lock-verify")]
939 lock_verify: bool,
940 /// Ignore all platform requirements (php, ext-*, lib-*). bougie
941 /// does not enforce platform requirements yet
942 #[arg(long = "ignore-platform-reqs")]
943 ignore_platform_reqs: bool,
944 /// Ignore a specific platform requirement
945 #[arg(long = "ignore-platform-req", value_name = "REQ")]
946 ignore_platform_req: Vec<String>,
947 /// Run composer.json root scripts, overriding `[scripts] run`
948 /// in bougie.toml. Off by default (opt-in)
949 #[arg(long, conflicts_with = "no_scripts")]
950 scripts: bool,
951 /// Skip composer.json root scripts, overriding `[scripts] run
952 /// = true` in bougie.toml
953 #[arg(long = "no-scripts")]
954 no_scripts: bool,
955 /// Apply patches, overriding `[patches] enable`. On by default
956 /// when patches are declared
957 #[arg(long, conflicts_with = "no_patches")]
958 patches: bool,
959 /// Skip native patch application for this install
960 #[arg(long = "no-patches")]
961 no_patches: bool,
962 },
963 /// Update dependencies and `composer.lock`
964 ///
965 /// Re-resolve the dependency graph, write a fresh `composer.lock`,
966 /// and install the result into `vendor/`. With no packages the whole
967 /// graph re-resolves; naming packages does a partial update, leaving
968 /// every other locked package pinned. `--no-install` stops after
969 /// writing the lock; `--dry-run` previews without writing. Aliased to
970 /// `upgrade` / `u`
971 #[command(visible_alias = "upgrade", alias = "u")]
972 Update {
973 /// Packages to update (`vendor/name`). When given, only these
974 /// packages re-resolve; every other package stays pinned to its
975 /// `composer.lock` version. With no packages, the whole graph
976 /// re-resolves from scratch
977 #[arg(value_name = "PACKAGES")]
978 packages: Vec<String>,
979 /// Write the lock but don't install into `vendor/`
980 #[arg(long = "no-install")]
981 no_install: bool,
982 /// Also update the named packages' dependencies (`-w`)
983 #[arg(short = 'w', long = "with-dependencies")]
984 with_dependencies: bool,
985 /// Also update all of the named packages' dependencies, including
986 /// ones shared with other packages (`-W`)
987 #[arg(short = 'W', long = "with-all-dependencies")]
988 with_all_dependencies: bool,
989 /// Run the update in this directory instead of CWD
990 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
991 working_dir: Option<std::path::PathBuf>,
992 /// Skip dev-only root requires when resolving
993 #[arg(long = "no-dev")]
994 no_dev: bool,
995 /// Version-preference policy when resolving
996 #[arg(long = "resolution", value_name = "STRATEGY", default_value = "highest")]
997 resolution: ResolutionStrategy,
998 /// Prefer the lowest matching versions. Equivalent to
999 /// `--resolution lowest`; when set it overrides `--resolution`
1000 #[arg(long = "prefer-lowest")]
1001 prefer_lowest: bool,
1002 /// Resolve and print the solution without writing
1003 /// `composer.lock` or touching `vendor/`. Without this flag,
1004 /// `update` writes a fresh `composer.lock`
1005 #[arg(long = "dry-run")]
1006 dry_run: bool,
1007 /// Ignore all platform requirements (php, ext-*, lib-*). bougie
1008 /// does not enforce platform requirements yet
1009 #[arg(long = "ignore-platform-reqs")]
1010 ignore_platform_reqs: bool,
1011 /// Ignore a specific platform requirement
1012 #[arg(long = "ignore-platform-req", value_name = "REQ")]
1013 ignore_platform_req: Vec<String>,
1014 },
1015 /// Validate composer.json structure and contents
1016 Validate {
1017 /// Run in this directory instead of CWD
1018 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1019 working_dir: Option<std::path::PathBuf>,
1020 /// Return non-zero exit code for warnings too
1021 #[arg(long)]
1022 strict: bool,
1023 /// Skip lock file freshness check
1024 #[arg(long = "no-check-lock")]
1025 no_check_lock: bool,
1026 /// Skip publish-only checks (name casing, required fields)
1027 #[arg(long = "no-check-publish")]
1028 no_check_publish: bool,
1029 /// Skip unbound/exact version constraint warnings
1030 #[arg(long = "no-check-all")]
1031 no_check_all: bool,
1032 /// Also validate installed dependencies' composer.json files
1033 #[arg(long = "with-dependencies")]
1034 with_dependencies: bool,
1035 /// Force lock file checking even when `config.lock` is false
1036 #[arg(long = "check-lock")]
1037 check_lock: bool,
1038 },
1039 /// Regenerate the autoloader files
1040 ///
1041 /// Regenerate `vendor/composer/autoload_*.php` against the current
1042 /// `composer.lock`. Aliased to `dump-autoload`
1043 #[command(alias = "dump-autoload")]
1044 DumpAutoloader {
1045 /// Optimize the classmap (`--optimize` / `-o`)
1046 #[arg(short = 'o', long = "optimize", alias = "optimize-autoloader")]
1047 optimize: bool,
1048 /// Emit the classmap-authoritative static loader
1049 /// (`--classmap-authoritative` / `-a`). Implies `--optimize`
1050 #[arg(short = 'a', long = "classmap-authoritative")]
1051 classmap_authoritative: bool,
1052 /// Skip dev autoload entries (`--no-dev`)
1053 #[arg(long = "no-dev")]
1054 no_dev: bool,
1055 /// Emit the `APCu` loader bootstrap (`--apcu-autoloader`)
1056 #[arg(long = "apcu-autoloader")]
1057 apcu_autoloader: bool,
1058 /// Explicit `APCu` prefix; implies `--apcu-autoloader`
1059 #[arg(long = "apcu-autoloader-prefix", value_name = "PREFIX")]
1060 apcu_prefix: Option<String>,
1061 /// Override the `ComposerAutoloaderInit<X>` class suffix —
1062 /// otherwise the value from `composer.json`'s
1063 /// `config.autoloader-suffix`, or the `composer.lock`
1064 /// content-hash
1065 #[arg(long = "autoloader-suffix", value_name = "SUFFIX")]
1066 autoloader_suffix: Option<String>,
1067 /// Run the dump in this directory instead of the current one
1068 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1069 working_dir: Option<std::path::PathBuf>,
1070 },
1071 /// Add packages to `composer.json` and install them
1072 ///
1073 /// Add one or more packages to `composer.json` `require` (or
1074 /// `require-dev`), re-resolve `composer.lock`, and install them. A
1075 /// bare `vendor/pkg` resolves the latest stable and writes a caret
1076 /// (`^X.Y`) constraint; set an explicit constraint with
1077 /// `vendor/pkg:^1.0`, `vendor/pkg=^1.0`, or a trailing argument
1078 /// (`vendor/pkg ^1.0`)
1079 Require {
1080 /// Packages to require (`vendor/pkg` or `vendor/pkg:<constraint>`)
1081 #[arg(value_name = "PACKAGES", required = true)]
1082 packages: Vec<String>,
1083 /// Add to `require-dev` instead of `require`
1084 #[arg(long = "dev")]
1085 dev: bool,
1086 /// Edit `composer.json` only — don't re-resolve `composer.lock`
1087 /// or touch `vendor/`
1088 #[arg(long = "no-update")]
1089 no_update: bool,
1090 /// Re-resolve and write `composer.lock` but don't install into
1091 /// `vendor/`
1092 #[arg(long = "no-install")]
1093 no_install: bool,
1094 /// Also update the new packages' dependencies (`-w`)
1095 #[arg(short = 'w', long = "with-dependencies")]
1096 with_dependencies: bool,
1097 /// Also update all dependencies, including shared ones (`-W`)
1098 #[arg(short = 'W', long = "with-all-dependencies")]
1099 with_all_dependencies: bool,
1100 /// Prefer the lowest matching versions when resolving
1101 #[arg(long = "prefer-lowest")]
1102 prefer_lowest: bool,
1103 /// Ignore all platform requirements (php, ext-*, lib-*)
1104 #[arg(long = "ignore-platform-reqs")]
1105 ignore_platform_reqs: bool,
1106 /// Ignore a specific platform requirement
1107 #[arg(long = "ignore-platform-req", value_name = "REQ")]
1108 ignore_platform_req: Vec<String>,
1109 /// Run in this directory instead of CWD (`-d`)
1110 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1111 working_dir: Option<std::path::PathBuf>,
1112 /// Resolve and report what would change without writing
1113 /// `composer.json`, `composer.lock`, or `vendor/`
1114 #[arg(long = "dry-run")]
1115 dry_run: bool,
1116 },
1117 /// Remove packages and uninstall them from `vendor/`
1118 ///
1119 /// Remove one or more packages from `composer.json`, re-resolve
1120 /// `composer.lock`, and uninstall them from `vendor/`
1121 Remove {
1122 /// Packages to remove (`vendor/name`)
1123 #[arg(value_name = "PACKAGES", required = true)]
1124 packages: Vec<String>,
1125 /// Remove from `require-dev` instead of `require`
1126 #[arg(long = "dev")]
1127 dev: bool,
1128 /// Edit `composer.json` only — don't re-resolve or touch
1129 /// `vendor/`
1130 #[arg(long = "no-update")]
1131 no_update: bool,
1132 /// Re-resolve and write `composer.lock` but don't touch
1133 /// `vendor/`
1134 #[arg(long = "no-install")]
1135 no_install: bool,
1136 /// Skip dev-only packages when resolving
1137 #[arg(long = "no-dev")]
1138 no_dev: bool,
1139 /// Ignore all platform requirements (php, ext-*, lib-*)
1140 #[arg(long = "ignore-platform-reqs")]
1141 ignore_platform_reqs: bool,
1142 /// Ignore a specific platform requirement
1143 #[arg(long = "ignore-platform-req", value_name = "REQ")]
1144 ignore_platform_req: Vec<String>,
1145 /// Run in this directory instead of CWD (`-d`)
1146 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1147 working_dir: Option<std::path::PathBuf>,
1148 /// Resolve and report what would change without writing
1149 /// `composer.json`, `composer.lock`, or `vendor/`
1150 #[arg(long = "dry-run")]
1151 dry_run: bool,
1152 },
1153 /// List installed packages, or show details for one
1154 ///
1155 /// Reads the project's `composer.lock`. Aliases `info`, `list`
1156 #[command(alias = "info", alias = "list")]
1157 Show {
1158 /// A single `vendor/name` to show details for. With no argument,
1159 /// every installed package is listed
1160 #[arg(value_name = "PACKAGE")]
1161 package: Option<String>,
1162 /// Render the dependency tree (`--tree` / `-t`)
1163 #[arg(short = 't', long = "tree")]
1164 tree: bool,
1165 /// Only the project's direct dependencies (`--direct` / `-D`)
1166 #[arg(short = 'D', long = "direct")]
1167 direct: bool,
1168 /// Only platform packages — php, ext-*, lib-* (`--platform` / `-p`)
1169 #[arg(short = 'p', long = "platform")]
1170 platform: bool,
1171 /// Show the root package's own info (`--self` / `-s`)
1172 #[arg(short = 's', long = "self")]
1173 self_: bool,
1174 /// Print package names only (`--name-only` / `-N`)
1175 #[arg(short = 'N', long = "name-only")]
1176 name_only: bool,
1177 /// Show each package's install path (`--path` / `-P`)
1178 #[arg(short = 'P', long = "path")]
1179 path: bool,
1180 /// Also fetch and show the latest available version
1181 /// (`--latest` / `-l`)
1182 #[arg(short = 'l', long = "latest")]
1183 latest: bool,
1184 /// Only packages with a newer version available
1185 /// (`--outdated` / `-o`). Implies `--latest`
1186 #[arg(short = 'o', long = "outdated")]
1187 outdated: bool,
1188 /// Skip dev dependencies (`--no-dev`)
1189 #[arg(long = "no-dev")]
1190 no_dev: bool,
1191 /// Run in this directory instead of CWD (`-d`)
1192 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1193 working_dir: Option<std::path::PathBuf>,
1194 },
1195 /// Show which packages depend on a given package
1196 ///
1197 /// Shows why a package is installed. Alias `depends`
1198 #[command(alias = "depends")]
1199 Why {
1200 /// The package to explain
1201 #[arg(value_name = "PACKAGE", required = true)]
1202 package: String,
1203 /// Recurse through the dependency chain (`--recursive` / `-r`)
1204 #[arg(short = 'r', long = "recursive")]
1205 recursive: bool,
1206 /// Render the full dependency-of tree (`--tree` / `-t`)
1207 #[arg(short = 't', long = "tree")]
1208 tree: bool,
1209 /// Run in this directory instead of CWD (`-d`)
1210 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1211 working_dir: Option<std::path::PathBuf>,
1212 },
1213 /// Show what prevents a package from being installed
1214 ///
1215 /// Reports the conflicting requirements for a package, optionally at
1216 /// a given version. Alias `prohibits`
1217 #[command(name = "why-not", alias = "prohibits")]
1218 WhyNot {
1219 /// The package to test
1220 #[arg(value_name = "PACKAGE", required = true)]
1221 package: String,
1222 /// The version (or constraint) to test against. Defaults to `*`
1223 #[arg(value_name = "VERSION")]
1224 version: Option<String>,
1225 /// Recurse through the dependency chain (`--recursive` / `-r`)
1226 #[arg(short = 'r', long = "recursive")]
1227 recursive: bool,
1228 /// Render the full tree (`--tree` / `-t`)
1229 #[arg(short = 't', long = "tree")]
1230 tree: bool,
1231 /// Run in this directory instead of CWD (`-d`)
1232 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1233 working_dir: Option<std::path::PathBuf>,
1234 },
1235 /// List installed packages with a newer version available
1236 ///
1237 /// Use the global `--format json` for JSON output
1238 Outdated {
1239 /// Optional `vendor/name` filters; with none, all packages are
1240 /// considered
1241 #[arg(value_name = "PACKAGES")]
1242 packages: Vec<String>,
1243 /// Only the project's direct dependencies (`--direct` / `-D`)
1244 #[arg(short = 'D', long = "direct")]
1245 direct: bool,
1246 /// Only show packages with a new major version (`--major-only`)
1247 #[arg(long = "major-only")]
1248 major_only: bool,
1249 /// Only show packages with a new minor version (`--minor-only`)
1250 #[arg(long = "minor-only")]
1251 minor_only: bool,
1252 /// Only show packages with a new patch version (`--patch-only`)
1253 #[arg(long = "patch-only")]
1254 patch_only: bool,
1255 /// Skip dev dependencies (`--no-dev`)
1256 #[arg(long = "no-dev")]
1257 no_dev: bool,
1258 /// Exit non-zero if any package is outdated (`--strict`)
1259 #[arg(long = "strict")]
1260 strict: bool,
1261 /// Run in this directory instead of CWD (`-d`)
1262 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1263 working_dir: Option<std::path::PathBuf>,
1264 },
1265 /// Check installed packages for security advisories
1266 ///
1267 /// Checks against the Packagist security-advisories database. Exits
1268 /// non-zero when advisories are found. Use the global `--format json`
1269 /// for JSON
1270 Audit {
1271 /// Skip dev dependencies (`--no-dev`)
1272 #[arg(long = "no-dev")]
1273 no_dev: bool,
1274 /// How to treat abandoned packages. Detection is not yet wired
1275 /// up
1276 #[arg(long = "abandoned", value_enum, default_value = "report")]
1277 abandoned: AbandonedHandling,
1278 /// Audit the locked set. bougie always reads `composer.lock`, so
1279 /// this is the default
1280 #[arg(long = "locked")]
1281 locked: bool,
1282 /// Run in this directory instead of CWD (`-d`)
1283 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1284 working_dir: Option<std::path::PathBuf>,
1285 },
1286 /// List the license of every installed package
1287 ///
1288 /// Use the global `--format json` for JSON
1289 Licenses {
1290 /// Skip dev dependencies (`--no-dev`)
1291 #[arg(long = "no-dev")]
1292 no_dev: bool,
1293 /// Run in this directory instead of CWD (`-d`)
1294 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1295 working_dir: Option<std::path::PathBuf>,
1296 },
1297 /// Report packages that look locally modified
1298 ///
1299 /// bougie installs from dist archives, so for the common case this
1300 /// reports "no local changes"
1301 Status {
1302 /// Run in this directory instead of CWD (`-d`)
1303 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1304 working_dir: Option<std::path::PathBuf>,
1305 },
1306 /// Show funding information for installed packages
1307 ///
1308 /// Grouped by vendor. Use `--format json` for JSON
1309 Fund {
1310 /// Skip dev dependencies (`--no-dev`)
1311 #[arg(long = "no-dev")]
1312 no_dev: bool,
1313 /// Run in this directory instead of CWD (`-d`)
1314 #[arg(short = 'd', long = "working-dir", value_name = "DIR")]
1315 working_dir: Option<std::path::PathBuf>,
1316 },
1317 /// Catch-all for any composer subcommand bougie does not implement
1318 /// natively (`create-project`, `archive`, `bump`, `global`, …). These
1319 /// return an error pointing at `bougie tool install composer/composer`
1320 #[command(external_subcommand)]
1321 External(Vec<OsString>),
1322}
1323
1324/// How `composer audit` treats abandoned packages.
1325#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
1326pub enum AbandonedHandling {
1327 /// Ignore abandoned packages entirely
1328 Ignore,
1329 /// Report abandoned packages but don't fail on them
1330 Report,
1331 /// Treat abandoned packages as an audit failure
1332 Fail,
1333}
1334
1335#[derive(Subcommand, Debug)]
1336pub enum CacheCommand {
1337 /// Wipe the full cache
1338 Clean,
1339 /// Remove unneeded library files
1340 Prune {
1341 /// Show what would be pruned without removing anything
1342 #[arg(long)]
1343 dry_run: bool,
1344 /// Also remove tracked projects that no longer exist on disk
1345 #[arg(long)]
1346 prune_projects: bool,
1347 },
1348 /// Show the location of the cache directory
1349 Dir,
1350 /// Show the cache size
1351 Size,
1352}
1353
1354#[derive(Subcommand, Debug)]
1355pub enum ToolCommand {
1356 /// Install a tool. Pass `<vendor>/<name>` optionally followed by
1357 /// `@<constraint>` (e.g. `phpstan/phpstan@^1.10`)
1358 Install {
1359 /// Composer package identifier, optionally with `@<constraint>`
1360 package: String,
1361 /// Pin the tool to a specific PHP. Accepts a version (`8.3`,
1362 /// `8.3.12`) or a constraint (`~8.3`, `>=8.2,<8.4`). When the
1363 /// requested PHP isn't installed, bougie installs it
1364 /// automatically. Defaults to the highest installed NTS PHP
1365 #[arg(long, value_name = "VER")]
1366 php: Option<String>,
1367 /// Additional Composer package (`vendor/name[@<constraint>]`)
1368 /// or PHP extension (`intl`, `redis`) to install alongside the
1369 /// tool. May be passed multiple times
1370 #[arg(long, value_name = "PKG_OR_EXT")]
1371 with: Vec<String>,
1372 /// Overwrite an existing executable at the bin-dir path
1373 #[arg(long)]
1374 force: bool,
1375 },
1376 /// Remove an installed tool by its `<vendor>/<name>` identifier
1377 Uninstall {
1378 /// Composer package identifier
1379 package: String,
1380 },
1381 /// Add an extra composer package or PHP extension to an
1382 /// installed tool. Re-resolves the tool's lock and updates the
1383 /// vendor tree in place
1384 Inject {
1385 /// Composer package identifier of the tool
1386 package: String,
1387 /// Extra to add (`vendor/name[@<constraint>]` for composer
1388 /// packages, bare name for PHP extensions). Repeatable
1389 #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1390 with: Vec<String>,
1391 },
1392 /// Remove an extra previously added via `--with` / `inject`
1393 Uninject {
1394 /// Composer package identifier of the tool
1395 package: String,
1396 /// Extra to remove. Repeatable
1397 #[arg(long, value_name = "PKG_OR_EXT", required = true)]
1398 with: Vec<String>,
1399 },
1400 /// List installed tools
1401 List,
1402 /// Print a tool's install directory, or the tools root if no
1403 /// package is given
1404 Dir {
1405 /// Composer package identifier; omit to print the tools root
1406 package: Option<String>,
1407 },
1408 /// Run an installed-or-cached tool one-off. Reuses an existing
1409 /// persistent install if `(package, constraint, php, with)` match
1410 /// exactly; otherwise materialises into the ephemeral cache.
1411 ///
1412 /// `bgx` is provided as a convenient alias for `bougie tool run`;
1413 /// their behavior is identical
1414 #[command(
1415 override_usage = "bougie tool run [OPTIONS] <PACKAGE> [ARGS]...",
1416 after_help = "Use `bgx` as a shortcut for `bougie tool run`.\n\n\
1417 Use `bougie help tool run` for more details",
1418 after_long_help = ""
1419 )]
1420 Run(ToolRunArgs),
1421 // Hidden alias for `bougie tool run` for the `bgx` command. The
1422 // variant is reached only via the `bgx` binary exec'ing into it;
1423 // it doesn't surface under `bougie tool --help`. Carrying it as
1424 // a separate variant (with `display_name`, `override_usage`)
1425 // lets clap render `bgx --help` and clap-level error messages
1426 // with `bgx` as the program name rather than leaking
1427 // `bougie tool run`.
1428 #[command(
1429 hide = true,
1430 override_usage = "bgx [OPTIONS] <PACKAGE> [ARGS]...",
1431 about = "Run a tool from a PHP package",
1432 long_about = None,
1433 after_help = "Use `bougie help tool run` for more details",
1434 after_long_help = "",
1435 display_name = "bgx",
1436 // `bgx --version` / `bgx -V` exec into `bougie tool bgx`; give
1437 // this variant its own version flag so it short-circuits before
1438 // the required `<PACKAGE>` positional and prints `bgx <version>`.
1439 version = LONG_VERSION
1440 )]
1441 Bgx(BgxArgs),
1442 /// Re-resolve a tool's lock and bring its vendor tree up to date.
1443 /// Pass `--all` to walk every installed tool, or `--reinstall` to
1444 /// wipe and rebuild from scratch (recovery for broken state)
1445 Upgrade {
1446 /// Composer package identifier. Required unless `--all`
1447 #[arg(required_unless_present = "all", conflicts_with = "all")]
1448 package: Option<String>,
1449 /// Upgrade every installed tool
1450 #[arg(long)]
1451 all: bool,
1452 /// Wipe the tool dir + every entrypoint symlink and reinstall
1453 /// from scratch using the receipt's pinned `(package,
1454 /// constraint, php_version, with, extensions)` tuple
1455 #[arg(long)]
1456 reinstall: bool,
1457 },
1458}
1459
1460#[derive(Subcommand, Debug)]
1461pub enum SelfCommand {
1462 /// Update bougie
1463 Update {
1464 /// Update even when bougie can't confirm it installed this
1465 /// binary. By default `self update` only touches a binary that
1466 /// bougie's own installer placed (per the install receipt);
1467 /// copies from a package manager, cargo, or nix are left for
1468 /// that tool to update. Pass `--force` only if you know this
1469 /// copy came from bougie's installer
1470 #[arg(long)]
1471 force: bool,
1472 },
1473 /// Show bougie's version
1474 Version {
1475 /// Only show the version
1476 #[arg(long)]
1477 short: bool,
1478 },
1479}
1480
1481#[derive(Args, Debug)]
1482pub struct ToolRunArgs {
1483 /// Pin the tool to a specific PHP for this run
1484 #[arg(long, value_name = "VER")]
1485 pub php: Option<String>,
1486 /// Extra composer package or PHP extension, same shape as
1487 /// `tool install --with`. Repeatable
1488 #[arg(long, value_name = "PKG_OR_EXT")]
1489 pub with: Vec<String>,
1490 /// The tool's Composer package (optionally `@<constraint>`) followed
1491 /// by the arguments to forward to it. bougie's own options must come
1492 /// *before* the package; everything from the package onward is passed
1493 /// to the tool verbatim, so no `--` separator is needed
1494 #[arg(
1495 trailing_var_arg = true,
1496 allow_hyphen_values = true,
1497 required = true,
1498 value_name = "PACKAGE"
1499 )]
1500 pub command: Vec<std::ffi::OsString>,
1501}
1502
1503/// Args for the hidden `bgx` alias. Wraps [`ToolRunArgs`] verbatim so
1504/// the two variants share their entire surface; the wrapper exists
1505/// only so clap renders help / errors with `bgx` as the program name.
1506#[derive(Args, Debug)]
1507pub struct BgxArgs {
1508 #[command(flatten)]
1509 pub tool_run: ToolRunArgs,
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 use super::*;
1515 use clap::Parser;
1516
1517 fn cmd(argv: &[&str]) -> Command {
1518 Cli::try_parse_from(argv).expect("parse").command
1519 }
1520
1521 #[test]
1522 fn start_is_its_own_verb() {
1523 assert!(matches!(cmd(&["bougie", "start"]), Command::Start { .. }));
1524 assert!(matches!(
1525 cmd(&["bougie", "start", "--no-sync", "--dry-run"]),
1526 Command::Start { no_sync: true, dry_run: true, .. }
1527 ));
1528 }
1529
1530 #[test]
1531 fn stop_takes_names_and_purge() {
1532 let Command::Stop { names, purge } = cmd(&["bougie", "stop", "redis", "--purge"]) else {
1533 panic!("expected stop");
1534 };
1535 assert_eq!(names, ["redis"]);
1536 assert!(purge);
1537 }
1538
1539 #[test]
1540 fn up_down_live_under_services() {
1541 assert!(matches!(
1542 cmd(&["bougie", "services", "up", "redis", "-d"]),
1543 Command::Services(ServicesCommand::Up { detach: true, .. })
1544 ));
1545 assert!(matches!(
1546 cmd(&["bougie", "services", "down", "--purge"]),
1547 Command::Services(ServicesCommand::Down { purge: true, .. })
1548 ));
1549 }
1550
1551 #[test]
1552 fn top_level_up_down_are_gone() {
1553 // The deprecated top-level aliases were removed; `up`/`down` only
1554 // exist under `services` now.
1555 assert!(Cli::try_parse_from(["bougie", "up"]).is_err());
1556 assert!(Cli::try_parse_from(["bougie", "down"]).is_err());
1557 }
1558
1559 #[test]
1560 fn server_detach_flag() {
1561 for argv in [
1562 &["bougie", "server", "-d"][..],
1563 &["bougie", "server", "--detach"][..],
1564 ] {
1565 let Command::Server(args) = cmd(argv) else {
1566 panic!("expected server for {argv:?}");
1567 };
1568 assert!(args.serve.detach, "detach should be set for {argv:?}");
1569 }
1570 // The old `--no-attach` spelling is gone.
1571 assert!(Cli::try_parse_from(["bougie", "server", "--no-attach"]).is_err());
1572 }
1573
1574 #[test]
1575 fn make_no_longer_aliases_start() {
1576 // `start` is no longer a clap alias of `make`; it's the
1577 // first-class verb above. `bougie make start` is just `make`
1578 // with the literal task `start`.
1579 let Command::Make { task, .. } = cmd(&["bougie", "make", "start"]) else {
1580 panic!("expected make");
1581 };
1582 assert_eq!(task.as_deref(), Some("start"));
1583
1584 // Bare `bougie make` parses with no task; the dispatcher turns
1585 // that into a task listing.
1586 let Command::Make { task, .. } = cmd(&["bougie", "make"]) else {
1587 panic!("expected make");
1588 };
1589 assert_eq!(task, None);
1590 }
1591}