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 read/write permissions → live-probe the key →
126 /// persist the credential (OS keyring, env-var fallback). Re-run
127 /// on an adopted gateway is an all-skip no-op. Composition flags
128 /// ride the bootstrap: --project deploys the CLI's WebDev routes,
129 /// --checkout lands every enabled project locally, --bake saves a
130 /// restore-ready gwbk
131 Adopt {
132 /// Gateway login user for the native OIDC dance
133 /// (default: admin)
134 #[arg(long, value_name = "NAME")]
135 user: Option<String>,
136
137 /// The API-key name — the idempotency key (default: ign-cli)
138 #[arg(long, value_name = "NAME")]
139 key_name: Option<String>,
140
141 /// The granted security level as a slash path
142 /// (default: Authenticated/Roles/Administrator)
143 #[arg(long, value_name = "PATH")]
144 level: Option<String>,
145
146 /// Deploy the CLI's WebDev routes (scriptExec on) into this
147 /// project after the bootstrap
148 #[arg(long, value_name = "NAME")]
149 project: Option<String>,
150
151 /// Ship the embedded TESTING bundle with the routes (the
152 /// Jython framework + testing/run|tags routes + the smoke
153 /// sentinel; the step asserts discover ≥1 module and a green
154 /// smoke run — the empty-suite trap)
155 #[arg(long, requires = "project")]
156 testing: bool,
157
158 /// Check out every enabled project into DIR/<project>
159 /// (scripts decoded — grep/lint ready); existing targets skip
160 #[arg(long, value_name = "DIR")]
161 checkout: Option<String>,
162
163 /// Download a roaming gwbk to FILE after everything landed —
164 /// a gateway reset restored from it keeps the key and routes
165 #[arg(long, value_name = "FILE")]
166 bake: Option<String>,
167 },
168
169 /// Obtain a live gateway login session (the native OIDC dance) —
170 /// returns the session cookie name/value, the CSRF token, the
171 /// gateway URL, and a ready-to-use Playwright `storageState`
172 /// document. The session material is emitted ONLY under the
173 /// global --json flag; the human render prints the cookie name
174 /// and the gateway URL and withholds the rest
175 #[command(arg_required_else_help = true)]
176 Session(SessionArgs),
177
178 /// Manage gateway projects: list with inheritance info, new, copy,
179 /// rename, set (reparent), delete, export/import (ZIP)
180 #[command(arg_required_else_help = true)]
181 Project(ProjectArgs),
182
183 /// Check out a project's resources to a local tree, inspect drift,
184 /// push guarded changes — the local edit loop over the
185 /// project-export interchange (project RESOURCES only; tag values
186 /// never appear in the tree — tags remain `ign tags` verbs)
187 #[command(arg_required_else_help = true)]
188 Workspace(WorkspaceArgs),
189
190 /// Fetch a gateway resource, edit it in $EDITOR, push back
191 /// (guarded) — the kubectl-edit loop over ONE resource:
192 /// unchanged saves are clean no-ops (nothing pushed, never
193 /// prompted), a gateway changed since fetch refuses (never
194 /// force-pushed), a JSON-breaking save refuses fail-closed with
195 /// the temp tree kept (path printed). Zero stdout in every mode —
196 /// the editor owns the terminal (see README's edit section)
197 Edit(EditArgs),
198
199 /// Manage a project's individual resources: list, get, put,
200 /// delete — the surgical edit loop (change one view/script
201 /// without re-importing everything)
202 #[command(arg_required_else_help = true)]
203 Resource(ResourceArgs),
204
205 /// Manage the CLI's own WebDev routes on the gateway: deploy the
206 /// embedded bundle, verify the version handshake
207 #[command(arg_required_else_help = true)]
208 Webdev(WebdevArgs),
209
210 /// Tag operations: manage providers (native REST) and browse /
211 /// read / write tag values (the deployed WebDev routes — run
212 /// `ign webdev deploy` first)
213 #[command(arg_required_else_help = true)]
214 Tags(TagsArgs),
215
216 /// Manage a Docker compose rig: up/down/reset/status/logs/trial
217 /// (snapshot arrives in a later plan) — docker-only, no profile
218 /// needed
219 #[command(arg_required_else_help = true)]
220 Rig(RigArgs),
221
222 /// Manage gateway backups (gwbk) on any profiled gateway:
223 /// download (streamed) and restore (guarded)
224 #[command(arg_required_else_help = true)]
225 Backup(BackupArgs),
226
227 /// EAM task orchestration (Enterprise Administration Module):
228 /// history/definitions reads, guarded task create + force —
229 /// every verb honestly reports the controller-mode state gate
230 #[command(arg_required_else_help = true)]
231 Eam(EamArgs),
232
233 /// Run gateway-side Python (Jython) through the secret-gated
234 /// scriptExec route — the opt-in is STRUCTURAL (`ign webdev
235 /// deploy --with-script-exec` deploys the route + persists its
236 /// secret); there is no --yes on this verb by design
237 #[command(arg_required_else_help = true)]
238 Script(ScriptArgs),
239
240 /// Run the gateway's own Jython test suite through the deployed
241 /// testing bundle — the agent/CI verb: a machine verdict without
242 /// the Designer (`ign webdev deploy --with-testing` or
243 /// `ign adopt --project NAME --testing` installs the bundle)
244 #[command(arg_required_else_help = true)]
245 Testing(TestingArgs),
246
247 /// Lint local project files by delegating to ignition-lint (PATH
248 /// discovery) — doctor posture: findings are DATA, exit 0
249 /// whenever the tool ran; --strict passes the tool's exit code
250 /// through for CI
251 Lint(LintArgs),
252
253 /// Browser end-to-end testing: diagnose the host + gateway a
254 /// Playwright suite needs (`doctor`), and scaffold one that logs
255 /// in through `ign session login` and talks to the gateway's
256 /// testing routes (`init`). There is no `e2e run` — the
257 /// scaffold's own `npm test` is the runner
258 #[command(arg_required_else_help = true)]
259 E2e(E2eArgs),
260
261 /// Raw passthrough to any gateway REST endpoint — the escape
262 /// hatch for the uncurated routes (the envelope's `data` is left
263 /// gateway-verbatim; see README's documented contract exception)
264 #[command(arg_required_else_help = true)]
265 Api(ApiArgs),
266
267 /// License inventory + trial state — the morning-check read
268 /// (trial companion merged into one command)
269 #[command(arg_required_else_help = true)]
270 License(LicenseArgs),
271
272 /// Gateway redundancy status — the morning-check read
273 #[command(arg_required_else_help = true)]
274 Redundancy(RedundancyArgs),
275
276 /// Gateway Area Network (GAN) overview — the morning-check read
277 #[command(arg_required_else_help = true)]
278 Gan(GanArgs),
279
280 /// Diagnostics support-bundle operations: generate, check
281 /// status, download (streamed), wait — the support-bundle slice
282 #[command(arg_required_else_help = true)]
283 Diagnostics(DiagnosticsArgs),
284
285 /// Manage gateway profiles
286 #[command(arg_required_else_help = true)]
287 Profile(ProfileArgs),
288
289 /// Serve the Model Context Protocol over stdio (hidden; for MCP
290 /// clients — the newline-delimited JSON-RPC 2.0 stream IS the
291 /// stdout product; the routes.rs OutOfBand row lands atomically
292 /// with this command, 08-06 contract)
293 #[command(hide = true)]
294 Mcp(McpArgs),
295
296 /// Serve LSP over stdio (hidden; for ignition-nvim — the
297 /// Content-Length-framed LSP stream IS the stdout product; the
298 /// routes.rs OutOfBand row lands atomically with this command,
299 /// 08-06 contract)
300 #[command(hide = true)]
301 Lsp,
302
303 /// Interactive TUI cockpit
304 #[cfg(feature = "tui")]
305 Tui,
306}
307
308/// Hidden args for `ign mcp serve` (14-01). `serve` is a RESTRICTED
309/// POSITIONAL — not a subcommand — so the clap walk (tui_coverage.rs)
310/// yields exactly ONE row-requiring node, `mcp` itself: an `mcp
311/// serve` subcommand leaf would need a second routes() row and extend
312/// the pinned OutOfBand set past its four-member contract (an orphan
313/// row fails the walk by design). clap's `value_parser = ["serve"]`
314/// keeps every other spelling (`ign mcp`, `ign mcp bogus`) a
315/// usage-class clap error — exit 2 BEFORE any protocol byte is
316/// written. Zero stdout-facing behavior here — the mode owns its own
317/// I/O entirely (`mcp::serve`).
318#[derive(Debug, clap::Args)]
319pub struct McpArgs {
320 /// Start the stdio MCP server
321 #[arg(value_parser = ["serve"], value_name = "SERVE")]
322 pub serve: String,
323}
324
325/// Wait targets (02-05, HLTH-11). `gateway` and `restart` poll the
326/// UNAUTHENTICATED /StatusPing (the dispatch builds a header-less
327/// client for them — waiting must work when auth is broken); `module`
328/// is an authed read.
329#[derive(Debug, clap::Args)]
330pub struct WaitArgs {
331 #[command(subcommand)]
332 pub command: WaitCmd,
333}
334
335#[derive(Debug, Subcommand)]
336pub enum WaitCmd {
337 /// Wait until the gateway reports RUNNING (unauthenticated
338 /// StatusPing — works even when auth is broken or absent)
339 Gateway {
340 /// Poll interval in seconds
341 #[arg(long, default_value_t = 2, value_name = "SECS")]
342 interval: u64,
343 /// Give up after this many seconds
344 #[arg(long, default_value_t = 120, value_name = "SECS")]
345 timeout: u64,
346 },
347 /// Wait for a restart to complete — restart-aware: shares
348 /// `restart --wait`'s semantics (non-RUNNING observed once →
349 /// RUNNING; a 5 s floor guards the all-RUNNING case)
350 Restart {
351 /// Poll interval in seconds
352 #[arg(long, default_value_t = 2, value_name = "SECS")]
353 interval: u64,
354 /// Give up after this many seconds (default 300)
355 #[arg(long, default_value_t = 300, value_name = "SECS")]
356 timeout: u64,
357 },
358 /// Wait until a module reports ACTIVE
359 Module {
360 /// Module id (see `ign modules`)
361 id: String,
362 /// Poll interval in seconds
363 #[arg(long, default_value_t = 2, value_name = "SECS")]
364 interval: u64,
365 /// Give up after this many seconds
366 #[arg(long, default_value_t = 120, value_name = "SECS")]
367 timeout: u64,
368 },
369}
370
371/// Project subcommands (03-01, PROJ-01/02). `delete` is the family's
372/// ONE destructive verb (`--yes`-guarded, exit 2 without); copy/
373/// rename/set create or relabel — never destroy — so they carry NO
374/// guard (planner decision per research).
375#[derive(Debug, clap::Args)]
376pub struct ProjectArgs {
377 #[command(subcommand)]
378 pub command: ProjectCommand,
379}
380
381#[derive(Debug, Subcommand)]
382pub enum ProjectCommand {
383 /// List every runnable project: name, title, enabled, parent,
384 /// inheritable (inheritance info from the items themselves)
385 List,
386 /// Create a project (only provided fields ride the create body)
387 New {
388 /// Project name
389 name: String,
390 /// Display title
391 #[arg(long, value_name = "TEXT")]
392 title: Option<String>,
393 /// Long description
394 #[arg(long, value_name = "TEXT")]
395 description: Option<String>,
396 /// Parent project (inheritance)
397 #[arg(long, value_name = "NAME")]
398 parent: Option<String>,
399 /// Mark this project eligible as a parent
400 #[arg(long)]
401 inheritable: bool,
402 /// Create the project disabled
403 #[arg(long)]
404 disabled: bool,
405 },
406 /// Copy a project with all its resources
407 Copy {
408 /// Source project name
409 src: String,
410 /// Destination name (must not exist)
411 dst: String,
412 },
413 /// Rename a project (native rename, not copy+delete)
414 Rename {
415 /// Current name
416 old_name: String,
417 /// New name
418 new_name: String,
419 },
420 /// Set project fields — --parent IS the inheritance move (reparent)
421 #[command(group(
422 clap::ArgGroup::new("set_fields")
423 .required(true)
424 .multiple(true)
425 .args(&["title", "description", "parent", "set_enabled", "disabled", "inheritable"])
426 ))]
427 Set {
428 /// Project name
429 name: String,
430 /// Display title
431 #[arg(long, value_name = "TEXT")]
432 title: Option<String>,
433 /// Long description
434 #[arg(long, value_name = "TEXT")]
435 description: Option<String>,
436 /// Parent project (reparent)
437 #[arg(long, value_name = "NAME")]
438 parent: Option<String>,
439 /// Enable the project
440 #[arg(long, conflicts_with = "disabled")]
441 set_enabled: bool,
442 /// Disable the project
443 #[arg(long)]
444 disabled: bool,
445 /// Whether this project may serve as a parent (true/false)
446 #[arg(long, value_name = "BOOL")]
447 inheritable: Option<bool>,
448 },
449 /// Delete a project — destructive, refused without --yes
450 Delete {
451 /// Project name
452 name: String,
453 },
454 /// Export a project as a ZIP archive (streams to disk)
455 Export {
456 /// Project name
457 name: String,
458 /// Output file (default: the gateway's Content-Disposition
459 /// name, else <name>.zip); with --decode-scripts, the output
460 /// DIRECTORY (default <name>-export/)
461 #[arg(short, long, value_name = "FILE")]
462 output: Option<PathBuf>,
463 /// Also decode embedded JSON scripts (Perspective view.json
464 /// etc.) into editable `<member>.<n>.py` sidecars beside a
465 /// copied member tree + scripts-manifest.json — the output is
466 /// a DIRECTORY (script-python members are already plain .py
467 /// and never decode; expressions pass through)
468 #[arg(long)]
469 decode_scripts: bool,
470 },
471 /// Import a project from a ZIP archive
472 Import {
473 /// Project name to import as
474 name: String,
475 /// ZIP file path, or - to read the archive from stdin; with
476 /// --encode-scripts, a decoded export DIRECTORY (re-zipped
477 /// with the sidecars spliced back before upload; stdin is
478 /// invalid in that mode)
479 #[arg(long, value_name = "PATH")]
480 file: String,
481 /// Collision policy: abort refuses when the name exists
482 /// (default); overwrite REPLACES the entire project —
483 /// destructive, requires --yes. merge is Designer-only (not a
484 /// value; the README documents why)
485 #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
486 collision_policy: CollisionPolicy,
487 /// --file points at a decoded export DIRECTORY (from
488 /// `export --decode-scripts`): the sidecars are spliced back
489 /// and the manifest stripped before the standard import path
490 #[arg(long)]
491 encode_scripts: bool,
492 },
493 /// Compare a project across two gateway profiles — statuses are
494 /// B-relative-to-A (`added` = in B only, `removed` = in A only,
495 /// `changed` = differing after resource.json normalization)
496 Diff {
497 /// Baseline profile (A)
498 profile_a: String,
499 /// Compared profile (B — the diff is B relative to A)
500 profile_b: String,
501 /// Project name
502 #[arg(long, value_name = "NAME")]
503 project: String,
504 },
505 /// Promote selected resources from profile A into profile B
506 /// (direction is ALWAYS A→B) — destructive on B: the whole
507 /// project is overwrite-imported, refused without --yes
508 Sync {
509 /// Source profile (A)
510 profile_a: String,
511 /// Target profile (B)
512 profile_b: String,
513 /// Project name
514 #[arg(long, value_name = "NAME")]
515 project: String,
516 /// Resource user path to promote (repeatable)
517 #[arg(long, value_name = "PATH")]
518 resource: Vec<String>,
519 /// Promote every resource the diff reports added or changed
520 /// (never removed ones — deletion needs --delete)
521 #[arg(long)]
522 all_changed: bool,
523 /// Also remove B's resources the diff reports removed
524 /// (default: upsert-only, nothing is ever deleted)
525 #[arg(long)]
526 delete: bool,
527 },
528}
529
530/// Workspace subcommands (13-07): the local edit loop. `checkout`
531/// writes the mapped tree + records `.ign-workspace.json` (the
532/// manifest IS the workspace identity — status/push read it and the
533/// user never re-types the project); `status` reports PUSH-RELATIVE
534/// drift (every row says what push would do); `push` is the guarded
535/// splice of local edits into a FRESH gateway export.
536#[derive(Debug, clap::Args)]
537pub struct WorkspaceArgs {
538 #[command(subcommand)]
539 pub command: WorkspaceCommand,
540}
541
542#[derive(Debug, Subcommand)]
543pub enum WorkspaceCommand {
544 /// Check out a project's resources to a local tree — mapped
545 /// paths + the recorded `.ign-workspace.json` manifest + an
546 /// idempotent `.gitignore` (manifest committed; codec artifacts
547 /// ignored). Read-only on the wire: one export GET, zero imports.
548 Checkout {
549 /// Project name to check out
550 project: String,
551 /// Target directory for the workspace tree (created if
552 /// absent; an unrelated non-empty directory refuses — never
553 /// clobbered)
554 target: PathBuf,
555 /// Decode embedded JSON scripts to editable .py sidecars
556 /// (nvim-editable; encodes back cleanly — an unedited
557 /// re-encode is byte-exact; `scripts-manifest.json` keys the
558 /// splice)
559 #[arg(long)]
560 decode_scripts: bool,
561 },
562 /// Report workspace drift against the gateway — states are
563 /// PUSH-RELATIVE (each row names what push would do: write /
564 /// leave / refuse); the project comes from the workspace
565 /// manifest, never re-typed. Read-only: one export GET.
566 Status {
567 /// Workspace root (default: the current directory)
568 #[arg(default_value = ".")]
569 path: PathBuf,
570 },
571 /// Push local edits to the gateway — guarded (refused without
572 /// --yes; the refusal message IS the blast-radius preview);
573 /// conflicts refuse EVEN WITH --yes (manual reconciliation);
574 /// deletions need --delete (default: reported, skipped);
575 /// untracked files are never imported; an empty selection
576 /// writes nothing.
577 Push {
578 /// Workspace root (default: the current directory)
579 #[arg(default_value = ".")]
580 path: PathBuf,
581 /// Also delete gateway members removed locally (default:
582 /// reported, skipped)
583 #[arg(long)]
584 delete: bool,
585 },
586}
587
588/// `ign edit` args (13-08): the kubectl-edit loop over ONE resource.
589/// The resource path scopes what $EDITOR opens (the whole tree still
590/// decodes for encode-back context); the push rides the
591/// project-import wire behind the ONE confirmation gate. `--yes` is
592/// the GLOBAL flag (globals-once) — never redeclared here.
593#[derive(Debug, clap::Args)]
594pub struct EditArgs {
595 /// Project containing the resource
596 pub project: String,
597 /// Resource user path to open in $EDITOR (e.g.
598 /// `ignition/script-python/e2e/scratch` — `ign resource list`
599 /// names the valid members)
600 pub resource_path: String,
601}
602
603/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
604/// destructive verb (`--yes`-guarded, exit 2 without — the
605/// sessions-terminate shape); `put` is an upsert (create-or-replace
606/// ONE resource with explicit content) and stays friction-free per
607/// the planner decision — agents pass exactly what they want written.
608#[derive(Debug, clap::Args)]
609pub struct ResourceArgs {
610 #[command(subcommand)]
611 pub command: ResourceCommand,
612}
613
614#[derive(Debug, Subcommand)]
615pub enum ResourceCommand {
616 /// List a project's resources (one path per line in human mode)
617 List {
618 /// Project name
619 project: String,
620 /// Only paths under this prefix (rides the wire as the
621 /// server-side `path` filter)
622 #[arg(long, value_name = "PREFIX")]
623 prefix: Option<String>,
624 },
625 /// Read one resource: JSON pretty-printed, text raw — binary
626 /// (data.bin-class) resources refuse with exit 6
627 Get {
628 /// Project name
629 project: String,
630 /// Resource path, slashes kept (e.g.
631 /// `ignition/script-python/e2e/scratch`)
632 path: String,
633 },
634 /// Write one resource (upsert: created if absent, replaced if
635 /// present) — JSON if parseable (application/json), else UTF-8
636 /// text (text/plain); binary-looking input refuses
637 Put {
638 /// Project name
639 project: String,
640 /// Resource path
641 path: String,
642 /// File to read the content from, or `-` for stdin
643 #[arg(long, value_name = "PATH")]
644 file: String,
645 },
646 /// Delete one resource — destructive, refused without --yes
647 Delete {
648 /// Project name
649 project: String,
650 /// Resource path
651 path: String,
652 },
653}
654
655/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
656/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
657/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
658/// on every deploy (replace-not-merge is the CONTRACT here; user
659/// projects are never touched — README documents). scriptExec rides
660/// only on explicit request, gated by a deploy-time generated shared
661/// secret stored in the profile config at 0600.
662#[derive(Debug, clap::Args)]
663pub struct WebdevArgs {
664 #[command(subcommand)]
665 pub command: WebdevCommand,
666}
667
668#[derive(Debug, Subcommand)]
669pub enum WebdevCommand {
670 /// Deploy the embedded route bundle into the dedicated project
671 /// (overwrite-replace — the CLI owns that project wholesale)
672 Deploy {
673 /// Target project (default ign-cli; the CLI owns it wholesale)
674 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
675 project: String,
676 /// Also deploy the secret-gated scriptExec route (a fresh
677 /// secret is generated and stored at 0600 when none exists)
678 #[arg(long)]
679 with_script_exec: bool,
680 /// Generate a FRESH scriptExec secret before deploying (any
681 /// route deployed with the old secret starts refusing)
682 #[arg(long, requires = "with_script_exec")]
683 rotate_secret: bool,
684 /// Also deploy the embedded TESTING bundle — the Jython test
685 /// framework (testing.runner & co., with the permanent
686 /// testing.__tests__ smoke sentinel) and the testing/run +
687 /// testing/tags WebDev routes
688 #[arg(long)]
689 with_testing: bool,
690 },
691 /// Probe every route's version handshake — a READ: exit 0
692 /// whenever the sweep completes, per-route degradation is data
693 Status {
694 /// Target project (default ign-cli)
695 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
696 project: String,
697 },
698}
699
700/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
701/// `tags provider …` nests one level deeper (the family's native
702/// REST half), browse/read/write ride the TOP level. The
703/// webdev-dependent arms carry `--project` (default `ign-cli`, the
704/// same default as deploy — the deployed routes live in that
705/// project).
706#[derive(Debug, clap::Args)]
707pub struct TagsArgs {
708 #[command(subcommand)]
709 pub command: TagsCommand,
710}
711
712#[derive(Debug, Subcommand)]
713pub enum TagsCommand {
714 /// Manage tag providers (native config-resource REST — no
715 /// deployed routes involved): list with tag counts + health,
716 /// create a STANDARD provider, delete (guarded)
717 #[command(subcommand)]
718 Provider(TagsProviderCommand),
719
720 /// Browse tags as a tree (Property children filtered by
721 /// default) — providers appear at the root; needs the deployed
722 /// routes (`ign webdev deploy`) — or run fully OFFLINE against
723 /// an export via --from-export
724 Browse {
725 /// Tag path to browse from (default: the root — providers)
726 path: Option<String>,
727 /// Case-insensitive substring filter on name and full path
728 #[arg(long, value_name = "SUBSTR")]
729 filter: Option<String>,
730 /// Include Property children (filtered out by default)
731 #[arg(long)]
732 include_properties: bool,
733 /// Project holding the deployed routes (default ign-cli)
734 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
735 project: String,
736 /// Browse an OFFLINE tag export instead of the gateway (no
737 /// profile, no credential, no routes): the CLI's own `tags
738 /// export` JSON, a legacy `<provider>.json` whole tree, or a
739 /// git-module tags/ directory — mutually exclusive with the
740 /// positional browse path
741 #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
742 from_export: Option<PathBuf>,
743 },
744 /// Read one or more tag values (quality and timestamp included)
745 /// — needs the deployed routes
746 Read {
747 /// Tag paths to read, e.g. `[default]T1` (one or more)
748 #[arg(value_name = "PATH", required = true)]
749 paths: Vec<String>,
750 /// Project holding the deployed routes (default ign-cli)
751 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
752 project: String,
753 },
754 /// Write a value to a tag — the value is parsed as a JSON
755 /// scalar (number/bool/null); anything unparseable is sent as a
756 /// string; arrays/objects refuse — needs the deployed routes
757 Write {
758 /// Tag path to write, e.g. `[default]T1`
759 path: String,
760 /// Value to write: JSON scalar (42, 1.5, true), else the raw
761 /// text is sent as a string
762 #[arg(long, value_name = "V")]
763 value: String,
764 /// Project holding the deployed routes (default ign-cli)
765 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
766 project: String,
767 },
768 /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
769 /// JSON out): get/create/edit/delete tag configs through the
770 /// deployed tagConfig route; stringified values are re-parsed so
771 /// agents see real JSON
772 #[command(subcommand)]
773 Config(TagsConfigCommand),
774 /// UDT types and definitions (recursive: parameters + nested
775 /// children) — needs the deployed routes
776 #[command(subcommand)]
777 Udt(TagsUdtCommand),
778 /// Alarm operations: active status (with filters), journal
779 /// history, acknowledge — needs the deployed routes
780 #[command(subcommand)]
781 Alarms(TagsAlarmsCommand),
782 /// Tag history queries (historian-backed values) — needs the
783 /// deployed routes; structurally safe on any rig (data requires
784 /// a provisioned historian)
785 #[command(subcommand)]
786 History(TagsHistoryCommand),
787 /// Export tag subtrees — json writes the gateway's native JSON
788 /// interchange (the lossless round-trip); xml passes the
789 /// gateway's own XML through byte-for-byte; csv is CLI-GENERATED
790 /// and LOSSY (no alarms, legacy 48-column set, numeric enums —
791 /// the gateway cannot export CSV)
792 Export {
793 /// Tag paths to export, e.g. `[default]P5` (one or more)
794 #[arg(value_name = "PATH", required = true)]
795 paths: Vec<String>,
796 /// Output file (`-` = stdout — the raw payload, no envelope;
797 /// default: `<last-path-segment>.<ext>` in the cwd, ext per
798 /// format)
799 #[arg(short = 'o', long, value_name = "FILE")]
800 output: Option<PathBuf>,
801 /// Payload format: json (the gateway's native interchange —
802 /// default), xml (raw gateway bytes), or csv (CLI-generated
803 /// and LOSSY: no alarms, legacy columns only, numeric enum
804 /// coercion — what was dropped prints on stderr)
805 #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
806 format: TransferFormat,
807 /// Project holding the deployed routes (default ign-cli)
808 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
809 project: String,
810 },
811 /// Import a tag export into a target provider — json is the
812 /// native interchange; xml/csv ride the gateway's importTags
813 /// passthrough. A loss scan reports what xml/csv would drop or
814 /// coerce BEFORE the import and refuses exit 2 unless --yes.
815 /// Collision behavior: abort (default) refuses on collisions;
816 /// overwrite replaces them (destructive: requires --yes)
817 Import {
818 /// The export file to import (`-` = stdin)
819 #[arg(long, value_name = "FILE")]
820 file: PathBuf,
821 /// Target tag provider (must exist — `ign tags provider
822 /// create NAME` first)
823 #[arg(long, value_name = "NAME")]
824 provider: String,
825 /// Payload format: json (the gateway's native interchange —
826 /// default), xml (gateway XML), or csv (legacy CSV — alarms
827 /// never arrive; the loss scan reports the gap)
828 #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
829 format: TransferFormat,
830 /// Collision policy: abort refuses when tags already exist
831 /// (default); overwrite replaces them (destructive:
832 /// requires --yes). merge is Designer-only (not a value)
833 #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
834 collision_policy: CollisionPolicy,
835 /// Project holding the deployed routes (default ign-cli)
836 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
837 project: String,
838 },
839}
840
841/// The bulk-transfer payload format (11-05) — the clap surface for
842/// core's `ExportFormat`/`ImportFormat` (core keeps plain enums; the
843/// From impls live beside this one, the CollisionPolicy precedent).
844/// json is the default so every pre-11-05 invocation parses and
845/// behaves byte-identically.
846#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
847pub enum TransferFormat {
848 /// The gateway's JSON interchange (default — lossless)
849 #[default]
850 Json,
851 /// Gateway XML — RAW-BYTE passthrough both directions (no
852 /// parse/normalize in the transfer path)
853 Xml,
854 /// Legacy CSV — import: gateway passthrough (alarms never
855 /// arrive); export: CLI-GENERATED and LOSSY (the gateway cannot
856 /// export CSV)
857 Csv,
858}
859
860impl std::fmt::Display for TransferFormat {
861 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
862 let label = match self {
863 Self::Json => "json",
864 Self::Xml => "xml",
865 Self::Csv => "csv",
866 };
867 f.write_str(label)
868 }
869}
870
871/// `tags config …` — the configuration CRUD subfamily (05-05,
872/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
873/// files are the configure shape (README's traps table: tagType
874/// discriminator, nested children, alarms-as-LIST).
875#[derive(Debug, Subcommand)]
876pub enum TagsConfigCommand {
877 /// Get a tag's configuration as (pretty) JSON — stringified
878 /// value/defaultValue sub-dicts re-parsed into real JSON
879 Get {
880 /// Tag path, e.g. `[default]P5/T1`
881 path: String,
882 /// Project holding the deployed routes (default ign-cli)
883 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
884 project: String,
885 },
886 /// Create a tag from a JSON definition file (`-` = stdin) —
887 /// aborts on an existing node (collision policy 'a')
888 Create {
889 /// Tag path to create, e.g. `[default]P5/T1`
890 path: String,
891 /// JSON definition file (`-` = stdin): `{tagType, value,
892 /// alarms, …}` — see the README configure-shape table
893 #[arg(long, value_name = "FILE")]
894 file: PathBuf,
895 /// Project holding the deployed routes (default ign-cli)
896 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
897 project: String,
898 },
899 /// Edit a tag's configuration from a JSON definition file (`-` =
900 /// stdin) — overwrites that single node (collision policy 'o')
901 Edit {
902 /// Tag path to edit, e.g. `[default]P5/T1`
903 path: String,
904 /// JSON definition file (`-` = stdin)
905 #[arg(long, value_name = "FILE")]
906 file: PathBuf,
907 /// Project holding the deployed routes (default ign-cli)
908 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
909 project: String,
910 },
911 /// Delete tag configurations — destructive, refused without
912 /// --yes (the guard fires before ANY resolution: zero network
913 /// work)
914 Delete {
915 /// Tag paths to delete (one or more)
916 #[arg(value_name = "PATH", required = true)]
917 paths: Vec<String>,
918 /// Project holding the deployed routes (default ign-cli)
919 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
920 project: String,
921 },
922}
923
924/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
925#[derive(Debug, Subcommand)]
926pub enum TagsUdtCommand {
927 /// List the provider's UDT types
928 Types {
929 /// Tag provider whose `_types_` folder to browse (default
930 /// `default`)
931 #[arg(long, default_value = "default", value_name = "NAME")]
932 provider: String,
933 /// Project holding the deployed routes (default ign-cli)
934 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
935 project: String,
936 },
937 /// Get a UDT definition (parameters + nested children, recursive)
938 Def {
939 /// UDT type name, e.g. `Motor`
940 name: String,
941 /// Tag provider (default `default`)
942 #[arg(long, default_value = "default", value_name = "NAME")]
943 provider: String,
944 /// Project holding the deployed routes (default ign-cli)
945 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
946 project: String,
947 },
948}
949
950#[derive(Debug, Subcommand)]
951pub enum TagsProviderCommand {
952 /// List the gateway's tag providers (tag counts + health)
953 List,
954 /// Create a STANDARD tag provider (DB-backed providers are out
955 /// of scope at MVP)
956 Create {
957 /// Provider name to create
958 name: String,
959 },
960 /// Delete a tag provider — destructive, refused without --yes
961 Delete {
962 /// Provider name to delete
963 name: String,
964 },
965}
966
967/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
968/// needs a JOURNAL-PROVISIONED gateway (database connection +
969/// alarm-journal profile + general-alarm-settings — default rigs
970/// refuse exit 6 with the provisioning hint); acknowledge is the
971/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
972/// default-guessing) and ack is deliberately NOT `--yes`-guarded
973/// (acknowledging never un-acknowledges anything — a state-advancing
974/// read-adjacent verb).
975#[derive(Debug, Subcommand)]
976pub enum TagsAlarmsCommand {
977 /// List active alarms — eventId/source/state/priority/name
978 Active {
979 /// Filter by alarm source (e.g. `prov:default`)
980 #[arg(long, value_name = "SOURCE")]
981 source: Option<String>,
982 /// Filter by priority (e.g. `High`)
983 #[arg(long, value_name = "PRIORITY")]
984 priority: Option<String>,
985 /// Filter by state (e.g. `Active, Unacknowledged`)
986 #[arg(long, value_name = "STATE")]
987 state: Option<String>,
988 /// Project holding the deployed routes (default ign-cli)
989 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
990 project: String,
991 },
992 /// Query alarm history — requires a journal-provisioned gateway
993 /// (default rigs refuse with the provisioning hint naming the
994 /// missing chain)
995 History {
996 /// Window start: RFC3339 timestamp or epoch-ms
997 #[arg(long, value_name = "T")]
998 start: String,
999 /// Window end: RFC3339 timestamp or epoch-ms
1000 #[arg(long, value_name = "T")]
1001 end: String,
1002 /// Project holding the deployed routes (default ign-cli)
1003 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1004 project: String,
1005 },
1006 /// Acknowledge alarms (explicit --username: the 3-arg wire form
1007 /// needs it). NOT --yes-guarded — acknowledging never
1008 /// un-acknowledges anything
1009 Ack {
1010 /// Alarm event ids to acknowledge (from `alarms active`)
1011 #[arg(value_name = "ID", required = true)]
1012 ids: Vec<String>,
1013 /// Acknowledgement note (default: empty)
1014 #[arg(long, value_name = "NOTE")]
1015 note: Option<String>,
1016 /// The username acknowledging (REQUIRED — the 3-arg wire
1017 /// form needs it; the CLI never guesses one)
1018 #[arg(long, value_name = "NAME")]
1019 username: String,
1020 /// Project holding the deployed routes (default ign-cli)
1021 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1022 project: String,
1023 },
1024}
1025
1026/// `tags history …` — the historian subfamily (05-06, TAGS-08).
1027#[derive(Debug, Subcommand)]
1028pub enum TagsHistoryCommand {
1029 /// Query historical tag values — t_stamp + one column per tag;
1030 /// structurally safe anywhere (data requires a provisioned
1031 /// historian)
1032 Query {
1033 /// Tag paths to query, e.g. `[default]T1` (one or more)
1034 #[arg(value_name = "PATH", required = true)]
1035 paths: Vec<String>,
1036 /// Window start: RFC3339 timestamp or epoch-ms
1037 #[arg(long, value_name = "T")]
1038 start: String,
1039 /// Window end: RFC3339 timestamp or epoch-ms
1040 #[arg(long, value_name = "T")]
1041 end: String,
1042 /// Maximum rows returned
1043 #[arg(long, value_name = "N")]
1044 return_size: Option<i64>,
1045 /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
1046 /// default is the route's LastValue
1047 #[arg(long, value_name = "MODE")]
1048 aggregation: Option<String>,
1049 /// Project holding the deployed routes (default ign-cli)
1050 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1051 project: String,
1052 },
1053}
1054
1055/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
1056/// SessionsArgs `--type` precedent) so every rig verb shares it. The
1057/// command enum carries ONLY the wired verbs — later plans extend it
1058/// one variant at a time (the established extend-per-plan chore; no
1059/// `unimplemented!()` stubs, every commit compiles clippy-clean).
1060#[derive(Debug, clap::Args)]
1061pub struct RigArgs {
1062 /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
1063 /// then the cwd/convention scan)
1064 #[arg(long, value_name = "NAME")]
1065 pub rig: Option<String>,
1066
1067 #[command(subcommand)]
1068 pub command: RigCommand,
1069}
1070
1071#[derive(Debug, Subcommand)]
1072pub enum RigCommand {
1073 /// Bring the rig up (compose up -d --wait) and wait for the
1074 /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
1075 /// hint in warnings)
1076 Up {
1077 /// Wait budget in seconds — BOTH compose's --wait-timeout and
1078 /// the commissioned probe deadline (default 300)
1079 #[arg(long, default_value_t = 300, value_name = "SECS")]
1080 timeout: u64,
1081
1082 /// Provision a third-party module for THIS invocation only
1083 /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. ADDITIVE to
1084 /// any `[rigs.NAME.modules.*]` declared in config — a flag
1085 /// naming an id config already declares OVERRIDES that id's
1086 /// version for this run; there is NO subtractive form (a
1087 /// config-declared module cannot be omitted via this flag —
1088 /// remove it from config instead)
1089 #[arg(long, value_name = "ID@VERSION")]
1090 with_module: Vec<String>,
1091
1092 /// Re-verify every already-cached module version against its
1093 /// release feed before trusting it: an unchanged digest reuses
1094 /// the cache, a changed one REFUSES (exit 6) naming both
1095 /// digests — never silently accepted. Mutually exclusive with
1096 /// --accept-upstream-change (never implied by it)
1097 #[arg(long, conflicts_with = "accept_upstream_change")]
1098 refresh: bool,
1099
1100 /// Accept a changed digest the feed now reports for an
1101 /// already-cached module version — the deliberate, explicit
1102 /// way to accept a re-released artifact (downloads and
1103 /// verifies the NEW bytes, cached alongside the old entry,
1104 /// never over it). Mutually exclusive with --refresh
1105 #[arg(long, conflicts_with = "refresh")]
1106 accept_upstream_change: bool,
1107 },
1108 /// Stop the rig (compose down --remove-orphans; volumes KEPT —
1109 /// `reset` owns the teardown half)
1110 Down,
1111 /// Tear the rig down AND remove its volumes (down -v
1112 /// --remove-orphans), then bring it back up fresh — destructive,
1113 /// refused without --yes; no stale project/trial state survives
1114 Reset {
1115 /// Wait budget in seconds — BOTH compose's --wait-timeout and
1116 /// the commissioned probe deadline (default 300)
1117 #[arg(long, default_value_t = 300, value_name = "SECS")]
1118 timeout: u64,
1119
1120 /// Provision a third-party module for THIS invocation only
1121 /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. Same
1122 /// additive/flag-wins/no-subtractive-form contract as `rig up
1123 /// --with-module` (`reset` runs the same up-half internally)
1124 #[arg(long, value_name = "ID@VERSION")]
1125 with_module: Vec<String>,
1126
1127 /// Re-verify every already-cached module version against its
1128 /// release feed before trusting it (see `rig up --refresh`).
1129 /// Mutually exclusive with --accept-upstream-change
1130 #[arg(long, conflicts_with = "accept_upstream_change")]
1131 refresh: bool,
1132
1133 /// Accept a changed digest the feed now reports for an
1134 /// already-cached module version (see `rig up
1135 /// --accept-upstream-change`). Mutually exclusive with --refresh
1136 #[arg(long, conflicts_with = "refresh")]
1137 accept_upstream_change: bool,
1138 },
1139 /// Structured status: services, ports, volumes (allowlist JSON;
1140 /// a down rig is exit-0 data)
1141 Status,
1142 /// Stream the rig's container logs (compose logs passthrough —
1143 /// raw lines, no envelope in any mode; the third streaming
1144 /// exception, README-documented)
1145 Logs {
1146 /// Lines to show from the end of each service's logs
1147 #[arg(long, default_value_t = 200, value_name = "N")]
1148 tail: u32,
1149 /// Follow: stream new lines as they occur (Ctrl-C stops —
1150 /// default process kill, no envelope)
1151 #[arg(short = 'f', long)]
1152 follow: bool,
1153 /// One service's logs only (see `ign rig status` for names)
1154 service: Option<String>,
1155 },
1156 /// Trial-license state: status is credential-free truth; reset
1157 /// (guarded) restarts an EXPIRED trial via the mechanism ladder
1158 /// (token-auth POST, else native gateway login)
1159 Trial(TrialArgs),
1160 /// Third-party module lifecycle beyond declaration: `uninstall`
1161 /// removes a module from the gateway entirely — guarded, and NOT
1162 /// undone by re-declaring it (Phase 19)
1163 Module(ModuleArgs),
1164 /// Snapshot the rig's gateway: native gwbk (roaming backup,
1165 /// streamed) + per-project exports + manifest.json, composed in a
1166 /// timestamped directory — repeatable state
1167 Snapshot {
1168 /// Output directory (default:
1169 /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
1170 #[arg(short = 'o', long, value_name = "DIR")]
1171 output: Option<PathBuf>,
1172 },
1173 /// Restore a gwbk onto the rig's gateway — destructive, refused
1174 /// without --yes; synchronous restore + restart, then a witnessed
1175 /// RUNNING wait
1176 Restore {
1177 /// The gwbk file to restore (from `ign rig snapshot`)
1178 #[arg(long, value_name = "PATH")]
1179 file: PathBuf,
1180 /// Post-restore RUNNING wait budget in seconds (floored at
1181 /// 300 — the gateway restarts after a restore)
1182 #[arg(long, default_value_t = 300, value_name = "SECS")]
1183 timeout: u64,
1184 },
1185}
1186
1187/// Trial subcommands (04-03, RIG-02/03). `status` reads the
1188/// unauthenticated trial endpoint + banners cross-check. `reset` is
1189/// the family's destructive verb (`--yes`-guarded, exit 2 without —
1190/// the reset precedent); the password NEVER rides a flag (env/secret
1191/// only — redaction discipline).
1192#[derive(Debug, clap::Args)]
1193pub struct TrialArgs {
1194 #[command(subcommand)]
1195 pub command: TrialCommand,
1196}
1197
1198#[derive(Debug, Clone, Subcommand)]
1199pub enum TrialCommand {
1200 /// Show the trial state: licenseMode, trialState, seconds left,
1201 /// expired — plus the banners cross-check. No credential needed
1202 /// (the endpoints answer unauthenticated — fresh-rig friendly)
1203 Status,
1204 /// Reset an EXPIRED trial to a fresh window — destructive,
1205 /// refused without --yes. Mechanism ladder: API-token POST
1206 /// (IGNITION_TOKEN) → native gateway login (--user /
1207 /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
1208 /// (trial_not_expired)
1209 Reset {
1210 /// Gateway admin username for the login rung (password comes
1211 /// from IGNITION_PASSWORD — never a flag)
1212 #[arg(long, value_name = "NAME")]
1213 user: Option<String>,
1214 },
1215}
1216
1217/// Module subcommands (Phase 19) — `uninstall` is the family's ONE
1218/// destructive verb: guarded (`--yes`-refused, the `rig reset`
1219/// precedent), and, unlike every other guarded verb in this codebase,
1220/// NOT undone by re-declaring the module afterward (recovery today
1221/// means `ign rig reset`, which destroys the rig's data volume).
1222#[derive(Debug, clap::Args)]
1223pub struct ModuleArgs {
1224 #[command(subcommand)]
1225 pub command: ModuleCommand,
1226}
1227
1228#[derive(Debug, Clone, Subcommand)]
1229pub enum ModuleCommand {
1230 /// Remove a module from the gateway entirely — destructive, refused
1231 /// without `--yes`. `ID` is a REGISTRY id (the same one `rig up
1232 /// --with-module` takes; see `ign rig up --with-module`), never a
1233 /// raw gateway module id — a hand-installed module cannot be named
1234 /// here. Needs `IGNITION_TOKEN`. The gateway refuses while the
1235 /// `.modl` is still mounted: undeclare the module and run `ign rig
1236 /// up` first. A successful uninstall is NOT reversible by
1237 /// re-declaring — recovery today means `ign rig reset --yes`,
1238 /// which destroys the rig's data volume.
1239 Uninstall {
1240 /// The registry id to uninstall (e.g. `git`).
1241 id: String,
1242 },
1243}
1244
1245/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
1246/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
1247/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
1248/// state); `download` is a streamed read, unguarded.
1249#[derive(Debug, clap::Args)]
1250pub struct BackupArgs {
1251 #[command(subcommand)]
1252 pub command: BackupCommand,
1253}
1254
1255#[derive(Debug, Subcommand)]
1256pub enum BackupCommand {
1257 /// Download a gwbk backup (streamed to disk)
1258 Download {
1259 /// Output file (default: the gateway's Content-Disposition
1260 /// name, else <profile>-backup.gwbk)
1261 #[arg(short, long, value_name = "FILE")]
1262 output: Option<PathBuf>,
1263 /// Backup type: roaming = portable across gateways (default);
1264 /// all includes gateway-specific state
1265 #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
1266 r#type: CliBackupType,
1267 },
1268 /// Restore a gwbk onto THIS gateway — destructive, refused
1269 /// without --yes; the gateway restarts and blocks for minutes
1270 /// after the restore
1271 Restore {
1272 /// The gwbk file to restore (from `ign backup download` or
1273 /// `ign rig snapshot`)
1274 file: PathBuf,
1275 },
1276}
1277
1278/// CLI value-enum mirror of the core `BackupType` (ignition-core
1279/// stays clap-free; `From` converts at the dispatch seam).
1280#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1281pub enum CliBackupType {
1282 /// Portable backup (cross-gateway)
1283 Roaming,
1284 /// Includes gateway-specific state
1285 All,
1286}
1287
1288impl From<CliBackupType> for ignition_core::client::backup::BackupType {
1289 fn from(value: CliBackupType) -> Self {
1290 match value {
1291 CliBackupType::Roaming => Self::Roaming,
1292 CliBackupType::All => Self::All,
1293 }
1294 }
1295}
1296
1297/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
1298/// guarded writes. Every runtime verb honestly reports the
1299/// controller-mode state gate (`eam_not_controller` on a stock
1300/// gateway — the README documents the manual flip).
1301#[derive(Debug, clap::Args)]
1302pub struct EamArgs {
1303 #[command(subcommand)]
1304 pub command: EamCommand,
1305}
1306
1307#[derive(Debug, Subcommand)]
1308pub enum EamCommand {
1309 /// EAM task run history (the gateway's own newest-first order)
1310 History {
1311 /// Max entries (default 200 — ALWAYS sent explicitly; the
1312 /// server default is unlimited)
1313 #[arg(long, value_name = "N")]
1314 limit: Option<u32>,
1315 /// Substring search over task names
1316 #[arg(long, value_name = "TEXT")]
1317 search: Option<String>,
1318 },
1319 /// Task definitions: bare `ign eam tasks` lists; with a name
1320 /// shows one definition + its scheduled state
1321 Tasks {
1322 /// One task definition's detail (omit to list all)
1323 name: Option<String>,
1324 },
1325 /// Task definition writes (create; force-dispatch) — `eam task`
1326 /// requires a subcommand (the `rig trial` shape)
1327 #[command(subcommand)]
1328 Task(EamTaskCommand),
1329}
1330
1331/// `eam task …` — the guarded writes (07-02 Task 3; the five
1332/// lifecycle/mutation verbs arrive 10-04). `new` carries the typed
1333/// guard ladder (backup+OnDemand unguarded; mutating types and any
1334/// non-OnDemand schedule need --yes; restore/install/upgrade types
1335/// refuse outright — see README); `force` dispatches NOW, always
1336/// guarded; `suspend`/`resume`/`cancel`/`modify`/`delete` are the
1337/// two-tier guarded verbs (pure precheck → blast-radius preview
1338/// fetch → confirm-with-preview → action — the refusal message IS
1339/// the blast radius).
1340#[derive(Debug, Subcommand)]
1341pub enum EamTaskCommand {
1342 /// Create a task definition (scheduleMode defaults to OnDemand —
1343 /// never auto-fires)
1344 New {
1345 /// Task definition name (any resource name — e.g. nightly-backup)
1346 name: String,
1347 /// Task type — the openapi taxonomy, three classes with different guard consequences:
1348 ///
1349 /// - benign (no --yes with the default OnDemand schedule): eam_backup
1350 ///
1351 /// - mutating (need --yes — they act on their agent targets when dispatched):
1352 /// eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1353 /// eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1354 ///
1355 /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1356 /// eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1357 ///
1358 /// Any OTHER type fails safe to the --yes rung (unknown future
1359 /// types stay accepted, never silently unguarded).
1360 ///
1361 /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1362 r#type: String,
1363 /// Target gateway name (repeatable; the GNET agent id)
1364 #[arg(long, value_name = "NAME")]
1365 target: Vec<String>,
1366 /// Setting as K=V with scalar auto-typing — bool/int ride
1367 /// typed, anything else stays a string (repeatable;
1368 /// arrays/objects need --definition)
1369 #[arg(long, value_name = "K=V")]
1370 setting: Vec<String>,
1371 /// Full-JSON settings file deep-merged over the composed
1372 /// `config.settings` (the typed/array settings path)
1373 #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1374 definition: Option<PathBuf>,
1375 /// Schedule mode (default OnDemand — never auto-fires;
1376 /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1377 #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1378 schedule_mode: ScheduleMode,
1379 },
1380 /// Force-dispatch a task NOW — destructive, refused without
1381 /// --yes (it dispatches to the agent targets immediately). The
1382 /// confirmation prompt (and every refusal) carries the
1383 /// blast-radius preview line.
1384 Force {
1385 /// Task definition name to dispatch
1386 name: String,
1387 },
1388 /// Suspend a task definition's scheduled dispatches — TASK-scoped
1389 /// runtime verb (there is NO agent-level suspend on the wire; an
1390 /// "agent" is suspended by suspending its tasks). Refused exit 2
1391 /// without --yes, the blast-radius preview in the refusal
1392 /// message; on a stock gateway the runtime seam honestly refuses
1393 /// `eam_not_controller` (exit 6).
1394 Suspend {
1395 /// Task definition name to suspend
1396 name: String,
1397 },
1398 /// Resume a suspended task definition — the suspend inverse
1399 /// (TASK-scoped, like suspend). Refused exit 2 without --yes
1400 /// with the blast-radius preview in the message.
1401 Resume {
1402 /// Task definition name to resume
1403 name: String,
1404 },
1405 /// Cancel a task's PENDING execution (TASK-scoped). Refused exit
1406 /// 2 without --yes with the blast-radius preview in the message;
1407 /// nothing pending is an honest no-op (`fired: false`), a
1408 /// `canCancel: false` row reports the gateway's own refusal.
1409 Cancel {
1410 /// Task definition whose pending execution to cancel
1411 name: String,
1412 },
1413 /// Rewrite targeted keys of a task definition — the full-record
1414 /// read-modify-write (every key the gateway answered rides back;
1415 /// only the targeted keys change). Rename is deliberately
1416 /// ABSENT (the wire answers PUT-rename with 404 — see README's
1417 /// create-new + delete-old composite). Refused exit 2 without
1418 /// --yes with the blast-radius preview in the message.
1419 ///
1420 /// Example: ign eam task modify nightly-backup --enable
1421 Modify {
1422 /// Task definition to modify
1423 name: String,
1424 /// Flip the definition's `enabled` key to true
1425 #[arg(long)]
1426 enable: bool,
1427 /// Flip the definition's `enabled` key to false
1428 /// (conflicts with --enable)
1429 #[arg(long, conflicts_with = "enable")]
1430 disable: bool,
1431 /// Rewrite `config.profile.scheduleMode` (e.g. OnDemand,
1432 /// Scheduled — the scheduleDetails string itself is out of
1433 /// scope; the read-back shows the landed pairing)
1434 #[arg(long, value_name = "MODE")]
1435 schedule_mode: Option<String>,
1436 /// Deep-merge a settings entry over the found
1437 /// `config.settings` (K=V with scalar auto-typing;
1438 /// repeatable — objects merge recursively, arrays/scalars
1439 /// replace)
1440 #[arg(long, value_name = "K=V")]
1441 setting: Vec<String>,
1442 /// Rewrite the definition's `description`
1443 #[arg(long, value_name = "TEXT")]
1444 description: Option<String>,
1445 },
1446 /// Delete a task definition — signature-keyed (the gateway
1447 /// refuses a stale signature), behind the same --yes guard.
1448 /// Refused exit 2 without --yes with the blast-radius preview
1449 /// in the message.
1450 Delete {
1451 /// Task definition to delete
1452 name: String,
1453 },
1454}
1455
1456/// Schedule modes (the openapi taxonomy's user-facing subset —
1457/// SuspendedByFailover is system-owned, not a CLI value).
1458#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1459pub enum ScheduleMode {
1460 /// Never fires on its own (force dispatches it)
1461 OnDemand,
1462 /// Fires immediately on create (requires --yes)
1463 Immediate,
1464 /// Fires on a schedule (requires --yes)
1465 Scheduled,
1466 /// Fires at an absolute time (requires --yes)
1467 AtTime,
1468 /// Fires after a delay (requires --yes)
1469 AtDelay,
1470}
1471
1472impl ScheduleMode {
1473 /// The wire token.
1474 pub fn wire(self) -> &'static str {
1475 match self {
1476 Self::OnDemand => "OnDemand",
1477 Self::Immediate => "Immediate",
1478 Self::Scheduled => "Scheduled",
1479 Self::AtTime => "AtTime",
1480 Self::AtDelay => "AtDelay",
1481 }
1482 }
1483}
1484
1485/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1486/// verb over the already-shipped, already-secured scriptExec route.
1487/// `script` requires a subcommand (the `rig trial` shape — no bare
1488/// row); the opt-in is STRUCTURAL (the route deploys only via
1489/// `ign webdev deploy --with-script-exec`, which persists the
1490/// secret at 0600), so `run` carries NO `--yes` guard by design —
1491/// the deploy flag IS the opt-in and agents need the verb
1492/// non-interactive (the research-adopted decision).
1493#[derive(Debug, clap::Args)]
1494pub struct ScriptArgs {
1495 #[command(subcommand)]
1496 pub command: ScriptCommand,
1497}
1498
1499/// Testing subcommands (QUICK-p0g) — the standalone wrapper over the
1500/// gateway testing bundle's `testing/run` route. `testing` requires a
1501/// subcommand (the `script` shape — no bare row); there is no `--yes`
1502/// guard: running a test suite is a read-shaped operation from the
1503/// CLI's side and agents need the verb non-interactive.
1504#[derive(Debug, clap::Args)]
1505pub struct TestingArgs {
1506 #[command(subcommand)]
1507 pub command: TestingCommand,
1508}
1509
1510/// `ign e2e` args (QUICK-tg4) — the browser-E2E pair.
1511#[derive(Debug, clap::Args)]
1512pub struct E2eArgs {
1513 #[command(subcommand)]
1514 pub command: E2eCmd,
1515}
1516
1517#[derive(Debug, Subcommand)]
1518pub enum E2eCmd {
1519 /// Diagnose the browser-E2E setup: node (≥20), npm,
1520 /// @playwright/test in the scaffold, a downloaded chromium, the
1521 /// gateway's testing bundle, and the gateway's trial state.
1522 /// Read-only and offline-safe — **exits 0 whenever the diagnosis
1523 /// completes**, so every finding is a `checks[]` row rather than
1524 /// an exit code. The two gateway rows report `skip` when no
1525 /// profile resolves or no --project is given
1526 Doctor {
1527 /// The scaffold directory to inspect (default: ./e2e)
1528 #[arg(value_name = "DIR")]
1529 dir: Option<PathBuf>,
1530 /// The gateway project whose testing bundle is probed. No
1531 /// default — without it the bundle row honestly skips
1532 #[arg(long, value_name = "NAME")]
1533 project: Option<String>,
1534 },
1535
1536 /// Scaffold a Playwright E2E suite into DIR (default ./e2e): a
1537 /// runner config, a global setup that logs in through `ign
1538 /// session login` and gates on `ign testing run`, gateway tag/
1539 /// script helpers, an example suite, a .gitignore, and a README —
1540 /// then `npm install`. **Idempotent**: an existing file is
1541 /// reported `skipped` and never overwritten. Requires the global
1542 /// --yes; without it the verb writes nothing, spawns nothing, and
1543 /// refuses exit 2 after previewing every file and both commands
1544 Init {
1545 /// Where the scaffold lands (default: ./e2e)
1546 #[arg(value_name = "DIR")]
1547 dir: Option<PathBuf>,
1548 /// The project whose WebDev testing routes the helpers call
1549 /// (default: ign-cli)
1550 #[arg(long, value_name = "NAME")]
1551 project: Option<String>,
1552 /// The Perspective project the browser test navigates to
1553 /// (default: the effective --project)
1554 #[arg(long, value_name = "NAME")]
1555 run_project: Option<String>,
1556 /// Also download the Chromium build after installing
1557 #[arg(long)]
1558 browsers: bool,
1559 },
1560}
1561
1562/// `ign session` args (QUICK-tg4) — the SINGULAR verb family: one
1563/// gateway login session for this CLI to hand to a browser harness.
1564///
1565/// The plural `ign sessions` family (`SessionsArgs` / `SessionsCommand`)
1566/// is a different thing entirely: it LISTS the gateway's connected
1567/// designer/Perspective/Vision sessions. The names are deliberately
1568/// kept apart here so a future reader never merges them.
1569#[derive(Debug, clap::Args)]
1570pub struct SessionArgs {
1571 #[command(subcommand)]
1572 pub command: SessionCmd,
1573}
1574
1575#[derive(Debug, Subcommand)]
1576pub enum SessionCmd {
1577 /// Log in to the gateway and return the live session: the
1578 /// `webui-sid-*` cookie name and value, the CSRF token for
1579 /// `X-CSRF-Token`, the gateway URL, and `storage_state` — a
1580 /// Playwright `storageState` document ready to write straight to
1581 /// disk and point `use.storageState` at. The password is env-only
1582 /// (`IGNITION_PASSWORD`; missing exits 3, rejected exits 5). The
1583 /// cookie value and CSRF token are exposed ONLY under the global
1584 /// --json flag
1585 Login {
1586 /// Gateway login user (default: $IGNITION_USER, else admin)
1587 #[arg(long, value_name = "NAME")]
1588 user: Option<String>,
1589 },
1590}
1591
1592/// The CLI-side `--format` enum (QUICK-p0g) — the clap `ValueEnum`
1593/// mirror of core's plain `TestingFormat`, the `TransferFormat`
1594/// precedent (core keeps framework-free enums; the `From` impl lives
1595/// beside this one). `get_possible_values()` is what feeds the MCP
1596/// tool schema a real enum instead of a free string.
1597///
1598/// EVERY value produces the SAME verdict and exit code: the route is
1599/// always asked for json and the junit/text reports are rendered
1600/// client-side, because the route's own junit/text answers stay HTTP
1601/// 200 and carry no counts — a passthrough would exit 0 on a red
1602/// suite.
1603#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
1604pub enum TestingFormatArg {
1605 /// Structured results only (default) — `data.report` stays empty
1606 #[default]
1607 Json,
1608 /// JUnit XML in `data.report`, for a CI test reporter
1609 Junit,
1610 /// Console-style text in `data.report`, for a human reading logs
1611 Text,
1612}
1613
1614impl From<TestingFormatArg> for ignition_core::actions::testing::TestingFormat {
1615 fn from(value: TestingFormatArg) -> Self {
1616 match value {
1617 TestingFormatArg::Json => Self::Json,
1618 TestingFormatArg::Junit => Self::Junit,
1619 TestingFormatArg::Text => Self::Text,
1620 }
1621 }
1622}
1623
1624#[derive(Debug, Subcommand)]
1625pub enum TestingCommand {
1626 /// Run the gateway-side Jython test suite and return the machine
1627 /// verdict (exit 0 green, exit 6 red with the FULL results still
1628 /// in the envelope)
1629 Run {
1630 /// The project whose testing bundle runs. REQUIRED — the
1631 /// bundle deploys per project and running a suite executes
1632 /// that project's gateway-side code, so there is
1633 /// deliberately no `ign-cli` default here
1634 #[arg(long, value_name = "NAME", required = true)]
1635 project: String,
1636 /// List the gateway's discovered test modules instead of
1637 /// running them
1638 #[arg(long, conflicts_with_all = ["module", "package"])]
1639 discover: bool,
1640 /// Run ONE dotted module (`proj.foo_test`); mutually
1641 /// exclusive with --package and --discover
1642 #[arg(long, value_name = "DOTTED", conflicts_with = "package")]
1643 module: Option<String>,
1644 /// Run every module under a package prefix (`proj.`);
1645 /// mutually exclusive with --module and --discover
1646 #[arg(long, value_name = "PREFIX")]
1647 package: Option<String>,
1648 /// Report rendered into `data.report`. The verdict and exit
1649 /// code are IDENTICAL in every format — the structured
1650 /// results always ride under `data.results`
1651 #[arg(long, value_name = "FORMAT", default_value = "json")]
1652 format: TestingFormatArg,
1653 },
1654}
1655
1656/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
1657/// PATHS map to `--target <path>` pairs on the child's arg vector;
1658/// `--` passthrough args ride verbatim after them (power users:
1659/// `--profile`, `--checks`, `--fail-on`, …).
1660#[derive(Debug, clap::Args)]
1661pub struct LintArgs {
1662 /// Paths to lint (one --target pair each)
1663 #[arg(value_name = "PATH", required = true)]
1664 pub paths: Vec<String>,
1665 /// Exit with the linter's own exit code (CI mode). Default is
1666 /// the doctor posture: findings + child_exit_code + the parsed
1667 /// report ride as data and the command exits 0 whenever the
1668 /// tool RAN
1669 #[arg(long)]
1670 pub strict: bool,
1671 /// Extra args passed to ignition-lint verbatim (after --)
1672 #[arg(last = true, value_name = "ARGS")]
1673 pub passthrough: Vec<String>,
1674}
1675
1676#[derive(Debug, Subcommand)]
1677pub enum ScriptCommand {
1678 /// Execute gateway-side Python (Jython) — non-interactive, the
1679 /// route's entire purpose
1680 Run {
1681 /// Inline Python source (a one-liner's best form)
1682 #[arg(long, value_name = "PY")]
1683 code: Option<String>,
1684 /// Python source file (`-` reads stdin — the agent pipe
1685 /// path); giving both --code and --file refuses
1686 /// `invalid_input` (exit 2) pre-resolution
1687 #[arg(long, value_name = "PATH")]
1688 file: Option<String>,
1689 /// The deployed routes' project (default ign-cli — where
1690 /// `ign webdev deploy` put scriptExec)
1691 #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1692 project: String,
1693 },
1694}
1695
1696/// `ign api` args (09-03, EXT-01) — the raw-passthrough family. One
1697/// verb today (`call`); the escape hatch for the 80+ gateway endpoint
1698/// families no curated command covers.
1699#[derive(Debug, clap::Args)]
1700pub struct ApiArgs {
1701 #[command(subcommand)]
1702 pub command: ApiCommand,
1703}
1704
1705#[derive(Debug, Subcommand)]
1706pub enum ApiCommand {
1707 /// Call any gateway REST endpoint raw: `--method`/`--path` are the
1708 /// request, `--data`/`--header`/`--query` shape it, and the
1709 /// envelope's `data.result.data` is the gateway's JSON VERBATIM
1710 /// (no field dropped, no value coerced, key order preserved — the
1711 /// README's documented contract exception). Auth-pattern headers
1712 /// (`Authorization`, `X-Ignition-API-Token`, `Cookie`) are
1713 /// refused pre-I/O — credentials come from the profile.
1714 Call(ApiCallArgs),
1715}
1716
1717/// `ign api call` args (09-03, EXT-01). Arbitrary verbs accepted —
1718/// reqwest's `Method` parser validates (usage-class refusal for
1719/// garbage); `--data` is RAW TEXT on ANY method (GET/DELETE bodies
1720/// allowed — curl parity; the gateway's answer classifies); ONE query
1721/// mechanism (repeatable `--query k=v`, never `?` in `--path`).
1722#[derive(Debug, clap::Args)]
1723pub struct ApiCallArgs {
1724 /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, …)
1725 #[arg(long, value_name = "METHOD")]
1726 pub method: String,
1727 /// Absolute path on the gateway (must start with `/`; no `?`, no host)
1728 #[arg(long, value_name = "PATH")]
1729 pub path: String,
1730 /// Raw body text passthrough (rides ANY method — curl parity)
1731 #[arg(long, value_name = "TEXT")]
1732 pub data: Option<String>,
1733 /// Extra request header `Name: Value` (repeatable; split on the
1734 /// FIRST `:`; auth-pattern names are refused pre-I/O)
1735 #[arg(long, value_name = "NAME: VALUE")]
1736 pub header: Vec<String>,
1737 /// Query pair `k=v` (repeatable; split on the FIRST `=` — the
1738 /// ONE query mechanism)
1739 #[arg(long, value_name = "K=V")]
1740 pub query: Vec<String>,
1741}
1742
1743/// `ign license` args (09-04, EXT-02) — the license morning-check
1744/// family. One verb today; room to grow (the uniform-leaf shape).
1745#[derive(Debug, clap::Args)]
1746pub struct LicenseArgs {
1747 #[command(subcommand)]
1748 pub command: LicenseCommand,
1749}
1750
1751#[derive(Debug, Subcommand)]
1752pub enum LicenseCommand {
1753 /// License inventory + trial state in ONE command: the
1754 /// hardware-key item rows and effective stamp ride the
1755 /// `/licenses` read, the license mode + countdown ride the trial
1756 /// companion (the mode is NOT on the licenses payload — capture
1757 /// fact)
1758 Status,
1759}
1760
1761/// `ign redundancy` args (09-04, EXT-02) — the redundancy
1762/// morning-check family.
1763#[derive(Debug, clap::Args)]
1764pub struct RedundancyArgs {
1765 #[command(subcommand)]
1766 pub command: RedundancyCommand,
1767}
1768
1769#[derive(Debug, Subcommand)]
1770pub enum RedundancyCommand {
1771 /// The flat redundancy status: role, project state, peer
1772 /// connection, config access, sync/failover pending, uptime
1773 /// (ms since gateway start — capture-proven) and last-sync (the
1774 /// `-1` never-synced sentinel on fresh rigs)
1775 Status,
1776}
1777
1778/// `ign gan` args (09-04, EXT-02) — the Gateway Area Network
1779/// morning-check family.
1780#[derive(Debug, clap::Args)]
1781pub struct GanArgs {
1782 #[command(subcommand)]
1783 pub command: GanCommand,
1784}
1785
1786#[derive(Debug, Subcommand)]
1787pub enum GanCommand {
1788 /// The GAN overview: total/running connections, in/out byte
1789 /// rates, remote gateways — all zeros are healthy data on a
1790 /// non-GAN gateway (the capture IS the canonical shape)
1791 Status,
1792}
1793
1794/// `ign diagnostics` args (09-05, EXT-02) — the support-bundle
1795/// family: `bundle` nests one level (the `tags provider` grouped-
1796/// subfamily pattern).
1797#[derive(Debug, clap::Args)]
1798pub struct DiagnosticsArgs {
1799 #[command(subcommand)]
1800 pub command: DiagnosticsCommand,
1801}
1802
1803#[derive(Debug, Subcommand)]
1804pub enum DiagnosticsCommand {
1805 /// Support-bundle operations (generate / status / download /
1806 /// wait) — the state machine is ENCODED FROM LIVE CAPTURES:
1807 /// observed states are exactly `Generating` (mid-generation) and
1808 /// `Valid` (terminal/ready), PascalCase, both rigs
1809 #[command(subcommand)]
1810 Bundle(BundleCommand),
1811}
1812
1813/// The four bundle verbs (09-05). Nothing here is destructive:
1814/// generate creates a support bundle, download writes a local file,
1815/// wait polls — none carry `--yes` (the backup-download posture).
1816#[derive(Debug, Subcommand)]
1817pub enum BundleCommand {
1818 /// Start bundle generation (POST, no body) — the 200 answer IS
1819 /// the fresh status (live capture: `{"state":"Generating"}`);
1820 /// generation takes ~2–6 s on a fresh rig, so pair with `wait`
1821 Generate,
1822 /// The bundle status: `state` rides the CAPTURED vocabulary
1823 /// (`Generating` / `Valid` — unknown future states pass through
1824 /// verbatim, never refused); `fileSize` (bytes) appears only
1825 /// when `Valid`
1826 Status,
1827 /// Download the bundle ZIP (streamed to disk; the request rides
1828 /// a 300 s per-request timeout — the 30 s client default would
1829 /// truncate MB-sized bundles)
1830 Download {
1831 /// Output file (default: the gateway's Content-Disposition
1832 /// name, else ignition-diagnostics-bundle-<unix_ts>.zip)
1833 #[arg(short, long, value_name = "FILE")]
1834 output: Option<PathBuf>,
1835 },
1836 /// Poll the status until a terminal captured state (`Valid`)
1837 /// — an UNKNOWN state (outside the captured vocabulary) keeps
1838 /// polling honestly until the deadline; deadline expiry is
1839 /// exit 4 `network_error` (the restart-wait convention, no new
1840 /// slug) with the last observed state in the message
1841 Wait {
1842 /// Poll interval in seconds
1843 #[arg(long, default_value_t = 2, value_name = "SECS")]
1844 interval: u64,
1845 /// Give up after this many seconds (default 300 — mirrors
1846 /// restart --wait)
1847 #[arg(long, default_value_t = 300, value_name = "SECS")]
1848 timeout: u64,
1849 },
1850}
1851
1852/// Profile subcommands (nested: a struct wrapper carrying the subcommand
1853/// enum, so `Commands::Profile` gets an `Args` payload).
1854#[derive(Debug, clap::Args)]
1855pub struct ProfileArgs {
1856 #[command(subcommand)]
1857 pub command: ProfileCmd,
1858}
1859
1860/// Sessions args: the family filter rides the TOP level so bare
1861/// `ign sessions [--type X]` lists (must-have truth #1) while
1862/// `ign sessions terminate …` carries the destructive half.
1863#[derive(Debug, clap::Args)]
1864pub struct SessionsArgs {
1865 /// Filter to one session family (default: all three merged)
1866 #[arg(long, value_enum)]
1867 pub r#type: Option<SessionType>,
1868
1869 #[command(subcommand)]
1870 pub command: Option<SessionsCmd>,
1871}
1872
1873#[derive(Debug, Subcommand)]
1874pub enum SessionsCmd {
1875 /// Terminate (designer: prune / vision: close) a session —
1876 /// destructive, refused without --yes
1877 Terminate {
1878 /// Session family holding the id
1879 #[arg(long, value_enum)]
1880 r#type: SessionType,
1881 /// Session/client id to terminate (see `ign sessions`)
1882 #[arg(long)]
1883 id: String,
1884 /// Message shown to the session's user (Perspective only)
1885 #[arg(long)]
1886 message: Option<String>,
1887 },
1888}
1889
1890/// Logs args: the query filters ride the TOP level so bare
1891/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
1892/// and the `loggers` subtree hang off the optional subcommand — the
1893/// SessionsArgs precedent.
1894#[derive(Debug, clap::Args)]
1895pub struct LogsArgs {
1896 /// Only entries from this logger (name prefix)
1897 #[arg(long, value_name = "NAME")]
1898 pub logger: Option<String>,
1899 /// Minimum level to include (server-side filter)
1900 #[arg(long, value_enum, value_name = "LEVEL")]
1901 pub min_level: Option<LogLevel>,
1902 /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
1903 /// 5min, 2h) — parsed to epoch-ms at arg-parse time
1904 #[arg(
1905 long,
1906 value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
1907 value_parser = parse_since_arg
1908 )]
1909 pub since: Option<i64>,
1910 /// Max entries — the server default is UNLIMITED, never used here
1911 #[arg(long, default_value_t = 200)]
1912 pub limit: i64,
1913 /// Follow: stream new entries as they occur (poll-based)
1914 #[arg(short = 'f', long)]
1915 pub follow: bool,
1916 /// Poll interval in seconds (follow mode)
1917 #[arg(long, default_value_t = 2, value_name = "SECS")]
1918 pub interval: u64,
1919 /// Stop after this many seconds (follow mode; default: until Ctrl-C)
1920 #[arg(long, value_name = "SECS")]
1921 pub timeout: Option<u64>,
1922 #[command(subcommand)]
1923 pub command: Option<LogsCmd>,
1924}
1925
1926#[derive(Debug, Subcommand)]
1927pub enum LogsCmd {
1928 /// Download the log archive — a SQLite .idb, never a zip
1929 Download {
1930 /// Output file (default: the gateway's Content-Disposition
1931 /// name, else <profile>-logs-<ts>.idb)
1932 #[arg(short, long, value_name = "FILE")]
1933 output: Option<PathBuf>,
1934 },
1935 /// List loggers / manage logger levels
1936 Loggers(LoggersArgs),
1937}
1938
1939#[derive(Debug, clap::Args)]
1940pub struct LoggersArgs {
1941 /// Substring search over logger names
1942 #[arg(long, value_name = "TEXT")]
1943 pub search: Option<String>,
1944 #[command(subcommand)]
1945 pub command: Option<LoggersCmd>,
1946}
1947
1948#[derive(Debug, Subcommand)]
1949pub enum LoggersCmd {
1950 /// Set one logger's level — a mutation, refused without --yes
1951 Set {
1952 /// Logger name (see `ign logs loggers`)
1953 name: String,
1954 /// Level to set
1955 #[arg(value_enum)]
1956 level: LogLevel,
1957 },
1958 /// Reset ALL logger levels to defaults — refused without --yes
1959 Reset,
1960}
1961
1962/// The seven spec-documented log levels (TRACE..OFF), value-enum form
1963/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
1964#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1965pub enum LogLevel {
1966 Trace,
1967 Debug,
1968 Info,
1969 Warn,
1970 Error,
1971 Fatal,
1972 Off,
1973}
1974
1975impl LogLevel {
1976 /// The uppercase wire token the gateway expects.
1977 pub fn wire(self) -> &'static str {
1978 match self {
1979 Self::Trace => "TRACE",
1980 Self::Debug => "DEBUG",
1981 Self::Info => "INFO",
1982 Self::Warn => "WARN",
1983 Self::Error => "ERROR",
1984 Self::Fatal => "FATAL",
1985 Self::Off => "OFF",
1986 }
1987 }
1988}
1989
1990/// clap value parser delegating to the core `--since` grammar against
1991/// the current time (a relative span resolves at parse time) — invalid
1992/// specs are clap usage errors (exit 2) like any bad flag value.
1993fn parse_since_arg(spec: &str) -> Result<i64, String> {
1994 let now_ms = std::time::SystemTime::now()
1995 .duration_since(std::time::UNIX_EPOCH)
1996 .expect("system clock is after the unix epoch")
1997 .as_millis() as i64;
1998 ignition_core::actions::logs::parse_since(spec, now_ms)
1999}
2000
2001/// CLI value-enum mirrors of the core action enums (ignition-core stays
2002/// clap-free; `From` converts at the dispatch seam).
2003#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2004pub enum SessionType {
2005 /// Designer sessions
2006 Designer,
2007 /// Perspective browser sessions
2008 Perspective,
2009 /// Vision clients
2010 Vision,
2011}
2012
2013impl From<SessionType> for ignition_core::actions::sessions::SessionType {
2014 fn from(value: SessionType) -> Self {
2015 match value {
2016 SessionType::Designer => Self::Designer,
2017 SessionType::Perspective => Self::Perspective,
2018 SessionType::Vision => Self::Vision,
2019 }
2020 }
2021}
2022
2023#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2024pub enum ConnectionType {
2025 /// Database connections
2026 Database,
2027 /// OPC connections
2028 Opc,
2029}
2030
2031impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
2032 fn from(value: ConnectionType) -> Self {
2033 match value {
2034 ConnectionType::Database => Self::Database,
2035 ConnectionType::Opc => Self::Opc,
2036 }
2037 }
2038}
2039
2040/// Import collision policy (03-02). Exactly the two values REST
2041/// exposes — `merge` is the Designer import popup's vocabulary and is
2042/// NOT a value: clap's invalid-value error lists the two real
2043/// choices, and the README documents merge as Designer-only (the
2044/// sanctioned rejection-with-hint shape).
2045#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2046pub enum CollisionPolicy {
2047 /// Refuse when the project already exists (default)
2048 Abort,
2049 /// Replace the ENTIRE project (resources absent from the ZIP are
2050 /// deleted) — destructive, requires --yes
2051 Overwrite,
2052}
2053
2054impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
2055 fn from(value: CollisionPolicy) -> Self {
2056 match value {
2057 CollisionPolicy::Abort => Self::Abort,
2058 CollisionPolicy::Overwrite => Self::Overwrite,
2059 }
2060 }
2061}
2062
2063#[derive(Debug, Subcommand)]
2064#[command(arg_required_else_help = true)]
2065pub enum ProfileCmd {
2066 /// Add (or overwrite) a gateway profile
2067 Add {
2068 /// Profile name
2069 name: String,
2070 /// Gateway base URL (e.g. http://localhost:9088)
2071 url: String,
2072 /// Optional display label
2073 #[arg(long, value_name = "TEXT")]
2074 label: Option<String>,
2075 /// Name of the env var holding the auth token
2076 #[arg(long, value_name = "VAR")]
2077 token_env: Option<String>,
2078 /// Keyring user string for the token (service is always ignition-cli)
2079 #[arg(long, value_name = "USER")]
2080 keyring: Option<String>,
2081 /// Name of the env var holding the basic-auth user (with --password-env)
2082 #[arg(long, value_name = "VAR")]
2083 user_env: Option<String>,
2084 /// Name of the env var holding the basic-auth password (with --user-env)
2085 #[arg(long, value_name = "VAR")]
2086 password_env: Option<String>,
2087 /// Make this profile the active one
2088 #[arg(long)]
2089 active: bool,
2090 },
2091 /// List configured profiles
2092 List,
2093 /// Switch the active profile
2094 Use {
2095 /// Profile name to activate
2096 name: String,
2097 },
2098}