ignition_cli/cli.rs
1//! CLI definition: the five global args (defined exactly once, here) and the
2//! subcommand enum.
3//!
4//! Placement rules (Phase 1 research, Pattern 1):
5//! - Globals set the `global` arg attribute so they propagate to every
6//! subcommand; subcommand structs never redeclare them.
7//! - Never mark a global arg `required` (clap rejects required globals).
8//! - Flags-only, zero interactive prompts — ever (research anti-pattern:
9//! "Prompting ever").
10
11use std::path::PathBuf;
12
13use clap::{ArgAction, Parser, Subcommand};
14
15#[derive(Debug, Parser)]
16#[command(
17 name = "ign",
18 version,
19 propagate_version = true,
20 about = "Operate Ignition 8.3+ gateways from the terminal"
21)]
22pub struct Cli {
23 /// Gateway profile to use (default: active profile in config)
24 #[arg(long, global = true, value_name = "NAME")]
25 pub profile: Option<String>,
26
27 /// Machine-readable JSON output (stable field names)
28 #[arg(long, global = true, action = ArgAction::SetTrue)]
29 pub json: bool,
30
31 /// One-line compact JSON (implies --json)
32 #[arg(long, global = true, action = ArgAction::SetTrue)]
33 pub compact: bool,
34
35 /// Non-interactive confirmation for destructive operations
36 #[arg(long, short = 'y', global = true, action = ArgAction::SetTrue)]
37 pub yes: bool,
38
39 /// Increase diagnostics (-vv for HTTP trace) to stderr
40 #[arg(long, short = 'v', global = true, action = ArgAction::Count)]
41 pub verbose: u8,
42
43 #[command(subcommand)]
44 pub command: Commands,
45}
46
47#[derive(Debug, Subcommand)]
48pub enum Commands {
49 /// Print version information (CLI always; gateway check when a profile resolves)
50 Version,
51
52 /// Generate shell completions (bash, zsh, fish, …) — the one stdout exception
53 #[command(arg_required_else_help = true)]
54 Completions {
55 /// Shell to generate completions for
56 #[arg(value_enum)]
57 shell: clap_complete::aot::Shell,
58 },
59
60 /// Gateway status: identity, platform, uptime, license (incl. trial countdown)
61 Status,
62
63 /// List gateway modules (healthy by default)
64 Modules {
65 /// Show quarantined modules instead of healthy ones
66 #[arg(long)]
67 quarantined: bool,
68 },
69
70 /// Gateway performance metrics (current gauges + thread counts)
71 Metrics {
72 /// Include historic chart datapoints
73 #[arg(long)]
74 history: bool,
75 },
76
77 /// List gateway sessions (designers, Perspective, Vision) — or
78 /// terminate one via the `terminate` subcommand
79 Sessions(SessionsArgs),
80
81 /// List database/OPC connections with healthcheck status as reported
82 Connections {
83 /// Filter to one connection family (default: both)
84 #[arg(long, value_enum)]
85 r#type: Option<ConnectionType>,
86 },
87
88 /// Query, tail, and download gateway logs; manage logger levels
89 Logs(LogsArgs),
90
91 /// Restart the gateway — destructive, refused without --yes;
92 /// --wait polls until RUNNING
93 Restart {
94 /// Wait for the gateway to return to RUNNING (POST, then poll
95 /// /StatusPing)
96 #[arg(long)]
97 wait: bool,
98 /// Wait budget in seconds (default 300)
99 #[arg(long, value_name = "SECS")]
100 timeout: Option<u64>,
101 /// Poll interval in seconds (default 2)
102 #[arg(long, value_name = "SECS")]
103 interval: Option<u64>,
104 },
105
106 /// Wait for a gateway state (readiness, restart completion, module)
107 #[command(arg_required_else_help = true)]
108 Wait(WaitArgs),
109
110 /// Diagnose the gateway setup: URL, liveness, commissioning, auth,
111 /// permissions, write, WebDev route, rig — exits 0 whenever the
112 /// diagnosis completes (failing checks are data)
113 Doctor {
114 /// Probe write permission (POST scan/projects — a harmless
115 /// project rescan)
116 #[arg(long)]
117 check_write: bool,
118 /// Probe one WebDev route's presence (/system/webdev/<NAME>)
119 #[arg(long, value_name = "NAME")]
120 webdev_route: Option<String>,
121 },
122
123 /// Adopt this profile's gateway: native login → mint an
124 /// Administrator-level API key (idempotent by name) → wire the
125 /// gateway's permissions → live-probe the key (read and write) →
126 /// persist the credential (OS keyring, env-var fallback). Write
127 /// access is granted only to the write levels (never a bare
128 /// Authenticated, which every API key holds); --read-only mints a
129 /// key that is proven refused write; --restrict-write removes the
130 /// bare Authenticated write entry an earlier adopt left behind.
131 /// Re-run on an adopted gateway is an all-skip no-op. Composition
132 /// flags ride the bootstrap: --project deploys the CLI's WebDev
133 /// routes, --checkout lands every enabled project locally, --bake
134 /// saves a restore-ready gwbk
135 Adopt {
136 /// Gateway login user for the native OIDC dance
137 /// (default: admin)
138 #[arg(long, value_name = "NAME")]
139 user: Option<String>,
140
141 /// The API-key name — the idempotency key (default: ign-cli)
142 #[arg(long, value_name = "NAME")]
143 key_name: Option<String>,
144
145 /// The granted security level as a slash path
146 /// (default: Authenticated/Roles/Administrator)
147 #[arg(long, value_name = "PATH")]
148 level: Option<String>,
149
150 /// A level to add to the gateway's write permissions and grant
151 /// to the key, as a slash path (repeatable; default:
152 /// Authenticated/IgnCliWrite). A bare Authenticated or Public
153 /// is refused: every API key would be able to write
154 #[arg(long = "write-level", value_name = "PATH")]
155 write_level: Vec<String>,
156
157 /// Mint a key at --level that the gateway refuses writes
158 /// (a check token). Needs its own --key-name (not the default
159 /// ign-cli, the write key). Never touches the write permissions;
160 /// refuses before minting when they already admit every key
161 #[arg(long, conflicts_with_all = ["write_level", "restrict_write"])]
162 read_only: bool,
163
164 /// Remove the bare Authenticated entry from the gateway's write
165 /// permissions after the write levels are in place (opt-in;
166 /// other API keys that relied on it lose write access)
167 #[arg(long)]
168 restrict_write: bool,
169
170 /// Deploy the CLI's WebDev routes (scriptExec on) into this
171 /// project after the bootstrap
172 #[arg(long, value_name = "NAME")]
173 project: Option<String>,
174
175 /// Ship the embedded TESTING bundle with the routes (the
176 /// Jython framework + testing/run|tags routes + the smoke
177 /// sentinel; the step asserts discover ≥1 module and a green
178 /// smoke run — the empty-suite trap)
179 #[arg(long, requires = "project")]
180 testing: bool,
181
182 /// Check out every enabled project into DIR/<project>
183 /// (scripts decoded — grep/lint ready); existing targets skip
184 #[arg(long, value_name = "DIR")]
185 checkout: Option<String>,
186
187 /// Download a roaming gwbk to FILE after everything landed —
188 /// a gateway reset restored from it keeps the key and routes
189 #[arg(long, value_name = "FILE")]
190 bake: Option<String>,
191 },
192
193 /// Obtain a live gateway login session (the native OIDC dance) —
194 /// returns the session cookie name/value, the CSRF token, the
195 /// gateway URL, and a ready-to-use Playwright `storageState`
196 /// document. The session material is emitted ONLY under the
197 /// global --json flag; the human render prints the cookie name
198 /// and the gateway URL and withholds the rest
199 #[command(arg_required_else_help = true)]
200 Session(SessionArgs),
201
202 /// Manage gateway projects: list with inheritance info, new, copy,
203 /// rename, set (reparent), delete, export/import (ZIP)
204 #[command(arg_required_else_help = true)]
205 Project(ProjectArgs),
206
207 /// Check out a project's resources to a local tree, inspect drift,
208 /// push guarded changes — the local edit loop over the
209 /// project-export interchange (project RESOURCES only; tag values
210 /// never appear in the tree — tags remain `ign tags` verbs)
211 #[command(arg_required_else_help = true)]
212 Workspace(WorkspaceArgs),
213
214 /// Declared gateway deploy: `render` turns a repo's
215 /// `gateways/<name>/` tree into the bytes the gateway holds on
216 /// disk for one environment — offline (no profile, no network, no
217 /// credentials), deterministic, with a `render_sha256` digest
218 #[command(arg_required_else_help = true)]
219 Deploy(DeployArgs),
220
221 /// Fetch a gateway resource, edit it in $EDITOR, push back
222 /// (guarded) — the kubectl-edit loop over ONE resource:
223 /// unchanged saves are clean no-ops (nothing pushed, never
224 /// prompted), a gateway changed since fetch refuses (never
225 /// force-pushed), a JSON-breaking save refuses fail-closed with
226 /// the temp tree kept (path printed). Zero stdout in every mode —
227 /// the editor owns the terminal (see README's edit section)
228 Edit(EditArgs),
229
230 /// Manage a project's individual resources: list, get, put,
231 /// delete — the surgical edit loop (change one view/script
232 /// without re-importing everything)
233 #[command(arg_required_else_help = true)]
234 Resource(ResourceArgs),
235
236 /// Manage the CLI's own WebDev routes on the gateway: deploy the
237 /// embedded bundle, verify the version handshake
238 #[command(arg_required_else_help = true)]
239 Webdev(WebdevArgs),
240
241 /// Tag operations: manage providers (native REST) and browse /
242 /// read / write tag values (the deployed WebDev routes — run
243 /// `ign webdev deploy` first)
244 #[command(arg_required_else_help = true)]
245 Tags(TagsArgs),
246
247 /// Manage a Docker compose rig: up/down/reset/status/logs/trial
248 /// (snapshot arrives in a later plan) — docker-only, no profile
249 /// needed
250 #[command(arg_required_else_help = true)]
251 Rig(RigArgs),
252
253 /// Manage gateway backups (gwbk) on any profiled gateway:
254 /// download (streamed) and restore (guarded)
255 #[command(arg_required_else_help = true)]
256 Backup(BackupArgs),
257
258 /// EAM task orchestration (Enterprise Administration Module):
259 /// history/definitions reads, guarded task create + force —
260 /// every verb honestly reports the controller-mode state gate
261 #[command(arg_required_else_help = true)]
262 Eam(EamArgs),
263
264 /// Run gateway-side Python (Jython) through the secret-gated
265 /// scriptExec route — the opt-in is STRUCTURAL (`ign webdev
266 /// deploy --with-script-exec` deploys the route + persists its
267 /// secret); there is no --yes on this verb by design
268 #[command(arg_required_else_help = true)]
269 Script(ScriptArgs),
270
271 /// Run the gateway's own Jython test suite through the deployed
272 /// testing bundle — the agent/CI verb: a machine verdict without
273 /// the Designer (`ign webdev deploy --with-testing` or
274 /// `ign adopt --project NAME --testing` installs the bundle)
275 #[command(arg_required_else_help = true)]
276 Testing(TestingArgs),
277
278 /// JVM profiling — a SPECIAL MODE, not a routine diagnostic:
279 /// these verbs ACTIVELY instrument the gateway JVM (stack
280 /// sampling burns CPU; flight recordings buffer JVM memory and
281 /// dump to gateway disk). NEVER use against a production server
282 /// outside an approved change window with a rollback plan. The
283 /// intrusive verbs require --i-accept-the-risk — the global
284 /// --yes deliberately does NOT satisfy that gate
285 #[command(arg_required_else_help = true)]
286 Jvm(JvmArgs),
287
288 /// Lint local project files by delegating to ignition-lint (PATH
289 /// discovery) — doctor posture: findings are DATA, exit 0
290 /// whenever the tool ran; --strict passes the tool's exit code
291 /// through for CI
292 Lint(LintArgs),
293
294 /// Browser end-to-end testing: diagnose the host + gateway a
295 /// Playwright suite needs (`doctor`), and scaffold one that logs
296 /// in through `ign session login` and talks to the gateway's
297 /// testing routes (`init`). There is no `e2e run` — the
298 /// scaffold's own `npm test` is the runner
299 #[command(arg_required_else_help = true)]
300 E2e(E2eArgs),
301
302 /// Raw passthrough to any gateway REST endpoint — the escape
303 /// hatch for the uncurated routes (the envelope's `data` is left
304 /// gateway-verbatim; see README's documented contract exception)
305 #[command(arg_required_else_help = true)]
306 Api(ApiArgs),
307
308 /// License inventory + trial state — the morning-check read
309 /// (trial companion merged into one command)
310 #[command(arg_required_else_help = true)]
311 License(LicenseArgs),
312
313 /// Gateway redundancy status — the morning-check read
314 #[command(arg_required_else_help = true)]
315 Redundancy(RedundancyArgs),
316
317 /// Gateway Area Network (GAN) overview — the morning-check read
318 #[command(arg_required_else_help = true)]
319 Gan(GanArgs),
320
321 /// Diagnostics support-bundle operations: generate, check
322 /// status, download (streamed), wait — the support-bundle slice
323 #[command(arg_required_else_help = true)]
324 Diagnostics(DiagnosticsArgs),
325
326 /// Manage gateway profiles
327 #[command(arg_required_else_help = true)]
328 Profile(ProfileArgs),
329
330 /// Serve the Model Context Protocol over stdio (hidden; for MCP
331 /// clients — the newline-delimited JSON-RPC 2.0 stream IS the
332 /// stdout product; the routes.rs OutOfBand row lands atomically
333 /// with this command, 08-06 contract)
334 #[command(hide = true)]
335 Mcp(McpArgs),
336
337 /// Serve LSP over stdio (hidden; for ignition-nvim — the
338 /// Content-Length-framed LSP stream IS the stdout product; the
339 /// routes.rs OutOfBand row lands atomically with this command,
340 /// 08-06 contract)
341 #[command(hide = true)]
342 Lsp,
343
344 /// Interactive TUI cockpit
345 #[cfg(feature = "tui")]
346 Tui,
347
348 /// Live gateway monitor — htop for Ignition: cpu/heap gauges,
349 /// sparkline history, and a sortable/filterable table of the
350 /// gateway's live entities (modules, sessions, connections,
351 /// providers); terminate a session with `x` (the routes.rs
352 /// Dashboard row lands atomically with this command)
353 #[cfg(feature = "tui")]
354 #[command(alias = "itop")]
355 Top,
356}
357
358/// Hidden args for `ign mcp serve` (14-01). `serve` is a RESTRICTED
359/// POSITIONAL — not a subcommand — so the clap walk (tui_coverage.rs)
360/// yields exactly ONE row-requiring node, `mcp` itself: an `mcp
361/// serve` subcommand leaf would need a second routes() row and extend
362/// the pinned OutOfBand set past its four-member contract (an orphan
363/// row fails the walk by design). clap's `value_parser = ["serve"]`
364/// keeps every other spelling (`ign mcp`, `ign mcp bogus`) a
365/// usage-class clap error — exit 2 BEFORE any protocol byte is
366/// written. Zero stdout-facing behavior here — the mode owns its own
367/// I/O entirely (`mcp::serve`).
368#[derive(Debug, clap::Args)]
369pub struct McpArgs {
370 /// Start the stdio MCP server
371 #[arg(value_parser = ["serve"], value_name = "SERVE")]
372 pub serve: String,
373}
374
375/// Wait targets (02-05, HLTH-11). `gateway` and `restart` poll the
376/// UNAUTHENTICATED /StatusPing (the dispatch builds a header-less
377/// client for them — waiting must work when auth is broken); `module`
378/// is an authed read.
379#[derive(Debug, clap::Args)]
380pub struct WaitArgs {
381 #[command(subcommand)]
382 pub command: WaitCmd,
383}
384
385#[derive(Debug, Subcommand)]
386pub enum WaitCmd {
387 /// Wait until the gateway reports RUNNING (unauthenticated
388 /// StatusPing — works even when auth is broken or absent)
389 Gateway {
390 /// Poll interval in seconds
391 #[arg(long, default_value_t = 2, value_name = "SECS")]
392 interval: u64,
393 /// Give up after this many seconds
394 #[arg(long, default_value_t = 120, value_name = "SECS")]
395 timeout: u64,
396 },
397 /// Wait for a restart to complete — restart-aware: shares
398 /// `restart --wait`'s semantics (non-RUNNING observed once →
399 /// RUNNING; a 5 s floor guards the all-RUNNING case)
400 Restart {
401 /// Poll interval in seconds
402 #[arg(long, default_value_t = 2, value_name = "SECS")]
403 interval: u64,
404 /// Give up after this many seconds (default 300)
405 #[arg(long, default_value_t = 300, value_name = "SECS")]
406 timeout: u64,
407 },
408 /// Wait until a module reports ACTIVE
409 Module {
410 /// Module id (see `ign modules`)
411 id: String,
412 /// Poll interval in seconds
413 #[arg(long, default_value_t = 2, value_name = "SECS")]
414 interval: u64,
415 /// Give up after this many seconds
416 #[arg(long, default_value_t = 120, value_name = "SECS")]
417 timeout: u64,
418 },
419}
420
421/// Project subcommands (03-01, PROJ-01/02). `delete` is the family's
422/// ONE destructive verb (`--yes`-guarded, exit 2 without); copy/
423/// rename/set create or relabel — never destroy — so they carry NO
424/// guard (planner decision per research).
425#[derive(Debug, clap::Args)]
426pub struct ProjectArgs {
427 #[command(subcommand)]
428 pub command: ProjectCommand,
429}
430
431#[derive(Debug, Subcommand)]
432pub enum ProjectCommand {
433 /// List every runnable project: name, title, enabled, parent,
434 /// inheritable (inheritance info from the items themselves)
435 List,
436 /// Create a project (only provided fields ride the create body)
437 New {
438 /// Project name
439 name: String,
440 /// Display title
441 #[arg(long, value_name = "TEXT")]
442 title: Option<String>,
443 /// Long description
444 #[arg(long, value_name = "TEXT")]
445 description: Option<String>,
446 /// Parent project (inheritance)
447 #[arg(long, value_name = "NAME")]
448 parent: Option<String>,
449 /// Mark this project eligible as a parent
450 #[arg(long)]
451 inheritable: bool,
452 /// Create the project disabled
453 #[arg(long)]
454 disabled: bool,
455 },
456 /// Copy a project with all its resources
457 Copy {
458 /// Source project name
459 src: String,
460 /// Destination name (must not exist)
461 dst: String,
462 },
463 /// Rename a project (native rename, not copy+delete)
464 Rename {
465 /// Current name
466 old_name: String,
467 /// New name
468 new_name: String,
469 },
470 /// Set project fields — --parent IS the inheritance move (reparent)
471 #[command(group(
472 clap::ArgGroup::new("set_fields")
473 .required(true)
474 .multiple(true)
475 .args(&["title", "description", "parent", "set_enabled", "disabled", "inheritable"])
476 ))]
477 Set {
478 /// Project name
479 name: String,
480 /// Display title
481 #[arg(long, value_name = "TEXT")]
482 title: Option<String>,
483 /// Long description
484 #[arg(long, value_name = "TEXT")]
485 description: Option<String>,
486 /// Parent project (reparent)
487 #[arg(long, value_name = "NAME")]
488 parent: Option<String>,
489 /// Enable the project
490 #[arg(long, conflicts_with = "disabled")]
491 set_enabled: bool,
492 /// Disable the project
493 #[arg(long)]
494 disabled: bool,
495 /// Whether this project may serve as a parent (true/false)
496 #[arg(long, value_name = "BOOL")]
497 inheritable: Option<bool>,
498 },
499 /// Delete a project — destructive, refused without --yes
500 Delete {
501 /// Project name
502 name: String,
503 },
504 /// Export a project as a ZIP archive (streams to disk)
505 Export {
506 /// Project name
507 name: String,
508 /// Output file (default: the gateway's Content-Disposition
509 /// name, else <name>.zip); with --decode-scripts, the output
510 /// DIRECTORY (default <name>-export/)
511 #[arg(short, long, value_name = "FILE")]
512 output: Option<PathBuf>,
513 /// Also decode embedded JSON scripts (Perspective view.json
514 /// etc.) into editable `<member>.<n>.py` sidecars beside a
515 /// copied member tree + scripts-manifest.json — the output is
516 /// a DIRECTORY (script-python members are already plain .py
517 /// and never decode; expressions pass through)
518 #[arg(long)]
519 decode_scripts: bool,
520 },
521 /// Import a project from a ZIP archive
522 Import {
523 /// Project name to import as
524 name: String,
525 /// ZIP file path, or - to read the archive from stdin; with
526 /// --encode-scripts, a decoded export DIRECTORY (re-zipped
527 /// with the sidecars spliced back before upload; stdin is
528 /// invalid in that mode)
529 #[arg(long, value_name = "PATH")]
530 file: String,
531 /// Collision policy: abort refuses when the name exists
532 /// (default); overwrite REPLACES the entire project —
533 /// destructive, requires --yes. merge is Designer-only (not a
534 /// value; the README documents why)
535 #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
536 collision_policy: CollisionPolicy,
537 /// --file points at a decoded export DIRECTORY (from
538 /// `export --decode-scripts`): the sidecars are spliced back
539 /// and the manifest stripped before the standard import path
540 #[arg(long)]
541 encode_scripts: bool,
542 },
543 /// Compare a project across two gateway profiles — statuses are
544 /// B-relative-to-A (`added` = in B only, `removed` = in A only,
545 /// `changed` = differing after resource.json normalization)
546 Diff {
547 /// Baseline profile (A)
548 profile_a: String,
549 /// Compared profile (B — the diff is B relative to A)
550 profile_b: String,
551 /// Project name
552 #[arg(long, value_name = "NAME")]
553 project: String,
554 },
555 /// Promote selected resources from profile A into profile B
556 /// (direction is ALWAYS A→B) — destructive on B: the whole
557 /// project is overwrite-imported, refused without --yes
558 Sync {
559 /// Source profile (A)
560 profile_a: String,
561 /// Target profile (B)
562 profile_b: String,
563 /// Project name
564 #[arg(long, value_name = "NAME")]
565 project: String,
566 /// Resource user path to promote (repeatable)
567 #[arg(long, value_name = "PATH")]
568 resource: Vec<String>,
569 /// Promote every resource the diff reports added or changed
570 /// (never removed ones — deletion needs --delete)
571 #[arg(long)]
572 all_changed: bool,
573 /// Also remove B's resources the diff reports removed
574 /// (default: upsert-only, nothing is ever deleted)
575 #[arg(long)]
576 delete: bool,
577 },
578}
579
580/// Workspace subcommands (13-07): the local edit loop. `checkout`
581/// writes the mapped tree + records `.ign-workspace.json` (the
582/// manifest IS the workspace identity — status/push read it and the
583/// user never re-types the project); `status` reports PUSH-RELATIVE
584/// drift (every row says what push would do); `push` is the guarded
585/// splice of local edits into a FRESH gateway export.
586#[derive(Debug, clap::Args)]
587pub struct WorkspaceArgs {
588 #[command(subcommand)]
589 pub command: WorkspaceCommand,
590}
591
592#[derive(Debug, Subcommand)]
593pub enum WorkspaceCommand {
594 /// Check out a project's resources to a local tree — mapped
595 /// paths + the recorded `.ign-workspace.json` manifest + an
596 /// idempotent `.gitignore` (manifest committed; codec artifacts
597 /// ignored). Read-only on the wire: one export GET, zero imports.
598 Checkout {
599 /// Project name to check out
600 project: String,
601 /// Target directory for the workspace tree (created if
602 /// absent; an unrelated non-empty directory refuses — never
603 /// clobbered)
604 target: PathBuf,
605 /// Decode embedded JSON scripts to editable .py sidecars
606 /// (nvim-editable; encodes back cleanly — an unedited
607 /// re-encode is byte-exact; `scripts-manifest.json` keys the
608 /// splice)
609 #[arg(long)]
610 decode_scripts: bool,
611 },
612 /// Report workspace drift against the gateway — states are
613 /// PUSH-RELATIVE (each row names what push would do: write /
614 /// leave / refuse); the project comes from the workspace
615 /// manifest, never re-typed. Read-only: one export GET.
616 Status {
617 /// Workspace root (default: the current directory)
618 #[arg(default_value = ".")]
619 path: PathBuf,
620 },
621 /// Push local edits to the gateway — guarded (refused without
622 /// --yes; the refusal message IS the blast-radius preview);
623 /// conflicts refuse EVEN WITH --yes (manual reconciliation);
624 /// deletions need --delete (default: reported, skipped);
625 /// untracked files are never imported; an empty selection
626 /// writes nothing.
627 Push {
628 /// Workspace root (default: the current directory)
629 #[arg(default_value = ".")]
630 path: PathBuf,
631 /// Also delete gateway members removed locally (default:
632 /// reported, skipped)
633 #[arg(long)]
634 delete: bool,
635 },
636}
637
638/// `ign deploy` args (21-04): the declared gateway deploy family.
639#[derive(Debug, clap::Args)]
640pub struct DeployArgs {
641 #[command(subcommand)]
642 pub command: DeployCommand,
643}
644
645#[derive(Debug, Subcommand)]
646pub enum DeployCommand {
647 /// Render `gateways/<name>/` for one environment into the bytes
648 /// the gateway holds on disk — offline (no profile, no network,
649 /// no credentials). Reports file and byte counts and the
650 /// `render_sha256` digest; refuses an invalid tree with
651 /// `deploy_tree_invalid`
652 Render {
653 /// Environment name; selects `targets/<ENV>.yaml`
654 #[arg(long, value_name = "ENV")]
655 env: String,
656 /// Declared repo root (default: the current directory)
657 #[arg(long, value_name = "DIR", default_value = ".")]
658 repo: PathBuf,
659 /// Directory holding `targets/<ENV>.yaml` and the target's
660 /// known_hosts files (default: --repo)
661 #[arg(long, value_name = "DIR")]
662 targets: Option<PathBuf>,
663 /// Also write the rendered tree and a `render.json` marker to
664 /// this directory: absent, empty, or a previous render of the
665 /// same gateway and environment. Never deletes; anything else
666 /// is refused (`invalid_input`) before any write
667 #[arg(long, value_name = "DIR")]
668 out: Option<PathBuf>,
669 },
670 /// Plan one environment: compare the rendered tree with the master
671 /// node's live `config/resources/` files and report each unit as
672 /// added, changed, removed, same or unmanaged. Read-only; exits 0
673 /// whether or not anything changed. Reads the host through the
674 /// master's `ssh` or `local` block; resolves no gateway session
675 Plan {
676 /// Environment name; selects `targets/<ENV>.yaml`
677 #[arg(long, value_name = "ENV")]
678 env: String,
679 /// Declared repo root (default: the current directory)
680 #[arg(long, value_name = "DIR", default_value = ".")]
681 repo: PathBuf,
682 /// Directory holding `targets/<ENV>.yaml` and the target's
683 /// known_hosts files (default: --repo)
684 #[arg(long, value_name = "DIR")]
685 targets: Option<PathBuf>,
686 /// A directory written by `deploy render --out`: a previous render
687 /// of this gateway, environment and mode. It is re-hashed, never
688 /// trusted; a base that is modified, incomplete or of another
689 /// gateway is refused (`invalid_input`). Each entry then carries
690 /// `drift`: true when a live file differs from both the base and
691 /// the desired tree
692 #[arg(long, value_name = "DIR")]
693 base: Option<PathBuf>,
694 /// Add a unified text diff (live against desired, 3 lines of
695 /// context, at most 65536 bytes per unit) to added, changed and
696 /// removed entries. Members that hold, or may hold, a secret are
697 /// withheld and print only their sha256 values; non-JSON members
698 /// print only that they differ
699 #[arg(long)]
700 diff: bool,
701 },
702 /// Bootstrap `gateways/<name>/` from the master node's live tree.
703 /// Read-only against the gateway: it reads the host through the
704 /// master's `ssh` or `local` block and writes only the local repo.
705 /// `core` files go to `resources/` and `tags/`, the target mode
706 /// collection's files to `modes/<ENV>/`. Units that hold a secret or
707 /// that render would not reproduce byte for byte are skipped and listed
708 /// with a reason; the next `plan` reports them as unmanaged. Replacing a
709 /// non-empty `resources/`, `tags/` or `modes/<ENV>/` needs the global
710 /// `--yes`; `manifest.yaml` and other environments are never touched
711 Pull {
712 /// Environment name; selects `targets/<ENV>.yaml`
713 #[arg(long, value_name = "ENV")]
714 env: String,
715 /// Declared repo root (default: the current directory)
716 #[arg(long, value_name = "DIR", default_value = ".")]
717 repo: PathBuf,
718 /// Directory holding `targets/<ENV>.yaml` and the target's
719 /// known_hosts files (default: --repo)
720 #[arg(long, value_name = "DIR")]
721 targets: Option<PathBuf>,
722 },
723 /// Verify that the master gateway runs what the target declares:
724 /// no scan running, the target's deployment mode, every declared
725 /// resource read back equal over REST, tag-unit files byte-equal on the
726 /// host, no `will not be loaded` WARN for a declared resource, and the
727 /// manifest's tag probes reading Good. Read-only: no scan is triggered,
728 /// no lock is taken, nothing is written. Resolves the master's profile
729 /// through the normal secret chain. Prints a verdict: exit 0 when green,
730 /// exit 6 with `data.slug` `deploy_verify_failed` when red
731 Verify {
732 /// Environment name; selects `targets/<ENV>.yaml`
733 #[arg(long, value_name = "ENV")]
734 env: String,
735 /// Declared repo root (default: the current directory)
736 #[arg(long, value_name = "DIR", default_value = ".")]
737 repo: PathBuf,
738 /// Directory holding `targets/<ENV>.yaml` and the target's
739 /// known_hosts files (default: --repo)
740 #[arg(long, value_name = "DIR")]
741 targets: Option<PathBuf>,
742 /// The project hosting the WebDev `tags` route used for the tag
743 /// probes
744 #[arg(long, value_name = "NAME", default_value = "ign-cli")]
745 project: String,
746 /// Which node to verify: `master` (the default) runs the six master
747 /// checks; `backup` runs only the backup node's checks (redundancy
748 /// linked, mode, REST read-back, files against the master's live
749 /// copy, and the secret names the render references); `both` runs
750 /// the master's checks, then the backup's. Needs `nodes.backup` for
751 /// `backup` and `both`. Verify never waits, never resyncs and never
752 /// writes
753 #[arg(long, value_enum, value_name = "NODE", default_value = "master")]
754 node: VerifyNodeArg,
755 },
756 /// Apply one environment: write the declared tree into the master
757 /// node's mode collection under the gateway scan-lock, scan, then verify
758 /// it; a red verify restores the pre-apply snapshot automatically
759 /// (`data.outcome` `rolled_back`, `data.slug` `deploy_rolled_back`, exit
760 /// 6). When the target has `nodes.backup`, the apply then waits for the
761 /// backup node to converge (`--backup-timeout`), sends at most one resync
762 /// to the master, and reports `data.backup`; a backup that cannot be
763 /// proven ends `data.outcome` `applied_backup_unsynced`, `data.slug`
764 /// `deploy_backup_unsynced`, exit 6, with the master left applied.
765 /// Guarded: without the global `--yes` it exits 2
766 /// `confirmation_required` and the message IS the plan (the changing
767 /// entries, the delete-set count and `plan_sha256`). `--plan-sha256`
768 /// binds the apply to the plan that was reviewed: a changed plan exits 6
769 /// `plan_stale`, and with `--json` or without a terminal the flag is
770 /// required. Takes a `.gwbk` backup first (`--skip-gwbk` records that it
771 /// did not). One fixed lock hold serves every target; a change of 1,528 or
772 /// more files (writes plus deletes) is refused by the `hold_budget`
773 /// preflight. There is no flag that skips the plan hash, the verify or
774 /// the rollback. Staging a large tree is silent on the wire and can take
775 /// minutes; in human mode progress lines go to stderr
776 Apply {
777 /// Environment name; selects `targets/<ENV>.yaml`
778 #[arg(long, value_name = "ENV")]
779 env: String,
780 /// Declared repo root (default: the current directory)
781 #[arg(long, value_name = "DIR", default_value = ".")]
782 repo: PathBuf,
783 /// Directory holding `targets/<ENV>.yaml` and the target's
784 /// known_hosts files (default: --repo)
785 #[arg(long, value_name = "DIR")]
786 targets: Option<PathBuf>,
787 /// A directory written by `deploy render --out`: a previous render
788 /// of this gateway, environment and mode. Enables the drift gate: a
789 /// live file that differs from both the base and the desired tree
790 /// refuses the apply (`destructive_change_refused`, exit 2) unless
791 /// `--allow-drift` gives a reason. An entry that a `core` copy
792 /// shadows is refused whatever the flags (`deploy_blocked`, exit 6)
793 #[arg(long, value_name = "DIR")]
794 base: Option<PathBuf>,
795 /// The project hosting the WebDev `tags` route the post-apply verify
796 /// uses for the tag probes, as in `deploy verify`
797 #[arg(long, value_name = "NAME", default_value = "ign-cli")]
798 project: String,
799 /// The `plan_sha256` of the plan that was reviewed. A different
800 /// current plan refuses the apply (`plan_stale`, exit 6). Required
801 /// with `--json` or when stdin or stdout is not a terminal
802 #[arg(long, value_name = "HASH")]
803 plan_sha256: Option<String>,
804 /// Allow a plan that deletes or replaces something destructive; the
805 /// plan lists each such entry. Without it the apply is refused
806 /// (`destructive_change_refused`, exit 2)
807 #[arg(long)]
808 allow_destructive: bool,
809 /// Go ahead over drifted entries (live changed since the `--base`
810 /// render) and record this reason in the run record (at most 500
811 /// characters)
812 #[arg(long, value_name = "REASON")]
813 allow_drift: Option<String>,
814 /// Allow a plan that wipes tag provider PROVIDER: it deletes every
815 /// tag-definition or tag-type-definition file the ledger lists for a
816 /// provider the manifest still lists in `tag_providers`, while the
817 /// render holds none of that type (`data.provider_wipes` of `deploy
818 /// plan`). Repeatable, one provider each, and `--allow-destructive`
819 /// is needed too; without it the apply is refused
820 /// (`destructive_change_refused` gate `provider_wipe`, exit 2), with
821 /// or without `--allow-destructive`. To hand a provider to a project
822 /// repo, drop it from `tag_providers` in the commit that deletes its
823 /// tree instead: nothing is deleted then
824 #[arg(long, value_name = "PROVIDER")]
825 allow_provider_wipe: Vec<String>,
826 /// What to do about open Designer sessions: `fail` refuses while any
827 /// is open, `wait` polls until none is or `--designers-timeout`
828 /// passes, `proceed` goes ahead and records the sessions
829 #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
830 designers: DesignersArg,
831 /// Seconds `--designers wait` polls before it refuses
832 #[arg(
833 long,
834 value_name = "SECS",
835 default_value_t = 600,
836 value_parser = clap::value_parser!(u64).range(1..=86400)
837 )]
838 designers_timeout: u64,
839 /// Take no `.gwbk` backup; the run record states that none was taken
840 #[arg(long)]
841 skip_gwbk: bool,
842 /// Seconds to wait for the backup node to converge after the master
843 /// is verified; then one resync is sent to the master and the wait
844 /// repeats once. Only used when the target has nodes.backup
845 #[arg(
846 long,
847 value_name = "SECS",
848 default_value_t = 300,
849 value_parser = clap::value_parser!(u64).range(1..=86400)
850 )]
851 backup_timeout: u64,
852 },
853 /// Roll back one past run by id, from a fresh process: restore the
854 /// on-box snapshot the run took into the master node's mode collection,
855 /// delete the files the run created, scan, and record the rollback (a
856 /// run directory of its own, `kind: rollback`). The run id is
857 /// `data.run_id` of the `deploy apply` that made it, or its run
858 /// directory's name. Also recovers an interrupted run: a refusal that
859 /// prints `ign deploy rollback --run <id> --env <env>` names the id to
860 /// pass. Guarded: without the global `--yes` it exits 2
861 /// `confirmation_required` and the message names the run, the gateway,
862 /// the mode and how many files it would restore and delete. A file
863 /// changed since the run refuses (`deploy_live_changed`, exit 6) unless
864 /// `--discard-live-changes`. One fixed lock hold (176 s) serves the
865 /// rollback. Not in the MCP catalog. In human mode progress lines go to
866 /// stderr
867 Rollback {
868 /// Environment name; selects `targets/<ENV>.yaml`
869 #[arg(long, value_name = "ENV")]
870 env: String,
871 /// Declared repo root (default: the current directory). Only the
872 /// target file is read; the tree is not rendered
873 #[arg(long, value_name = "DIR", default_value = ".")]
874 repo: PathBuf,
875 /// Directory holding `targets/<ENV>.yaml` and the target's
876 /// known_hosts files (default: --repo)
877 #[arg(long, value_name = "DIR")]
878 targets: Option<PathBuf>,
879 /// The run to roll back: the `run_id` that `deploy apply` printed
880 /// (or its directory below `.ign/deploy/<gateway>/`)
881 #[arg(long, value_name = "ID")]
882 run: String,
883 /// Overwrite files changed after that run and record which. Without
884 /// it a changed file refuses the rollback (`deploy_live_changed`,
885 /// exit 6)
886 #[arg(long)]
887 discard_live_changes: bool,
888 /// What to do about open Designer sessions: `fail` refuses while any
889 /// is open, `wait` polls until none is or `--designers-timeout`
890 /// passes, `proceed` goes ahead and records the sessions
891 #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
892 designers: DesignersArg,
893 /// Seconds `--designers wait` polls before it refuses
894 #[arg(
895 long,
896 value_name = "SECS",
897 default_value_t = 600,
898 value_parser = clap::value_parser!(u64).range(1..=86400)
899 )]
900 designers_timeout: u64,
901 /// Seconds to wait for the backup node to converge after the master
902 /// is restored; then one resync is sent to the master and the wait
903 /// repeats once. Only used when the target has nodes.backup
904 #[arg(
905 long,
906 value_name = "SECS",
907 default_value_t = 300,
908 value_parser = clap::value_parser!(u64).range(1..=86400)
909 )]
910 backup_timeout: u64,
911 },
912 /// Deploy one Ignition project from its own repo to a gateway. Reads
913 /// `deploy/project.yaml` (the gateway project name and the environments
914 /// it deploys to) and the root `project.json`, reads `targets/<ENV>.yaml`,
915 /// builds the artifact from the checkout and compares it with the live
916 /// project. `--check` reports `artifact_sha256`, `live_sha256`, the action
917 /// (`create` for a project absent from the gateway, `skip` when the live
918 /// project equals the artifact and this release is recorded, `record` when
919 /// it equals the artifact but no record names this release, `import`
920 /// otherwise), the member counts and the live members an overwrite would
921 /// delete, over GET requests only; it writes nothing and creates no
922 /// `.ign/` directory. Without `--check` the deploy asks for confirmation
923 /// (`--yes`), requires `--artifact-sha256` (the hash the check reported)
924 /// for an unattended run, and `--tags-sha256` too when the project owns
925 /// tag providers, saves the live project under `.ign/deploy/`,
926 /// imports the artifact, reads the project back to compare it by
927 /// canonical hash and records the release on the gateway host. Other
928 /// projects on the gateway are listed by name and never read or changed.
929 /// Not in the MCP catalog
930 Projects {
931 /// Environment name; must be listed in `deploy/project.yaml` and
932 /// selects `targets/<ENV>.yaml`
933 #[arg(long, value_name = "ENV")]
934 env: String,
935 /// The release tag being deployed: semver after one optional leading
936 /// `v` (for example v1.4.0). A pre-release is older than its release.
937 /// Required unless `--rollback-run` is given
938 #[arg(long, value_name = "TAG", required_unless_present = "rollback_run")]
939 release: Option<String>,
940 /// Undo the latest tag-owning run of this project on this gateway
941 /// from the copy the gateway host keeps (the `run_id` a deploy
942 /// printed, or the one a `deploy_journal_present` refusal names):
943 /// the owned tags, their groups, provider settings and tag records
944 /// and the project go back to that run's pre-image. With `--check`
945 /// it lists what it would restore and writes nothing; without it it
946 /// asks for confirmation (`--yes`). Tags the run deleted are
947 /// re-created; tags it created in an existing provider are removed
948 /// through the master's tag-delete route when it is configured and
949 /// ready, and otherwise end it `rollback_failed`
950 #[arg(
951 long,
952 value_name = "RUN_ID",
953 conflicts_with_all = [
954 "release",
955 "artifact_sha256",
956 "tags_sha256",
957 "allow_downgrade",
958 "allow_first_change",
959 "allow_destructive",
960 ]
961 )]
962 rollback_run: Option<String>,
963 /// The project repo root (default: the current directory)
964 #[arg(long, value_name = "DIR", default_value = ".")]
965 repo: PathBuf,
966 /// A root holding `targets/<ENV>.yaml` (default: `--repo`). Gateway
967 /// targets may live in another repository than the project
968 #[arg(long, value_name = "DIR")]
969 targets: Option<PathBuf>,
970 /// Report what would change and write nothing; no confirmation and
971 /// no `--artifact-sha256` needed
972 #[arg(long)]
973 check: bool,
974 /// The `artifact_sha256` that `--check --json` reported for this
975 /// checkout (64 lowercase hex characters). Required without `--check`
976 /// with `--json`, `--compact` or a stdin or stdout that is not a
977 /// terminal; a different artifact is refused before anything is
978 /// uploaded
979 #[arg(long, value_name = "HASH")]
980 artifact_sha256: Option<String>,
981 /// The `data.tags.tags_sha256` that `--check --json` reported for
982 /// this checkout's owned tags (64 lowercase hex characters). Required
983 /// without `--check`, under the same rule as `--artifact-sha256`,
984 /// when `deploy/project.yaml` lists `tag_providers`; different repo
985 /// tag content is refused before anything is contacted
986 #[arg(long, value_name = "HASH")]
987 tags_sha256: Option<String>,
988 /// Let a release older than the one the gateway last received from
989 /// this tool replace it. Without it that is refused as a downgrade
990 #[arg(long)]
991 allow_downgrade: bool,
992 /// Let the first deploy of a project by this tool change what the
993 /// gateway runs, with a reason of 1 to 500 characters. Without it the
994 /// release must equal the live project
995 #[arg(long, value_name = "REASON")]
996 allow_first_change: Option<String>,
997 /// Let the deploy delete the tags the previous deploy of this
998 /// project placed and the repo no longer holds; `--check` lists them
999 /// (`data.tags.removed`). The gateway needs the tag-delete route
1000 /// (`ign webdev deploy --with-tag-delete`). Without it such a removal
1001 /// is refused (`destructive_change_refused`, exit 2); tags only on
1002 /// the gateway are never deleted
1003 #[arg(long)]
1004 allow_destructive: bool,
1005 /// What to do about open Designer sessions before an import: `fail`
1006 /// refuses while any is open, `wait` polls until none is or
1007 /// `--designers-timeout` passes, `proceed` goes ahead and records
1008 /// the sessions. A release-record-only run skips the check
1009 #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
1010 designers: DesignersArg,
1011 /// Seconds `--designers wait` polls before it refuses
1012 #[arg(
1013 long,
1014 value_name = "SECS",
1015 default_value_t = 600,
1016 value_parser = clap::value_parser!(u64).range(1..=86400)
1017 )]
1018 designers_timeout: u64,
1019 /// Seconds to wait for the backup node to hold the owned tags after
1020 /// a green run or a rollback; then one resync is sent to the master
1021 /// and the wait repeats once. Only used for a project that lists
1022 /// tag_providers on a target with nodes.backup, whose session is
1023 /// only read. A backup that does not converge ends a green run
1024 /// `applied_backup_unsynced` (exit 6) with the master left applied
1025 #[arg(
1026 long,
1027 value_name = "SECS",
1028 default_value_t = ignition_core::deploy::pair::BACKUP_TIMEOUT_DEFAULT_S,
1029 value_parser = clap::value_parser!(u64).range(1..=86400)
1030 )]
1031 backup_timeout: u64,
1032 },
1033}
1034
1035/// The `--node` words of `deploy verify` (D-12).
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1037pub enum VerifyNodeArg {
1038 /// The six master checks (the default)
1039 Master,
1040 /// Only the backup node's checks
1041 Backup,
1042 /// The master's checks, then the backup's
1043 Both,
1044}
1045
1046/// The `--designers` words of `deploy apply`, `deploy rollback` and
1047/// `deploy projects` (D-14, D-15).
1048#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1049pub enum DesignersArg {
1050 /// Refuse while any Designer session is open (the default)
1051 Fail,
1052 /// Poll until none is open or the timeout passes, then refuse
1053 Wait,
1054 /// Go ahead, and record the open sessions
1055 Proceed,
1056}
1057
1058/// `ign edit` args (13-08): the kubectl-edit loop over ONE resource.
1059/// The resource path scopes what $EDITOR opens (the whole tree still
1060/// decodes for encode-back context); the push rides the
1061/// project-import wire behind the ONE confirmation gate. `--yes` is
1062/// the GLOBAL flag (globals-once) — never redeclared here.
1063#[derive(Debug, clap::Args)]
1064pub struct EditArgs {
1065 /// Project containing the resource
1066 pub project: String,
1067 /// Resource user path to open in $EDITOR (e.g.
1068 /// `ignition/script-python/e2e/scratch` — `ign resource list`
1069 /// names the valid members)
1070 pub resource_path: String,
1071}
1072
1073/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
1074/// destructive verb (`--yes`-guarded, exit 2 without — the
1075/// sessions-terminate shape); `put` is an upsert (create-or-replace
1076/// ONE resource with explicit content) and stays friction-free per
1077/// the planner decision — agents pass exactly what they want written.
1078#[derive(Debug, clap::Args)]
1079pub struct ResourceArgs {
1080 #[command(subcommand)]
1081 pub command: ResourceCommand,
1082}
1083
1084#[derive(Debug, Subcommand)]
1085pub enum ResourceCommand {
1086 /// List a project's resources (one path per line in human mode)
1087 List {
1088 /// Project name
1089 project: String,
1090 /// Only paths under this prefix (rides the wire as the
1091 /// server-side `path` filter)
1092 #[arg(long, value_name = "PREFIX")]
1093 prefix: Option<String>,
1094 },
1095 /// Read one resource: JSON pretty-printed, text raw — binary
1096 /// (data.bin-class) resources refuse with exit 6
1097 Get {
1098 /// Project name
1099 project: String,
1100 /// Resource path, slashes kept (e.g.
1101 /// `ignition/script-python/e2e/scratch`)
1102 path: String,
1103 },
1104 /// Write one resource (upsert: created if absent, replaced if
1105 /// present) — JSON if parseable (application/json), else UTF-8
1106 /// text (text/plain); binary-looking input refuses
1107 Put {
1108 /// Project name
1109 project: String,
1110 /// Resource path
1111 path: String,
1112 /// File to read the content from, or `-` for stdin
1113 #[arg(long, value_name = "PATH")]
1114 file: String,
1115 },
1116 /// Delete one resource — destructive, refused without --yes
1117 Delete {
1118 /// Project name
1119 project: String,
1120 /// Resource path
1121 path: String,
1122 },
1123}
1124
1125/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
1126/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
1127/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
1128/// on every deploy (replace-not-merge is the CONTRACT here; user
1129/// projects are never touched — README documents). scriptExec rides
1130/// only on explicit request, gated by a deploy-time generated shared
1131/// secret stored in the profile config at 0600.
1132#[derive(Debug, clap::Args)]
1133pub struct WebdevArgs {
1134 #[command(subcommand)]
1135 pub command: WebdevCommand,
1136}
1137
1138#[derive(Debug, Subcommand)]
1139pub enum WebdevCommand {
1140 /// Deploy the embedded route bundle into the dedicated project
1141 /// (overwrite-replace — the CLI owns that project wholesale)
1142 Deploy {
1143 /// Target project (default ign-cli; the CLI owns it wholesale)
1144 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1145 project: String,
1146 /// Also deploy the secret-gated scriptExec route (a fresh
1147 /// secret is generated and stored at 0600 when none exists)
1148 #[arg(long)]
1149 with_script_exec: bool,
1150 /// Generate a FRESH scriptExec secret before deploying (any
1151 /// route deployed with the old secret starts refusing)
1152 #[arg(long, requires = "with_script_exec")]
1153 rotate_secret: bool,
1154 /// Also deploy the embedded TESTING bundle — the Jython test
1155 /// framework (testing.runner & co., with the permanent
1156 /// testing.__tests__ smoke sentinel) and the testing/run +
1157 /// testing/tags WebDev routes
1158 #[arg(long)]
1159 with_testing: bool,
1160 /// Also deploy the authenticated tagDelete route (rig-only in this
1161 /// release): Basic of a user of --tag-delete-user-source holding
1162 /// --tag-delete-role, plus a generated secret stored at 0600; the
1163 /// provider allowlist is the repo's deploy/project.yaml
1164 /// tag_providers. A deploy without this flag removes the route
1165 #[arg(
1166 long,
1167 requires_all = ["tag_delete_repo", "tag_delete_user_source", "tag_delete_role"]
1168 )]
1169 with_tag_delete: bool,
1170 /// The project repo whose deploy/project.yaml names the providers
1171 /// the route may delete in (relative to the working directory).
1172 /// Repeat it to allow every repo's providers: the allowlist is the
1173 /// union, and an install that would drop a provider the live route
1174 /// allows is refused
1175 #[arg(
1176 long,
1177 value_name = "PATH",
1178 requires = "with_tag_delete",
1179 action = clap::ArgAction::Append
1180 )]
1181 tag_delete_repo: Vec<std::path::PathBuf>,
1182 /// The gateway user source the route authenticates callers against
1183 #[arg(long, value_name = "NAME", requires = "with_tag_delete")]
1184 tag_delete_user_source: Option<String>,
1185 /// The role a caller must hold
1186 #[arg(long, value_name = "ROLE", requires = "with_tag_delete")]
1187 tag_delete_role: Option<String>,
1188 /// Generate a FRESH tagDelete secret before deploying
1189 #[arg(long, requires = "with_tag_delete")]
1190 rotate_tag_delete_secret: bool,
1191 },
1192 /// Probe every route's version handshake — a READ: exit 0
1193 /// whenever the sweep completes, per-route degradation is data
1194 Status {
1195 /// Target project (default ign-cli)
1196 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1197 project: String,
1198 },
1199}
1200
1201/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
1202/// `tags provider …` nests one level deeper (the family's native
1203/// REST half), browse/read/write ride the TOP level. The
1204/// webdev-dependent arms carry `--project` (default `ign-cli`, the
1205/// same default as deploy — the deployed routes live in that
1206/// project).
1207#[derive(Debug, clap::Args)]
1208pub struct TagsArgs {
1209 #[command(subcommand)]
1210 pub command: TagsCommand,
1211}
1212
1213#[derive(Debug, Subcommand)]
1214pub enum TagsCommand {
1215 /// Manage tag providers (native config-resource REST — no
1216 /// deployed routes involved): list with tag counts + health,
1217 /// create a STANDARD provider, delete (guarded)
1218 #[command(subcommand)]
1219 Provider(TagsProviderCommand),
1220
1221 /// Browse tags as a tree (Property children filtered by
1222 /// default) — providers appear at the root; needs the deployed
1223 /// routes (`ign webdev deploy`) — or run fully OFFLINE against
1224 /// an export via --from-export
1225 Browse {
1226 /// Tag path to browse from (default: the root — providers)
1227 path: Option<String>,
1228 /// Case-insensitive substring filter on name and full path
1229 #[arg(long, value_name = "SUBSTR")]
1230 filter: Option<String>,
1231 /// Include Property children (filtered out by default)
1232 #[arg(long)]
1233 include_properties: bool,
1234 /// Project holding the deployed routes (default ign-cli)
1235 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1236 project: String,
1237 /// Browse an OFFLINE tag export instead of the gateway (no
1238 /// profile, no credential, no routes): the CLI's own `tags
1239 /// export` JSON, a legacy `<provider>.json` whole tree, or a
1240 /// git-module tags/ directory — mutually exclusive with the
1241 /// positional browse path
1242 #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
1243 from_export: Option<PathBuf>,
1244 },
1245 /// Read one or more tag values (quality and timestamp included)
1246 /// — needs the deployed routes
1247 Read {
1248 /// Tag paths to read, e.g. `[default]T1` (one or more)
1249 #[arg(value_name = "PATH", required = true)]
1250 paths: Vec<String>,
1251 /// Project holding the deployed routes (default ign-cli)
1252 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1253 project: String,
1254 },
1255 /// Write a value to a tag — the value is parsed as a JSON
1256 /// scalar (number/bool/null); anything unparseable is sent as a
1257 /// string; arrays/objects refuse — needs the deployed routes
1258 Write {
1259 /// Tag path to write, e.g. `[default]T1`
1260 path: String,
1261 /// Value to write: JSON scalar (42, 1.5, true), else the raw
1262 /// text is sent as a string
1263 #[arg(long, value_name = "V")]
1264 value: String,
1265 /// Project holding the deployed routes (default ign-cli)
1266 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1267 project: String,
1268 },
1269 /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
1270 /// JSON out): get/create/edit/delete tag configs through the
1271 /// deployed tagConfig route; stringified values are re-parsed so
1272 /// agents see real JSON
1273 #[command(subcommand)]
1274 Config(TagsConfigCommand),
1275 /// UDT types and definitions (recursive: parameters + nested
1276 /// children) — needs the deployed routes
1277 #[command(subcommand)]
1278 Udt(TagsUdtCommand),
1279 /// Alarm operations: active status (with filters), journal
1280 /// history, acknowledge — needs the deployed routes
1281 #[command(subcommand)]
1282 Alarms(TagsAlarmsCommand),
1283 /// Tag history queries (historian-backed values) — needs the
1284 /// deployed routes; structurally safe on any rig (data requires
1285 /// a provisioned historian)
1286 #[command(subcommand)]
1287 History(TagsHistoryCommand),
1288 /// Export tag subtrees — json writes the gateway's native JSON
1289 /// interchange (the lossless round-trip); xml passes the
1290 /// gateway's own XML through byte-for-byte; csv is CLI-GENERATED
1291 /// and LOSSY (no alarms, legacy 48-column set, numeric enums —
1292 /// the gateway cannot export CSV)
1293 Export {
1294 /// Tag paths to export, e.g. `[default]P5` (one or more)
1295 #[arg(value_name = "PATH", required = true)]
1296 paths: Vec<String>,
1297 /// Output file (`-` = stdout — the raw payload, no envelope;
1298 /// default: `<last-path-segment>.<ext>` in the cwd, ext per
1299 /// format)
1300 #[arg(short = 'o', long, value_name = "FILE")]
1301 output: Option<PathBuf>,
1302 /// Payload format: json (the gateway's native interchange —
1303 /// default), xml (raw gateway bytes), or csv (CLI-generated
1304 /// and LOSSY: no alarms, legacy columns only, numeric enum
1305 /// coercion — what was dropped prints on stderr)
1306 #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1307 format: TransferFormat,
1308 /// Project holding the deployed routes (default ign-cli)
1309 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1310 project: String,
1311 },
1312 /// Import a tag export into a target provider — json is the
1313 /// native interchange; xml/csv ride the gateway's importTags
1314 /// passthrough. A loss scan reports what xml/csv would drop or
1315 /// coerce BEFORE the import and refuses exit 2 unless --yes.
1316 /// Collision behavior: abort (default) refuses on collisions;
1317 /// overwrite replaces them (destructive: requires --yes)
1318 Import {
1319 /// The export file to import (`-` = stdin)
1320 #[arg(long, value_name = "FILE")]
1321 file: PathBuf,
1322 /// Target tag provider (must exist — `ign tags provider
1323 /// create NAME` first)
1324 #[arg(long, value_name = "NAME")]
1325 provider: String,
1326 /// Payload format: json (the gateway's native interchange —
1327 /// default), xml (gateway XML), or csv (legacy CSV — alarms
1328 /// never arrive; the loss scan reports the gap)
1329 #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1330 format: TransferFormat,
1331 /// Collision policy: abort refuses when tags already exist
1332 /// (default); overwrite replaces them (destructive:
1333 /// requires --yes). merge is Designer-only (not a value)
1334 #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
1335 collision_policy: CollisionPolicy,
1336 /// Project holding the deployed routes (default ign-cli)
1337 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1338 project: String,
1339 },
1340}
1341
1342/// The bulk-transfer payload format (11-05) — the clap surface for
1343/// core's `ExportFormat`/`ImportFormat` (core keeps plain enums; the
1344/// From impls live beside this one, the CollisionPolicy precedent).
1345/// json is the default so every pre-11-05 invocation parses and
1346/// behaves byte-identically.
1347#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
1348pub enum TransferFormat {
1349 /// The gateway's JSON interchange (default — lossless)
1350 #[default]
1351 Json,
1352 /// Gateway XML — RAW-BYTE passthrough both directions (no
1353 /// parse/normalize in the transfer path)
1354 Xml,
1355 /// Legacy CSV — import: gateway passthrough (alarms never
1356 /// arrive); export: CLI-GENERATED and LOSSY (the gateway cannot
1357 /// export CSV)
1358 Csv,
1359}
1360
1361impl std::fmt::Display for TransferFormat {
1362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1363 let label = match self {
1364 Self::Json => "json",
1365 Self::Xml => "xml",
1366 Self::Csv => "csv",
1367 };
1368 f.write_str(label)
1369 }
1370}
1371
1372/// `tags config …` — the configuration CRUD subfamily (05-05,
1373/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
1374/// files are the configure shape (README's traps table: tagType
1375/// discriminator, nested children, alarms-as-LIST).
1376#[derive(Debug, Subcommand)]
1377pub enum TagsConfigCommand {
1378 /// Get a tag's configuration as (pretty) JSON — stringified
1379 /// value/defaultValue sub-dicts re-parsed into real JSON
1380 Get {
1381 /// Tag path, e.g. `[default]P5/T1`
1382 path: String,
1383 /// Project holding the deployed routes (default ign-cli)
1384 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1385 project: String,
1386 },
1387 /// Create a tag from a JSON definition file (`-` = stdin) —
1388 /// aborts on an existing node (collision policy 'a')
1389 Create {
1390 /// Tag path to create, e.g. `[default]P5/T1`
1391 path: String,
1392 /// JSON definition file (`-` = stdin): `{tagType, value,
1393 /// alarms, …}` — see the README configure-shape table
1394 #[arg(long, value_name = "FILE")]
1395 file: PathBuf,
1396 /// Project holding the deployed routes (default ign-cli)
1397 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1398 project: String,
1399 },
1400 /// Edit a tag's configuration from a JSON definition file (`-` =
1401 /// stdin) — overwrites that single node (collision policy 'o')
1402 Edit {
1403 /// Tag path to edit, e.g. `[default]P5/T1`
1404 path: String,
1405 /// JSON definition file (`-` = stdin)
1406 #[arg(long, value_name = "FILE")]
1407 file: PathBuf,
1408 /// Project holding the deployed routes (default ign-cli)
1409 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1410 project: String,
1411 },
1412 /// Delete tag configurations — destructive, refused without
1413 /// --yes (the guard fires before ANY resolution: zero network
1414 /// work)
1415 Delete {
1416 /// Tag paths to delete (one or more)
1417 #[arg(value_name = "PATH", required = true)]
1418 paths: Vec<String>,
1419 /// Project holding the deployed routes (default ign-cli)
1420 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1421 project: String,
1422 },
1423}
1424
1425/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
1426#[derive(Debug, Subcommand)]
1427pub enum TagsUdtCommand {
1428 /// List the provider's UDT types
1429 Types {
1430 /// Tag provider whose `_types_` folder to browse (default
1431 /// `default`)
1432 #[arg(long, default_value = "default", value_name = "NAME")]
1433 provider: String,
1434 /// Project holding the deployed routes (default ign-cli)
1435 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1436 project: String,
1437 },
1438 /// Get a UDT definition (parameters + nested children, recursive)
1439 Def {
1440 /// UDT type name, e.g. `Motor`
1441 name: String,
1442 /// Tag provider (default `default`)
1443 #[arg(long, default_value = "default", value_name = "NAME")]
1444 provider: String,
1445 /// Project holding the deployed routes (default ign-cli)
1446 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1447 project: String,
1448 },
1449}
1450
1451#[derive(Debug, Subcommand)]
1452pub enum TagsProviderCommand {
1453 /// List the gateway's tag providers (tag counts + health)
1454 List,
1455 /// Create a STANDARD tag provider (DB-backed providers are out
1456 /// of scope at MVP)
1457 Create {
1458 /// Provider name to create
1459 name: String,
1460 },
1461 /// Delete a tag provider — destructive, refused without --yes
1462 Delete {
1463 /// Provider name to delete
1464 name: String,
1465 },
1466}
1467
1468/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
1469/// needs a JOURNAL-PROVISIONED gateway (database connection +
1470/// alarm-journal profile + general-alarm-settings — default rigs
1471/// refuse exit 6 with the provisioning hint); acknowledge is the
1472/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
1473/// default-guessing) and ack is deliberately NOT `--yes`-guarded
1474/// (acknowledging never un-acknowledges anything — a state-advancing
1475/// read-adjacent verb).
1476#[derive(Debug, Subcommand)]
1477pub enum TagsAlarmsCommand {
1478 /// List active alarms — eventId/source/state/priority/name
1479 Active {
1480 /// Filter by alarm source (e.g. `prov:default`)
1481 #[arg(long, value_name = "SOURCE")]
1482 source: Option<String>,
1483 /// Filter by priority (e.g. `High`)
1484 #[arg(long, value_name = "PRIORITY")]
1485 priority: Option<String>,
1486 /// Filter by state (e.g. `Active, Unacknowledged`)
1487 #[arg(long, value_name = "STATE")]
1488 state: Option<String>,
1489 /// Project holding the deployed routes (default ign-cli)
1490 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1491 project: String,
1492 },
1493 /// Query alarm history — requires a journal-provisioned gateway
1494 /// (default rigs refuse with the provisioning hint naming the
1495 /// missing chain)
1496 History {
1497 /// Window start: RFC3339 timestamp or epoch-ms
1498 #[arg(long, value_name = "T")]
1499 start: String,
1500 /// Window end: RFC3339 timestamp or epoch-ms
1501 #[arg(long, value_name = "T")]
1502 end: String,
1503 /// Project holding the deployed routes (default ign-cli)
1504 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1505 project: String,
1506 },
1507 /// Acknowledge alarms (explicit --username: the 3-arg wire form
1508 /// needs it). NOT --yes-guarded — acknowledging never
1509 /// un-acknowledges anything
1510 Ack {
1511 /// Alarm event ids to acknowledge (from `alarms active`)
1512 #[arg(value_name = "ID", required = true)]
1513 ids: Vec<String>,
1514 /// Acknowledgement note (default: empty)
1515 #[arg(long, value_name = "NOTE")]
1516 note: Option<String>,
1517 /// The username acknowledging (REQUIRED — the 3-arg wire
1518 /// form needs it; the CLI never guesses one)
1519 #[arg(long, value_name = "NAME")]
1520 username: String,
1521 /// Project holding the deployed routes (default ign-cli)
1522 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1523 project: String,
1524 },
1525}
1526
1527/// `tags history …` — the historian subfamily (05-06, TAGS-08).
1528#[derive(Debug, Subcommand)]
1529pub enum TagsHistoryCommand {
1530 /// Query historical tag values — t_stamp + one column per tag;
1531 /// structurally safe anywhere (data requires a provisioned
1532 /// historian)
1533 Query {
1534 /// Tag paths to query, e.g. `[default]T1` (one or more)
1535 #[arg(value_name = "PATH", required = true)]
1536 paths: Vec<String>,
1537 /// Window start: RFC3339 timestamp or epoch-ms
1538 #[arg(long, value_name = "T")]
1539 start: String,
1540 /// Window end: RFC3339 timestamp or epoch-ms
1541 #[arg(long, value_name = "T")]
1542 end: String,
1543 /// Maximum rows returned
1544 #[arg(long, value_name = "N")]
1545 return_size: Option<i64>,
1546 /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
1547 /// default is the route's LastValue
1548 #[arg(long, value_name = "MODE")]
1549 aggregation: Option<String>,
1550 /// Project holding the deployed routes (default ign-cli)
1551 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1552 project: String,
1553 },
1554}
1555
1556/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
1557/// SessionsArgs `--type` precedent) so every rig verb shares it. The
1558/// command enum carries ONLY the wired verbs — later plans extend it
1559/// one variant at a time (the established extend-per-plan chore; no
1560/// `unimplemented!()` stubs, every commit compiles clippy-clean).
1561#[derive(Debug, clap::Args)]
1562pub struct RigArgs {
1563 /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
1564 /// then the cwd/convention scan)
1565 #[arg(long, value_name = "NAME")]
1566 pub rig: Option<String>,
1567
1568 #[command(subcommand)]
1569 pub command: RigCommand,
1570}
1571
1572#[derive(Debug, Subcommand)]
1573pub enum RigCommand {
1574 /// Bring the rig up (compose up -d --wait) and wait for the
1575 /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
1576 /// hint in warnings)
1577 Up {
1578 /// Wait budget in seconds — BOTH compose's --wait-timeout and
1579 /// the commissioned probe deadline (default 300)
1580 #[arg(long, default_value_t = 300, value_name = "SECS")]
1581 timeout: u64,
1582
1583 /// Provision a third-party module for THIS invocation only
1584 /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. ADDITIVE to
1585 /// any `[rigs.NAME.modules.*]` declared in config — a flag
1586 /// naming an id config already declares OVERRIDES that id's
1587 /// version for this run; there is NO subtractive form (a
1588 /// config-declared module cannot be omitted via this flag —
1589 /// remove it from config instead)
1590 #[arg(long, value_name = "ID@VERSION")]
1591 with_module: Vec<String>,
1592
1593 /// Re-verify every already-cached module version against its
1594 /// release feed before trusting it: an unchanged digest reuses
1595 /// the cache, a changed one REFUSES (exit 6) naming both
1596 /// digests — never silently accepted. Mutually exclusive with
1597 /// --accept-upstream-change (never implied by it)
1598 #[arg(long, conflicts_with = "accept_upstream_change")]
1599 refresh: bool,
1600
1601 /// Accept a changed digest the feed now reports for an
1602 /// already-cached module version — the deliberate, explicit
1603 /// way to accept a re-released artifact (downloads and
1604 /// verifies the NEW bytes, cached alongside the old entry,
1605 /// never over it). Mutually exclusive with --refresh
1606 #[arg(long, conflicts_with = "refresh")]
1607 accept_upstream_change: bool,
1608 },
1609 /// Stop the rig (compose down --remove-orphans; volumes KEPT —
1610 /// `reset` owns the teardown half)
1611 Down,
1612 /// Tear the rig down AND remove its volumes (down -v
1613 /// --remove-orphans), then bring it back up fresh — destructive,
1614 /// refused without --yes; no stale project/trial state survives
1615 Reset {
1616 /// Wait budget in seconds — BOTH compose's --wait-timeout and
1617 /// the commissioned probe deadline (default 300)
1618 #[arg(long, default_value_t = 300, value_name = "SECS")]
1619 timeout: u64,
1620
1621 /// Provision a third-party module for THIS invocation only
1622 /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. Same
1623 /// additive/flag-wins/no-subtractive-form contract as `rig up
1624 /// --with-module` (`reset` runs the same up-half internally)
1625 #[arg(long, value_name = "ID@VERSION")]
1626 with_module: Vec<String>,
1627
1628 /// Re-verify every already-cached module version against its
1629 /// release feed before trusting it (see `rig up --refresh`).
1630 /// Mutually exclusive with --accept-upstream-change
1631 #[arg(long, conflicts_with = "accept_upstream_change")]
1632 refresh: bool,
1633
1634 /// Accept a changed digest the feed now reports for an
1635 /// already-cached module version (see `rig up
1636 /// --accept-upstream-change`). Mutually exclusive with --refresh
1637 #[arg(long, conflicts_with = "refresh")]
1638 accept_upstream_change: bool,
1639 },
1640 /// Structured status: services, ports, volumes (allowlist JSON;
1641 /// a down rig is exit-0 data)
1642 Status,
1643 /// Stream the rig's container logs (compose logs passthrough —
1644 /// raw lines, no envelope in any mode; the third streaming
1645 /// exception, README-documented)
1646 Logs {
1647 /// Lines to show from the end of each service's logs
1648 #[arg(long, default_value_t = 200, value_name = "N")]
1649 tail: u32,
1650 /// Follow: stream new lines as they occur (Ctrl-C stops —
1651 /// default process kill, no envelope)
1652 #[arg(short = 'f', long)]
1653 follow: bool,
1654 /// One service's logs only (see `ign rig status` for names)
1655 service: Option<String>,
1656 },
1657 /// Trial-license state: status is credential-free truth; reset
1658 /// (guarded) restarts an EXPIRED trial via the mechanism ladder
1659 /// (token-auth POST, else native gateway login)
1660 Trial(TrialArgs),
1661 /// Third-party module lifecycle beyond declaration: `uninstall`
1662 /// removes a module from the gateway entirely — guarded, and NOT
1663 /// undone by re-declaring it (Phase 19)
1664 Module(ModuleArgs),
1665 /// Snapshot the rig's gateway: native gwbk (roaming backup,
1666 /// streamed) + per-project exports + manifest.json, composed in a
1667 /// timestamped directory — repeatable state
1668 Snapshot {
1669 /// Output directory (default:
1670 /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
1671 #[arg(short = 'o', long, value_name = "DIR")]
1672 output: Option<PathBuf>,
1673 },
1674 /// Restore a gwbk onto the rig's gateway — destructive, refused
1675 /// without --yes; synchronous restore + restart, then a witnessed
1676 /// RUNNING wait
1677 Restore {
1678 /// The gwbk file to restore (from `ign rig snapshot`)
1679 #[arg(long, value_name = "PATH")]
1680 file: PathBuf,
1681 /// Post-restore RUNNING wait budget in seconds (floored at
1682 /// 300 — the gateway restarts after a restore)
1683 #[arg(long, default_value_t = 300, value_name = "SECS")]
1684 timeout: u64,
1685 },
1686}
1687
1688/// Trial subcommands (04-03, RIG-02/03). `status` reads the
1689/// unauthenticated trial endpoint + banners cross-check. `reset` is
1690/// the family's destructive verb (`--yes`-guarded, exit 2 without —
1691/// the reset precedent); the password NEVER rides a flag (env/secret
1692/// only — redaction discipline).
1693#[derive(Debug, clap::Args)]
1694pub struct TrialArgs {
1695 #[command(subcommand)]
1696 pub command: TrialCommand,
1697}
1698
1699#[derive(Debug, Clone, Subcommand)]
1700pub enum TrialCommand {
1701 /// Show the trial state: licenseMode, trialState, seconds left,
1702 /// expired — plus the banners cross-check. No credential needed
1703 /// (the endpoints answer unauthenticated — fresh-rig friendly)
1704 Status,
1705 /// Reset an EXPIRED trial to a fresh window — destructive,
1706 /// refused without --yes. Mechanism ladder: API-token POST
1707 /// (IGNITION_TOKEN) → native gateway login (--user /
1708 /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
1709 /// (trial_not_expired)
1710 Reset {
1711 /// Gateway admin username for the login rung (password comes
1712 /// from IGNITION_PASSWORD — never a flag)
1713 #[arg(long, value_name = "NAME")]
1714 user: Option<String>,
1715 },
1716}
1717
1718/// Module subcommands (Phase 19) — `uninstall` is the family's ONE
1719/// destructive verb: guarded (`--yes`-refused, the `rig reset`
1720/// precedent), and, unlike every other guarded verb in this codebase,
1721/// NOT undone by re-declaring the module afterward (recovery today
1722/// means `ign rig reset`, which destroys the rig's data volume).
1723#[derive(Debug, clap::Args)]
1724pub struct ModuleArgs {
1725 #[command(subcommand)]
1726 pub command: ModuleCommand,
1727}
1728
1729#[derive(Debug, Clone, Subcommand)]
1730pub enum ModuleCommand {
1731 /// Remove a module from the gateway entirely — destructive, refused
1732 /// without `--yes`. `ID` is a REGISTRY id (the same one `rig up
1733 /// --with-module` takes; see `ign rig up --with-module`), never a
1734 /// raw gateway module id — a hand-installed module cannot be named
1735 /// here. Needs `IGNITION_TOKEN`. The gateway refuses while the
1736 /// `.modl` is still mounted: undeclare the module and run `ign rig
1737 /// up` first. A successful uninstall is NOT reversible by
1738 /// re-declaring — recovery today means `ign rig reset --yes`,
1739 /// which destroys the rig's data volume.
1740 Uninstall {
1741 /// The registry id to uninstall (e.g. `git`).
1742 id: String,
1743 },
1744}
1745
1746/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
1747/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
1748/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
1749/// state); `download` is a streamed read, unguarded.
1750#[derive(Debug, clap::Args)]
1751pub struct BackupArgs {
1752 #[command(subcommand)]
1753 pub command: BackupCommand,
1754}
1755
1756#[derive(Debug, Subcommand)]
1757pub enum BackupCommand {
1758 /// Download a gwbk backup (streamed to disk)
1759 Download {
1760 /// Output file (default: the gateway's Content-Disposition
1761 /// name, else <profile>-backup.gwbk)
1762 #[arg(short, long, value_name = "FILE")]
1763 output: Option<PathBuf>,
1764 /// Backup type: roaming = portable across gateways (default);
1765 /// all includes gateway-specific state
1766 #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
1767 r#type: CliBackupType,
1768 },
1769 /// Restore a gwbk onto THIS gateway — destructive, refused
1770 /// without --yes; the gateway restarts and blocks for minutes
1771 /// after the restore
1772 Restore {
1773 /// The gwbk file to restore (from `ign backup download` or
1774 /// `ign rig snapshot`)
1775 file: PathBuf,
1776 },
1777}
1778
1779/// CLI value-enum mirror of the core `BackupType` (ignition-core
1780/// stays clap-free; `From` converts at the dispatch seam).
1781#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1782pub enum CliBackupType {
1783 /// Portable backup (cross-gateway)
1784 Roaming,
1785 /// Includes gateway-specific state
1786 All,
1787}
1788
1789impl From<CliBackupType> for ignition_core::client::backup::BackupType {
1790 fn from(value: CliBackupType) -> Self {
1791 match value {
1792 CliBackupType::Roaming => Self::Roaming,
1793 CliBackupType::All => Self::All,
1794 }
1795 }
1796}
1797
1798/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
1799/// guarded writes. Every runtime verb honestly reports the
1800/// controller-mode state gate (`eam_not_controller` on a stock
1801/// gateway — the README documents the manual flip).
1802#[derive(Debug, clap::Args)]
1803pub struct EamArgs {
1804 #[command(subcommand)]
1805 pub command: EamCommand,
1806}
1807
1808#[derive(Debug, Subcommand)]
1809pub enum EamCommand {
1810 /// EAM task run history (the gateway's own newest-first order)
1811 History {
1812 /// Max entries (default 200 — ALWAYS sent explicitly; the
1813 /// server default is unlimited)
1814 #[arg(long, value_name = "N")]
1815 limit: Option<u32>,
1816 /// Substring search over task names
1817 #[arg(long, value_name = "TEXT")]
1818 search: Option<String>,
1819 },
1820 /// Task definitions: bare `ign eam tasks` lists; with a name
1821 /// shows one definition + its scheduled state
1822 Tasks {
1823 /// One task definition's detail (omit to list all)
1824 name: Option<String>,
1825 },
1826 /// Task definition writes (create; force-dispatch) — `eam task`
1827 /// requires a subcommand (the `rig trial` shape)
1828 #[command(subcommand)]
1829 Task(EamTaskCommand),
1830}
1831
1832/// `eam task …` — the guarded writes (07-02 Task 3; the five
1833/// lifecycle/mutation verbs arrive 10-04). `new` carries the typed
1834/// guard ladder (backup+OnDemand unguarded; mutating types and any
1835/// non-OnDemand schedule need --yes; restore/install/upgrade types
1836/// refuse outright — see README); `force` dispatches NOW, always
1837/// guarded; `suspend`/`resume`/`cancel`/`modify`/`delete` are the
1838/// two-tier guarded verbs (pure precheck → blast-radius preview
1839/// fetch → confirm-with-preview → action — the refusal message IS
1840/// the blast radius).
1841#[derive(Debug, Subcommand)]
1842pub enum EamTaskCommand {
1843 /// Create a task definition (scheduleMode defaults to OnDemand —
1844 /// never auto-fires)
1845 New {
1846 /// Task definition name (any resource name — e.g. nightly-backup)
1847 name: String,
1848 /// Task type — the openapi taxonomy, three classes with different guard consequences:
1849 ///
1850 /// - benign (no --yes with the default OnDemand schedule): eam_backup
1851 ///
1852 /// - mutating (need --yes — they act on their agent targets when dispatched):
1853 /// eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1854 /// eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1855 ///
1856 /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1857 /// eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1858 ///
1859 /// Any OTHER type fails safe to the --yes rung (unknown future
1860 /// types stay accepted, never silently unguarded).
1861 ///
1862 /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1863 r#type: String,
1864 /// Target gateway name (repeatable; the GNET agent id)
1865 #[arg(long, value_name = "NAME")]
1866 target: Vec<String>,
1867 /// Setting as K=V with scalar auto-typing — bool/int ride
1868 /// typed, anything else stays a string (repeatable;
1869 /// arrays/objects need --definition)
1870 #[arg(long, value_name = "K=V")]
1871 setting: Vec<String>,
1872 /// Full-JSON settings file deep-merged over the composed
1873 /// `config.settings` (the typed/array settings path)
1874 #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1875 definition: Option<PathBuf>,
1876 /// Schedule mode (default OnDemand — never auto-fires;
1877 /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1878 #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1879 schedule_mode: ScheduleMode,
1880 },
1881 /// Force-dispatch a task NOW — destructive, refused without
1882 /// --yes (it dispatches to the agent targets immediately). The
1883 /// confirmation prompt (and every refusal) carries the
1884 /// blast-radius preview line.
1885 Force {
1886 /// Task definition name to dispatch
1887 name: String,
1888 },
1889 /// Suspend a task definition's scheduled dispatches — TASK-scoped
1890 /// runtime verb (there is NO agent-level suspend on the wire; an
1891 /// "agent" is suspended by suspending its tasks). Refused exit 2
1892 /// without --yes, the blast-radius preview in the refusal
1893 /// message; on a stock gateway the runtime seam honestly refuses
1894 /// `eam_not_controller` (exit 6).
1895 Suspend {
1896 /// Task definition name to suspend
1897 name: String,
1898 },
1899 /// Resume a suspended task definition — the suspend inverse
1900 /// (TASK-scoped, like suspend). Refused exit 2 without --yes
1901 /// with the blast-radius preview in the message.
1902 Resume {
1903 /// Task definition name to resume
1904 name: String,
1905 },
1906 /// Cancel a task's PENDING execution (TASK-scoped). Refused exit
1907 /// 2 without --yes with the blast-radius preview in the message;
1908 /// nothing pending is an honest no-op (`fired: false`), a
1909 /// `canCancel: false` row reports the gateway's own refusal.
1910 Cancel {
1911 /// Task definition whose pending execution to cancel
1912 name: String,
1913 },
1914 /// Rewrite targeted keys of a task definition — the full-record
1915 /// read-modify-write (every key the gateway answered rides back;
1916 /// only the targeted keys change). Rename is deliberately
1917 /// ABSENT (the wire answers PUT-rename with 404 — see README's
1918 /// create-new + delete-old composite). Refused exit 2 without
1919 /// --yes with the blast-radius preview in the message.
1920 ///
1921 /// Example: ign eam task modify nightly-backup --enable
1922 Modify {
1923 /// Task definition to modify
1924 name: String,
1925 /// Flip the definition's `enabled` key to true
1926 #[arg(long)]
1927 enable: bool,
1928 /// Flip the definition's `enabled` key to false
1929 /// (conflicts with --enable)
1930 #[arg(long, conflicts_with = "enable")]
1931 disable: bool,
1932 /// Rewrite `config.profile.scheduleMode` (e.g. OnDemand,
1933 /// Scheduled — the scheduleDetails string itself is out of
1934 /// scope; the read-back shows the landed pairing)
1935 #[arg(long, value_name = "MODE")]
1936 schedule_mode: Option<String>,
1937 /// Deep-merge a settings entry over the found
1938 /// `config.settings` (K=V with scalar auto-typing;
1939 /// repeatable — objects merge recursively, arrays/scalars
1940 /// replace)
1941 #[arg(long, value_name = "K=V")]
1942 setting: Vec<String>,
1943 /// Rewrite the definition's `description`
1944 #[arg(long, value_name = "TEXT")]
1945 description: Option<String>,
1946 },
1947 /// Delete a task definition — signature-keyed (the gateway
1948 /// refuses a stale signature), behind the same --yes guard.
1949 /// Refused exit 2 without --yes with the blast-radius preview
1950 /// in the message.
1951 Delete {
1952 /// Task definition to delete
1953 name: String,
1954 },
1955}
1956
1957/// Schedule modes (the openapi taxonomy's user-facing subset —
1958/// SuspendedByFailover is system-owned, not a CLI value).
1959#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1960pub enum ScheduleMode {
1961 /// Never fires on its own (force dispatches it)
1962 OnDemand,
1963 /// Fires immediately on create (requires --yes)
1964 Immediate,
1965 /// Fires on a schedule (requires --yes)
1966 Scheduled,
1967 /// Fires at an absolute time (requires --yes)
1968 AtTime,
1969 /// Fires after a delay (requires --yes)
1970 AtDelay,
1971}
1972
1973impl ScheduleMode {
1974 /// The wire token.
1975 pub fn wire(self) -> &'static str {
1976 match self {
1977 Self::OnDemand => "OnDemand",
1978 Self::Immediate => "Immediate",
1979 Self::Scheduled => "Scheduled",
1980 Self::AtTime => "AtTime",
1981 Self::AtDelay => "AtDelay",
1982 }
1983 }
1984}
1985
1986/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1987/// verb over the already-shipped, already-secured scriptExec route.
1988/// `script` requires a subcommand (the `rig trial` shape — no bare
1989/// row); the opt-in is STRUCTURAL (the route deploys only via
1990/// `ign webdev deploy --with-script-exec`, which persists the
1991/// secret at 0600), so `run` carries NO `--yes` guard by design —
1992/// the deploy flag IS the opt-in and agents need the verb
1993/// non-interactive (the research-adopted decision).
1994#[derive(Debug, clap::Args)]
1995pub struct ScriptArgs {
1996 #[command(subcommand)]
1997 pub command: ScriptCommand,
1998}
1999
2000/// Testing subcommands (QUICK-p0g) — the standalone wrapper over the
2001/// gateway testing bundle's `testing/run` route. `testing` requires a
2002/// subcommand (the `script` shape — no bare row); there is no `--yes`
2003/// guard: running a test suite is a read-shaped operation from the
2004/// CLI's side and agents need the verb non-interactive.
2005#[derive(Debug, clap::Args)]
2006pub struct TestingArgs {
2007 #[command(subcommand)]
2008 pub command: TestingCommand,
2009}
2010
2011/// `ign e2e` args (QUICK-tg4) — the browser-E2E pair.
2012#[derive(Debug, clap::Args)]
2013pub struct E2eArgs {
2014 #[command(subcommand)]
2015 pub command: E2eCmd,
2016}
2017
2018#[derive(Debug, Subcommand)]
2019pub enum E2eCmd {
2020 /// Diagnose the browser-E2E setup: node (≥20), npm,
2021 /// @playwright/test in the scaffold, a downloaded chromium, the
2022 /// gateway's testing bundle, and the gateway's trial state.
2023 /// Read-only and offline-safe — **exits 0 whenever the diagnosis
2024 /// completes**, so every finding is a `checks[]` row rather than
2025 /// an exit code. The two gateway rows report `skip` when no
2026 /// profile resolves or no --project is given
2027 Doctor {
2028 /// The scaffold directory to inspect (default: ./e2e)
2029 #[arg(value_name = "DIR")]
2030 dir: Option<PathBuf>,
2031 /// The gateway project whose testing bundle is probed. No
2032 /// default — without it the bundle row honestly skips
2033 #[arg(long, value_name = "NAME")]
2034 project: Option<String>,
2035 },
2036
2037 /// Scaffold a Playwright E2E suite into DIR (default ./e2e): a
2038 /// runner config, a global setup that logs in through `ign
2039 /// session login` and gates on `ign testing run`, gateway tag/
2040 /// script helpers, an example suite, a .gitignore, and a README —
2041 /// then `npm install`. **Idempotent**: an existing file is
2042 /// reported `skipped` and never overwritten. Requires the global
2043 /// --yes; without it the verb writes nothing, spawns nothing, and
2044 /// refuses exit 2 after previewing every file and both commands
2045 Init {
2046 /// Where the scaffold lands (default: ./e2e)
2047 #[arg(value_name = "DIR")]
2048 dir: Option<PathBuf>,
2049 /// The project whose WebDev testing routes the helpers call
2050 /// (default: ign-cli)
2051 #[arg(long, value_name = "NAME")]
2052 project: Option<String>,
2053 /// The Perspective project the browser test navigates to
2054 /// (default: the effective --project)
2055 #[arg(long, value_name = "NAME")]
2056 run_project: Option<String>,
2057 /// Also download the Chromium build after installing
2058 #[arg(long)]
2059 browsers: bool,
2060 },
2061}
2062
2063/// `ign session` args (QUICK-tg4) — the SINGULAR verb family: one
2064/// gateway login session for this CLI to hand to a browser harness.
2065///
2066/// The plural `ign sessions` family (`SessionsArgs` / `SessionsCommand`)
2067/// is a different thing entirely: it LISTS the gateway's connected
2068/// designer/Perspective/Vision sessions. The names are deliberately
2069/// kept apart here so a future reader never merges them.
2070#[derive(Debug, clap::Args)]
2071pub struct SessionArgs {
2072 #[command(subcommand)]
2073 pub command: SessionCmd,
2074}
2075
2076#[derive(Debug, Subcommand)]
2077pub enum SessionCmd {
2078 /// Log in to the gateway and return the live session: the
2079 /// `webui-sid-*` cookie name and value, the CSRF token for
2080 /// `X-CSRF-Token`, the gateway URL, and `storage_state` — a
2081 /// Playwright `storageState` document ready to write straight to
2082 /// disk and point `use.storageState` at. The password is env-only
2083 /// (`IGNITION_PASSWORD`; missing exits 3, rejected exits 5). The
2084 /// cookie value and CSRF token are exposed ONLY under the global
2085 /// --json flag
2086 Login {
2087 /// Gateway login user (default: $IGNITION_USER, else admin)
2088 #[arg(long, value_name = "NAME")]
2089 user: Option<String>,
2090 },
2091}
2092
2093/// The CLI-side `--format` enum (QUICK-p0g) — the clap `ValueEnum`
2094/// mirror of core's plain `TestingFormat`, the `TransferFormat`
2095/// precedent (core keeps framework-free enums; the `From` impl lives
2096/// beside this one). `get_possible_values()` is what feeds the MCP
2097/// tool schema a real enum instead of a free string.
2098///
2099/// EVERY value produces the SAME verdict and exit code: the route is
2100/// always asked for json and the junit/text reports are rendered
2101/// client-side, because the route's own junit/text answers stay HTTP
2102/// 200 and carry no counts — a passthrough would exit 0 on a red
2103/// suite.
2104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
2105pub enum TestingFormatArg {
2106 /// Structured results only (default) — `data.report` stays empty
2107 #[default]
2108 Json,
2109 /// JUnit XML in `data.report`, for a CI test reporter
2110 Junit,
2111 /// Console-style text in `data.report`, for a human reading logs
2112 Text,
2113}
2114
2115impl From<TestingFormatArg> for ignition_core::actions::testing::TestingFormat {
2116 fn from(value: TestingFormatArg) -> Self {
2117 match value {
2118 TestingFormatArg::Json => Self::Json,
2119 TestingFormatArg::Junit => Self::Junit,
2120 TestingFormatArg::Text => Self::Text,
2121 }
2122 }
2123}
2124
2125#[derive(Debug, Subcommand)]
2126pub enum TestingCommand {
2127 /// Run the gateway-side Jython test suite and return the machine
2128 /// verdict (exit 0 green, exit 6 red with the FULL results still
2129 /// in the envelope)
2130 Run {
2131 /// The project whose testing bundle runs. REQUIRED — the
2132 /// bundle deploys per project and running a suite executes
2133 /// that project's gateway-side code, so there is
2134 /// deliberately no `ign-cli` default here
2135 #[arg(long, value_name = "NAME", required = true)]
2136 project: String,
2137 /// List the gateway's discovered test modules instead of
2138 /// running them
2139 #[arg(long, conflicts_with_all = ["module", "package"])]
2140 discover: bool,
2141 /// Run ONE dotted module (`proj.foo_test`); mutually
2142 /// exclusive with --package and --discover
2143 #[arg(long, value_name = "DOTTED", conflicts_with = "package")]
2144 module: Option<String>,
2145 /// Run every module under a package prefix (`proj.`);
2146 /// mutually exclusive with --module and --discover
2147 #[arg(long, value_name = "PREFIX")]
2148 package: Option<String>,
2149 /// Report rendered into `data.report`. The verdict and exit
2150 /// code are IDENTICAL in every format — the structured
2151 /// results always ride under `data.results`
2152 #[arg(long, value_name = "FORMAT", default_value = "json")]
2153 format: TestingFormatArg,
2154 },
2155}
2156
2157/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
2158/// PATHS map to `--target <path>` pairs on the child's arg vector;
2159/// `--` passthrough args ride verbatim after them (power users:
2160/// `--profile`, `--checks`, `--fail-on`, …).
2161#[derive(Debug, clap::Args)]
2162pub struct LintArgs {
2163 /// Paths to lint (one --target pair each)
2164 #[arg(value_name = "PATH", required = true)]
2165 pub paths: Vec<String>,
2166 /// Exit with the linter's own exit code (CI mode). Default is
2167 /// the doctor posture: findings + child_exit_code + the parsed
2168 /// report ride as data and the command exits 0 whenever the
2169 /// tool RAN
2170 #[arg(long)]
2171 pub strict: bool,
2172 /// Extra args passed to ignition-lint verbatim (after --)
2173 #[arg(last = true, value_name = "ARGS")]
2174 pub passthrough: Vec<String>,
2175}
2176
2177#[derive(Debug, Subcommand)]
2178pub enum ScriptCommand {
2179 /// Execute gateway-side Python (Jython) — non-interactive, the
2180 /// route's entire purpose
2181 Run {
2182 /// Inline Python source (a one-liner's best form)
2183 #[arg(long, value_name = "PY")]
2184 code: Option<String>,
2185 /// Python source file (`-` reads stdin — the agent pipe
2186 /// path); giving both --code and --file refuses
2187 /// `invalid_input` (exit 2) pre-resolution
2188 #[arg(long, value_name = "PATH")]
2189 file: Option<String>,
2190 /// The deployed routes' project (default ign-cli — where
2191 /// `ign webdev deploy` put scriptExec)
2192 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2193 project: String,
2194 },
2195}
2196
2197/// `ign api` args (09-03, EXT-01) — the raw-passthrough family. One
2198/// verb today (`call`); the escape hatch for the 80+ gateway endpoint
2199/// families no curated command covers.
2200#[derive(Debug, clap::Args)]
2201pub struct ApiArgs {
2202 #[command(subcommand)]
2203 pub command: ApiCommand,
2204}
2205
2206#[derive(Debug, Subcommand)]
2207pub enum ApiCommand {
2208 /// Call any gateway REST endpoint raw: `--method`/`--path` are the
2209 /// request, `--data`/`--header`/`--query` shape it, and the
2210 /// envelope's `data.result.data` is the gateway's JSON VERBATIM
2211 /// (no field dropped, no value coerced, key order preserved — the
2212 /// README's documented contract exception). Auth-pattern headers
2213 /// (`Authorization`, `X-Ignition-API-Token`, `Cookie`) are
2214 /// refused pre-I/O — credentials come from the profile.
2215 Call(ApiCallArgs),
2216}
2217
2218/// `ign api call` args (09-03, EXT-01). Arbitrary verbs accepted —
2219/// reqwest's `Method` parser validates (usage-class refusal for
2220/// garbage); `--data` is RAW TEXT on ANY method (GET/DELETE bodies
2221/// allowed — curl parity; the gateway's answer classifies); ONE query
2222/// mechanism (repeatable `--query k=v`, never `?` in `--path`).
2223#[derive(Debug, clap::Args)]
2224pub struct ApiCallArgs {
2225 /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, …)
2226 #[arg(long, value_name = "METHOD")]
2227 pub method: String,
2228 /// Absolute path on the gateway (must start with `/`; no `?`, no host)
2229 #[arg(long, value_name = "PATH")]
2230 pub path: String,
2231 /// Raw body text passthrough (rides ANY method — curl parity)
2232 #[arg(long, value_name = "TEXT")]
2233 pub data: Option<String>,
2234 /// Extra request header `Name: Value` (repeatable; split on the
2235 /// FIRST `:`; auth-pattern names are refused pre-I/O)
2236 #[arg(long, value_name = "NAME: VALUE")]
2237 pub header: Vec<String>,
2238 /// Query pair `k=v` (repeatable; split on the FIRST `=` — the
2239 /// ONE query mechanism)
2240 #[arg(long, value_name = "K=V")]
2241 pub query: Vec<String>,
2242}
2243
2244/// `ign license` args (09-04, EXT-02) — the license morning-check
2245/// family. One verb today; room to grow (the uniform-leaf shape).
2246#[derive(Debug, clap::Args)]
2247pub struct LicenseArgs {
2248 #[command(subcommand)]
2249 pub command: LicenseCommand,
2250}
2251
2252#[derive(Debug, Subcommand)]
2253pub enum LicenseCommand {
2254 /// License inventory + trial state in ONE command: the
2255 /// hardware-key item rows and effective stamp ride the
2256 /// `/licenses` read, the license mode + countdown ride the trial
2257 /// companion (the mode is NOT on the licenses payload — capture
2258 /// fact)
2259 Status,
2260}
2261
2262/// `ign redundancy` args (09-04, EXT-02) — the redundancy
2263/// morning-check family.
2264#[derive(Debug, clap::Args)]
2265pub struct RedundancyArgs {
2266 #[command(subcommand)]
2267 pub command: RedundancyCommand,
2268}
2269
2270#[derive(Debug, Subcommand)]
2271pub enum RedundancyCommand {
2272 /// The flat redundancy status: role, project state, peer
2273 /// connection, config access, sync/failover pending, uptime
2274 /// (ms since gateway start — capture-proven) and last-sync (the
2275 /// `-1` never-synced sentinel on fresh rigs)
2276 Status {
2277 /// Also read a second node through PROFILE and name the active
2278 /// node (activity level decides, never role)
2279 #[arg(long, value_name = "PROFILE")]
2280 peer: Option<String>,
2281 },
2282}
2283
2284/// `ign gan` args (09-04, EXT-02) — the Gateway Area Network
2285/// morning-check family.
2286#[derive(Debug, clap::Args)]
2287pub struct GanArgs {
2288 #[command(subcommand)]
2289 pub command: GanCommand,
2290}
2291
2292#[derive(Debug, Subcommand)]
2293pub enum GanCommand {
2294 /// The GAN overview: total/running connections, in/out byte
2295 /// rates, remote gateways — all zeros are healthy data on a
2296 /// non-GAN gateway (the capture IS the canonical shape)
2297 Status,
2298}
2299
2300/// `ign diagnostics` args (09-05, EXT-02) — the support-bundle
2301/// family: `bundle` nests one level (the `tags provider` grouped-
2302/// subfamily pattern).
2303#[derive(Debug, clap::Args)]
2304pub struct DiagnosticsArgs {
2305 #[command(subcommand)]
2306 pub command: DiagnosticsCommand,
2307}
2308
2309#[derive(Debug, Subcommand)]
2310pub enum DiagnosticsCommand {
2311 /// Support-bundle operations (generate / status / download /
2312 /// wait) — the state machine is ENCODED FROM LIVE CAPTURES:
2313 /// observed states are exactly `Generating` (mid-generation) and
2314 /// `Valid` (terminal/ready), PascalCase, both rigs
2315 #[command(subcommand)]
2316 Bundle(BundleCommand),
2317}
2318
2319/// The four bundle verbs (09-05). Nothing here is destructive:
2320/// generate creates a support bundle, download writes a local file,
2321/// wait polls — none carry `--yes` (the backup-download posture).
2322#[derive(Debug, Subcommand)]
2323pub enum BundleCommand {
2324 /// Start bundle generation (POST, no body) — the 200 answer IS
2325 /// the fresh status (live capture: `{"state":"Generating"}`);
2326 /// generation takes ~2–6 s on a fresh rig, so pair with `wait`
2327 Generate,
2328 /// The bundle status: `state` rides the CAPTURED vocabulary
2329 /// (`Generating` / `Valid` — unknown future states pass through
2330 /// verbatim, never refused); `fileSize` (bytes) appears only
2331 /// when `Valid`
2332 Status,
2333 /// Download the bundle ZIP (streamed to disk; the request rides
2334 /// a 300 s per-request timeout — the 30 s client default would
2335 /// truncate MB-sized bundles)
2336 Download {
2337 /// Output file (default: the gateway's Content-Disposition
2338 /// name, else ignition-diagnostics-bundle-<unix_ts>.zip)
2339 #[arg(short, long, value_name = "FILE")]
2340 output: Option<PathBuf>,
2341 },
2342 /// Poll the status until a terminal captured state (`Valid`)
2343 /// — an UNKNOWN state (outside the captured vocabulary) keeps
2344 /// polling honestly until the deadline; deadline expiry is
2345 /// exit 4 `network_error` (the restart-wait convention, no new
2346 /// slug) with the last observed state in the message
2347 Wait {
2348 /// Poll interval in seconds
2349 #[arg(long, default_value_t = 2, value_name = "SECS")]
2350 interval: u64,
2351 /// Give up after this many seconds (default 300 — mirrors
2352 /// restart --wait)
2353 #[arg(long, default_value_t = 300, value_name = "SECS")]
2354 timeout: u64,
2355 },
2356}
2357
2358/// `ign jvm` args — the SPECIAL-MODE profiling family. Every leaf
2359/// carries `--project` (the deployed scriptExec route's home, the
2360/// `script run` default) and the intrusive leaves carry the family's
2361/// own gate flag: `--i-accept-the-risk` is deliberately NOT the
2362/// global `--yes` — a generic "confirm destructive ops" blanket must
2363/// never silently cover ACTIVE JVM instrumentation.
2364#[derive(Debug, clap::Args)]
2365pub struct JvmArgs {
2366 #[command(subcommand)]
2367 pub command: JvmCommand,
2368}
2369
2370#[derive(Debug, Subcommand)]
2371pub enum JvmCommand {
2372 /// Sample every JVM thread's stack for a capped window and rank
2373 /// the hottest methods and stacks (the folded output is
2374 /// flamegraph-ready). ACTIVE instrumentation: CPU overhead grows
2375 /// with thread count x frequency — refused without
2376 /// --i-accept-the-risk; duration is HARD-CAPPED at 120s
2377 Sample {
2378 /// Seconds to sample per window (default 30 — 10 with
2379 /// --follow; 5–120, the cap is not configurable)
2380 #[arg(long, value_name = "SECS")]
2381 duration_secs: Option<u64>,
2382 /// Milliseconds between thread dumps (default 20 — 50 with
2383 /// --follow; 5–1000 — below 5ms the sampler thread itself
2384 /// skews the JVM)
2385 #[arg(long, value_name = "MS")]
2386 interval_ms: Option<u64>,
2387 /// Hot-method rows to report (default 25; cap 100)
2388 #[arg(long, default_value_t = 25, value_name = "N")]
2389 top: u64,
2390 /// Also write the folded stacks to this file (flamegraph.pl /
2391 /// speedscope input; one-shot only — refused with --follow)
2392 #[arg(long, value_name = "PATH")]
2393 folded_out: Option<PathBuf>,
2394 /// CONTINUOUS MODE: repeat bounded sampling windows
2395 /// back-to-back, streaming each window's rollup as it lands
2396 /// (NDJSON envelopes under --json/--compact, the `logs -f`
2397 /// precedent), until Ctrl-C. Continuous sampling IS
2398 /// continuous gateway overhead — watch it, then stop it
2399 #[arg(long, action = ArgAction::SetTrue)]
2400 follow: bool,
2401 /// REQUIRED acknowledgment that this actively instruments the
2402 /// gateway JVM and must not run on production casually —
2403 /// --yes does NOT satisfy this gate
2404 #[arg(long, action = ArgAction::SetTrue)]
2405 i_accept_the_risk: bool,
2406 /// The deployed routes' project (default ign-cli — where
2407 /// `ign webdev deploy` put scriptExec)
2408 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2409 project: String,
2410 },
2411 /// Java Flight Recorder operations — `start` is gated active
2412 /// instrumentation; `status` and `stop` are ungated (checking and
2413 /// cleaning up must always be cheap to reach)
2414 #[command(arg_required_else_help = true)]
2415 Jfr(JvmJfrArgs),
2416}
2417
2418/// `ign jvm jfr` args (the `tags provider` grouped-subfamily pattern).
2419#[derive(Debug, clap::Args)]
2420pub struct JvmJfrArgs {
2421 #[command(subcommand)]
2422 pub command: JvmJfrCommand,
2423}
2424
2425#[derive(Debug, Subcommand)]
2426pub enum JvmJfrCommand {
2427 /// Start a flight recording named `ign-cli` — memory-buffered
2428 /// (nothing touches gateway disk until `stop` dumps) and
2429 /// duration-capped so it auto-stops even if you forget. Refused
2430 /// without --i-accept-the-risk; duration HARD-CAPPED at 300s
2431 Start {
2432 /// Recording duration in seconds — the auto-stop deadline
2433 /// (default 60; 10–300, the cap is not configurable)
2434 #[arg(long, default_value_t = 60, value_name = "SECS")]
2435 duration_secs: u64,
2436 /// The JDK's bundled settings: `default` (lower overhead) or
2437 /// `profile` (deeper, heavier)
2438 #[arg(long, value_enum, default_value_t = JfrSettingsArg::Default, value_name = "NAME")]
2439 settings: JfrSettingsArg,
2440 /// REQUIRED acknowledgment that this actively instruments the
2441 /// gateway JVM and must not run on production casually —
2442 /// --yes does NOT satisfy this gate
2443 #[arg(long, action = ArgAction::SetTrue)]
2444 i_accept_the_risk: bool,
2445 /// The deployed routes' project (default ign-cli)
2446 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2447 project: String,
2448 },
2449 /// List the JVM's flight recordings verbatim (READ-only — not
2450 /// gated; this is how you confirm a recording is really gone)
2451 Status {
2452 /// The deployed routes' project (default ign-cli)
2453 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2454 project: String,
2455 },
2456 /// Stop the `ign-cli` recording, dump it, fetch the .jfr, and
2457 /// delete the gateway-side file (cleanup — NOT gated: stopping
2458 /// reduces risk and must never be refused). Dumps over 32 MiB
2459 /// are left on the gateway with the remote path reported
2460 Stop {
2461 /// Local output file (default: ign-cli-<unix_ts>.jfr)
2462 #[arg(short, long, value_name = "FILE")]
2463 output: Option<PathBuf>,
2464 /// The deployed routes' project (default ign-cli)
2465 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2466 project: String,
2467 },
2468}
2469
2470/// The JFR settings whitelist as a clap ValueEnum (free text never
2471/// reaches the MBean invocation).
2472#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2473pub enum JfrSettingsArg {
2474 /// `settings=default` — the lower-overhead bundled config
2475 Default,
2476 /// `settings=profile` — the deeper, heavier bundled config
2477 Profile,
2478}
2479
2480/// Profile subcommands (nested: a struct wrapper carrying the subcommand
2481/// enum, so `Commands::Profile` gets an `Args` payload).
2482#[derive(Debug, clap::Args)]
2483pub struct ProfileArgs {
2484 #[command(subcommand)]
2485 pub command: ProfileCmd,
2486}
2487
2488/// Sessions args: the family filter rides the TOP level so bare
2489/// `ign sessions [--type X]` lists (must-have truth #1) while
2490/// `ign sessions terminate …` carries the destructive half.
2491#[derive(Debug, clap::Args)]
2492pub struct SessionsArgs {
2493 /// Filter to one session family (default: all three merged)
2494 #[arg(long, value_enum)]
2495 pub r#type: Option<SessionType>,
2496
2497 #[command(subcommand)]
2498 pub command: Option<SessionsCmd>,
2499}
2500
2501#[derive(Debug, Subcommand)]
2502pub enum SessionsCmd {
2503 /// Terminate (designer: prune / vision: close) a session —
2504 /// destructive, refused without --yes
2505 Terminate {
2506 /// Session family holding the id
2507 #[arg(long, value_enum)]
2508 r#type: SessionType,
2509 /// Session/client id to terminate (see `ign sessions`)
2510 #[arg(long)]
2511 id: String,
2512 /// Message shown to the session's user (Perspective only)
2513 #[arg(long)]
2514 message: Option<String>,
2515 },
2516}
2517
2518/// Logs args: the query filters ride the TOP level so bare
2519/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
2520/// and the `loggers` subtree hang off the optional subcommand — the
2521/// SessionsArgs precedent.
2522#[derive(Debug, clap::Args)]
2523pub struct LogsArgs {
2524 /// Only entries from this logger (name prefix)
2525 #[arg(long, value_name = "NAME")]
2526 pub logger: Option<String>,
2527 /// Minimum level to include (server-side filter)
2528 #[arg(long, value_enum, value_name = "LEVEL")]
2529 pub min_level: Option<LogLevel>,
2530 /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
2531 /// 5min, 2h) — parsed to epoch-ms at arg-parse time
2532 #[arg(
2533 long,
2534 value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
2535 value_parser = parse_since_arg
2536 )]
2537 pub since: Option<i64>,
2538 /// Max entries — the server default is UNLIMITED, never used here
2539 #[arg(long, default_value_t = 200)]
2540 pub limit: i64,
2541 /// Follow: stream new entries as they occur (poll-based)
2542 #[arg(short = 'f', long)]
2543 pub follow: bool,
2544 /// Poll interval in seconds (follow mode)
2545 #[arg(long, default_value_t = 2, value_name = "SECS")]
2546 pub interval: u64,
2547 /// Stop after this many seconds (follow mode; default: until Ctrl-C)
2548 #[arg(long, value_name = "SECS")]
2549 pub timeout: Option<u64>,
2550 #[command(subcommand)]
2551 pub command: Option<LogsCmd>,
2552}
2553
2554#[derive(Debug, Subcommand)]
2555pub enum LogsCmd {
2556 /// Download the log archive — a SQLite .idb, never a zip
2557 Download {
2558 /// Output file (default: the gateway's Content-Disposition
2559 /// name, else <profile>-logs-<ts>.idb)
2560 #[arg(short, long, value_name = "FILE")]
2561 output: Option<PathBuf>,
2562 },
2563 /// List loggers / manage logger levels
2564 Loggers(LoggersArgs),
2565}
2566
2567#[derive(Debug, clap::Args)]
2568pub struct LoggersArgs {
2569 /// Substring search over logger names
2570 #[arg(long, value_name = "TEXT")]
2571 pub search: Option<String>,
2572 #[command(subcommand)]
2573 pub command: Option<LoggersCmd>,
2574}
2575
2576#[derive(Debug, Subcommand)]
2577pub enum LoggersCmd {
2578 /// Set one logger's level — a mutation, refused without --yes
2579 Set {
2580 /// Logger name (see `ign logs loggers`)
2581 name: String,
2582 /// Level to set
2583 #[arg(value_enum)]
2584 level: LogLevel,
2585 },
2586 /// Reset ALL logger levels to defaults — refused without --yes
2587 Reset,
2588}
2589
2590/// The seven spec-documented log levels (TRACE..OFF), value-enum form
2591/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
2592#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2593pub enum LogLevel {
2594 Trace,
2595 Debug,
2596 Info,
2597 Warn,
2598 Error,
2599 Fatal,
2600 Off,
2601}
2602
2603impl LogLevel {
2604 /// The uppercase wire token the gateway expects.
2605 pub fn wire(self) -> &'static str {
2606 match self {
2607 Self::Trace => "TRACE",
2608 Self::Debug => "DEBUG",
2609 Self::Info => "INFO",
2610 Self::Warn => "WARN",
2611 Self::Error => "ERROR",
2612 Self::Fatal => "FATAL",
2613 Self::Off => "OFF",
2614 }
2615 }
2616}
2617
2618/// clap value parser delegating to the core `--since` grammar against
2619/// the current time (a relative span resolves at parse time) — invalid
2620/// specs are clap usage errors (exit 2) like any bad flag value.
2621fn parse_since_arg(spec: &str) -> Result<i64, String> {
2622 let now_ms = std::time::SystemTime::now()
2623 .duration_since(std::time::UNIX_EPOCH)
2624 .expect("system clock is after the unix epoch")
2625 .as_millis() as i64;
2626 ignition_core::actions::logs::parse_since(spec, now_ms)
2627}
2628
2629/// CLI value-enum mirrors of the core action enums (ignition-core stays
2630/// clap-free; `From` converts at the dispatch seam).
2631#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2632pub enum SessionType {
2633 /// Designer sessions
2634 Designer,
2635 /// Perspective browser sessions
2636 Perspective,
2637 /// Vision clients
2638 Vision,
2639}
2640
2641impl From<SessionType> for ignition_core::actions::sessions::SessionType {
2642 fn from(value: SessionType) -> Self {
2643 match value {
2644 SessionType::Designer => Self::Designer,
2645 SessionType::Perspective => Self::Perspective,
2646 SessionType::Vision => Self::Vision,
2647 }
2648 }
2649}
2650
2651#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2652pub enum ConnectionType {
2653 /// Database connections
2654 Database,
2655 /// OPC connections
2656 Opc,
2657}
2658
2659impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
2660 fn from(value: ConnectionType) -> Self {
2661 match value {
2662 ConnectionType::Database => Self::Database,
2663 ConnectionType::Opc => Self::Opc,
2664 }
2665 }
2666}
2667
2668/// Import collision policy (03-02). Exactly the two values REST
2669/// exposes — `merge` is the Designer import popup's vocabulary and is
2670/// NOT a value: clap's invalid-value error lists the two real
2671/// choices, and the README documents merge as Designer-only (the
2672/// sanctioned rejection-with-hint shape).
2673#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2674pub enum CollisionPolicy {
2675 /// Refuse when the project already exists (default)
2676 Abort,
2677 /// Replace the ENTIRE project (resources absent from the ZIP are
2678 /// deleted) — destructive, requires --yes
2679 Overwrite,
2680}
2681
2682impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
2683 fn from(value: CollisionPolicy) -> Self {
2684 match value {
2685 CollisionPolicy::Abort => Self::Abort,
2686 CollisionPolicy::Overwrite => Self::Overwrite,
2687 }
2688 }
2689}
2690
2691#[derive(Debug, Subcommand)]
2692#[command(arg_required_else_help = true)]
2693pub enum ProfileCmd {
2694 /// Add (or overwrite) a gateway profile
2695 Add {
2696 /// Profile name
2697 name: String,
2698 /// Gateway base URL (e.g. http://localhost:9088)
2699 url: String,
2700 /// Optional display label
2701 #[arg(long, value_name = "TEXT")]
2702 label: Option<String>,
2703 /// Name of the env var holding the auth token
2704 #[arg(long, value_name = "VAR")]
2705 token_env: Option<String>,
2706 /// Keyring user string for the token (service is always ignition-cli)
2707 #[arg(long, value_name = "USER")]
2708 keyring: Option<String>,
2709 /// Name of the env var holding the basic-auth user (with --password-env)
2710 #[arg(long, value_name = "VAR")]
2711 user_env: Option<String>,
2712 /// Name of the env var holding the basic-auth password (with --user-env)
2713 #[arg(long, value_name = "VAR")]
2714 password_env: Option<String>,
2715 /// Make this profile the active one
2716 #[arg(long)]
2717 active: bool,
2718 },
2719 /// List configured profiles
2720 List,
2721 /// Switch the active profile
2722 Use {
2723 /// Profile name to activate
2724 name: String,
2725 },
2726}