Skip to main content

greentic_deployer/cli/
dispatch.rs

1//! Clap-derive dispatcher for `greentic-deployer op …` (`A3`).
2//!
3//! Owns the `OpCommand` clap tree + the `dispatch_op` entry point. The
4//! actual per-noun command logic lives in `cli::env`/`cli::env_packs`/etc;
5//! this module is the wiring layer that converts argv → typed payloads →
6//! library calls and prints the JSON envelope to stdout.
7
8use std::path::PathBuf;
9
10use clap::{Args, Parser, Subcommand};
11use greentic_deploy_spec::CapabilitySlot;
12
13use crate::environment::LocalFsStore;
14
15use super::{OpError, OpFlags, OpOutcome, render_error};
16
17/// `greentic-deployer op …`. Mirrors the `gtc op …` surface; the gtc-side
18/// passthrough shells out to `greentic-operator op …`, which dispatches
19/// through the same `OpCommand` clap tree.
20#[derive(Parser, Debug)]
21#[command(
22    after_help = "Nouns: env, env-packs, extensions, bundles, revisions, traffic, config, credentials, secrets, messaging.\n\
23                  Every verb honors:\n\
24                    --schema             dump the JSON schema of the payload it would accept, then exit\n\
25                    --answers <PATH>     read the payload from a JSON or YAML file\n\n\
26                  Examples:\n\
27                    greentic-operator op env create --answers env.json\n\
28                    greentic-operator op revisions warm --answers warm.yaml\n\
29                    greentic-operator op env show <env-id>\n\n\
30                  Errors are written to stderr as a JSON envelope:\n\
31                    {\"op\":\"<verb>\",\"noun\":\"<noun>\",\"error\":{\"kind\":\"…\",\"message\":\"…\"}}\n\
32                  Success output goes to stdout as:\n\
33                    {\"op\":\"<verb>\",\"noun\":\"<noun>\",\"result\":…}"
34)]
35pub struct OpCommand {
36    /// Optional root for the local `EnvironmentStore`. Defaults to
37    /// `~/.greentic/environments`.
38    #[arg(long, global = true)]
39    pub store_root: Option<PathBuf>,
40
41    /// Target a remote operator store over HTTP instead of the local FS
42    /// store. Overrides `GREENTIC_STORE_URL`. When set (and not `--schema`),
43    /// mutation verbs run against the remote A8 HTTP store.
44    #[arg(long, global = true)]
45    pub store_url: Option<String>,
46
47    /// Bearer token for the remote `--store-url` store. Overrides
48    /// `GREENTIC_STORE_TOKEN`.
49    #[arg(long, global = true)]
50    pub store_token: Option<String>,
51
52    /// Dump the JSON Schema of the input payload this verb accepts, then
53    /// exit. The library is free to return a hand-written stub until A1's
54    /// `schemars` derive wiring lands.
55    #[arg(long, global = true)]
56    pub schema: bool,
57
58    /// Read the verb's payload from this JSON or YAML file instead of
59    /// reading positionals.
60    #[arg(long, global = true)]
61    pub answers: Option<PathBuf>,
62
63    #[command(subcommand)]
64    pub noun: OpNoun,
65}
66
67#[derive(Subcommand, Debug)]
68pub enum OpNoun {
69    /// Environment CRUD (`create`/`update`/`list`/`show`/`doctor`/`destroy`).
70    Env {
71        #[command(subcommand)]
72        verb: EnvVerb,
73    },
74    /// Env-pack bindings (`add`/`update`/`remove`/`rollback`/`list`).
75    EnvPacks {
76        #[command(subcommand)]
77        verb: EnvPacksVerb,
78    },
79    /// Application bundle deployments.
80    Bundles {
81        #[command(subcommand)]
82        verb: BundlesVerb,
83    },
84    /// Revision lifecycle.
85    Revisions {
86        #[command(subcommand)]
87        verb: RevisionsVerb,
88    },
89    /// Traffic split per deployment.
90    Traffic {
91        #[command(subcommand)]
92        verb: TrafficVerb,
93    },
94    /// One-shot bundle deployment: add (if new) → stage → warm → route 100 %
95    /// traffic, with sensible defaults. Re-deploying an existing bundle stages
96    /// a new revision and blue-green shifts traffic onto it. The
97    /// `bundles`/`revisions`/`traffic` verbs remain for fine-tuning.
98    Deploy(BundleDeployArgs),
99    /// Host/setup/runtime config inspection.
100    Config {
101        #[command(subcommand)]
102        verb: ConfigVerb,
103    },
104    /// Cloud credentials.
105    Credentials {
106        #[command(subcommand)]
107        verb: CredentialsVerb,
108    },
109    /// Secrets management.
110    Secrets {
111        #[command(subcommand)]
112        verb: SecretsVerb,
113    },
114    /// Per-environment trust-root management (C2). Lists, adds, or removes
115    /// `(key_id, public_pem)` pairs verifiers consult to validate DSSE
116    /// envelopes (revenue policy today; bundle/revision signatures next).
117    #[command(name = "trust-root")]
118    TrustRoot {
119        #[command(subcommand)]
120        verb: TrustRootVerb,
121    },
122    /// Per-environment messaging endpoints (`Phase M1`). N-per-env instances
123    /// of messaging providers (e.g. multiple Teams bots), each with its own
124    /// curated bundle set and optional welcome flow.
125    Messaging {
126        #[command(subcommand)]
127        verb: MessagingNoun,
128    },
129    /// Open-namespace extension bindings (`Path 3`). N-per-env capabilities
130    /// resolved by workloads via `ext://<path>[/<instance>]` — config-shaped,
131    /// no typed host interface, no schema bump per family. Contrast
132    /// `env-packs`, which manages the closed, 1-per-slot core `packs`.
133    Extensions {
134        #[command(subcommand)]
135        verb: ExtensionsVerb,
136    },
137    /// Per-environment update-channel enrollment (`P1b`). `enroll` mints a
138    /// client certificate at the Cert-CA and persists it to the env secrets
139    /// backend; `status` reports the stored certificate's serial + validity.
140    Updates {
141        #[command(subcommand)]
142        verb: UpdatesVerb,
143    },
144}
145
146#[derive(Subcommand, Debug)]
147pub enum MessagingNoun {
148    /// Manage a per-environment messaging endpoint (`add`/`list`/`show`/
149    /// `link-bundle`/`unlink-bundle`/`set-welcome-flow`/`remove`).
150    Endpoint {
151        #[command(subcommand)]
152        verb: MessagingEndpointVerb,
153    },
154}
155
156#[derive(Subcommand, Debug)]
157pub enum MessagingEndpointVerb {
158    /// Mint a new endpoint for `<env>` with `<provider_type>` /
159    /// `<provider_id>` instance identity. Bundle linkage and welcome-flow
160    /// follow via dedicated verbs.
161    Add(MessagingEndpointAddArgs),
162    /// List every endpoint in `<env>`.
163    List { env_id: String },
164    /// Show one endpoint in `<env>` by `<endpoint_id>` (ULID).
165    Show { env_id: String, endpoint_id: String },
166    /// Link a bundle to an endpoint. Bundle must already be deployed in the env.
167    #[command(name = "link-bundle")]
168    LinkBundle(MessagingEndpointLinkBundleArgs),
169    /// Remove a bundle from an endpoint's `linked_bundles`. Fails when the
170    /// bundle owns the endpoint's welcome_flow — clear that first.
171    #[command(name = "unlink-bundle")]
172    UnlinkBundle(MessagingEndpointLinkBundleArgs),
173    /// Set the endpoint's default welcome flow (referenced on first contact
174    /// per M1.5). The flow's bundle must already be linked.
175    #[command(name = "set-welcome-flow")]
176    SetWelcomeFlow(MessagingEndpointSetWelcomeFlowArgs),
177    /// Remove an endpoint. Idempotent: removing an absent endpoint succeeds.
178    Remove(MessagingEndpointRemoveArgs),
179    /// Rotate the webhook secret on an existing endpoint. Generates a fresh
180    /// 32-char CSPRNG secret, bumps generation. Idempotent on the same key.
181    #[command(name = "rotate-webhook-secret")]
182    RotateWebhookSecret(MessagingEndpointRemoveArgs),
183}
184
185/// Inline-flag form of `messaging.endpoint add`. When all required fields
186/// (`--env`, `--provider-type`, `--provider-id`, `--display-name`,
187/// `--idempotency-key`, `--updated-by`) are present, the payload is built
188/// directly from these flags. Otherwise dispatch falls through to
189/// `--answers <PATH>` / `--schema`, matching the `trust-root add` precedent.
190#[derive(Args, Debug)]
191pub struct MessagingEndpointAddArgs {
192    /// Environment id, e.g. `local`.
193    #[arg(long)]
194    pub env: Option<String>,
195    /// Provider type — `telegram`, `teams`, `slack`, `whatsapp`, `webex`,
196    /// `email`, `webchat`.
197    #[arg(long = "provider-type")]
198    pub provider_type: Option<String>,
199    /// Per-environment instance identity for the provider, e.g.
200    /// `telegram-legal-bot`. Together with `--provider-type` it must be
201    /// unique inside the env.
202    #[arg(long = "provider-id")]
203    pub provider_id: Option<String>,
204    /// Human-readable label for the endpoint.
205    #[arg(long = "display-name")]
206    pub display_name: Option<String>,
207    /// Secret reference URI, e.g. `secret://local/global/telegram/bot_token`.
208    /// Repeating.
209    #[arg(long = "secret-ref", value_name = "URI")]
210    pub secret_ref: Vec<String>,
211    /// Per-endpoint webhook secret ref (telegram-class only). Required when
212    /// adding a telegram-class endpoint against a remote `--store-url` store
213    /// (which never mints secrets — the operator provisions the value and
214    /// passes the ref); omit on the local store to auto-mint into the dev-store.
215    #[arg(long = "webhook-secret-ref", value_name = "URI")]
216    pub webhook_secret_ref: Option<String>,
217    /// Idempotency key. Required for safe retries; mutations replay no-op when
218    /// the same key + identity is supplied.
219    #[arg(long = "idempotency-key")]
220    pub idempotency_key: Option<String>,
221    /// Free-form actor label that appears in the env-audit log.
222    #[arg(long = "updated-by")]
223    pub updated_by: Option<String>,
224}
225
226/// Inline-flag form of `messaging.endpoint link-bundle` and `unlink-bundle`.
227/// When all four required fields are present the payload is built directly;
228/// otherwise dispatch falls through to `--answers` / `--schema`.
229#[derive(Args, Debug)]
230pub struct MessagingEndpointLinkBundleArgs {
231    /// Environment id.
232    #[arg(long)]
233    pub env: Option<String>,
234    /// Endpoint id (ULID).
235    #[arg(long = "endpoint-id")]
236    pub endpoint_id: Option<String>,
237    /// Bundle id. Must already be deployed in the env.
238    #[arg(long = "bundle-id")]
239    pub bundle_id: Option<String>,
240    /// Idempotency key.
241    #[arg(long = "idempotency-key")]
242    pub idempotency_key: Option<String>,
243    /// Free-form actor label.
244    #[arg(long = "updated-by")]
245    pub updated_by: Option<String>,
246}
247
248/// Inline-flag form of `messaging.endpoint set-welcome-flow`. When all six
249/// required fields are present the payload is built directly; otherwise
250/// dispatch falls through to `--answers` / `--schema`.
251#[derive(Args, Debug)]
252pub struct MessagingEndpointSetWelcomeFlowArgs {
253    /// Environment id.
254    #[arg(long)]
255    pub env: Option<String>,
256    /// Endpoint id (ULID).
257    #[arg(long = "endpoint-id")]
258    pub endpoint_id: Option<String>,
259    /// Bundle id of the welcome-flow's pack. Must already be linked to the
260    /// endpoint via `link-bundle`.
261    #[arg(long = "bundle-id")]
262    pub bundle_id: Option<String>,
263    /// Pack id that hosts the welcome flow.
264    #[arg(long = "pack-id")]
265    pub pack_id: Option<String>,
266    /// Flow id (the welcome-flow entry point).
267    #[arg(long = "flow-id")]
268    pub flow_id: Option<String>,
269    /// Idempotency key.
270    #[arg(long = "idempotency-key")]
271    pub idempotency_key: Option<String>,
272    /// Free-form actor label.
273    #[arg(long = "updated-by")]
274    pub updated_by: Option<String>,
275}
276
277/// Inline-flag form of `messaging.endpoint remove` and `rotate-webhook-secret`.
278/// When all four required fields are present the payload is built directly;
279/// otherwise dispatch falls through to `--answers` / `--schema`.
280#[derive(Args, Debug)]
281pub struct MessagingEndpointRemoveArgs {
282    /// Environment id.
283    #[arg(long)]
284    pub env: Option<String>,
285    /// Endpoint id (ULID).
286    #[arg(long = "endpoint-id")]
287    pub endpoint_id: Option<String>,
288    /// Idempotency key.
289    #[arg(long = "idempotency-key")]
290    pub idempotency_key: Option<String>,
291    /// Free-form actor label.
292    #[arg(long = "updated-by")]
293    pub updated_by: Option<String>,
294}
295
296#[derive(Subcommand, Debug)]
297pub enum EnvVerb {
298    /// Idempotent bootstrap of the `local` environment with the five default
299    /// env-pack bindings (A4 helper exposed as a CLI verb). Creates the env
300    /// if missing, fills in any missing default bindings on an existing env,
301    /// or reports `untouched` if the env is already complete. User-bound
302    /// non-default descriptors are NEVER overwritten.
303    Init(EnvInitArgs),
304    /// Declarative, upsert-only environment apply. Reads a
305    /// `greentic.env-manifest.v1` document via `--answers <PATH>` and
306    /// reconciles the env toward it: validate → diff → plan → execute →
307    /// verify. Re-running an unchanged manifest is a visible no-op.
308    /// `--dry-run` previews the plan; `--check` is the CI gate (exit 1 on
309    /// pending diff).
310    Apply(EnvApplyArgs),
311    Create(EnvCreateArgs),
312    Update(EnvUpdateArgs),
313    /// `op env set-public-url <env_id> <URL>`. Replaces the env's persisted
314    /// `host_config.public_base_url`. Equivalent to
315    /// `op config set --public-url <URL>` but easier to discover for the
316    /// common "I set the URL once and forget it" path.
317    SetPublicUrl(EnvSetPublicUrlArgs),
318    List,
319    Show {
320        env_id: String,
321    },
322    Doctor {
323        env_id: String,
324    },
325    /// Run per-binding tool preflight. Resolves each env-pack binding via the
326    /// registry and invokes its handler's `preflight()` to check external
327    /// tools (binary presence, version, auth, scope) needed for real work.
328    /// The built-in `local` handlers report no external tools.
329    ToolCheck {
330        env_id: String,
331    },
332    /// Render the env's declarative desired state through the deployer
333    /// env-pack's manifest renderer, without applying anything (plan §6
334    /// step 10). With `--output` writes one YAML file per object in apply
335    /// order; otherwise embeds the manifests in the JSON outcome.
336    Render(EnvRenderArgs),
337    /// Apply the env's declarative desired state to its live cluster and
338    /// prune the workers of revisions no longer present (the apply-side
339    /// counterpart of `render`). K8s deployer env-pack only today; connects
340    /// through the binding's `kubeconfig_context` answer.
341    Reconcile(EnvReconcileArgs),
342    /// Bring a SINGLE revision's worker resources into agreement with its
343    /// recorded lifecycle (present → apply the worker pair, absent → tear it
344    /// down) — the surgical counterpart of `reconcile`. K8s deployer env-pack
345    /// only today; connects through the binding's `kubeconfig_context` answer.
346    ApplyRevision(EnvApplyRevisionArgs),
347    /// Push one deployment's recorded traffic split to its live ALB listener
348    /// (the routing-side counterpart of `apply-revision`). AWS-ECS deployer
349    /// env-pack only — K8s serves splits from its in-process runtime router, so
350    /// `op traffic set` alone suffices there. The split itself is recorded by
351    /// `op traffic set`; this verb makes it observable in the live runtime.
352    ///
353    /// Routing depends on the binding's ALB routing answers. With a routing
354    /// condition set (`alb_routing_host` / `alb_routing_path`), this writes a
355    /// per-deployment listener RULE keyed by that host/path, leaving the default
356    /// action and sibling deployments' rules intact — deployments coexist behind
357    /// one listener. With NO routing condition, it falls back to REPLACING the
358    /// listener's default action, which assumes the `alb_listener_arn` is
359    /// DEDICATED to this one deployment (a split then clobbers any sibling
360    /// routing / auth / redirect on that listener).
361    ApplyTraffic(EnvApplyTrafficArgs),
362    Destroy {
363        env_id: String,
364        #[arg(long)]
365        confirm: bool,
366    },
367    /// Migrate the legacy `dev` environment to `<target>` (typically `local`).
368    /// Run with `--check` to scan without touching state; `--apply` performs
369    /// the one-shot rewrite only when the check is clean (A4b).
370    MigrateDev {
371        /// Target env id — typically `local`.
372        target: String,
373        /// Scan for blocking findings and report; do not touch state.
374        #[arg(long, conflicts_with = "apply")]
375        check: bool,
376        /// Re-run the check; if clean, perform the migration.
377        #[arg(long, conflicts_with = "check")]
378        apply: bool,
379    },
380    /// Archive the legacy `<state_dir>/deploy/` artifact tree (A6).
381    /// `--apply` renames it to a hidden `.deploy-migrated-<ts>/` sentinel;
382    /// contents are NOT copied into the new layout. Quiesce live deploys
383    /// first — `apply::run` does not participate in the migration lock.
384    MigrateState {
385        /// Target env id — must exist in EnvironmentStore.
386        target: String,
387        /// Scan for blocking findings and report; do not touch state.
388        #[arg(long, conflicts_with = "apply")]
389        check: bool,
390        /// Re-run the check; if clean, rename the legacy tree.
391        #[arg(long, conflicts_with = "check")]
392        apply: bool,
393        /// Override the legacy state-dir root. Defaults to
394        /// `$HOME/.greentic/state`.
395        #[arg(long = "state-dir")]
396        state_dir: Option<PathBuf>,
397    },
398}
399
400#[derive(Subcommand, Debug)]
401pub enum TrustRootVerb {
402    /// Seed the env trust root with the local operator key. The
403    /// revenue-policy writer never auto-seeds, so this verb is the
404    /// authorized path that grants signing rights to a new env. Idempotent.
405    Bootstrap { env_id: Option<String> },
406    /// List trusted keys for one env.
407    List { env_id: String },
408    /// Add a `(key_id, public_pem)` pair. PEM source: `--public-key-pem` (inline)
409    /// or `--public-key-file <PATH>`.
410    Add(TrustRootAddArgs),
411    /// Remove a key by `key_id` (case-insensitive). No-op if absent.
412    Remove(TrustRootRemoveArgs),
413}
414
415#[derive(Args, Debug)]
416pub struct TrustRootAddArgs {
417    pub env_id: Option<String>,
418    #[arg(long = "key-id")]
419    pub key_id: Option<String>,
420    #[arg(long = "public-key-pem")]
421    pub public_key_pem: Option<String>,
422    #[arg(long = "public-key-file")]
423    pub public_key_file: Option<PathBuf>,
424}
425
426/// Args for `op env init`. Only `--public-url` is settable inline;
427/// init is otherwise an idempotent bootstrap of the canonical `local` env.
428/// If the env already exists, an inline `--public-url` is NOT applied —
429/// use `op env set-public-url` (or `op config set --public-url`) to mutate
430/// an existing env's URL.
431#[derive(Args, Debug)]
432pub struct EnvInitArgs {
433    /// Persistent public base URL the runtime exposes
434    /// (`https://host[:port]`, origin only). Stored in
435    /// `Environment.host_config.public_base_url`. Only takes effect when
436    /// `init` actually creates the env; on `untouched` / `healed` outcomes
437    /// the existing URL is preserved.
438    #[arg(long = "public-url")]
439    pub public_url: Option<String>,
440}
441
442/// Args for `op env apply`. The manifest itself arrives via the global
443/// `--answers <PATH>` flag (it IS the verb's payload); these flags only
444/// shape how the plan is executed.
445#[derive(Args, Debug)]
446pub struct EnvApplyArgs {
447    /// Validate + diff + print the plan, then exit without mutating
448    /// anything (exit 0 even when changes are pending — it's a preview).
449    #[arg(long = "dry-run")]
450    pub dry_run: bool,
451    /// CI convergence gate: validate + diff + print the plan, then exit
452    /// non-zero when the plan contains pending diffable changes, 0 when the
453    /// env is converged. Always-put secret steps don't count as drift
454    /// (values cannot be diffed until A9); they're reported separately.
455    /// Never mutates anything.
456    #[arg(long, conflicts_with = "dry_run")]
457    pub check: bool,
458    /// Never prompt — neither for missing secret values nor the plan
459    /// confirmation (implies `--yes`). Missing inputs are collected and
460    /// reported (JSON `missing` section) instead of asked for; any missing
461    /// input fails the apply before it mutates.
462    #[arg(long = "non-interactive")]
463    pub non_interactive: bool,
464    /// Write a skeleton `greentic.env-manifest.v1` document to PATH and
465    /// exit — a starting point for `--answers`. Needs no manifest and
466    /// touches no store state.
467    #[arg(
468        long = "emit-answers-template",
469        value_name = "PATH",
470        conflicts_with_all = ["dry_run", "check"]
471    )]
472    pub emit_answers_template: Option<PathBuf>,
473    /// Audit principal forwarded to every composed mutation. Defaults to
474    /// `env-apply`.
475    #[arg(long = "updated-by")]
476    pub updated_by: Option<String>,
477    /// Skip the interactive confirmation shown when the plan contains
478    /// mutations and stdin/stdout are a TTY. Non-TTY implies `--yes`.
479    #[arg(long)]
480    pub yes: bool,
481}
482
483impl EnvApplyArgs {
484    pub(crate) fn into_options(self) -> super::env_apply::ApplyOptions {
485        use super::env_apply::ApplyMode;
486        // clap's `conflicts_with` guarantees at most one of `--dry-run` /
487        // `--check` is set.
488        let mode = if self.check {
489            ApplyMode::Check
490        } else if self.dry_run {
491            ApplyMode::DryRun
492        } else {
493            ApplyMode::Apply
494        };
495        super::env_apply::ApplyOptions {
496            mode,
497            updated_by: self.updated_by,
498            yes: self.yes,
499            non_interactive: self.non_interactive,
500            // The CLI never pre-collects paste-sourced secrets; an unset value
501            // is prompted (interactive) or reported missing (headless).
502            ..Default::default()
503        }
504    }
505}
506
507/// Args for `op env create` and `op env update`. All fields are optional
508/// at the clap layer so `--answers` / `--schema` keep working unchanged;
509/// the dispatcher builds an `EnvCreatePayload` only when the required
510/// inline flags are supplied, otherwise hands `None` to the library
511/// function (`resolve_payload` then defers to `--answers`).
512#[derive(Args, Debug)]
513pub struct EnvCreateArgs {
514    /// Environment id (e.g. `local`, `prod-eu`).
515    pub environment_id: Option<String>,
516    /// Display name. Defaults to `--environment-id` if omitted on the CLI
517    /// path; required on the JSON path. Pass either positionally below or
518    /// via `--name`.
519    #[arg(long = "name")]
520    pub name: Option<String>,
521    /// Optional region tag (free-form).
522    #[arg(long = "region")]
523    pub region: Option<String>,
524    /// Tenant organization id this env belongs to.
525    #[arg(long = "tenant-org")]
526    pub tenant_org_id: Option<String>,
527    /// Bind address for the runtime's local HTTP listener (e.g.
528    /// `127.0.0.1:8080`). Validated as `SocketAddr` before any state is
529    /// touched.
530    #[arg(long = "listen-addr")]
531    pub listen_addr: Option<String>,
532    /// Persistent public base URL the runtime exposes
533    /// (`https://host[:port]`, origin only).
534    #[arg(long = "public-url")]
535    pub public_url: Option<String>,
536}
537
538/// Args for `op env update`. Update accepts the metadata fields only.
539/// URL changes go through `op env set-public-url` (single-field verb)
540/// or `op config set --public-url` (URL inside a multi-field host_config
541/// update); listen-addr changes through `op config set --listen-addr`.
542#[derive(Args, Debug)]
543pub struct EnvUpdateArgs {
544    /// Environment id (e.g. `local`, `prod-eu`).
545    pub environment_id: Option<String>,
546    /// Display name. Defaults to `--environment-id` if omitted on the CLI
547    /// path; required on the JSON path. Pass either positionally below or
548    /// via `--name`.
549    #[arg(long = "name")]
550    pub name: Option<String>,
551    /// Optional region tag (free-form).
552    #[arg(long = "region")]
553    pub region: Option<String>,
554    /// Tenant organization id this env belongs to.
555    #[arg(long = "tenant-org")]
556    pub tenant_org_id: Option<String>,
557}
558
559/// Args for `op env render <env_id> [--kind <descriptor>] [--output <dir>]`.
560/// Read-only: renders without applying. `--answers`/`--schema` payload
561/// machinery does not apply — the inputs are these three direct args.
562#[derive(Args, Debug)]
563pub struct EnvRenderArgs {
564    /// Environment id (e.g. `zain-prod`).
565    pub env_id: String,
566    /// Deployer env-pack kind to render with — a full `<path>@<version>`
567    /// descriptor, or a bare path matching the env's deployer binding.
568    /// Defaults to the env's Deployer-slot binding.
569    #[arg(long)]
570    pub kind: Option<String>,
571    /// Directory to write one YAML file per object (created if missing;
572    /// same-named files are overwritten). Files are named
573    /// `<NN>-<kind>-<name>.yaml` in apply order. Without this flag the
574    /// manifests are embedded in the JSON outcome instead.
575    #[arg(long)]
576    pub output: Option<PathBuf>,
577}
578
579/// Args for `op env reconcile`. Applies desired state to the live cluster —
580/// the apply-side counterpart of `render` (use `render` for a no-side-effect
581/// preview).
582#[derive(Args, Debug)]
583pub struct EnvReconcileArgs {
584    /// Environment id (e.g. `zain-prod`).
585    pub env_id: String,
586    /// Deployer env-pack kind to reconcile with — a full `<path>@<version>`
587    /// descriptor, or a bare path matching the env's deployer binding.
588    /// Defaults to the env's Deployer-slot binding.
589    #[arg(long)]
590    pub kind: Option<String>,
591}
592
593/// Args for `op env apply-revision <env_id> <revision_id> [--kind <descriptor>]`.
594/// Surgical single-revision counterpart of `reconcile`: brings ONE revision's
595/// worker resources into agreement with its recorded lifecycle (present →
596/// apply, absent → tear down). Assumes the env-level set (namespace, router)
597/// already exists — `reconcile` establishes that.
598#[derive(Args, Debug)]
599pub struct EnvApplyRevisionArgs {
600    /// Environment id (e.g. `zain-prod`).
601    pub env_id: String,
602    /// Revision id (ULID) to apply — must already exist in the env.
603    pub revision_id: String,
604    /// Deployer env-pack kind to apply with — a full `<path>@<version>`
605    /// descriptor, or a bare path matching the env's deployer binding.
606    /// Defaults to the env's Deployer-slot binding.
607    #[arg(long)]
608    pub kind: Option<String>,
609}
610
611/// Args for `op env apply-traffic <env_id> <deployment_id> [--kind <descriptor>]`.
612/// Pushes the deployment's recorded traffic split to its live ALB listener
613/// (AWS-ECS only). Record the split first with `op traffic set`.
614#[derive(Args, Debug)]
615pub struct EnvApplyTrafficArgs {
616    /// Environment id (e.g. `zain-prod`).
617    pub env_id: String,
618    /// Deployment id (ULID) whose recorded split to apply.
619    pub deployment_id: String,
620    /// Deployer env-pack kind to apply with — a full `<path>@<version>`
621    /// descriptor, or a bare path matching the env's deployer binding.
622    /// Defaults to the env's Deployer-slot binding.
623    #[arg(long)]
624    pub kind: Option<String>,
625}
626
627/// Args for `op env set-public-url <env_id> <URL>`. Both fields are
628/// required positional — this verb only sets the public URL, no other
629/// host_config fields. `--answers` is rejected: this is a dedicated
630/// single-purpose verb, not an `op config set` alias.
631#[derive(Args, Debug)]
632pub struct EnvSetPublicUrlArgs {
633    /// Environment id (e.g. `local`).
634    pub env_id: String,
635    /// Public base URL the runtime exposes (`https://host[:port]`, origin
636    /// only — no path, query, or fragment).
637    pub url: String,
638}
639
640#[derive(Args, Debug)]
641pub struct TrustRootRemoveArgs {
642    pub env_id: Option<String>,
643    #[arg(long = "key-id")]
644    pub key_id: Option<String>,
645}
646
647#[derive(Subcommand, Debug)]
648pub enum EnvPacksVerb {
649    Add,
650    Update,
651    Remove,
652    Rollback,
653    List { env_id: String },
654}
655
656#[derive(Subcommand, Debug)]
657pub enum ExtensionsVerb {
658    Add,
659    Update,
660    Remove,
661    Rollback,
662    List { env_id: String },
663}
664
665#[derive(Subcommand, Debug)]
666pub enum UpdatesVerb {
667    /// Enroll the env's update channel: mint a key + CSR, exchange it at the
668    /// Cert-CA for a signed client certificate, and persist cert/key/CA into the
669    /// env secrets backend. Re-running re-enrolls (manual rotation).
670    Enroll(UpdatesEnrollArgs),
671    /// Report the enrolled update-channel certificate's serial + validity window.
672    Status { env_id: Option<String> },
673    /// Fetch a signed update plan (over the enrolled mTLS channel or from a
674    /// local file), verify it against the env trust root, and stage it.
675    Get(UpdatesGetArgs),
676    /// Apply a staged update plan to its environment: re-verify, snapshot,
677    /// converge via the env-apply pipeline, and roll back on failure.
678    Apply(UpdatesApplyArgs),
679    /// Force-fail a plan stranded in `applying` by a crashed applier
680    /// (`applying → failed`, audited), so a fresh `get` + `apply` can proceed.
681    /// Requires `--force`; does not roll back partial changes.
682    Recover(UpdatesRecoverArgs),
683    /// Set the update-channel notification policy (`update-channel.json`):
684    /// whether the runtime acts on a discovered update, and the fallback poll
685    /// interval. Only the flags supplied are changed. Disabled by default.
686    ConfigSet(UpdatesConfigSetArgs),
687    /// Show the update-channel notification policy (stored fields + resolved
688    /// effective values). Read-only.
689    ConfigShow { env_id: Option<String> },
690    /// Build and DSSE-sign an UpdatePlan carrying a content target
691    /// (`--target-file`), one or more binary artifacts (`--binary`), or both,
692    /// writing `plan.json` + `plan.json.sig` that round-trip through the
693    /// existing `verify_update_plan`. Producer side of the update path.
694    PlanBuild(UpdatesPlanBuildArgs),
695}
696
697#[derive(Args, Debug)]
698pub struct UpdatesEnrollArgs {
699    pub env_id: Option<String>,
700    #[arg(long = "ca-url")]
701    pub ca_url: Option<String>,
702}
703
704#[derive(Args, Debug)]
705pub struct UpdatesApplyArgs {
706    pub env_id: Option<String>,
707    /// Plan id of the staged plan to apply (from a prior `op updates get`).
708    #[arg(long = "plan-id")]
709    pub plan_id: Option<String>,
710}
711
712#[derive(Args, Debug)]
713pub struct UpdatesRecoverArgs {
714    pub env_id: Option<String>,
715    /// Plan id of the `applying` plan to force-fail (from a prior `op updates get`).
716    #[arg(long = "plan-id")]
717    pub plan_id: Option<String>,
718    /// Assert the applier is dead and force-fail `applying → failed`. Required —
719    /// recover refuses without it (a live apply is indistinguishable on disk).
720    #[arg(long)]
721    pub force: bool,
722}
723
724#[derive(Args, Debug)]
725pub struct UpdatesConfigSetArgs {
726    pub env_id: Option<String>,
727    /// Master switch for the update-channel notification machinery. Omit to
728    /// leave unchanged (absent = disabled, deny-by-default).
729    #[arg(long)]
730    pub enabled: Option<bool>,
731    /// Action on a verified notification: `record-only` or `stage`. Omit to
732    /// leave unchanged (unset resolves to `stage`).
733    #[arg(long = "on-notify")]
734    pub on_notify: Option<String>,
735    /// Fallback poll interval in seconds (>= 60). Omit to leave unchanged.
736    #[arg(long = "poll-interval-secs")]
737    pub poll_interval_secs: Option<u64>,
738}
739
740#[derive(Args, Debug)]
741pub struct UpdatesPlanBuildArgs {
742    /// Target environment id.
743    pub env_id: Option<String>,
744    /// Monotonic plan sequence (the consumer enforces anti-rollback).
745    #[arg(long)]
746    pub sequence: Option<u64>,
747    /// Binary artifact spec (repeatable). Comma-separated key=value:
748    /// `name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:<hex>[,source=https://...]`.
749    /// Required unless `--target-file` is supplied.
750    #[arg(long = "binary")]
751    pub binaries: Vec<String>,
752    /// PKCS#8 Ed25519 private key PEM for signing. Default: the global operator key.
753    #[arg(long = "signing-key")]
754    pub signing_key: Option<PathBuf>,
755    /// JSON file for the plan target (env-manifest.v1). Required unless
756    /// `--binary` is supplied. Default: minimal manifest with schema + env id.
757    #[arg(long = "target-file")]
758    pub target_file: Option<PathBuf>,
759    /// Minimum runtime version (semver) for `compat.min_runtime`.
760    #[arg(long = "min-runtime")]
761    pub min_runtime: Option<String>,
762    /// Output directory for `plan.json` + `plan.json.sig`. Default: current dir.
763    #[arg(long = "out-dir")]
764    pub out_dir: Option<PathBuf>,
765}
766
767#[derive(Args, Debug)]
768pub struct UpdatesGetArgs {
769    pub env_id: Option<String>,
770    /// Fetch the signed plan (document + `.sig` sidecar) from this base URL over
771    /// the enrolled mTLS channel.
772    #[arg(long = "plan-url", conflicts_with_all = ["plan_file", "plan_sig_file"])]
773    pub plan_url: Option<String>,
774    /// Local plan document (airgap import / testing). Requires `--plan-sig-file`.
775    #[arg(long = "plan-file", requires = "plan_sig_file")]
776    pub plan_file: Option<PathBuf>,
777    /// DSSE envelope sidecar for `--plan-file`.
778    #[arg(long = "plan-sig-file", requires = "plan_file")]
779    pub plan_sig_file: Option<PathBuf>,
780}
781
782#[derive(Subcommand, Debug)]
783pub enum BundlesVerb {
784    Add,
785    Update,
786    Remove,
787    List { env_id: String },
788}
789
790#[derive(Subcommand, Debug)]
791pub enum RevisionsVerb {
792    Stage(RevisionStageArgs),
793    Warm,
794    Drain,
795    Archive,
796    List { env_id: String },
797}
798
799/// Args for `op revisions stage`. All fields are optional at the clap layer so
800/// `--answers` / `--payload-json` / `--schema` keep working unchanged; the
801/// dispatcher builds a `RevisionStagePayload` only when the positional args are
802/// supplied, otherwise hands `None` to the library function.
803#[derive(Args, Debug)]
804pub struct RevisionStageArgs {
805    /// Environment id, e.g. `local`.
806    pub env_id: Option<String>,
807    /// Deployment ULID the revision belongs to.
808    #[arg(long)]
809    pub deployment: Option<String>,
810    /// Local `.gtbundle` to extract and pin into the revision's pack-list.
811    #[arg(long)]
812    pub bundle: Option<PathBuf>,
813}
814
815#[derive(Subcommand, Debug)]
816pub enum TrafficVerb {
817    /// Replace the traffic split for one deployment. Entries are
818    /// `<revision_id>=<percent>` (integer 0..=100, optional `%` suffix) or
819    /// `<revision_id>=<N>bps` (basis points 0..=10_000). Sum must equal
820    /// 100 % (10,000 bps). Validates revisions are `Ready` before saving.
821    Set(TrafficSetArgs),
822    /// Show the live split for one deployment.
823    Show(TrafficTargetArgs),
824    /// Roll back to the previously-saved split for one deployment.
825    Rollback(TrafficTargetArgs),
826}
827
828/// Args for `op traffic set`. All fields are optional at the clap layer so
829/// that `--answers` / `--payload-json` / `--schema` continue to work
830/// unchanged; the dispatcher builds a `TrafficSetPayload` only when the
831/// required clap args are supplied, and otherwise hands `None` to the
832/// library function so it can resolve the payload from `--answers`.
833#[derive(Args, Debug)]
834pub struct TrafficSetArgs {
835    /// Environment id, e.g. `local`.
836    pub env_id: Option<String>,
837    /// Repeated `<revision_id>=<weight>` entries — weight is `N`/`N%`
838    /// (percent) or `Nbps` (basis points). Sum must reach 100 %.
839    pub entries: Vec<String>,
840    /// Deployment ULID.
841    #[arg(long)]
842    pub deployment: Option<String>,
843    /// Idempotency key (required). Same key = no-op replay; different key
844    /// overwrites the rollback target. Any stable string works.
845    #[arg(long)]
846    pub idempotency_key: Option<String>,
847    /// Actor recorded on the split. Defaults to `operator`.
848    #[arg(long)]
849    pub updated_by: Option<String>,
850    /// Sidecar authorization-doc path. Defaults to `auth.json`.
851    #[arg(long)]
852    pub authorization_ref: Option<PathBuf>,
853}
854
855/// Args for `op deploy`. All fields are optional at the clap layer so
856/// `--answers` / `--schema` keep working unchanged; the dispatcher builds a
857/// `BundleDeployPayload` only when args are supplied, and requires `--bundle`
858/// on that direct path.
859#[derive(Args, Debug)]
860pub struct BundleDeployArgs {
861    /// Local `.gtbundle` to deploy. Required on the direct CLI path.
862    #[arg(long)]
863    pub bundle: Option<PathBuf>,
864    /// Environment id. Defaults to `local`.
865    #[arg(long)]
866    pub env: Option<String>,
867    /// Bundle id. Defaults to the `.gtbundle` filename stem.
868    #[arg(long = "bundle-id")]
869    pub bundle_id: Option<String>,
870    /// Billing principal (P6). Defaults to `local-dev` on the `local` env;
871    /// required for any other env.
872    #[arg(long = "customer-id")]
873    pub customer_id: Option<String>,
874    /// Idempotency key for the traffic cut-over. Defaults to a value derived
875    /// from the freshly-minted revision id.
876    #[arg(long = "idempotency-key")]
877    pub idempotency_key: Option<String>,
878    /// D.4: per-pack provider config override (string values). Repeating,
879    /// formatted as `<pack_id>:<key>=<value>`. The value is ALWAYS stored as
880    /// a JSON string — no type coercion. Use `--config-override-json` for
881    /// typed values (bool, number, object, array).
882    /// Example: `--config-override messaging-telegram:api_base_url=https://staging.example.com`.
883    #[arg(long = "config-override", value_name = "PACK_ID:KEY=VALUE")]
884    pub config_override: Vec<String>,
885    /// D.4: per-pack provider config override (typed JSON values). Repeating,
886    /// formatted as `<pack_id>:<key>=<json>`. The value is parsed as JSON;
887    /// a parse error is rejected. Both `--config-override` and
888    /// `--config-override-json` merge into the same map (later flags win
889    /// per-(pack,key)).
890    /// Example: `--config-override-json messaging-telegram:retry_max=5`.
891    #[arg(long = "config-override-json", value_name = "PACK_ID:KEY=JSON")]
892    pub config_override_json: Vec<String>,
893    /// D.4: bulk-load config overrides from a JSON file shaped
894    /// `{"<pack_id>": {"<key>": <value>, ...}, ...}`. Individual
895    /// `--config-override` / `--config-override-json` flags MERGE on top
896    /// (per-pack, per-key).
897    #[arg(long = "config-overrides-from", value_name = "FILE")]
898    pub config_overrides_from: Option<PathBuf>,
899    /// Route binding: path prefix to dispatch into this bundle, e.g. `/legal`.
900    /// Repeating. Sets `route_binding.path_prefixes` at deploy time so a
901    /// follow-up `bundles update` isn't needed for the common case.
902    #[arg(long = "path-prefix", value_name = "PREFIX")]
903    pub path_prefix: Vec<String>,
904    /// Route binding: host to dispatch into this bundle. Repeating.
905    /// Sets `route_binding.hosts` at deploy time.
906    #[arg(long = "host", value_name = "HOST")]
907    pub host: Vec<String>,
908    /// Route binding: tenant id for `tenant_selector`. When supplied, the
909    /// resolved deployment carries this tenant through the runtime config so
910    /// per-tenant secret URIs (e.g. `secrets://<env>/<tenant>/…`) resolve
911    /// correctly. Requires no other routing flag — pair with `--path-prefix`
912    /// or `--host` to make the deployment reachable.
913    #[arg(long = "tenant", value_name = "TENANT")]
914    pub tenant: Option<String>,
915    /// Route binding: team id for `tenant_selector`. Defaults to `default`
916    /// when `--tenant` is supplied without `--team`.
917    #[arg(long = "team", value_name = "TEAM")]
918    pub team: Option<String>,
919}
920
921#[derive(Args, Debug)]
922pub struct TrafficTargetArgs {
923    /// Environment id, e.g. `local`.
924    pub env_id: Option<String>,
925    /// Deployment ULID.
926    #[arg(long)]
927    pub deployment: Option<String>,
928}
929
930#[derive(Subcommand, Debug)]
931pub enum ConfigVerb {
932    Show,
933    Set,
934}
935
936#[derive(Subcommand, Debug)]
937pub enum CredentialsVerb {
938    Requirements,
939    Bootstrap,
940    Rotate,
941}
942
943#[derive(Subcommand, Debug)]
944pub enum SecretsVerb {
945    List,
946    Put,
947    Get,
948    Rotate,
949}
950
951#[derive(Args, Debug)]
952pub struct PayloadArg {
953    /// Inline payload as a JSON string. Mutually exclusive with `--answers`.
954    #[arg(long, conflicts_with = "answers")]
955    pub payload_json: Option<String>,
956}
957
958/// Build a [`LocalFsStore`] honoring `--store-root`, falling back to the
959/// per-user default.
960pub fn build_store(cmd: &OpCommand) -> Result<LocalFsStore, OpError> {
961    let root = match &cmd.store_root {
962        Some(p) => p.clone(),
963        None => LocalFsStore::default_root().ok_or_else(|| {
964            OpError::InvalidArgument("no --store-root and HOME / USERPROFILE not set".to_string())
965        })?,
966    };
967    Ok(LocalFsStore::new(root))
968}
969
970/// Print an `OpOutcome` to stdout as compact JSON and return `Ok(())`.
971pub fn print_outcome(outcome: &OpOutcome) -> Result<(), OpError> {
972    let value = serde_json::to_value(outcome)
973        .map_err(|e| OpError::InvalidArgument(format!("serialize outcome: {e}")))?;
974    println!("{value}");
975    Ok(())
976}
977
978/// Print a typed error in the JSON envelope and return the same error so
979/// the caller can map it to the process exit code.
980pub fn print_error(noun: &'static str, op: &'static str, err: &OpError) {
981    let value = render_error(noun, op, err);
982    eprintln!("{value}");
983}
984
985/// Top-level dispatcher. The verb modules each load their own payload via
986/// `--answers` or `--payload-json`; this function only routes argv into
987/// the right library call.
988///
989/// Uses the built-in env-pack registry (five default `local` handlers).
990/// Phase D plug-ins that register additional handlers should call
991/// [`dispatch_op_with_registry`] instead, passing a registry populated
992/// with both built-ins and their plug-in handlers.
993///
994/// On success, the per-verb result is written to stdout as the documented
995/// `{op, noun, result}` envelope. On error, the documented
996/// `{op, noun, error: {kind, message}}` envelope is written to stderr and
997/// the same `OpError` is returned so callers can map it to a process exit
998/// code without re-rendering. Stdout and stderr never cross-contaminate.
999pub fn dispatch_op(cmd: OpCommand) -> Result<(), OpError> {
1000    let registry = crate::env_packs::EnvPackRegistry::with_builtins();
1001    dispatch_op_with_registry(cmd, &registry)
1002}
1003
1004/// Phase D plug-in entry point — register handlers on the registry, then
1005/// call this. The `credentials` noun (and any future noun that resolves
1006/// handlers through the registry) will see every handler the caller
1007/// registered.
1008pub fn dispatch_op_with_registry(
1009    cmd: OpCommand,
1010    registry: &crate::env_packs::EnvPackRegistry,
1011) -> Result<(), OpError> {
1012    let flags = OpFlags {
1013        schema_only: cmd.schema,
1014        answers: cmd.answers.clone(),
1015    };
1016    let (noun, verb) = noun_verb_labels(&cmd.noun);
1017
1018    // Remote store selection (PR-3c). --store-url / GREENTIC_STORE_URL picks
1019    // the A8 HTTP backend. Schema-only requests stay local (schema is
1020    // store-independent and never touches the FS), so the operator can
1021    // inspect payloads without a running server.
1022    //
1023    // URL and token are paired by ORIGIN: an env-configured
1024    // GREENTIC_STORE_TOKEN must not leak to an ad-hoc `--store-url` flag
1025    // endpoint. A flag URL only accepts a flag token; an env URL accepts a
1026    // flag token or the env token.
1027    let (store_url, store_token) = crate::cli::dispatch_remote::resolve_remote_target(
1028        cmd.store_url.clone(),
1029        cmd.store_token.clone(),
1030        std::env::var("GREENTIC_STORE_URL").ok(),
1031        std::env::var("GREENTIC_STORE_TOKEN").ok(),
1032    );
1033    if let Some(raw_url) = store_url
1034        && !cmd.schema
1035    {
1036        return crate::cli::dispatch_remote::dispatch_op_remote(&raw_url, store_token, cmd, &flags)
1037            .inspect_err(|err| print_error(noun, verb, err));
1038    }
1039
1040    let store = build_store(&cmd).inspect_err(|err| print_error(noun, verb, err))?;
1041    let result = match cmd.noun {
1042        OpNoun::Env { verb } => dispatch_env(&store, registry, &flags, verb),
1043        OpNoun::EnvPacks { verb } => dispatch_env_packs(&store, &flags, verb),
1044        OpNoun::Bundles { verb } => dispatch_bundles(&store, &flags, verb),
1045        OpNoun::Revisions { verb } => dispatch_revisions(&store, &flags, verb),
1046        OpNoun::Traffic { verb } => dispatch_traffic(&store, &flags, verb),
1047        OpNoun::Deploy(args) => dispatch_deploy(&store, &flags, args),
1048        OpNoun::Config { verb } => dispatch_config(&store, &flags, verb),
1049        OpNoun::Credentials { verb } => dispatch_credentials(&store, registry, &flags, verb),
1050        OpNoun::Secrets { verb } => dispatch_secrets(&store, &flags, verb),
1051        OpNoun::TrustRoot { verb } => dispatch_trust_root(&store, &flags, verb),
1052        OpNoun::Messaging { verb } => dispatch_messaging(&store, &flags, verb),
1053        OpNoun::Extensions { verb } => dispatch_extensions(&store, &flags, verb),
1054        OpNoun::Updates { verb } => dispatch_updates(&store, &flags, verb),
1055    };
1056    result.inspect_err(|err| print_error(noun, verb, err))
1057}
1058
1059/// Map an [`OpNoun`] to its `(noun, verb)` static label pair for envelope
1060/// rendering. Kept in lockstep with the verb enums above; adding a new
1061/// noun/verb requires extending the match here too.
1062pub fn noun_verb_labels(noun: &OpNoun) -> (&'static str, &'static str) {
1063    match noun {
1064        OpNoun::Env { verb } => (
1065            "env",
1066            match verb {
1067                EnvVerb::Init(_) => "init",
1068                EnvVerb::Apply(_) => "apply",
1069                EnvVerb::Create(_) => "create",
1070                EnvVerb::Update(_) => "update",
1071                EnvVerb::SetPublicUrl(_) => "set-public-url",
1072                EnvVerb::List => "list",
1073                EnvVerb::Show { .. } => "show",
1074                EnvVerb::Doctor { .. } => "doctor",
1075                EnvVerb::ToolCheck { .. } => "tool-check",
1076                EnvVerb::Render(_) => "render",
1077                EnvVerb::Reconcile(_) => "reconcile",
1078                EnvVerb::ApplyRevision(_) => "apply-revision",
1079                EnvVerb::ApplyTraffic(_) => "apply-traffic",
1080                EnvVerb::Destroy { .. } => "destroy",
1081                EnvVerb::MigrateDev { .. } => "migrate-dev",
1082                EnvVerb::MigrateState { .. } => "migrate-state",
1083            },
1084        ),
1085        OpNoun::EnvPacks { verb } => (
1086            "env-packs",
1087            match verb {
1088                EnvPacksVerb::Add => "add",
1089                EnvPacksVerb::Update => "update",
1090                EnvPacksVerb::Remove => "remove",
1091                EnvPacksVerb::Rollback => "rollback",
1092                EnvPacksVerb::List { .. } => "list",
1093            },
1094        ),
1095        OpNoun::Bundles { verb } => (
1096            "bundles",
1097            match verb {
1098                BundlesVerb::Add => "add",
1099                BundlesVerb::Update => "update",
1100                BundlesVerb::Remove => "remove",
1101                BundlesVerb::List { .. } => "list",
1102            },
1103        ),
1104        OpNoun::Revisions { verb } => (
1105            "revisions",
1106            match verb {
1107                RevisionsVerb::Stage(_) => "stage",
1108                RevisionsVerb::Warm => "warm",
1109                RevisionsVerb::Drain => "drain",
1110                RevisionsVerb::Archive => "archive",
1111                RevisionsVerb::List { .. } => "list",
1112            },
1113        ),
1114        OpNoun::Traffic { verb } => (
1115            "traffic",
1116            match verb {
1117                TrafficVerb::Set(_) => "set",
1118                TrafficVerb::Show(_) => "show",
1119                TrafficVerb::Rollback(_) => "rollback",
1120            },
1121        ),
1122        OpNoun::Deploy(_) => ("deploy", "run"),
1123        OpNoun::Config { verb } => (
1124            "config",
1125            match verb {
1126                ConfigVerb::Show => "show",
1127                ConfigVerb::Set => "set",
1128            },
1129        ),
1130        OpNoun::Credentials { verb } => (
1131            "credentials",
1132            match verb {
1133                CredentialsVerb::Requirements => "requirements",
1134                CredentialsVerb::Bootstrap => "bootstrap",
1135                CredentialsVerb::Rotate => "rotate",
1136            },
1137        ),
1138        OpNoun::Secrets { verb } => (
1139            "secrets",
1140            match verb {
1141                SecretsVerb::List => "list",
1142                SecretsVerb::Put => "put",
1143                SecretsVerb::Get => "get",
1144                SecretsVerb::Rotate => "rotate",
1145            },
1146        ),
1147        OpNoun::TrustRoot { verb } => (
1148            "trust-root",
1149            match verb {
1150                TrustRootVerb::Bootstrap { .. } => "bootstrap",
1151                TrustRootVerb::List { .. } => "list",
1152                TrustRootVerb::Add(_) => "add",
1153                TrustRootVerb::Remove(_) => "remove",
1154            },
1155        ),
1156        OpNoun::Messaging { verb } => (
1157            "messaging.endpoint",
1158            match verb {
1159                MessagingNoun::Endpoint { verb } => match verb {
1160                    MessagingEndpointVerb::Add(_) => "add",
1161                    MessagingEndpointVerb::List { .. } => "list",
1162                    MessagingEndpointVerb::Show { .. } => "show",
1163                    MessagingEndpointVerb::LinkBundle(_) => "link-bundle",
1164                    MessagingEndpointVerb::UnlinkBundle(_) => "unlink-bundle",
1165                    MessagingEndpointVerb::SetWelcomeFlow(_) => "set-welcome-flow",
1166                    MessagingEndpointVerb::Remove(_) => "remove",
1167                    MessagingEndpointVerb::RotateWebhookSecret(_) => "rotate-webhook-secret",
1168                },
1169            },
1170        ),
1171        OpNoun::Extensions { verb } => (
1172            "extensions",
1173            match verb {
1174                ExtensionsVerb::Add => "add",
1175                ExtensionsVerb::Update => "update",
1176                ExtensionsVerb::Remove => "remove",
1177                ExtensionsVerb::Rollback => "rollback",
1178                ExtensionsVerb::List { .. } => "list",
1179            },
1180        ),
1181        OpNoun::Updates { verb } => (
1182            "updates",
1183            match verb {
1184                UpdatesVerb::Enroll(_) => "enroll",
1185                UpdatesVerb::Status { .. } => "status",
1186                UpdatesVerb::Get(_) => "get",
1187                UpdatesVerb::Apply(_) => "apply",
1188                UpdatesVerb::Recover(_) => "recover",
1189                UpdatesVerb::ConfigSet(_) => "config-set",
1190                UpdatesVerb::ConfigShow { .. } => "config-show",
1191                UpdatesVerb::PlanBuild(_) => "plan-build",
1192            },
1193        ),
1194    }
1195}
1196
1197fn dispatch_env(
1198    store: &LocalFsStore,
1199    registry: &crate::env_packs::EnvPackRegistry,
1200    flags: &OpFlags,
1201    verb: EnvVerb,
1202) -> Result<(), OpError> {
1203    let outcome = match verb {
1204        EnvVerb::Init(args) => super::env::init(store, flags, args.into_payload(flags)?)?,
1205        EnvVerb::Apply(mut args) => {
1206            if let Some(path) = args.emit_answers_template.take() {
1207                super::env_apply::emit_answers_template(&path)?
1208            } else {
1209                super::env_apply::apply(store, flags, args.into_options())?
1210            }
1211        }
1212        EnvVerb::Create(args) => {
1213            super::env::create(store, flags, args.into_payload("create", flags)?)?
1214        }
1215        EnvVerb::Update(args) => {
1216            super::env::update(store, flags, args.into_payload("update", flags)?)?
1217        }
1218
1219        EnvVerb::SetPublicUrl(args) => {
1220            super::env::set_public_url(store, flags, &args.env_id, &args.url)?
1221        }
1222        EnvVerb::List => super::env::list(store, flags)?,
1223        EnvVerb::Show { env_id } => super::env::show(store, flags, &env_id)?,
1224        EnvVerb::Doctor { env_id } => super::env::doctor(store, flags, &env_id)?,
1225        EnvVerb::ToolCheck { env_id } => super::env::tool_check(store, flags, &env_id)?,
1226        EnvVerb::Render(args) => super::env::render(store, registry, flags, args)?,
1227        EnvVerb::Reconcile(args) => super::env::reconcile(store, registry, flags, args)?,
1228        EnvVerb::ApplyRevision(args) => super::env::apply_revision(store, registry, flags, args)?,
1229        EnvVerb::ApplyTraffic(args) => super::env::apply_traffic(store, registry, flags, args)?,
1230        EnvVerb::Destroy { env_id, confirm } => {
1231            super::env::destroy(store, flags, &env_id, confirm)?
1232        }
1233        EnvVerb::MigrateDev {
1234            target,
1235            check,
1236            apply,
1237        } => {
1238            if !(check ^ apply) {
1239                return Err(OpError::InvalidArgument(
1240                    "migrate-dev requires exactly one of --check or --apply".to_string(),
1241                ));
1242            }
1243            if check {
1244                super::migrate::check(store, flags, &target)?
1245            } else {
1246                super::migrate::apply(store, flags, &target)?
1247            }
1248        }
1249        EnvVerb::MigrateState {
1250            target,
1251            check,
1252            apply,
1253            state_dir,
1254        } => {
1255            if !(check ^ apply) {
1256                return Err(OpError::InvalidArgument(
1257                    "migrate-state requires exactly one of --check or --apply".to_string(),
1258                ));
1259            }
1260            if check {
1261                super::migrate_state::check(store, flags, &target, state_dir.as_deref())?
1262            } else {
1263                super::migrate_state::apply(store, flags, &target, state_dir.as_deref())?
1264            }
1265        }
1266    };
1267    print_outcome(&outcome)
1268}
1269
1270fn dispatch_env_packs(
1271    store: &LocalFsStore,
1272    flags: &OpFlags,
1273    verb: EnvPacksVerb,
1274) -> Result<(), OpError> {
1275    let outcome = match verb {
1276        EnvPacksVerb::Add => super::env_packs::add(store, flags, None)?,
1277        EnvPacksVerb::Update => super::env_packs::update(store, flags, None)?,
1278        EnvPacksVerb::Remove => super::env_packs::remove(store, flags, None)?,
1279        EnvPacksVerb::Rollback => super::env_packs::rollback(store, flags, None)?,
1280        EnvPacksVerb::List { env_id } => super::env_packs::list(store, flags, &env_id)?,
1281    };
1282    print_outcome(&outcome)
1283}
1284
1285fn dispatch_extensions(
1286    store: &LocalFsStore,
1287    flags: &OpFlags,
1288    verb: ExtensionsVerb,
1289) -> Result<(), OpError> {
1290    let outcome = match verb {
1291        ExtensionsVerb::Add => super::extensions::add(store, flags, None)?,
1292        ExtensionsVerb::Update => super::extensions::update(store, flags, None)?,
1293        ExtensionsVerb::Remove => super::extensions::remove(store, flags, None)?,
1294        ExtensionsVerb::Rollback => super::extensions::rollback(store, flags, None)?,
1295        ExtensionsVerb::List { env_id } => super::extensions::list(store, flags, &env_id)?,
1296    };
1297    print_outcome(&outcome)
1298}
1299
1300fn dispatch_bundles(
1301    store: &LocalFsStore,
1302    flags: &OpFlags,
1303    verb: BundlesVerb,
1304) -> Result<(), OpError> {
1305    let outcome = match verb {
1306        BundlesVerb::Add => super::bundles::add(store, flags, None)?,
1307        BundlesVerb::Update => super::bundles::update(store, flags, None)?,
1308        BundlesVerb::Remove => super::bundles::remove(store, flags, None)?,
1309        BundlesVerb::List { env_id } => super::bundles::list(store, flags, &env_id)?,
1310    };
1311    print_outcome(&outcome)
1312}
1313
1314fn dispatch_revisions(
1315    store: &LocalFsStore,
1316    flags: &OpFlags,
1317    verb: RevisionsVerb,
1318) -> Result<(), OpError> {
1319    let outcome = match verb {
1320        RevisionsVerb::Stage(args) => {
1321            let payload = super::revisions::payload_from_stage_args(args)?;
1322            super::revisions::stage(store, flags, payload)?
1323        }
1324        RevisionsVerb::Warm => super::revisions::warm(store, flags, None)?,
1325        RevisionsVerb::Drain => super::revisions::drain(store, flags, None)?,
1326        RevisionsVerb::Archive => super::revisions::archive(store, flags, None)?,
1327        RevisionsVerb::List { env_id } => super::revisions::list(store, flags, &env_id)?,
1328    };
1329    print_outcome(&outcome)
1330}
1331
1332fn dispatch_traffic(
1333    store: &LocalFsStore,
1334    flags: &OpFlags,
1335    verb: TrafficVerb,
1336) -> Result<(), OpError> {
1337    let outcome = match verb {
1338        TrafficVerb::Set(args) => {
1339            let payload = super::traffic::payload_from_set_args(args)?;
1340            super::traffic::set(store, flags, payload)?
1341        }
1342        TrafficVerb::Show(args) => {
1343            let payload = super::traffic::payload_from_target_args(args)?;
1344            super::traffic::show(store, flags, payload)?
1345        }
1346        TrafficVerb::Rollback(args) => {
1347            let payload = super::traffic::payload_from_target_args(args)?;
1348            super::traffic::rollback(store, flags, payload)?
1349        }
1350    };
1351    print_outcome(&outcome)
1352}
1353
1354fn dispatch_deploy(
1355    store: &LocalFsStore,
1356    flags: &OpFlags,
1357    args: BundleDeployArgs,
1358) -> Result<(), OpError> {
1359    let payload = super::deploy::payload_from_deploy_args(args)?;
1360    let outcome = super::deploy::deploy(store, flags, payload)?;
1361    print_outcome(&outcome)
1362}
1363
1364fn dispatch_config(store: &LocalFsStore, flags: &OpFlags, verb: ConfigVerb) -> Result<(), OpError> {
1365    let outcome = match verb {
1366        ConfigVerb::Show => super::config::show(store, flags, None)?,
1367        ConfigVerb::Set => super::config::set(store, flags, None)?,
1368    };
1369    print_outcome(&outcome)
1370}
1371
1372fn dispatch_credentials(
1373    store: &LocalFsStore,
1374    registry: &crate::env_packs::EnvPackRegistry,
1375    flags: &OpFlags,
1376    verb: CredentialsVerb,
1377) -> Result<(), OpError> {
1378    let outcome = match verb {
1379        CredentialsVerb::Requirements => {
1380            super::credentials::requirements(store, registry, flags, None)?
1381        }
1382        CredentialsVerb::Bootstrap => super::credentials::bootstrap(store, registry, flags, None)?,
1383        CredentialsVerb::Rotate => super::credentials::rotate(store, registry, flags, None)?,
1384    };
1385    print_outcome(&outcome)
1386}
1387
1388fn dispatch_secrets(
1389    store: &LocalFsStore,
1390    flags: &OpFlags,
1391    verb: SecretsVerb,
1392) -> Result<(), OpError> {
1393    let outcome = match verb {
1394        SecretsVerb::List => super::secrets::list(store, flags, None)?,
1395        SecretsVerb::Put => super::secrets::put(store, flags, None)?,
1396        SecretsVerb::Get => super::secrets::get(store, flags, None)?,
1397        SecretsVerb::Rotate => super::secrets::rotate(store, flags, None)?,
1398    };
1399    print_outcome(&outcome)
1400}
1401
1402fn dispatch_trust_root(
1403    store: &LocalFsStore,
1404    flags: &OpFlags,
1405    verb: TrustRootVerb,
1406) -> Result<(), OpError> {
1407    let outcome = match verb {
1408        TrustRootVerb::Bootstrap { env_id } => {
1409            let payload = env_id
1410                .map(|id| super::trust_root::TrustRootBootstrapPayload { environment_id: id });
1411            super::trust_root::bootstrap(store, flags, payload)?
1412        }
1413        TrustRootVerb::List { env_id } => super::trust_root::list(store, flags, &env_id)?,
1414        TrustRootVerb::Add(args) => {
1415            let payload = match (args.env_id, args.key_id) {
1416                (Some(env_id), Some(key_id)) => Some(super::trust_root::TrustRootAddPayload {
1417                    environment_id: env_id,
1418                    key_id,
1419                    public_key_pem: args.public_key_pem,
1420                    public_key_file: args.public_key_file,
1421                }),
1422                _ => None, // fall through to --answers / --schema
1423            };
1424            super::trust_root::add(store, flags, payload)?
1425        }
1426        TrustRootVerb::Remove(args) => {
1427            let payload = match (args.env_id, args.key_id) {
1428                (Some(env_id), Some(key_id)) => Some(super::trust_root::TrustRootRemovePayload {
1429                    environment_id: env_id,
1430                    key_id,
1431                }),
1432                _ => None,
1433            };
1434            super::trust_root::remove(store, flags, payload)?
1435        }
1436    };
1437    print_outcome(&outcome)
1438}
1439
1440fn dispatch_updates(
1441    store: &LocalFsStore,
1442    flags: &OpFlags,
1443    verb: UpdatesVerb,
1444) -> Result<(), OpError> {
1445    let outcome = match verb {
1446        UpdatesVerb::Enroll(args) => {
1447            let payload = match (args.env_id, args.ca_url) {
1448                (Some(environment_id), Some(ca_url)) => {
1449                    Some(super::updates::UpdatesEnrollPayload {
1450                        environment_id,
1451                        ca_url,
1452                    })
1453                }
1454                _ => None, // fall through to --answers / --schema
1455            };
1456            super::updates::enroll(store, flags, payload)?
1457        }
1458        UpdatesVerb::Status { env_id } => {
1459            let payload = env_id
1460                .map(|environment_id| super::updates::UpdatesStatusPayload { environment_id });
1461            super::updates::status(store, flags, payload)?
1462        }
1463        UpdatesVerb::Get(args) => {
1464            let payload = args
1465                .env_id
1466                .map(|environment_id| super::updates::UpdatesGetPayload {
1467                    environment_id,
1468                    plan_url: args.plan_url,
1469                    plan_file: args.plan_file,
1470                    plan_sig_file: args.plan_sig_file,
1471                });
1472            super::updates::get(store, flags, payload)?
1473        }
1474        UpdatesVerb::Apply(args) => {
1475            let payload = match (args.env_id, args.plan_id) {
1476                (Some(environment_id), Some(plan_id)) => {
1477                    Some(super::updates::ApplyUpdatesPayload {
1478                        environment_id,
1479                        plan_id,
1480                    })
1481                }
1482                _ => None, // fall through to --answers / --schema
1483            };
1484            super::updates::apply_updates(store, flags, payload)?
1485        }
1486        UpdatesVerb::Recover(args) => {
1487            // `--force` is operator attestation, not a payload field: thread it
1488            // separately so it applies whether the ids come from the CLI or from
1489            // `--answers` (it is never silently dropped on the answers path).
1490            let force = args.force;
1491            let payload = match (args.env_id, args.plan_id) {
1492                (Some(environment_id), Some(plan_id)) => {
1493                    Some(super::updates::RecoverUpdatesPayload {
1494                        environment_id,
1495                        plan_id,
1496                    })
1497                }
1498                _ => None, // fall through to --answers / --schema
1499            };
1500            super::updates::recover_updates(store, flags, payload, force)?
1501        }
1502        UpdatesVerb::ConfigSet(args) => {
1503            let payload =
1504                args.env_id
1505                    .map(|environment_id| super::updates::UpdateConfigSetPayload {
1506                        environment_id,
1507                        enabled: args.enabled,
1508                        on_notify: args.on_notify,
1509                        poll_interval_secs: args.poll_interval_secs,
1510                    });
1511            super::updates::config_set(store, flags, payload)?
1512        }
1513        UpdatesVerb::ConfigShow { env_id } => {
1514            let payload = env_id
1515                .map(|environment_id| super::updates::UpdateConfigShowFilter { environment_id });
1516            super::updates::config_show(store, flags, payload)?
1517        }
1518        UpdatesVerb::PlanBuild(args) => super::updates::plan_build(store, flags, args)?,
1519    };
1520    print_outcome(&outcome)
1521}
1522
1523fn dispatch_messaging(
1524    store: &LocalFsStore,
1525    flags: &OpFlags,
1526    verb: MessagingNoun,
1527) -> Result<(), OpError> {
1528    let outcome = match verb {
1529        MessagingNoun::Endpoint { verb } => match verb {
1530            MessagingEndpointVerb::Add(args) => {
1531                super::messaging::add(store, flags, args.into_payload("add", flags)?)?
1532            }
1533            MessagingEndpointVerb::List { env_id } => {
1534                super::messaging::list(store, flags, &env_id)?
1535            }
1536            MessagingEndpointVerb::Show {
1537                env_id,
1538                endpoint_id,
1539            } => super::messaging::show(store, flags, &env_id, &endpoint_id)?,
1540            MessagingEndpointVerb::LinkBundle(args) => super::messaging::link_bundle(
1541                store,
1542                flags,
1543                args.into_payload("link-bundle", flags)?,
1544            )?,
1545            MessagingEndpointVerb::UnlinkBundle(args) => super::messaging::unlink_bundle(
1546                store,
1547                flags,
1548                args.into_payload("unlink-bundle", flags)?,
1549            )?,
1550            MessagingEndpointVerb::SetWelcomeFlow(args) => super::messaging::set_welcome_flow(
1551                store,
1552                flags,
1553                args.into_payload("set-welcome-flow", flags)?,
1554            )?,
1555            MessagingEndpointVerb::Remove(args) => {
1556                super::messaging::remove(store, flags, args.into_remove_payload("remove", flags)?)?
1557            }
1558            MessagingEndpointVerb::RotateWebhookSecret(args) => {
1559                super::messaging::rotate_webhook_secret(
1560                    store,
1561                    flags,
1562                    args.into_rotate_payload("rotate-webhook-secret", flags)?,
1563                )?
1564            }
1565        },
1566    };
1567    print_outcome(&outcome)
1568}
1569
1570/// Silence the `CapabilitySlot` re-export warning while preserving the symbol
1571/// for downstream noun modules that take a slot positional in future work.
1572#[allow(dead_code)]
1573fn _slot_anchor(_: CapabilitySlot) {}