1use std::{
2 borrow::Cow,
3 collections::{BTreeMap, BTreeSet},
4 convert::TryFrom,
5 env, fs,
6 io::{self, IsTerminal, Write},
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10
11use anyhow::{Context, anyhow};
12use chrono::{TimeZone, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::{Map as JsonMap, Value as JsonValue, json};
15
16use crate::bin_resolver::{self, ResolveCtx};
17use crate::capabilities::ResolveScope;
18use crate::config;
19use crate::config_gate::{self, ConfigGateItem, ConfigValueSource};
20use crate::demo::{
21 self, BuildOptions, DemoRepl, DemoRunner,
22 card::{detect_adaptive_card_view, print_card_summary},
23 input as demo_input, pack_resolve,
24 runner_host::{DemoRunnerHost, FlowOutcome, OperatorContext, primary_provider_type},
25};
26use crate::dev_store_path;
27use crate::discovery;
28use crate::domains::{self, Domain, DomainAction};
29use crate::gmap::{self, Policy};
30use crate::messaging_universal::{
31 dto::{EncodeInV1, EncodeOutV1, RenderPlanOutV1, SendPayloadOutV1},
32 egress,
33};
34use crate::operator_i18n;
35use crate::operator_log;
36use crate::project;
37use crate::provider_registry;
38use crate::qa_setup_wizard;
39use crate::runner_exec;
40use crate::runner_integration;
41use crate::runtime_state::RuntimePaths;
42use crate::secrets_gate::{self, DynSecretsManager};
43use crate::secrets_manager;
44use crate::secrets_setup::resolve_env;
45use crate::setup_input::{SetupInputAnswers, collect_setup_answers, load_setup_input};
46use crate::state_layout;
47use crate::subscriptions_universal::{
48 build_runner,
49 scheduler::Scheduler,
50 service::{SubscriptionEnsureRequest, SubscriptionService},
51 state_root,
52 store::{AuthUserRefV1, SubscriptionStore},
53};
54use crate::wizard;
55use crate::wizard_executor;
56use crate::wizard_i18n;
57use crate::wizard_plan_builder;
58use crate::wizard_spec_builder;
59use base64::Engine as _;
60use clap::{ArgAction, Parser, Subcommand, ValueEnum};
61use greentic_qa_lib::{
62 I18nConfig, QaLibError, QaRunner, ResolvedI18nMap, WizardDriver, WizardFrontend,
63 WizardRunConfig,
64};
65use greentic_runner_host::secrets::default_manager;
66use greentic_start::{
67 CloudflaredModeArg as StartCloudflaredModeArg, NatsModeArg as StartNatsModeArg,
68 NgrokModeArg as StartNgrokModeArg, RestartTarget as StartRestartTarget, StartRequest,
69 StopRequest, run_restart_request, run_start_request, run_stop_request,
70};
71use greentic_types::{ChannelMessageEnvelope, Destination, EnvId, TeamId, TenantCtx, TenantId};
72use std::time::Duration;
73use uuid::Uuid;
74
75#[derive(Parser)]
76#[command(name = "greentic-operator")]
77#[command(about = "Greentic operator tooling", version)]
78pub struct Cli {
79 #[arg(long, global = true, help = "CLI locale (for translated output).")]
80 locale: Option<String>,
81 #[command(subcommand)]
82 command: Command,
83}
84
85#[derive(Subcommand)]
86enum Command {
87 Demo(Box<DemoCommand>),
88}
89
90#[derive(Parser)]
91struct DemoCommand {
92 #[arg(long, global = true)]
93 debug: bool,
94 #[command(subcommand)]
95 command: DemoSubcommand,
96}
97
98#[derive(Subcommand)]
99enum DemoSubcommand {
100 Build(DemoBuildArgs),
101 #[command(hide = true)]
102 Up(DemoUpArgs),
103 Start(DemoUpArgs),
104 Restart(DemoUpArgs),
105 Stop(DemoStopArgs),
106 Setup(DemoSetupArgs),
107 Send(DemoSendArgs),
108 #[command(about = "Send a synthetic HTTP request through the messaging ingress pipeline")]
109 Ingress(DemoIngressArgs),
110 New(DemoNewArgs),
111 Status(DemoStatusArgs),
112 Logs(DemoLogsArgs),
113 Doctor(DemoDoctorArgs),
114 #[command(about = "Allow a tenant/team access to a pack/flow/node")]
115 Allow(DemoPolicyArgs),
116 #[command(about = "Forbid a tenant/team access to a pack/flow/node")]
117 Forbid(DemoPolicyArgs),
118 #[command(about = "Manage demo subscriptions via provider components")]
119 Subscriptions(DemoSubscriptionsCommand),
120 #[command(about = "Manage capability resolution/invocation in demo bundles")]
121 Capability(DemoCapabilityCommand),
122 #[command(about = "Run a pack/flow with inline input")]
123 Run(DemoRunArgs),
124 #[command(about = "List resolved packs from a bundle")]
125 ListPacks(DemoListPacksArgs),
126 #[command(about = "List flows declared by a pack")]
127 ListFlows(DemoListFlowsArgs),
128 #[command(hide = true)]
129 Wizard(DemoWizardArgs),
130 #[command(about = "Run interactive card-based setup wizard for a provider pack")]
131 SetupWizard(DemoSetupWizardArgs),
132}
133
134#[derive(Clone, Copy, Debug, ValueEnum)]
135enum DomainArg {
136 Messaging,
137 Events,
138 Secrets,
139 #[value(name = "oauth", alias = "o-auth")]
140 OAuth,
141}
142
143#[derive(Clone, Copy, Debug, ValueEnum)]
144enum PlanFormat {
145 Text,
146 Json,
147 Yaml,
148}
149
150#[derive(Clone, Copy, Debug, ValueEnum)]
151enum CloudflaredModeArg {
152 On,
153 Off,
154}
155
156#[derive(Clone, Copy, Debug, ValueEnum)]
157enum NgrokModeArg {
158 On,
159 Off,
160}
161
162#[derive(Clone, Copy, Debug, ValueEnum)]
163enum RestartTarget {
164 All,
165 Cloudflared,
166 Ngrok,
167 Nats,
168 Gateway,
169 Egress,
170 Subscriptions,
171}
172
173#[derive(Parser)]
174#[command(
175 about = "Build a portable demo bundle.",
176 long_about = "Copies packs/providers/tenants and writes resolved manifests under the output directory.",
177 after_help = "Main options:\n --out <DIR>\n\nOptional options:\n --tenant <TENANT>\n --team <TEAM>\n --allow-pack-dirs\n --only-used-providers\n --doctor\n --skip-doctor\n --project-root <PATH> (default: current directory)"
178)]
179struct DemoBuildArgs {
180 #[arg(long)]
181 out: PathBuf,
182 #[arg(long)]
183 tenant: Option<String>,
184 #[arg(long)]
185 team: Option<String>,
186 #[arg(long)]
187 allow_pack_dirs: bool,
188 #[arg(long)]
189 only_used_providers: bool,
190 #[arg(long)]
191 doctor: bool,
192 #[arg(long)]
193 skip_doctor: bool,
194 #[arg(long)]
195 project_root: Option<PathBuf>,
196}
197
198#[derive(Parser)]
199#[command(
200 about = "Start demo services from a bundle.",
201 long_about = "Delegates demo lifecycle execution to greentic-start using bundle/config runtime flags."
202)]
203struct DemoUpArgs {
204 #[arg(
205 long,
206 help_heading = "Main options",
207 help = "Path to the bundle directory to run in bundle mode."
208 )]
209 bundle: Option<PathBuf>,
210 #[arg(
211 long,
212 help_heading = "Optional options",
213 help = "Tenant to target when running the bundle (defaults to demo)."
214 )]
215 tenant: Option<String>,
216 #[arg(
217 long,
218 help_heading = "Optional options",
219 help = "Team to assign when running demo services."
220 )]
221 team: Option<String>,
222 #[arg(
223 long,
224 help_heading = "Optional options",
225 help = "Legacy flag (sets --nats=external) still honored for compatibility.",
226 hide = true,
227 conflicts_with = "nats"
228 )]
229 no_nats: bool,
230 #[arg(
231 long = "nats",
232 value_enum,
233 default_value_t = NatsModeArg::Off,
234 help_heading = "Optional options",
235 help = "Selects the NATS mode: off (default), on (legacy local NATS), or external (explicit URL)."
236 )]
237 nats: NatsModeArg,
238 #[arg(
239 long,
240 help_heading = "Optional options",
241 help = "URL of an existing NATS server to use instead of spawning one (default: nats://127.0.0.1:4222)."
242 )]
243 nats_url: Option<String>,
244 #[arg(
245 long,
246 help_heading = "Optional options",
247 help = "Path to a prebuilt config file to use instead of auto-discovery."
248 )]
249 config: Option<PathBuf>,
250 #[arg(long, value_enum, default_value_t = CloudflaredModeArg::On, help_heading = "Optional options", help = "Whether to start cloudflared for webhook tunneling.")]
251 cloudflared: CloudflaredModeArg,
252 #[arg(
253 long,
254 help_heading = "Optional options",
255 help = "Explicit path to the cloudflared binary used when cloudflared mode is on."
256 )]
257 cloudflared_binary: Option<PathBuf>,
258 #[arg(long, value_enum, default_value_t = NgrokModeArg::Off, help_heading = "Optional options", help = "Whether to start ngrok for webhook tunneling (alternative to cloudflared).")]
259 ngrok: NgrokModeArg,
260 #[arg(
261 long,
262 help_heading = "Optional options",
263 help = "Explicit path to the ngrok binary used when ngrok mode is on."
264 )]
265 ngrok_binary: Option<PathBuf>,
266 #[arg(
267 long,
268 value_enum,
269 value_delimiter = ',',
270 help_heading = "Optional options",
271 help = "Comma-separated list of services to restart before running demo (e.g. gateway)."
272 )]
273 restart: Vec<RestartTarget>,
274 #[arg(
275 long,
276 help_heading = "Optional options",
277 help = "Path to a greentic-runner binary override."
278 )]
279 runner_binary: Option<PathBuf>,
280 #[arg(
281 long,
282 value_name = "DIR",
283 help_heading = "Optional options",
284 help = "Directory to write operator.log, cloudflared.log, and nats.log (default: ./logs or bundle/logs)."
285 )]
286 log_dir: Option<PathBuf>,
287 #[arg(
288 long,
289 help_heading = "Optional options",
290 help = "Enable verbose operator logging (debug level).",
291 conflicts_with = "quiet"
292 )]
293 verbose: bool,
294 #[arg(
295 long,
296 help_heading = "Optional options",
297 help = "Suppress operator logging below warnings.",
298 conflicts_with = "verbose"
299 )]
300 quiet: bool,
301}
302
303#[derive(Parser)]
304#[command(
305 about = "Stop demo services from a bundle.",
306 long_about = "Stops demo lifecycle services using greentic-start library orchestration."
307)]
308struct DemoStopArgs {
309 #[arg(
310 long,
311 help_heading = "Main options",
312 help = "Path to the bundle directory to run in bundle mode."
313 )]
314 bundle: Option<PathBuf>,
315 #[arg(
316 long,
317 help_heading = "Optional options",
318 help = "Override the state directory instead of deriving it from the bundle."
319 )]
320 state_dir: Option<PathBuf>,
321 #[arg(
322 long,
323 default_value = DEMO_DEFAULT_TENANT,
324 help_heading = "Optional options",
325 help = "Tenant to target when stopping demo services."
326 )]
327 tenant: String,
328 #[arg(
329 long,
330 default_value = DEMO_DEFAULT_TEAM,
331 help_heading = "Optional options",
332 help = "Team to target when stopping demo services."
333 )]
334 team: String,
335}
336
337#[derive(Clone, Copy, Debug, ValueEnum)]
338enum DemoSetupDomainArg {
339 Messaging,
340 Events,
341 Secrets,
342 #[value(name = "oauth", alias = "o-auth")]
343 OAuth,
344 #[value(alias = "auto")]
345 All,
346}
347
348#[derive(Clone, Copy, Debug, ValueEnum)]
349enum NatsModeArg {
350 Off,
351 On,
352 External,
353}
354
355impl DemoSetupDomainArg {
356 fn resolve_domains(self, discovery: Option<&discovery::DiscoveryResult>) -> Vec<Domain> {
357 match self {
358 DemoSetupDomainArg::Messaging => vec![Domain::Messaging],
359 DemoSetupDomainArg::Events => vec![Domain::Events],
360 DemoSetupDomainArg::Secrets => vec![Domain::Secrets],
361 DemoSetupDomainArg::OAuth => vec![Domain::OAuth],
362 DemoSetupDomainArg::All => {
363 let mut enabled = Vec::new();
364 let has_messaging = discovery
365 .map(|value| value.domains.messaging)
366 .unwrap_or(true);
367 let has_events = discovery.map(|value| value.domains.events).unwrap_or(true);
368 let has_oauth = discovery.map(|value| value.domains.oauth).unwrap_or(true);
369 if has_messaging {
370 enabled.push(Domain::Messaging);
371 }
372 if has_events {
373 enabled.push(Domain::Events);
374 }
375 enabled.push(Domain::Secrets);
376 if has_oauth {
377 enabled.push(Domain::OAuth);
378 }
379 enabled
380 }
381 }
382 }
383}
384
385#[derive(Parser)]
386#[command(
387 about = "Run provider setup flows against a demo bundle.",
388 long_about = "Executes setup flows for provider packs included in the bundle.",
389 after_help = "Main options:\n --bundle <DIR>\n --tenant <TENANT>\n\nOptional options:\n --team <TEAM>\n --domain <messaging|events|secrets|oauth|all> (default: all)\n --provider <FILTER>\n --dry-run\n --format <text|json|yaml> (default: text)\n --parallel <N> (default: 1)\n --allow-missing-setup\n --allow-contract-change\n --backup\n --online\n --secrets-env <ENV>\n --skip-secrets-init\n --setup-input <PATH>\n --runner-binary <PATH>\n --best-effort"
390)]
391struct DemoSetupArgs {
392 #[arg(long)]
393 bundle: PathBuf,
394 #[arg(long)]
395 tenant: String,
396 #[arg(long)]
397 team: Option<String>,
398 #[arg(long, value_enum, default_value_t = DemoSetupDomainArg::All)]
399 domain: DemoSetupDomainArg,
400 #[arg(long)]
401 provider: Option<String>,
402 #[arg(long)]
403 dry_run: bool,
404 #[arg(long, value_enum, default_value_t = Format::Text)]
405 format: Format,
406 #[arg(long, default_value_t = 1)]
407 parallel: usize,
408 #[arg(long)]
409 allow_missing_setup: bool,
410 #[arg(long)]
411 allow_contract_change: bool,
412 #[arg(long)]
413 backup: bool,
414 #[arg(long)]
415 online: bool,
416 #[arg(long)]
417 secrets_env: Option<String>,
418 #[arg(long)]
419 skip_secrets_init: bool,
420 #[arg(long)]
421 state_dir: Option<PathBuf>,
422 #[arg(long)]
423 runner_binary: Option<PathBuf>,
424 #[arg(long)]
425 setup_input: Option<PathBuf>,
426 #[arg(long)]
427 best_effort: bool,
428}
429
430#[derive(Parser)]
431#[command(
432 long_about = "Updates the demo bundle's gmap, reruns the resolver, and copies the updated manifest so demo start sees the change immediately.",
433 after_help = "Main options:\n --bundle <DIR>\n --tenant <TENANT>\n --path <PACK[/FLOW[/NODE]] (up to 3 segments)\n\nOptional options:\n --team <TEAM>\n\nPaths use the same PACK[/FLOW[/NODE]] syntax as the dev allow/forbid commands (max 3 segments). The command modifies tenants/<tenant>[/teams/<team>]/(tenant|team).gmap, resolves state/resolved/<tenant>[.<team>].yaml, and overwrites resolved/<tenant>[.<team>].yaml so demo start picks it up without a rebuild."
434)]
435struct DemoPolicyArgs {
436 #[arg(long, help = "Path to the demo bundle directory.")]
437 bundle: PathBuf,
438 #[arg(long, help = "Tenant owning the gmap rule.")]
439 tenant: String,
440 #[arg(long, help = "Team owning the gmap rule.")]
441 team: Option<String>,
442 #[arg(long, help = "Gmap path to allow or forbid.")]
443 path: String,
444}
445
446#[derive(Parser)]
447#[command(
448 about = "Plan/create a demo bundle with pack refs and allow rules.",
449 long_about = "Builds a deterministic wizard plan first. Execution reuses the same gmap + resolver + resolved-copy lifecycle as demo allow.",
450 after_help = "Main options:\n --mode <create|update|remove>\n --bundle <DIR> (or provide in --answers/--qa-answers)\n\nOptional options:\n --answers <PATH>\n --qa-answers <PATH> (legacy alias)\n --emit-answers <PATH>\n --schema-version <VER>\n --migrate\n --validate\n --apply\n --catalog-pack <ID> (repeatable)\n --pack-ref <REF> (repeatable, oci://|repo://|store://)\n --provider-registry <REF>\n --provider-registry-refresh\n --locale <TAG> (default: detected from system locale)\n --tenant <TENANT> (default: demo)\n --team <TEAM>\n --target <tenant[:team]> (repeatable)\n --allow <PACK[/FLOW[/NODE]]> (repeatable)\n --execute\n --dry-run\n --offline\n --verbose\n --run-setup"
451)]
452struct DemoWizardArgs {
453 #[arg(long, value_enum, default_value_t = WizardModeArg::Create)]
454 mode: WizardModeArg,
455 #[arg(long, alias = "out", help = "Path to the demo bundle to create.")]
456 bundle: Option<PathBuf>,
457 #[arg(
458 long = "answers",
459 help = "AnswerDocument JSON/YAML — local path or https:// URL."
460 )]
461 answers: Option<String>,
462 #[arg(
463 long = "emit-answers",
464 help = "Write merged answers as AnswerDocument JSON."
465 )]
466 emit_answers: Option<PathBuf>,
467 #[arg(
468 long = "schema-version",
469 help = "Schema version to embed in emitted AnswerDocument."
470 )]
471 schema_version: Option<String>,
472 #[arg(
473 long = "migrate",
474 help = "Allow migrating AnswerDocument schema version when needed."
475 )]
476 migrate: bool,
477 #[arg(
478 long = "validate",
479 conflicts_with_all = ["apply", "execute"],
480 help = "Validate/plan only (no side effects)."
481 )]
482 validate: bool,
483 #[arg(
484 long = "apply",
485 conflicts_with_all = ["validate", "dry_run"],
486 help = "Apply side effects (alias of --execute)."
487 )]
488 apply: bool,
489 #[arg(
490 long = "qa-answers",
491 conflicts_with = "answers",
492 help = "Optional JSON/YAML answers emitted by greentic-qa."
493 )]
494 qa_answers: Option<PathBuf>,
495 #[arg(
496 long = "catalog-pack",
497 help = "Catalog pack id to include (repeatable)."
498 )]
499 catalog_packs: Vec<String>,
500 #[arg(long = "catalog-file", help = "Optional catalog JSON/YAML file.")]
501 catalog_file: Option<PathBuf>,
502 #[arg(
503 long = "pack-ref",
504 help = "Custom pack ref (oci://, repo://, store://); repeatable."
505 )]
506 pack_refs: Vec<String>,
507 #[arg(
508 long = "provider-registry",
509 help = "Provider registry override (oci://, repo://, store://, file://<path>, or local path)."
510 )]
511 provider_registry: Option<String>,
512 #[arg(
513 long = "provider-registry-refresh",
514 help = "Refresh provider registry from remote source when possible (falls back to cache on failure)."
515 )]
516 provider_registry_refresh: bool,
517 #[arg(long, default_value = "demo", help = "Tenant for allow rules.")]
518 tenant: String,
519 #[arg(long, help = "Optional team for allow rules.")]
520 team: Option<String>,
521 #[arg(
522 long = "target",
523 help = "Tenant target in tenant[:team] form; repeatable."
524 )]
525 targets: Vec<String>,
526 #[arg(
527 long = "allow",
528 help = "Allow path PACK[/FLOW[/NODE]] for tenant/team; repeatable."
529 )]
530 allow_paths: Vec<String>,
531 #[arg(
532 long,
533 conflicts_with = "dry_run",
534 help = "Execute the plan. Without this, only prints plan."
535 )]
536 execute: bool,
537 #[arg(
538 long,
539 conflicts_with = "execute",
540 help = "Force plan-only mode (dry-run)."
541 )]
542 dry_run: bool,
543 #[arg(long, help = "Resolve packs in offline mode (cache-only).")]
544 offline: bool,
545 #[arg(long, help = "Locale tag for wizard QA rendering.")]
546 locale: Option<String>,
547 #[arg(long, help = "Print detailed plan step fields.")]
548 verbose: bool,
549 #[arg(long, help = "Run existing provider setup flows after execution.")]
550 run_setup: bool,
551 #[arg(long, help = "Optional JSON/YAML setup-input passed to setup runner.")]
552 setup_input: Option<PathBuf>,
553}
554
555#[derive(Parser)]
556#[command(about = "Run interactive card-based setup wizard for a provider pack.")]
557struct DemoSetupWizardArgs {
558 #[arg(long, help = "Path to the .gtpack file.")]
559 pack: PathBuf,
560 #[arg(long, help = "Provider ID (default: derived from pack manifest).")]
561 provider: Option<String>,
562 #[arg(long, default_value = "demo", help = "Tenant ID.")]
563 tenant: String,
564 #[arg(long, help = "Team ID.")]
565 team: Option<String>,
566 #[arg(long, help = "Setup flow to run (default: setup_default).")]
567 flow: Option<String>,
568 #[arg(long, help = "Path to demo bundle (for secrets resolution).")]
569 bundle: Option<PathBuf>,
570}
571
572#[derive(Clone, Copy, Debug, ValueEnum)]
573enum WizardModeArg {
574 Create,
575 Update,
576 Remove,
577}
578
579const WIZARD_ANSWER_DOC_ID: &str = "greentic-operator.wizard.demo";
580const WIZARD_ANSWER_SCHEMA_ID: &str = "greentic-operator.demo.wizard";
581const WIZARD_ANSWER_SCHEMA_VERSION: &str = "1.0.0";
582
583#[derive(Debug, Clone, Deserialize, Serialize)]
584struct WizardAnswerDocument {
585 wizard_id: String,
586 schema_id: String,
587 schema_version: String,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 locale: Option<String>,
590 answers: JsonValue,
591 #[serde(default)]
592 locks: JsonMap<String, JsonValue>,
593}
594
595#[derive(Debug, Default, Deserialize, Serialize)]
596struct WizardQaAnswers {
597 #[serde(alias = "bundle_path")]
598 bundle: Option<PathBuf>,
599 bundle_name: Option<String>,
600 #[serde(default)]
601 catalog_packs: Vec<WizardCatalogPackAnswer>,
602 #[serde(default)]
603 pack_refs: Vec<WizardPackRefAnswer>,
604 tenant: Option<String>,
605 team: Option<String>,
606 #[serde(default)]
607 targets: Vec<WizardTargetAnswer>,
608 #[serde(default)]
609 allow_paths: Vec<String>,
610 #[serde(default)]
611 providers: Vec<WizardProviderAnswer>,
612 #[serde(default)]
613 custom_provider_refs: Vec<WizardCustomProviderRefAnswer>,
614 #[serde(default)]
615 update_ops: Vec<WizardUpdateOpAnswer>,
616 #[serde(default)]
617 packs_remove: Vec<WizardPackRemoveAnswer>,
618 #[serde(default)]
619 providers_remove: Vec<WizardProviderAnswer>,
620 #[serde(default)]
621 tenants_remove: Vec<WizardTargetAnswer>,
622 #[serde(default)]
623 access_change: Vec<WizardAccessChangeAnswer>,
624 access_mode: Option<String>,
625 #[serde(default)]
626 remove_targets: Vec<WizardRemoveTargetAnswer>,
627 locale: Option<String>,
628 execution_mode: Option<String>,
629 #[serde(default)]
632 setup_answers: JsonMap<String, JsonValue>,
633}
634
635#[derive(Debug, Deserialize, Serialize)]
636#[serde(untagged)]
637enum WizardCatalogPackAnswer {
638 Id(String),
639 Item { id: String },
640}
641
642#[derive(Debug, Deserialize, Serialize)]
643#[serde(untagged)]
644enum WizardPackRefAnswer {
645 Ref(String),
646 Item {
647 pack_ref: String,
648 #[serde(default)]
649 access_scope: Option<String>,
650 #[serde(default)]
651 #[serde(alias = "make_default_scope")]
652 make_default_pack: Option<String>,
653 #[serde(default)]
654 tenant_id: Option<String>,
655 #[serde(default)]
656 team_id: Option<String>,
657 },
658}
659
660#[derive(Debug, Deserialize, Serialize)]
661#[serde(untagged)]
662enum WizardTargetAnswer {
663 Target(String),
664 Item {
665 tenant_id: String,
666 #[serde(default)]
667 team_id: Option<String>,
668 },
669}
670
671#[derive(Debug, Deserialize, Serialize)]
672#[serde(untagged)]
673enum WizardProviderAnswer {
674 Id(String),
675 Item {
676 provider_id: Option<String>,
677 id: Option<String>,
678 },
679}
680
681#[derive(Debug, Deserialize, Serialize)]
682#[serde(untagged)]
683enum WizardCustomProviderRefAnswer {
684 Ref(String),
685 Item { pack_ref: String },
686}
687
688#[derive(Debug, Deserialize, Serialize)]
689#[serde(untagged)]
690enum WizardUpdateOpAnswer {
691 Op(String),
692 Item { op: String },
693}
694
695#[derive(Debug, Deserialize, Serialize)]
696#[serde(untagged)]
697enum WizardRemoveTargetAnswer {
698 Target(String),
699 Item {
700 target_type: Option<String>,
701 target: Option<String>,
702 },
703}
704
705#[derive(Debug, Deserialize, Serialize)]
706#[serde(untagged)]
707enum WizardPackRemoveAnswer {
708 Pack(String),
709 Item {
710 pack_identifier: Option<String>,
711 pack_id: Option<String>,
712 pack_ref: Option<String>,
713 scope: Option<String>,
714 tenant_id: Option<String>,
715 team_id: Option<String>,
716 },
717}
718
719#[derive(Debug, Deserialize, Serialize)]
720#[serde(untagged)]
721enum WizardAccessChangeAnswer {
722 Item {
723 pack_id: Option<String>,
724 pack_ref: Option<String>,
725 operation: Option<String>,
726 tenant_id: String,
727 #[serde(default)]
728 team_id: Option<String>,
729 },
730}
731
732const DEFAULT_PROVIDER_REGISTRY_REF: &str = "oci://ghcr.io/greenticai/registries/providers:latest";
733#[derive(Parser)]
734#[command(
735 about = "Show demo service status using runtime state.",
736 long_about = "Lists pidfiles under state/pids for the selected tenant/team.",
737 after_help = "Main options:\n (none)\n\nOptional options:\n --tenant <TENANT> (default: demo)\n --team <TEAM> (default: default)\n --state-dir <PATH> (default: ./state or <bundle>/state)\n --bundle <DIR> (legacy mode if --state-dir omitted)\n --verbose\n --no-nats"
738)]
739struct DemoStatusArgs {
740 #[arg(long)]
741 bundle: Option<PathBuf>,
742 #[arg(long, default_value = "demo")]
743 tenant: String,
744 #[arg(long, default_value = "default")]
745 team: String,
746 #[arg(long)]
747 state_dir: Option<PathBuf>,
748 #[arg(long)]
749 verbose: bool,
750 #[arg(long)]
751 no_nats: bool,
752}
753
754#[derive(Parser)]
755#[command(
756 about = "Show demo logs produced by the operator and services.",
757 long_about = "Prints or tails logs under logs/operator.log or tenant/service logs in the log directory.",
758 after_help = "Main options:\n <SERVICE> (operator|messaging|nats|cloudflared)\n\nOptional options:\n --tail\n --tenant <TENANT> (default: demo)\n --team <TEAM> (default: default)\n --log-dir <PATH> (default: ./logs or <bundle>/logs)\n --bundle <DIR>\n --verbose\n --no-nats"
759)]
760struct DemoLogsArgs {
761 #[arg(default_value = "operator")]
762 service: String,
763 #[arg(long)]
764 tail: bool,
765 #[arg(long)]
766 bundle: Option<PathBuf>,
767 #[arg(long, default_value = "demo")]
768 tenant: String,
769 #[arg(long, default_value = "default")]
770 team: String,
771 #[arg(long)]
772 log_dir: Option<PathBuf>,
773 #[arg(long)]
774 verbose: bool,
775 #[arg(long)]
776 no_nats: bool,
777}
778
779#[derive(Parser)]
780#[command(
781 about = "Run demo doctor validation from a bundle.",
782 long_about = "Runs greentic-pack doctor against packs in the demo bundle.",
783 after_help = "Main options:\n --bundle <DIR>"
784)]
785struct DemoDoctorArgs {
786 #[arg(long)]
787 bundle: PathBuf,
788}
789
790#[derive(Parser)]
791#[command(
792 about = "Send a demo message via a provider pack.",
793 long_about = "Runs provider requirements or sends a generic message payload.",
794 after_help = "Main options:\n --bundle <DIR>\n --provider <PROVIDER>\n\nOptional options:\n --text <TEXT>\n --card <FILE>\n --arg <k=v>...\n --args-json <JSON>\n --env <ENV> (default: demo)\n --tenant <TENANT> (default: demo)\n --team <TEAM> (default: default)\n --print-required-args"
795)]
796struct DemoSendArgs {
797 #[arg(long)]
798 bundle: PathBuf,
799 #[arg(long)]
800 provider: String,
801 #[arg(long)]
802 text: Option<String>,
803 #[arg(long = "arg")]
804 args: Vec<String>,
805 #[arg(long)]
806 args_json: Option<String>,
807 #[arg(long, default_value = "demo")]
808 tenant: String,
809 #[arg(long, default_value = "default")]
810 team: String,
811 #[arg(long)]
812 print_required_args: bool,
813 #[arg(long)]
814 runner_binary: Option<PathBuf>,
815 #[arg(long, default_value = "demo")]
816 env: String,
817 #[arg(long, help = "Destination identifier (repeatable).")]
818 to: Vec<String>,
819 #[arg(
820 long = "to-kind",
821 help = "Optional destination kind (chat, channel, room, email, etc.)."
822 )]
823 to_kind: Option<String>,
824 #[arg(
825 long,
826 value_name = "FILE",
827 help = "JSON file containing the adaptive card to include in the message."
828 )]
829 card: Option<PathBuf>,
830}
831
832#[derive(Parser)]
833#[command(
834 about = "Manage demo subscriptions via provider components.",
835 long_about = "Ensure, renew, or delete provider-managed subscriptions from a demo bundle."
836)]
837struct DemoSubscriptionsCommand {
838 #[command(subcommand)]
839 command: DemoSubscriptionsSubcommand,
840}
841
842#[derive(Parser)]
843#[command(
844 about = "Manage capabilities in a demo bundle.",
845 long_about = "Resolve, invoke, and mark setup status for capability offers."
846)]
847struct DemoCapabilityCommand {
848 #[command(subcommand)]
849 command: DemoCapabilitySubcommand,
850}
851
852#[derive(Subcommand)]
853enum DemoCapabilitySubcommand {
854 Invoke(DemoCapabilityInvokeArgs),
855 SetupPlan(DemoCapabilitySetupPlanArgs),
856 MarkReady(DemoCapabilityMarkReadyArgs),
857 MarkFailed(DemoCapabilityMarkFailedArgs),
858}
859
860#[derive(Parser)]
861#[command(
862 about = "Resolve and invoke a capability provider op.",
863 long_about = "Uses capability registry resolution and routes to the selected provider op."
864)]
865struct DemoCapabilityInvokeArgs {
866 #[arg(long)]
867 bundle: PathBuf,
868 #[arg(long)]
869 cap_id: String,
870 #[arg(long, default_value = "")]
871 op: String,
872 #[arg(long)]
873 payload_json: Option<String>,
874 #[arg(long, default_value = "demo")]
875 tenant: String,
876 #[arg(long, default_value = "default")]
877 team: String,
878 #[arg(long)]
879 env: Option<String>,
880}
881
882#[derive(Parser)]
883#[command(
884 about = "Print capabilities that require setup.",
885 long_about = "Builds capability setup plan for current tenant/team scope."
886)]
887struct DemoCapabilitySetupPlanArgs {
888 #[arg(long)]
889 bundle: PathBuf,
890 #[arg(long, default_value = "demo")]
891 tenant: String,
892 #[arg(long, default_value = "default")]
893 team: String,
894}
895
896#[derive(Parser)]
897#[command(
898 about = "Mark resolved capability as setup-ready.",
899 long_about = "Writes capability install record with ready status for the selected capability."
900)]
901struct DemoCapabilityMarkReadyArgs {
902 #[arg(long)]
903 bundle: PathBuf,
904 #[arg(long)]
905 cap_id: String,
906 #[arg(long, default_value = "demo")]
907 tenant: String,
908 #[arg(long, default_value = "default")]
909 team: String,
910}
911
912#[derive(Parser)]
913#[command(
914 about = "Mark resolved capability as setup-failed.",
915 long_about = "Writes capability install record with failed status for the selected capability."
916)]
917struct DemoCapabilityMarkFailedArgs {
918 #[arg(long)]
919 bundle: PathBuf,
920 #[arg(long)]
921 cap_id: String,
922 #[arg(long, default_value = "setup_failed")]
923 key: String,
924 #[arg(long, default_value = "demo")]
925 tenant: String,
926 #[arg(long, default_value = "default")]
927 team: String,
928}
929
930#[derive(Parser)]
931#[command(
932 about = "Run a pack/flow with inline input.",
933 long_about = "Resolves the selected pack, picks the requested or default flow, parses any provided input, and prints a run summary."
934)]
935struct DemoRunArgs {
936 #[arg(long, default_value = "./packs")]
937 packs_dir: PathBuf,
938 #[arg(long)]
939 bundle: Option<PathBuf>,
940 #[arg(long)]
941 pack: String,
942 #[arg(long)]
943 tenant: String,
944 #[arg(long)]
945 team: Option<String>,
946 #[arg(long)]
947 flow: Option<String>,
948 #[arg(long)]
949 input: Option<String>,
950}
951
952#[derive(Parser)]
953#[command(
954 about = "List provider packs for a domain",
955 long_about = "Prints each pack_id and how many entry flows it declares for the selected domain."
956)]
957struct DemoListPacksArgs {
958 #[arg(long, default_value = ".")]
959 bundle: PathBuf,
960 #[arg(long, value_enum, default_value_t = DomainArg::Messaging)]
961 domain: DomainArg,
962}
963
964#[derive(Parser)]
965#[command(
966 about = "List flows exposed by a provider pack",
967 long_about = "Shows the entry flows declared by the matching pack so you can pass --flow to demo run."
968)]
969struct DemoListFlowsArgs {
970 #[arg(long, default_value = ".")]
971 bundle: PathBuf,
972 #[arg(long)]
973 pack: String,
974 #[arg(long, value_enum, default_value_t = DomainArg::Messaging)]
975 domain: DomainArg,
976}
977
978#[derive(Subcommand)]
979enum DemoSubscriptionsSubcommand {
980 Ensure(DemoSubscriptionsEnsureArgs),
981 Status(DemoSubscriptionsStatusArgs),
982 Renew(DemoSubscriptionsRenewArgs),
983 Delete(DemoSubscriptionsDeleteArgs),
984}
985
986#[derive(Parser)]
987#[command(
988 about = "Ensure a subscription binding via a demo provider.",
989 long_about = "Invokes the provider's subscription_ensure flow, persists the binding state, and returns the binding_id."
990)]
991struct DemoSubscriptionsEnsureArgs {
992 #[arg(long)]
993 bundle: PathBuf,
994 #[arg(long)]
995 provider: String,
996 #[arg(long, default_value = "demo")]
997 tenant: String,
998 #[arg(long, default_value = "default")]
999 team: String,
1000 #[arg(long)]
1001 binding_id: Option<String>,
1002 #[arg(long)]
1003 resource: Option<String>,
1004 #[arg(long = "change-type", action = ArgAction::Append)]
1005 change_types: Vec<String>,
1006 #[arg(long)]
1007 notification_url: Option<String>,
1008 #[arg(long)]
1009 client_state: Option<String>,
1010 #[arg(long)]
1011 user_id: Option<String>,
1012 #[arg(long)]
1013 user_token_key: Option<String>,
1014}
1015
1016#[derive(Parser)]
1017#[command(
1018 about = "List demo subscription bindings persisted by the operator.",
1019 long_about = "Prints provider/tenant/team/binding info for demo-managed subscriptions."
1020)]
1021struct DemoSubscriptionsStatusArgs {
1022 #[arg(long)]
1023 bundle: PathBuf,
1024 #[arg(long)]
1025 provider: Option<String>,
1026 #[arg(long)]
1027 binding_id: Option<String>,
1028 #[arg(long, default_value = "demo")]
1029 tenant: String,
1030 #[arg(long, default_value = "default")]
1031 team: String,
1032}
1033
1034#[derive(Parser)]
1035#[command(
1036 about = "Renew stored subscriptions that are near expiry.",
1037 long_about = "Runs the scheduler to renew eligible bindings or a single binding if --binding-id is provided."
1038)]
1039struct DemoSubscriptionsRenewArgs {
1040 #[arg(long)]
1041 bundle: PathBuf,
1042 #[arg(long)]
1043 binding_id: Option<String>,
1044 #[arg(long)]
1045 provider: Option<String>,
1046 #[arg(long, default_value = "demo")]
1047 tenant: String,
1048 #[arg(long, default_value = "default")]
1049 team: String,
1050 #[arg(long, default_value = "10")]
1051 skew_minutes: u64,
1052}
1053
1054#[derive(Parser)]
1055#[command(
1056 about = "Delete a persisted demo subscription binding through the provider.",
1057 long_about = "Invokes subscription_delete for the binding and removes the stored state file."
1058)]
1059struct DemoSubscriptionsDeleteArgs {
1060 #[arg(long)]
1061 bundle: PathBuf,
1062 #[arg(long)]
1063 binding_id: String,
1064 #[arg(long)]
1065 provider: String,
1066 #[arg(long, default_value = "demo")]
1067 tenant: String,
1068 #[arg(long, default_value = "default")]
1069 team: String,
1070}
1071
1072impl DemoSubscriptionsCommand {
1073 fn run(self) -> anyhow::Result<()> {
1074 match self.command {
1075 DemoSubscriptionsSubcommand::Ensure(args) => args.run(),
1076 DemoSubscriptionsSubcommand::Status(args) => args.run(),
1077 DemoSubscriptionsSubcommand::Renew(args) => args.run(),
1078 DemoSubscriptionsSubcommand::Delete(args) => args.run(),
1079 }
1080 }
1081}
1082
1083impl DemoCapabilityCommand {
1084 fn run(self) -> anyhow::Result<()> {
1085 match self.command {
1086 DemoCapabilitySubcommand::Invoke(args) => args.run(),
1087 DemoCapabilitySubcommand::SetupPlan(args) => args.run(),
1088 DemoCapabilitySubcommand::MarkReady(args) => args.run(),
1089 DemoCapabilitySubcommand::MarkFailed(args) => args.run(),
1090 }
1091 }
1092}
1093
1094impl DemoRunArgs {
1095 fn run(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1096 let packs_dir = self
1097 .bundle
1098 .clone()
1099 .map(|bundle| bundle.join("packs"))
1100 .unwrap_or(self.packs_dir);
1101 let pack = pack_resolve::resolve_pack(&packs_dir, &self.pack)?;
1102 let pack_path = ensure_pack_within_root(&packs_dir, &pack.pack_path)?;
1103 let flow_id = pack.select_flow(self.flow.as_deref())?;
1104 let parsed_input = match self.input {
1105 Some(value) => Some(demo_input::parse_input(&value)?),
1106 None => None,
1107 };
1108 let team_display = self.team.as_deref().unwrap_or("default");
1109 let input_desc = match &parsed_input {
1110 None => "none".to_string(),
1111 Some(parsed) => match &parsed.source {
1112 demo_input::InputSource::Inline(encoding) => {
1113 format!("inline ({})", encoding.label())
1114 }
1115 demo_input::InputSource::File { path, encoding } => {
1116 format!("file {} ({})", path.display(), encoding.label())
1117 }
1118 },
1119 };
1120 println!(
1121 "{}",
1122 operator_i18n::tr("cli.run.summary_header", "Run summary:")
1123 );
1124 println!(
1125 "{}",
1126 operator_i18n::trf(
1127 "cli.run.summary_pack",
1128 " pack: {} ({})",
1129 &[&pack.pack_id, &pack_path.display().to_string()]
1130 )
1131 );
1132 println!(
1133 "{}",
1134 operator_i18n::trf(
1135 "cli.run.summary_tenant_team",
1136 " tenant: {} team: {}",
1137 &[&self.tenant, team_display]
1138 )
1139 );
1140 println!(
1141 "{}",
1142 operator_i18n::trf("cli.run.summary_flow", " flow: {}", &[&flow_id])
1143 );
1144 println!(
1145 "{}",
1146 operator_i18n::trf("cli.run.summary_input", " input: {}", &[&input_desc])
1147 );
1148
1149 let initial_input = parsed_input
1150 .as_ref()
1151 .map(|parsed| parsed.value.clone())
1152 .unwrap_or_else(|| json!({}));
1153 let secrets_manager = if let Some(bundle) = &self.bundle {
1154 let secrets_handle =
1155 secrets_gate::resolve_secrets_manager(bundle, &self.tenant, self.team.as_deref())?;
1156 secrets_handle.runtime_manager(Some(&pack.pack_id))
1157 } else {
1158 default_manager()?
1159 };
1160 let runner = DemoRunner::with_entry_flow(
1161 pack_path,
1162 &self.tenant,
1163 self.team.clone(),
1164 flow_id.clone(),
1165 pack.pack_id.clone(),
1166 initial_input,
1167 secrets_manager,
1168 )?;
1169 let mut repl = DemoRepl::new(runner);
1170 println!(
1171 "{}",
1172 operator_i18n::tr(
1173 "cli.run.enter_interactive",
1174 "Entering interactive mode (type @help for commands)."
1175 )
1176 );
1177 repl.run()?;
1178 Ok(())
1179 }
1180}
1181
1182fn ensure_pack_within_root(root: &Path, pack_path: &Path) -> anyhow::Result<PathBuf> {
1183 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
1184 let resolved = std::fs::canonicalize(pack_path).unwrap_or_else(|_| pack_path.to_path_buf());
1185 if resolved.starts_with(&root) {
1186 return Ok(pack_path.to_path_buf());
1187 }
1188 let file_name = pack_path
1189 .file_name()
1190 .ok_or_else(|| anyhow::anyhow!("pack path missing file name"))?;
1191 let cache_dir = root.join(".resolved");
1192 std::fs::create_dir_all(&cache_dir)?;
1193 let dest = cache_dir.join(file_name);
1194 std::fs::copy(&resolved, &dest)?;
1195 Ok(dest)
1196}
1197
1198impl DemoListPacksArgs {
1199 fn run(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1200 let domain = Domain::from(self.domain);
1201 let cfg = domains::config(domain);
1202 let packs = demo_provider_packs(&self.bundle, domain)?;
1203 let providers_root = self.bundle.join(cfg.providers_dir);
1204 let apps_root = self.bundle.join("packs");
1205 let mut provider_packs = Vec::new();
1206 let mut app_packs = Vec::new();
1207 for pack in packs {
1208 if pack.path.starts_with(&providers_root) {
1209 provider_packs.push(pack);
1210 } else if pack.path.starts_with(&apps_root) {
1211 app_packs.push(pack);
1212 } else {
1213 provider_packs.push(pack);
1214 }
1215 }
1216
1217 if provider_packs.is_empty() {
1218 println!(
1219 "{}",
1220 operator_i18n::trf(
1221 "cli.list_packs.none_for_domain",
1222 "no packs found for domain {}",
1223 &[domains::domain_name(domain)]
1224 )
1225 );
1226 } else {
1227 println!(
1228 "{}",
1229 operator_i18n::trf(
1230 "cli.list_packs.for_domain",
1231 "packs for {}:",
1232 &[domains::domain_name(domain)]
1233 )
1234 );
1235 for pack in &provider_packs {
1236 println!(
1237 " {} ({} entry flows) {}",
1238 pack.pack_id,
1239 pack.entry_flows.len(),
1240 pack.file_name
1241 );
1242 }
1243 }
1244
1245 if !app_packs.is_empty() {
1246 if !provider_packs.is_empty() {
1247 println!();
1248 }
1249 println!(
1250 "{}",
1251 operator_i18n::tr("cli.list_packs.for_applications", "packs for applications:")
1252 );
1253 for pack in app_packs {
1254 let relative = pack
1255 .path
1256 .strip_prefix(&apps_root)
1257 .unwrap_or_else(|_| Path::new(&pack.file_name));
1258 let mut trimmed = relative.to_string_lossy().to_string();
1259 if let Some(stripped) = trimmed.strip_suffix(".gtpack") {
1260 trimmed = stripped.to_string();
1261 }
1262 let has_parent = relative
1263 .parent()
1264 .map(|parent| !parent.as_os_str().is_empty())
1265 .unwrap_or(false);
1266 let display_name = if has_parent {
1267 format!("/{trimmed}")
1268 } else {
1269 trimmed
1270 };
1271 let depth = relative.components().count().saturating_sub(1);
1272 let indent = " ".repeat(depth);
1273 println!(
1274 " {indent}{display_name} ({} entry flows) {}",
1275 pack.entry_flows.len(),
1276 pack.file_name
1277 );
1278 }
1279 }
1280 Ok(())
1281 }
1282}
1283
1284impl DemoListFlowsArgs {
1285 fn run(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1286 let domain = Domain::from(self.domain);
1287 let pack = demo_provider_pack_by_filter(&self.bundle, domain, &self.pack)?;
1288 println!(
1289 "{}",
1290 operator_i18n::trf(
1291 "cli.list_flows.header",
1292 "flows declared by pack {} ({}):",
1293 &[&pack.pack_id, &pack.file_name]
1294 )
1295 );
1296 for flow_id in pack.entry_flows {
1297 println!(
1298 "{}",
1299 operator_i18n::trf("cli.list_flows.item", " - {}", &[&flow_id])
1300 );
1301 }
1302 Ok(())
1303 }
1304}
1305
1306impl DemoSubscriptionsEnsureArgs {
1307 fn run(self) -> anyhow::Result<()> {
1308 let DemoSubscriptionsEnsureArgs {
1309 bundle,
1310 provider,
1311 tenant,
1312 team,
1313 binding_id,
1314 resource,
1315 change_types,
1316 notification_url,
1317 client_state,
1318 user_id,
1319 user_token_key,
1320 } = self;
1321
1322 let team_override = if team.trim().is_empty() {
1323 None
1324 } else {
1325 Some(team)
1326 };
1327
1328 domains::ensure_cbor_packs(&bundle)?;
1329 let pack = resolve_demo_provider_pack(
1330 &bundle,
1331 &tenant,
1332 team_override.as_deref(),
1333 &provider,
1334 Domain::Messaging,
1335 )?;
1336 let discovery = discovery::discover_with_options(
1337 &bundle,
1338 discovery::DiscoveryOptions { cbor_only: true },
1339 )?;
1340 let provider_map = discovery_map(&discovery.providers);
1341 let provider_id = provider_id_for_pack(&pack.path, &pack.pack_id, Some(&provider_map));
1342
1343 let secrets_handle =
1344 secrets_gate::resolve_secrets_manager(&bundle, &tenant, team_override.as_deref())?;
1345 let runner_host = DemoRunnerHost::new(
1346 bundle.clone(),
1347 &discovery,
1348 None,
1349 secrets_handle.clone(),
1350 false,
1351 )?;
1352 let context = OperatorContext {
1353 tenant: tenant.clone(),
1354 team: team_override.clone(),
1355 correlation_id: None,
1356 };
1357 let service = SubscriptionService::new(runner_host, context);
1358
1359 let binding_id = binding_id.unwrap_or_else(|| Uuid::new_v4().to_string());
1360 let request = build_subscription_request(
1361 &binding_id,
1362 resource,
1363 change_types,
1364 notification_url,
1365 client_state,
1366 user_id,
1367 user_token_key,
1368 );
1369 let state = service.ensure_once(&provider_id, &request)?;
1370
1371 let store = SubscriptionStore::new(state_root(&bundle));
1372 store.write_state(&state)?;
1373 let state_path = store.state_path(
1374 &state.provider,
1375 &state.tenant,
1376 state.team.as_deref(),
1377 &state.binding_id,
1378 );
1379 println!(
1380 "subscription binding {} persisted to {}",
1381 state.binding_id,
1382 state_path.display()
1383 );
1384 Ok(())
1385 }
1386}
1387
1388fn build_subscription_request(
1389 binding_id: &str,
1390 resource: Option<String>,
1391 change_types: Vec<String>,
1392 notification_url: Option<String>,
1393 client_state: Option<String>,
1394 user_id: Option<String>,
1395 user_token_key: Option<String>,
1396) -> SubscriptionEnsureRequest {
1397 let change_types = if change_types.is_empty() {
1398 vec!["created".to_string()]
1399 } else {
1400 change_types
1401 };
1402 let user = match (user_id, user_token_key) {
1403 (Some(user_id), Some(token_key)) => Some(AuthUserRefV1 {
1404 user_id,
1405 token_key,
1406 tenant_id: None,
1407 email: None,
1408 display_name: None,
1409 }),
1410 _ => None,
1411 };
1412 SubscriptionEnsureRequest {
1413 binding_id: binding_id.to_string(),
1414 resource,
1415 change_types,
1416 notification_url,
1417 client_state,
1418 user,
1419 expiration_target_unix_ms: None,
1420 }
1421}
1422
1423impl DemoSubscriptionsStatusArgs {
1424 fn run(self) -> anyhow::Result<()> {
1425 let DemoSubscriptionsStatusArgs {
1426 bundle,
1427 provider,
1428 binding_id,
1429 tenant,
1430 team,
1431 } = self;
1432 let team = if team.trim().is_empty() {
1433 None
1434 } else {
1435 Some(team.clone())
1436 };
1437 let store = SubscriptionStore::new(state_root(&bundle));
1438 let states = store.list_states()?;
1439 let filtered = states
1440 .into_iter()
1441 .filter(|state| state.tenant == tenant)
1442 .filter(|state| match team.as_deref() {
1443 Some(team) => state.team.as_deref().unwrap_or("default") == team,
1444 None => true,
1445 })
1446 .filter(|state| {
1447 provider
1448 .as_deref()
1449 .map(|value| state.provider == value)
1450 .unwrap_or(true)
1451 })
1452 .filter(|state| {
1453 binding_id
1454 .as_deref()
1455 .map(|value| state.binding_id == value)
1456 .unwrap_or(true)
1457 })
1458 .collect::<Vec<_>>();
1459 if filtered.is_empty() {
1460 println!(
1461 "{}",
1462 operator_i18n::tr("cli.subscriptions.none", "no subscriptions found")
1463 );
1464 return Ok(());
1465 }
1466 for state in filtered {
1467 let team_label = state.team.as_deref().unwrap_or("default");
1468 let expiry = state.expiration_unix_ms.and_then(|ms| {
1469 Utc.timestamp_millis_opt(ms)
1470 .single()
1471 .map(|value| value.to_rfc3339())
1472 });
1473 println!(
1474 "{} {} {} binding={} tenant={} team={} expires={}",
1475 state.provider,
1476 state.subscription_id.as_deref().unwrap_or("<unknown>"),
1477 state.change_types.join(","),
1478 state.binding_id,
1479 state.tenant,
1480 team_label,
1481 expiry.unwrap_or_else(|| "<unknown>".to_string())
1482 );
1483 }
1484 Ok(())
1485 }
1486}
1487
1488impl DemoSubscriptionsRenewArgs {
1489 fn run(self) -> anyhow::Result<()> {
1490 let DemoSubscriptionsRenewArgs {
1491 bundle,
1492 binding_id,
1493 provider,
1494 tenant,
1495 team,
1496 skew_minutes,
1497 } = self;
1498 let team_override = if team.trim().is_empty() {
1499 None
1500 } else {
1501 Some(team)
1502 };
1503 let (runner_host, context) = build_runner(&bundle, &tenant, team_override.clone())?;
1504 let store = SubscriptionStore::new(state_root(&bundle));
1505 let scheduler = Scheduler::new(
1506 SubscriptionService::new(runner_host, context),
1507 store.clone(),
1508 );
1509
1510 if let Some(binding) = binding_id {
1511 let provider = provider
1512 .ok_or_else(|| anyhow!("--provider is required when renewing a single binding"))?;
1513 let state = store
1514 .read_state(&provider, &tenant, team_override.as_deref(), &binding)?
1515 .ok_or_else(|| {
1516 anyhow!("subscription {binding} not found for provider {provider}")
1517 })?;
1518 scheduler.renew_binding(&state)?;
1519 println!(
1520 "{}",
1521 operator_i18n::trf("cli.subscriptions.renewed", "renewed {}", &[&binding])
1522 );
1523 return Ok(());
1524 }
1525
1526 let skew = Duration::from_secs(skew_minutes * 60);
1527 scheduler.renew_due(skew)?;
1528 println!(
1529 "{}",
1530 operator_i18n::tr(
1531 "cli.subscriptions.renewed_eligible",
1532 "renewed eligible subscriptions"
1533 )
1534 );
1535 Ok(())
1536 }
1537}
1538
1539impl DemoSubscriptionsDeleteArgs {
1540 fn run(self) -> anyhow::Result<()> {
1541 let DemoSubscriptionsDeleteArgs {
1542 bundle,
1543 binding_id,
1544 provider,
1545 tenant,
1546 team,
1547 } = self;
1548 let team_override = if team.trim().is_empty() {
1549 None
1550 } else {
1551 Some(team)
1552 };
1553 let (runner_host, context) = build_runner(&bundle, &tenant, team_override.clone())?;
1554 let store = SubscriptionStore::new(state_root(&bundle));
1555 let scheduler = Scheduler::new(
1556 SubscriptionService::new(runner_host, context),
1557 store.clone(),
1558 );
1559 let state = store
1560 .read_state(&provider, &tenant, team_override.as_deref(), &binding_id)?
1561 .ok_or_else(|| {
1562 anyhow!("subscription {binding_id} not found for provider {provider}")
1563 })?;
1564 scheduler.delete_binding(&state)?;
1565 println!(
1566 "{}",
1567 operator_i18n::trf("cli.subscriptions.deleted", "deleted {}", &[&binding_id])
1568 );
1569 Ok(())
1570 }
1571}
1572
1573impl DemoCapabilityInvokeArgs {
1574 fn run(self) -> anyhow::Result<()> {
1575 if let Some(env_value) = self.env.as_ref() {
1576 unsafe {
1578 env::set_var("GREENTIC_ENV", env_value);
1579 }
1580 }
1581 domains::ensure_cbor_packs(&self.bundle)?;
1582 let discovery = discovery::discover_with_options(
1583 &self.bundle,
1584 discovery::DiscoveryOptions { cbor_only: true },
1585 )?;
1586 let secrets_handle =
1587 secrets_gate::resolve_secrets_manager(&self.bundle, &self.tenant, Some(&self.team))?;
1588 let runner_host =
1589 DemoRunnerHost::new(self.bundle.clone(), &discovery, None, secrets_handle, false)?;
1590 let ctx = OperatorContext {
1591 tenant: self.tenant.clone(),
1592 team: Some(self.team.clone()),
1593 correlation_id: None,
1594 };
1595 let payload_value = if let Some(raw) = self.payload_json.as_ref() {
1596 serde_json::from_str::<JsonValue>(raw)
1597 .map_err(|err| anyhow!("invalid --payload-json: {err}"))?
1598 } else {
1599 json!({})
1600 };
1601 let payload_bytes = serde_json::to_vec(&payload_value)?;
1602 let outcome =
1603 runner_host.invoke_capability(&self.cap_id, &self.op, &payload_bytes, &ctx)?;
1604 print_capability_outcome(&outcome)?;
1605 if !outcome.success {
1606 anyhow::bail!(
1607 "capability invoke failed cap_id={} op={}",
1608 self.cap_id,
1609 if self.op.is_empty() {
1610 "<binding-default>"
1611 } else {
1612 self.op.as_str()
1613 }
1614 );
1615 }
1616 Ok(())
1617 }
1618}
1619
1620impl DemoCapabilitySetupPlanArgs {
1621 fn run(self) -> anyhow::Result<()> {
1622 domains::ensure_cbor_packs(&self.bundle)?;
1623 let discovery = discovery::discover_with_options(
1624 &self.bundle,
1625 discovery::DiscoveryOptions { cbor_only: true },
1626 )?;
1627 let secrets_handle =
1628 secrets_gate::resolve_secrets_manager(&self.bundle, &self.tenant, Some(&self.team))?;
1629 let runner_host =
1630 DemoRunnerHost::new(self.bundle.clone(), &discovery, None, secrets_handle, false)?;
1631 let ctx = OperatorContext {
1632 tenant: self.tenant,
1633 team: Some(self.team),
1634 correlation_id: None,
1635 };
1636 let plan = runner_host.capability_setup_plan(&ctx);
1637 if plan.is_empty() {
1638 println!(
1639 "{}",
1640 operator_i18n::tr(
1641 "cli.capabilities.none_requiring_setup",
1642 "no capabilities requiring setup found"
1643 )
1644 );
1645 return Ok(());
1646 }
1647 for item in plan {
1648 println!(
1649 "{} | cap={} | pack={} | op={} | qa_ref={}",
1650 item.stable_id,
1651 item.cap_id,
1652 item.pack_id,
1653 item.provider_op,
1654 item.setup_qa_ref.as_deref().unwrap_or("<none>")
1655 );
1656 }
1657 Ok(())
1658 }
1659}
1660
1661impl DemoCapabilityMarkReadyArgs {
1662 fn run(self) -> anyhow::Result<()> {
1663 domains::ensure_cbor_packs(&self.bundle)?;
1664 let discovery = discovery::discover_with_options(
1665 &self.bundle,
1666 discovery::DiscoveryOptions { cbor_only: true },
1667 )?;
1668 let secrets_handle =
1669 secrets_gate::resolve_secrets_manager(&self.bundle, &self.tenant, Some(&self.team))?;
1670 let runner_host =
1671 DemoRunnerHost::new(self.bundle.clone(), &discovery, None, secrets_handle, false)?;
1672 let scope = ResolveScope {
1673 env: env::var("GREENTIC_ENV").ok(),
1674 tenant: Some(self.tenant.clone()),
1675 team: Some(self.team.clone()),
1676 };
1677 let Some(binding) = runner_host.resolve_capability(&self.cap_id, None, scope) else {
1678 anyhow::bail!(
1679 "capability {} is not offered in current pack set",
1680 self.cap_id
1681 );
1682 };
1683 let ctx = OperatorContext {
1684 tenant: self.tenant,
1685 team: Some(self.team),
1686 correlation_id: None,
1687 };
1688 let path = runner_host.mark_capability_ready(&ctx, &binding)?;
1689 println!(
1690 "{}",
1691 operator_i18n::trf(
1692 "cli.capabilities.marked_ready",
1693 "capability marked ready: {}",
1694 &[&path.display().to_string()]
1695 )
1696 );
1697 Ok(())
1698 }
1699}
1700
1701impl DemoCapabilityMarkFailedArgs {
1702 fn run(self) -> anyhow::Result<()> {
1703 domains::ensure_cbor_packs(&self.bundle)?;
1704 let discovery = discovery::discover_with_options(
1705 &self.bundle,
1706 discovery::DiscoveryOptions { cbor_only: true },
1707 )?;
1708 let secrets_handle =
1709 secrets_gate::resolve_secrets_manager(&self.bundle, &self.tenant, Some(&self.team))?;
1710 let runner_host =
1711 DemoRunnerHost::new(self.bundle.clone(), &discovery, None, secrets_handle, false)?;
1712 let scope = ResolveScope {
1713 env: env::var("GREENTIC_ENV").ok(),
1714 tenant: Some(self.tenant.clone()),
1715 team: Some(self.team.clone()),
1716 };
1717 let Some(binding) = runner_host.resolve_capability(&self.cap_id, None, scope) else {
1718 anyhow::bail!(
1719 "capability {} is not offered in current pack set",
1720 self.cap_id
1721 );
1722 };
1723 let ctx = OperatorContext {
1724 tenant: self.tenant,
1725 team: Some(self.team),
1726 correlation_id: None,
1727 };
1728 let path = runner_host.mark_capability_failed(&ctx, &binding, &self.key)?;
1729 println!(
1730 "{}",
1731 operator_i18n::trf(
1732 "cli.capabilities.marked_failed",
1733 "capability marked failed: {}",
1734 &[&path.display().to_string()]
1735 )
1736 );
1737 Ok(())
1738 }
1739}
1740
1741#[derive(Parser)]
1742#[command(
1743 about = "Create a new demo bundle scaffold.",
1744 long_about = "Initializes the directory layout and metadata files that the demo commands expect.",
1745 after_help = "Main options:\n <BUNDLE-NAME>\n\nOptional options:\n --out <DIR> (default: current working directory)"
1746)]
1747struct DemoNewArgs {
1748 #[arg(value_name = "BUNDLE-NAME")]
1749 bundle: String,
1750 #[arg(long)]
1751 out: Option<PathBuf>,
1752}
1753#[derive(Parser)]
1754#[command(
1755 about = "Allow/forbid a gmap rule for tenant or team.",
1756 long_about = "Updates the appropriate gmap file with a deterministic ordering.",
1757 after_help = "Main options:\n --tenant <TENANT>\n --path <PACK[/FLOW[/NODE]]>\n\nOptional options:\n --team <TEAM>\n --project-root <PATH> (default: current directory)"
1758)]
1759struct DevPolicyArgs {
1760 #[arg(long)]
1761 tenant: String,
1762 #[arg(long)]
1763 team: Option<String>,
1764 #[arg(long)]
1765 path: String,
1766 #[arg(long)]
1767 project_root: Option<PathBuf>,
1768}
1769
1770#[derive(Clone, Copy, Debug, ValueEnum)]
1771enum Format {
1772 Text,
1773 Json,
1774 Yaml,
1775}
1776
1777impl Cli {
1778 pub fn run(self) -> anyhow::Result<()> {
1779 let selected_locale = operator_i18n::select_locale(self.locale.as_deref());
1780 operator_i18n::set_locale(&selected_locale);
1781 let ctx = AppCtx {};
1782 match self.command {
1783 Command::Demo(demo) => demo.run(&ctx),
1784 }
1785 }
1786}
1787
1788struct AppCtx {}
1789
1790impl DemoCommand {
1791 fn run(self, ctx: &AppCtx) -> anyhow::Result<()> {
1792 if env::var("GREENTIC_ENV").is_err() {
1793 unsafe {
1795 std::env::set_var("GREENTIC_ENV", "demo");
1796 }
1797 }
1798 if self.debug {
1799 unsafe {
1800 std::env::set_var("GREENTIC_OPERATOR_DEMO_DEBUG", "1");
1801 }
1802 }
1803 match self.command {
1804 DemoSubcommand::Build(args) => args.run(ctx),
1805 DemoSubcommand::Up(args) => args.run_start(ctx),
1806 DemoSubcommand::Start(args) => args.run_start(ctx),
1807 DemoSubcommand::Restart(args) => args.run_restart(ctx),
1808 DemoSubcommand::Stop(args) => args.run(),
1809 DemoSubcommand::Setup(args) => args.run(),
1810 DemoSubcommand::Send(args) => args.run(),
1811 DemoSubcommand::Ingress(args) => args.run(),
1812 DemoSubcommand::New(args) => args.run(),
1813 DemoSubcommand::Status(args) => args.run(),
1814 DemoSubcommand::Logs(args) => args.run(),
1815 DemoSubcommand::Doctor(args) => args.run(ctx),
1816 DemoSubcommand::ListPacks(args) => args.run(ctx),
1817 DemoSubcommand::ListFlows(args) => args.run(ctx),
1818 DemoSubcommand::Allow(args) => args.run(Policy::Public),
1819 DemoSubcommand::Forbid(args) => args.run(Policy::Forbidden),
1820 DemoSubcommand::Subscriptions(args) => args.run(),
1821 DemoSubcommand::Capability(args) => args.run(),
1822 DemoSubcommand::Run(args) => args.run(ctx),
1823 DemoSubcommand::Wizard(args) => args.run(),
1824 DemoSubcommand::SetupWizard(args) => args.run(),
1825 }
1826 }
1827}
1828
1829impl DemoBuildArgs {
1830 fn run(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1831 let root = project_root(self.project_root)?;
1832 if demo_debug_enabled() {
1833 println!(
1834 "[demo] build root={} out={} tenant={:?} team={:?} doctor={}",
1835 root.display(),
1836 self.out.display(),
1837 self.tenant,
1838 self.team,
1839 self.doctor
1840 );
1841 }
1842 let env_skip_doctor = std::env::var("GREENTIC_OPERATOR_SKIP_DOCTOR").is_ok();
1843 let skip_doctor = self.skip_doctor || env_skip_doctor;
1844 let run_doctor = self.doctor || !skip_doctor;
1845 if demo_debug_enabled() && skip_doctor {
1846 println!(
1847 "[demo] skipping doctor gate (skip_doctor flag or GREENTIC_OPERATOR_SKIP_DOCTOR set)"
1848 );
1849 }
1850 let options = BuildOptions {
1851 out_dir: self.out,
1852 tenant: self.tenant,
1853 team: self.team,
1854 allow_pack_dirs: self.allow_pack_dirs,
1855 only_used_providers: self.only_used_providers,
1856 run_doctor,
1857 };
1858 let config = config::load_operator_config(&root)?;
1859 let pack_command = if options.run_doctor {
1860 let explicit = config::binary_override(config.as_ref(), "greentic-pack", &root);
1861 Some(bin_resolver::resolve_binary(
1862 "greentic-pack",
1863 &ResolveCtx {
1864 config_dir: root.clone(),
1865 explicit_path: explicit,
1866 },
1867 )?)
1868 } else {
1869 None
1870 };
1871 demo::build_bundle(&root, options, pack_command.as_deref())
1872 }
1873}
1874
1875impl DemoUpArgs {
1876 fn run_start(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1877 run_start_request(self.to_start_request())
1878 }
1879
1880 fn run_restart(self, _ctx: &AppCtx) -> anyhow::Result<()> {
1881 run_restart_request(self.to_start_request())
1882 }
1883
1884 fn to_start_request(&self) -> StartRequest {
1885 StartRequest {
1886 bundle: self.bundle.as_ref().map(|path| path.display().to_string()),
1887 tenant: self.tenant.clone(),
1888 team: self.team.clone(),
1889 no_nats: self.no_nats,
1890 nats: match self.nats {
1891 NatsModeArg::Off => StartNatsModeArg::Off,
1892 NatsModeArg::On => StartNatsModeArg::On,
1893 NatsModeArg::External => StartNatsModeArg::External,
1894 },
1895 nats_url: self.nats_url.clone(),
1896 config: self.config.clone(),
1897 cloudflared: match self.cloudflared {
1898 CloudflaredModeArg::On => StartCloudflaredModeArg::On,
1899 CloudflaredModeArg::Off => StartCloudflaredModeArg::Off,
1900 },
1901 cloudflared_binary: self.cloudflared_binary.clone(),
1902 ngrok: match self.ngrok {
1903 NgrokModeArg::On => StartNgrokModeArg::On,
1904 NgrokModeArg::Off => StartNgrokModeArg::Off,
1905 },
1906 ngrok_binary: self.ngrok_binary.clone(),
1907 runner_binary: self.runner_binary.clone(),
1908 restart: self.restart.iter().map(map_restart_target).collect(),
1909 log_dir: self.log_dir.clone(),
1910 verbose: self.verbose,
1911 quiet: self.quiet,
1912 admin: false,
1913 admin_port: 8443,
1914 admin_certs_dir: None,
1915 admin_allowed_clients: Vec::new(),
1916 }
1917 }
1918}
1919
1920impl DemoStopArgs {
1921 fn run(self) -> anyhow::Result<()> {
1922 run_stop_request(StopRequest {
1923 bundle: self.bundle.map(|path| path.display().to_string()),
1924 state_dir: self.state_dir,
1925 tenant: self.tenant,
1926 team: self.team,
1927 })
1928 }
1929}
1930
1931const DEMO_DEFAULT_TENANT: &str = "demo";
1932const DEMO_DEFAULT_TEAM: &str = "default";
1933
1934impl DemoSetupArgs {
1935 fn run(self) -> anyhow::Result<()> {
1936 domains::ensure_cbor_packs(&self.bundle)?;
1937 let discovery = discovery::discover_with_options(
1938 &self.bundle,
1939 discovery::DiscoveryOptions { cbor_only: true },
1940 )?;
1941 discovery::persist(&self.bundle, &self.tenant, &discovery)?;
1942 let domains = self.domain.resolve_domains(Some(&discovery));
1943 if demo_debug_enabled() {
1944 println!(
1945 "[demo] setup bundle={} tenant={} team={:?} domains={:?} provider_filter={:?} dry_run={} parallel={} skip_secrets_init={}",
1946 self.bundle.display(),
1947 self.tenant,
1948 self.team,
1949 domains,
1950 self.provider,
1951 self.dry_run,
1952 self.parallel,
1953 self.skip_secrets_init
1954 );
1955 }
1956 let format = match self.format {
1957 Format::Text => PlanFormat::Text,
1958 Format::Json => PlanFormat::Json,
1959 Format::Yaml => PlanFormat::Yaml,
1960 };
1961 let setup_secrets_handle = secrets_gate::resolve_secrets_manager(
1962 &self.bundle,
1963 &self.tenant,
1964 self.team.as_deref(),
1965 )?;
1966 let setup_runner = DemoRunnerHost::new(
1967 self.bundle.clone(),
1968 &discovery,
1969 self.runner_binary.clone(),
1970 setup_secrets_handle,
1971 demo_debug_enabled(),
1972 )?;
1973 crate::capability_bootstrap::log_capability_bootstrap_report(
1974 &setup_runner,
1975 &OperatorContext {
1976 tenant: self.tenant.clone(),
1977 team: self.team.clone(),
1978 correlation_id: None,
1979 },
1980 &domains,
1981 );
1982 for domain in domains {
1983 let discovered_providers = match domain {
1984 Domain::Messaging | Domain::Events | Domain::OAuth => Some(
1985 discovery
1986 .providers
1987 .iter()
1988 .filter(|provider| provider.domain == domains::domain_name(domain))
1989 .cloned()
1990 .collect(),
1991 ),
1992 Domain::Secrets => None,
1993 };
1994 run_domain_command(DomainRunArgs {
1995 root: self.bundle.clone(),
1996 state_root: self.state_dir.clone(),
1997 domain,
1998 action: DomainAction::Setup,
1999 tenant: self.tenant.clone(),
2000 team: self.team.clone(),
2001 provider_filter: self.provider.clone(),
2002 dry_run: self.dry_run,
2003 format,
2004 parallel: self.parallel,
2005 allow_missing_setup: self.allow_missing_setup,
2006 allow_contract_change: self.allow_contract_change,
2007 backup: self.backup,
2008 online: self.online,
2009 secrets_env: if self.skip_secrets_init {
2010 None
2011 } else {
2012 self.secrets_env.clone()
2013 },
2014 runner_binary: self.runner_binary.clone(),
2015 best_effort: self.best_effort,
2016 setup_input: self.setup_input.clone(),
2017 allowed_providers: None,
2018 preloaded_setup_answers: None,
2019 public_base_url: None,
2020 secrets_manager: None,
2021 discovered_providers,
2022 })?;
2023 }
2024 Ok(())
2025 }
2026}
2027
2028impl DemoPolicyArgs {
2029 fn run(self, policy: Policy) -> anyhow::Result<()> {
2030 let effective_team = if let Some(team) = self.team.clone() {
2031 Some(team)
2032 } else if self
2033 .bundle
2034 .join("tenants")
2035 .join(&self.tenant)
2036 .join("teams")
2037 .join("default")
2038 .exists()
2039 {
2040 Some("default".to_string())
2041 } else {
2042 None
2043 };
2044 let gmap_path =
2045 demo_bundle_gmap_path(&self.bundle, &self.tenant, effective_team.as_deref());
2046 gmap::upsert_policy(&gmap_path, &self.path, policy)?;
2047 project::sync_project(&self.bundle)?;
2048 copy_resolved_manifest(&self.bundle, &self.tenant, effective_team.as_deref())?;
2049 Ok(())
2050 }
2051}
2052
2053impl DemoSetupWizardArgs {
2054 fn run(self) -> anyhow::Result<()> {
2055 let meta = domains::read_pack_meta(&self.pack)
2056 .with_context(|| format!("failed to read pack {}", self.pack.display()))?;
2057 let provider_id = self.provider.unwrap_or(meta.pack_id);
2058 let setup_flow = self.flow.unwrap_or_else(|| "setup_default".to_string());
2059
2060 let answers = qa_setup_wizard::run_interactive_card_wizard(&self.pack, &provider_id)?;
2062
2063 let input = json!({
2065 "tenant": &self.tenant,
2066 "team": self.team.as_deref().unwrap_or("default"),
2067 "id": &provider_id,
2068 "setup_answers": &answers,
2069 "config": { "id": &provider_id },
2070 "msg": {
2071 "id": format!("{provider_id}.setup"),
2072 "tenant": { "env": "dev", "tenant": &self.tenant },
2073 "channel": "setup",
2074 "session_id": "setup",
2075 },
2076 "payload": {},
2077 });
2078
2079 println!("\nRunning flow '{setup_flow}' with collected answers...");
2080
2081 let secrets_manager = if let Some(bundle) = &self.bundle {
2083 secrets_gate::resolve_secrets_manager(bundle, &self.tenant, self.team.as_deref())?
2084 .runtime_manager(Some(&provider_id))
2085 } else {
2086 default_manager()?
2087 };
2088
2089 let mut runner = DemoRunner::with_entry_flow(
2091 self.pack.clone(),
2092 &self.tenant,
2093 self.team.clone(),
2094 setup_flow.clone(),
2095 provider_id.clone(),
2096 input,
2097 secrets_manager,
2098 )?;
2099
2100 match runner.run_until_blocked() {
2101 demo::DemoBlockedOn::Finished(output) => {
2102 println!("\nFlow '{setup_flow}' completed:");
2103 println!(
2104 "{}",
2105 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "<invalid>".into())
2106 );
2107 }
2108 demo::DemoBlockedOn::Waiting { reason, output, .. } => {
2109 println!(
2110 "\nFlow '{setup_flow}' is waiting for input: {}",
2111 reason.as_deref().unwrap_or("unknown")
2112 );
2113 println!(
2114 "Output so far: {}",
2115 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "<invalid>".into())
2116 );
2117 }
2118 demo::DemoBlockedOn::Error(err) => {
2119 return Err(err.context(format!("flow '{setup_flow}' failed")));
2120 }
2121 }
2122
2123 Ok(())
2124 }
2125}
2126
2127impl DemoWizardArgs {
2128 fn run(self) -> anyhow::Result<()> {
2129 let mode: wizard::WizardMode = self.mode.into();
2130 let effective_locale = self.locale.clone().unwrap_or_else(detect_system_locale_tag);
2131 let schema_version = resolve_wizard_schema_version(self.schema_version.as_deref());
2132 let provider_registry_ref = self
2133 .provider_registry
2134 .clone()
2135 .or_else(|| std::env::var("GTC_PROVIDER_REGISTRY_REF").ok())
2136 .unwrap_or_else(|| DEFAULT_PROVIDER_REGISTRY_REF.to_string());
2137 let qa_catalog_bundle_hint = self.bundle.clone().unwrap_or_else(|| PathBuf::from("."));
2138 let qa_catalog_path = provider_registry::resolve_catalog_path(
2139 self.catalog_file.clone().or_else(|| {
2140 std::env::var("GREENTIC_OPERATOR_WIZARD_CATALOG")
2141 .ok()
2142 .map(PathBuf::from)
2143 }),
2144 Some(provider_registry_ref.as_str()),
2145 self.offline,
2146 self.provider_registry_refresh,
2147 &qa_catalog_bundle_hint,
2148 )?;
2149 let qa_catalog_entries = {
2150 let path = qa_catalog_path.ok_or_else(|| {
2151 anyhow!(
2152 "provider registry is required; set --provider-registry <ref> or GTC_PROVIDER_REGISTRY_REF"
2153 )
2154 })?;
2155 wizard::load_catalog_from_file(&path)?
2156 };
2157 let qa_provider_labels = qa_catalog_entries
2158 .iter()
2159 .map(|entry| entry.label.clone())
2160 .collect::<Vec<_>>();
2161 let label_to_id: std::collections::HashMap<String, String> = qa_catalog_entries
2162 .iter()
2163 .map(|entry| (entry.label.clone(), entry.id.clone()))
2164 .collect();
2165 let prefilled_answers = build_prefilled_wizard_answers_from_cli(&self, &effective_locale);
2166 let answers_input = self
2167 .answers
2168 .as_deref()
2169 .map(|s| s.to_string())
2170 .or_else(|| self.qa_answers.as_ref().map(|p| p.display().to_string()));
2171 let (mut answers, loaded_doc) = if let Some(ref source) = answers_input {
2172 load_wizard_answers_from_source(source, &schema_version, self.migrate)?
2173 } else {
2174 (
2175 run_wizard_via_qa(
2176 mode,
2177 &effective_locale,
2178 prefilled_answers,
2179 &qa_provider_labels,
2180 self.verbose,
2181 )?,
2182 None,
2183 )
2184 };
2185 merge_cli_overrides_into_wizard_answers(&mut answers, &self, &effective_locale);
2186
2187 for provider in &mut answers.providers {
2189 match provider {
2190 WizardProviderAnswer::Id(label) => {
2191 if let Some(id) = label_to_id.get(label.as_str()) {
2192 *label = id.clone();
2193 }
2194 }
2195 WizardProviderAnswer::Item { provider_id, id } => {
2196 if let Some(label) = provider_id.as_ref() {
2197 if let Some(mapped_id) = label_to_id.get(label.as_str()) {
2198 *provider_id = Some(mapped_id.clone());
2199 }
2200 }
2201 if let Some(label) = id.as_ref() {
2202 if let Some(mapped_id) = label_to_id.get(label.as_str()) {
2203 *id = Some(mapped_id.clone());
2204 }
2205 }
2206 }
2207 }
2208 }
2209
2210 let bundle = self
2211 .bundle
2212 .clone()
2213 .or(answers.bundle.clone())
2214 .ok_or_else(|| anyhow!("bundle path is required via --bundle or wizard answers"))?;
2215
2216 let catalog_path = provider_registry::resolve_catalog_path(
2217 self.catalog_file.clone().or_else(|| {
2218 std::env::var("GREENTIC_OPERATOR_WIZARD_CATALOG")
2219 .ok()
2220 .map(PathBuf::from)
2221 }),
2222 Some(provider_registry_ref.as_str()),
2223 self.offline,
2224 self.provider_registry_refresh,
2225 &bundle,
2226 )?;
2227
2228 let catalog_path = catalog_path.ok_or_else(|| {
2229 anyhow!(
2230 "provider registry is required; set --provider-registry <ref> or GTC_PROVIDER_REGISTRY_REF"
2231 )
2232 })?;
2233 let catalog_entries = wizard::load_catalog_from_file(&catalog_path)?;
2234 if mode != wizard::WizardMode::Create || bundle.exists() {
2235 if let Some(local_path) = parse_local_registry_ref(provider_registry_ref.as_str()) {
2236 if local_path.exists() {
2237 let _ = provider_registry::cache_registry_file(
2238 &bundle,
2239 provider_registry_ref.as_str(),
2240 &local_path,
2241 );
2242 }
2243 } else if catalog_path.exists() {
2244 let _ = provider_registry::cache_registry_file(
2245 &bundle,
2246 provider_registry_ref.as_str(),
2247 &catalog_path,
2248 );
2249 }
2250 }
2251 let by_id = catalog_entries
2252 .into_iter()
2253 .map(|entry| (entry.id.clone(), entry))
2254 .collect::<std::collections::BTreeMap<_, _>>();
2255 let mut refs = normalize_pack_refs(&answers.pack_refs);
2256 refs.extend(self.pack_refs.clone());
2257 refs.extend(normalize_custom_provider_refs(
2258 &answers.custom_provider_refs,
2259 ));
2260 let provider_ids = normalize_provider_ids(&answers.providers);
2261 for provider_id in &provider_ids {
2262 if let Some(item) = by_id.get(provider_id) {
2263 refs.push(item.reference.clone());
2264 }
2265 }
2266
2267 let mut catalog_ids = normalize_catalog_packs(&answers.catalog_packs);
2268 catalog_ids.extend(self.catalog_packs.clone());
2269 for id in &catalog_ids {
2270 let item = by_id.get(id).ok_or_else(|| {
2271 anyhow!(
2272 "unknown --catalog-pack {}; available: {}",
2273 id,
2274 by_id.keys().cloned().collect::<Vec<_>>().join(", ")
2275 )
2276 })?;
2277 refs.push(item.reference.clone());
2278 }
2279
2280 let mut tenants = Vec::new();
2281 let merged_allow_paths = if self.allow_paths.is_empty() {
2282 answers.allow_paths.clone()
2283 } else {
2284 self.allow_paths.clone()
2285 };
2286 let merged_targets = if self.targets.is_empty() {
2287 normalize_targets(&answers.targets)
2288 } else {
2289 self.targets.clone()
2290 };
2291 if merged_targets.is_empty() {
2292 tenants.push(wizard::TenantSelection {
2293 tenant: if self.tenant == "demo" {
2294 answers.tenant.clone().unwrap_or(self.tenant.clone())
2295 } else {
2296 self.tenant.clone()
2297 },
2298 team: self.team.clone().or(answers.team.clone()),
2299 allow_paths: merged_allow_paths.clone(),
2300 });
2301 } else {
2302 for target in &merged_targets {
2303 let (tenant, team) = parse_wizard_target(target)?;
2304 tenants.push(wizard::TenantSelection {
2305 tenant,
2306 team,
2307 allow_paths: merged_allow_paths.clone(),
2308 });
2309 }
2310 }
2311
2312 let update_ops = normalize_update_ops(&answers.update_ops);
2313 let remove_targets = normalize_remove_targets(&answers.remove_targets);
2314 let packs_remove = normalize_pack_removes(&answers.packs_remove)?;
2315 let providers_remove = normalize_provider_ids(&answers.providers_remove);
2316 let tenants_remove = normalize_target_selections(&answers.tenants_remove);
2317 let access_changes = if mode == wizard::WizardMode::Create {
2318 normalize_access_changes_from_pack_refs(&answers.pack_refs, &tenants)?
2319 } else {
2320 build_access_changes(
2321 mode,
2322 answers.access_mode.as_deref(),
2323 &tenants,
2324 &refs,
2325 normalize_access_changes(&answers.access_change),
2326 )?
2327 };
2328 let default_assignments = normalize_default_assignments_from_pack_refs(&answers.pack_refs)?;
2329
2330 let request = wizard::WizardCreateRequest {
2331 bundle: bundle.clone(),
2332 bundle_name: answers.bundle_name.clone(),
2333 pack_refs: refs,
2334 tenants,
2335 default_assignments,
2336 providers: provider_ids,
2337 update_ops,
2338 remove_targets,
2339 packs_remove,
2340 providers_remove,
2341 tenants_remove,
2342 access_changes,
2343 setup_answers: answers.setup_answers.clone(),
2344 };
2345 let qa_execute = matches!(answers.execution_mode.as_deref(), Some("execute"));
2346 let (execute_requested, dry_run) = if self.apply {
2347 (true, false)
2348 } else if self.validate {
2349 (false, true)
2350 } else if self.execute || self.dry_run {
2351 (self.execute, self.dry_run || !self.execute)
2352 } else {
2353 (qa_execute, !qa_execute)
2354 };
2355 let plan = wizard_plan_builder::build_plan(mode, &request, dry_run)?;
2356 wizard::print_plan_summary(&plan);
2357 if self.verbose {
2358 for step in &plan.steps {
2359 if step.details.is_empty() {
2360 println!(
2361 "{}",
2362 operator_i18n::trf(
2363 "cli.wizard.step_details_none",
2364 "step details {:?}: <none>",
2365 &[&format!("{:?}", step.kind)]
2366 )
2367 );
2368 continue;
2369 }
2370 println!(
2371 "{}",
2372 operator_i18n::trf(
2373 "cli.wizard.step_details_header",
2374 "step details {:?}:",
2375 &[&format!("{:?}", step.kind)]
2376 )
2377 );
2378 for (key, value) in &step.details {
2379 println!(
2380 "{}",
2381 operator_i18n::trf(
2382 "cli.wizard.step_details_item",
2383 " {}={}",
2384 &[key, value]
2385 )
2386 );
2387 }
2388 }
2389 }
2390
2391 if !execute_requested {
2392 let output_path = if let Some(path) = self.emit_answers.as_ref() {
2393 path.clone()
2394 } else {
2395 prompt_output_answers_path()?
2396 };
2397 let mut answer_doc = wizard_answer_document_from_answers(
2398 &answers,
2399 answers
2400 .locale
2401 .clone()
2402 .or_else(|| Some(effective_locale.clone())),
2403 &schema_version,
2404 )?;
2405 if let Some(previous) = loaded_doc.as_ref() {
2406 answer_doc.locks = previous.locks.clone();
2407 }
2408 write_wizard_answer_document(&output_path, &answer_doc)?;
2409 println!(
2410 "{} {}",
2411 operator_i18n::tr("cli.wizard.saved_answers", "saved wizard answers:"),
2412 output_path.display()
2413 );
2414 return Ok(());
2415 }
2416
2417 if mode == wizard::WizardMode::Create
2418 && bundle.exists()
2419 && !prompt_yes_no(
2420 &format!(
2421 "Bundle path {} already exists. Overwrite bundle? [y, N]",
2422 bundle.display()
2423 ),
2424 false,
2425 )?
2426 {
2427 println!(
2428 "{}",
2429 operator_i18n::tr(
2430 "cli.wizard.execution_aborted",
2431 "wizard execution aborted by user"
2432 )
2433 );
2434 return Ok(());
2435 }
2436 if mode == wizard::WizardMode::Create && bundle.exists() {
2437 std::fs::remove_dir_all(&bundle)
2438 .with_context(|| format!("remove existing bundle {}", bundle.display()))?;
2439 }
2440
2441 let report = wizard_executor::execute(mode, &plan, self.offline)?;
2442 let no_op_count = plan
2443 .steps
2444 .iter()
2445 .filter(|step| step.kind == wizard::WizardStepKind::NoOp)
2446 .count();
2447 println!(
2448 "{}",
2449 operator_i18n::trf(
2450 "cli.wizard.execute_complete",
2451 "wizard execute complete bundle={} packs={} manifests={} providers={} no_ops={}",
2452 &[
2453 &report.bundle.display().to_string(),
2454 &report.resolved_packs.len().to_string(),
2455 &report.resolved_manifests.len().to_string(),
2456 &report.provider_updates.to_string(),
2457 &no_op_count.to_string()
2458 ]
2459 )
2460 );
2461 for manifest in &report.resolved_manifests {
2462 println!(
2463 "{}",
2464 operator_i18n::trf(
2465 "cli.wizard.resolved_manifest",
2466 "resolved manifest: {}",
2467 &[&manifest.display().to_string()]
2468 )
2469 );
2470 }
2471 for warning in &report.warnings {
2472 println!(
2473 "{}",
2474 operator_i18n::trf("cli.wizard.warning", "warning: {}", &[warning])
2475 );
2476 }
2477
2478 if self.run_setup && mode != wizard::WizardMode::Remove {
2479 let setup_provider_ids = report
2480 .resolved_packs
2481 .iter()
2482 .filter(|pack| pack.entry_flows.iter().any(|flow| flow == "setup_default"))
2483 .map(|pack| pack.pack_id.clone())
2484 .collect::<BTreeSet<_>>();
2485 let allowed_providers = if setup_provider_ids.is_empty() {
2486 None
2487 } else {
2488 Some(setup_provider_ids)
2489 };
2490 let preloaded_setup_answers = if let Some(allowed) = allowed_providers.as_ref() {
2491 Some(build_wizard_setup_answers(
2492 &plan.bundle,
2493 &report.resolved_packs,
2494 allowed,
2495 self.setup_input.as_ref(),
2496 )?)
2497 } else {
2498 None
2499 };
2500 for tenant in &plan.metadata.tenants {
2501 run_wizard_setup_for_target(
2502 &plan.bundle,
2503 &tenant.tenant,
2504 tenant.team.as_deref(),
2505 self.setup_input.as_ref(),
2506 allowed_providers.clone(),
2507 preloaded_setup_answers.clone(),
2508 )?;
2509 }
2510 } else if self.run_setup && mode == wizard::WizardMode::Remove {
2511 println!(
2512 "{}",
2513 operator_i18n::tr("cli.wizard.skip_setup_remove", "skip setup for remove mode")
2514 );
2515 }
2516 Ok(())
2517 }
2518}
2519
2520fn parse_wizard_target(input: &str) -> anyhow::Result<(String, Option<String>)> {
2521 let trimmed = input.trim();
2522 if trimmed.is_empty() {
2523 return Err(anyhow!("target must not be empty"));
2524 }
2525 let mut parts = trimmed.splitn(2, ':');
2526 let tenant = parts.next().unwrap_or_default().trim().to_string();
2527 if tenant.is_empty() {
2528 return Err(anyhow!("target tenant must not be empty"));
2529 }
2530 let team = parts
2531 .next()
2532 .map(|value| value.trim().to_string())
2533 .filter(|value| !value.is_empty());
2534 Ok((tenant, team))
2535}
2536
2537fn prompt_output_answers_path() -> anyhow::Result<PathBuf> {
2538 print!(
2539 "{} ",
2540 operator_i18n::tr(
2541 "cli.wizard.answers_output_prompt",
2542 "Answers output file [answers.json]:"
2543 )
2544 );
2545 io::stdout().flush().context("flush stdout")?;
2546 let mut input = String::new();
2547 let read = io::stdin()
2548 .read_line(&mut input)
2549 .context("read answers output file path")?;
2550 if read == 0 {
2551 return Err(anyhow!("stdin closed"));
2552 }
2553 let trimmed = input.trim();
2554 if trimmed.is_empty() {
2555 return Ok(PathBuf::from("answers.json"));
2556 }
2557 Ok(PathBuf::from(trimmed))
2558}
2559
2560fn prompt_yes_no(prompt: &str, default_yes: bool) -> anyhow::Result<bool> {
2561 loop {
2562 print!("{prompt} ");
2563 io::stdout().flush().context("flush stdout")?;
2564 let mut input = String::new();
2565 let read = io::stdin()
2566 .read_line(&mut input)
2567 .context("read yes/no input")?;
2568 if read == 0 {
2569 return Err(anyhow!("stdin closed"));
2570 }
2571 let normalized = input.trim().to_ascii_lowercase();
2572 if normalized.is_empty() {
2573 return Ok(default_yes);
2574 }
2575 if let Some(value) = parse_yes_no_token(&normalized) {
2576 return Ok(value);
2577 }
2578 println!(
2579 "{}",
2580 operator_i18n::tr("cli.common.answer_yes_no", "please answer y or n")
2581 );
2582 }
2583}
2584
2585fn parse_yes_no_token(token: &str) -> Option<bool> {
2586 match token {
2587 "y" | "yes" | "j" | "ja" => Some(true),
2588 "n" | "no" | "nee" | "nein" => Some(false),
2589 _ => None,
2590 }
2591}
2592
2593fn resolve_wizard_schema_version(override_version: Option<&str>) -> String {
2594 override_version
2595 .map(str::trim)
2596 .filter(|value| !value.is_empty())
2597 .unwrap_or(WIZARD_ANSWER_SCHEMA_VERSION)
2598 .to_string()
2599}
2600
2601fn load_wizard_answers_from_source(
2603 source: &str,
2604 expected_schema_version: &str,
2605 allow_migrate: bool,
2606) -> anyhow::Result<(WizardQaAnswers, Option<WizardAnswerDocument>)> {
2607 let raw = if source.starts_with("https://") || source.starts_with("http://") {
2608 let output = std::process::Command::new("curl")
2609 .args(["-sfSL", "--max-time", "30", source])
2610 .output()
2611 .with_context(|| format!("fetch wizard answers from {source}"))?;
2612 if !output.status.success() {
2613 let stderr = String::from_utf8_lossy(&output.stderr);
2614 return Err(anyhow!("failed to fetch {source}: {stderr}"));
2615 }
2616 String::from_utf8(output.stdout)
2617 .with_context(|| format!("decode response from {source}"))?
2618 } else {
2619 let path = Path::new(source);
2620 std::fs::read_to_string(path)
2621 .with_context(|| format!("read wizard answers {}", path.display()))?
2622 };
2623 let value: JsonValue = serde_json::from_str(&raw)
2624 .or_else(|_| serde_yaml_bw::from_str(&raw))
2625 .with_context(|| format!("parse wizard answers from {source}"))?;
2626 parse_wizard_answers_input_value(value, expected_schema_version, allow_migrate)
2627}
2628
2629fn parse_wizard_answers_input_value(
2630 value: JsonValue,
2631 expected_schema_version: &str,
2632 allow_migrate: bool,
2633) -> anyhow::Result<(WizardQaAnswers, Option<WizardAnswerDocument>)> {
2634 if let Ok(mut doc) = serde_json::from_value::<WizardAnswerDocument>(value.clone()) {
2635 ensure_wizard_answer_ids(&doc)?;
2636 if doc.schema_version != expected_schema_version {
2637 if allow_migrate {
2638 doc = migrate_wizard_answer_document(doc, expected_schema_version)?;
2639 } else {
2640 return Err(anyhow!(
2641 "wizard answers schema_version={} does not match expected {}; re-run with --migrate",
2642 doc.schema_version,
2643 expected_schema_version
2644 ));
2645 }
2646 }
2647 let qa = parse_wizard_qa_answers_value(doc.answers.clone())?;
2648 return Ok((qa, Some(doc)));
2649 }
2650 let qa = parse_wizard_qa_answers_value(value)?;
2651 Ok((qa, None))
2652}
2653
2654fn ensure_wizard_answer_ids(doc: &WizardAnswerDocument) -> anyhow::Result<()> {
2655 if doc.wizard_id.trim().is_empty() {
2656 return Err(anyhow!("wizard answers missing wizard_id"));
2657 }
2658 if doc.schema_id.trim().is_empty() {
2659 return Err(anyhow!("wizard answers missing schema_id"));
2660 }
2661 if doc.schema_version.trim().is_empty() {
2662 return Err(anyhow!("wizard answers missing schema_version"));
2663 }
2664 Ok(())
2665}
2666
2667fn migrate_wizard_answer_document(
2668 mut doc: WizardAnswerDocument,
2669 expected_schema_version: &str,
2670) -> anyhow::Result<WizardAnswerDocument> {
2671 let from = semver::Version::parse(doc.schema_version.trim())
2672 .with_context(|| format!("parse source schema_version {}", doc.schema_version))?;
2673 let to = semver::Version::parse(expected_schema_version)
2674 .with_context(|| format!("parse target schema_version {expected_schema_version}"))?;
2675 if from > to {
2676 return Err(anyhow!(
2677 "wizard answers schema_version={} is newer than supported {}",
2678 doc.schema_version,
2679 expected_schema_version
2680 ));
2681 }
2682 doc.schema_version = expected_schema_version.to_string();
2683 Ok(doc)
2684}
2685
2686fn wizard_answer_document_from_answers(
2687 answers: &WizardQaAnswers,
2688 locale: Option<String>,
2689 schema_version: &str,
2690) -> anyhow::Result<WizardAnswerDocument> {
2691 Ok(WizardAnswerDocument {
2692 wizard_id: WIZARD_ANSWER_DOC_ID.to_string(),
2693 schema_id: WIZARD_ANSWER_SCHEMA_ID.to_string(),
2694 schema_version: schema_version.to_string(),
2695 locale,
2696 answers: serde_json::to_value(answers).context("serialize wizard answers payload")?,
2697 locks: JsonMap::new(),
2698 })
2699}
2700
2701fn write_wizard_answer_document(path: &Path, doc: &WizardAnswerDocument) -> anyhow::Result<()> {
2702 let payload = serde_json::to_string_pretty(doc).context("serialize wizard answer document")?;
2703 std::fs::write(path, payload)
2704 .with_context(|| format!("write wizard answers {}", path.display()))
2705}
2706
2707fn parse_wizard_qa_answers_value(value: JsonValue) -> anyhow::Result<WizardQaAnswers> {
2708 serde_json::from_value(value).context("parse wizard answers object")
2709}
2710
2711fn run_wizard_via_qa(
2712 mode: wizard::WizardMode,
2713 locale: &str,
2714 initial_answers: JsonValue,
2715 provider_ids: &[String],
2716 verbose: bool,
2717) -> anyhow::Result<WizardQaAnswers> {
2718 let spec = wizard_spec_builder::build_validation_form_with_providers(mode, provider_ids);
2719 let prefilled_answers = initial_answers.clone();
2720 let config = WizardRunConfig {
2721 spec_json: spec.to_string(),
2722 initial_answers_json: Some(initial_answers.to_string()),
2723 frontend: WizardFrontend::Text,
2724 i18n: I18nConfig {
2725 locale: Some(locale.to_string()),
2726 resolved: Some(load_wizard_i18n(locale)?),
2727 debug: false,
2728 },
2729 verbose,
2730 };
2731 let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
2732 let result = if interactive {
2733 let mut driver = WizardDriver::new(config)
2734 .map_err(|err| anyhow!("wizard QA flow failed (greentic-qa-lib): {err}"))?;
2735 loop {
2736 let _ = driver
2737 .next_payload_json()
2738 .map_err(|err| anyhow!("wizard QA flow failed (greentic-qa-lib): {err}"))?;
2739 if driver.is_complete() {
2740 break;
2741 }
2742 let ui_raw = driver.last_ui_json().ok_or_else(|| {
2743 anyhow!("wizard QA flow failed (greentic-qa-lib): missing ui payload")
2744 })?;
2745 let ui: JsonValue = serde_json::from_str(ui_raw)
2746 .with_context(|| "wizard QA flow failed (greentic-qa-lib): parse ui payload")?;
2747 let question_id = ui
2748 .get("next_question_id")
2749 .and_then(JsonValue::as_str)
2750 .ok_or_else(|| {
2751 anyhow!("wizard QA flow failed (greentic-qa-lib): missing next_question_id")
2752 })?
2753 .to_string();
2754 let answer = answer_for_question(&prefilled_answers, &ui, &question_id)?;
2755 if verbose {
2756 eprintln!(
2757 "{}",
2758 operator_i18n::trf(
2759 "cli.wizard.qa.submitting_answer",
2760 "wizard qa: submitting answer [{}]",
2761 &[&question_id]
2762 )
2763 );
2764 let _ = io::stderr().flush();
2765 }
2766 let submit = driver
2767 .submit_patch_json(&json!({ question_id: answer }).to_string())
2768 .map_err(|err| anyhow!("wizard QA flow failed (greentic-qa-lib): {err}"))?;
2769 if submit.status == "error" {
2770 if verbose {
2771 eprintln!(
2772 "{}",
2773 operator_i18n::tr(
2774 "cli.wizard.qa.submit_validation_error",
2775 "wizard qa: submit returned validation error"
2776 )
2777 );
2778 let _ = io::stderr().flush();
2779 }
2780 } else if verbose {
2781 eprintln!(
2782 "{}",
2783 operator_i18n::trf(
2784 "cli.wizard.qa.submit_accepted",
2785 "wizard qa: submit accepted (status={})",
2786 &[&submit.status]
2787 )
2788 );
2789 let _ = io::stderr().flush();
2790 }
2791 }
2792 driver
2793 .finish()
2794 .map_err(|err| anyhow!("wizard QA flow failed (greentic-qa-lib): {err}"))?
2795 } else {
2796 match QaRunner::run_wizard_non_interactive(config) {
2797 Ok(result) => result,
2798 Err(QaLibError::NeedsInteraction) => {
2799 return Err(anyhow!(
2800 "wizard requires additional answers. Re-run with --qa-answers <PATH> generated by greentic-qa."
2801 ));
2802 }
2803 Err(err) => return Err(anyhow!("wizard QA flow failed (greentic-qa-lib): {err}")),
2804 }
2805 };
2806 parse_wizard_qa_answers_value(result.answer_set.answers)
2807}
2808
2809fn prefilled_answer_for_question(
2810 prefilled_answers: &JsonValue,
2811 question_id: &str,
2812) -> Option<JsonValue> {
2813 let value = prefilled_answers.get(question_id)?;
2814 if value.is_null() {
2815 return None;
2816 }
2817 Some(value.clone())
2818}
2819
2820fn answer_for_question(
2821 prefilled_answers: &JsonValue,
2822 ui: &JsonValue,
2823 question_id: &str,
2824) -> anyhow::Result<JsonValue> {
2825 if let Some(value) = prefilled_answer_for_question(prefilled_answers, question_id) {
2826 return Ok(value);
2827 }
2828 let question = question_for_id(ui, question_id)?;
2829 prompt_for_wizard_answer(question_id, question)
2830 .map_err(|err| anyhow!("wizard QA flow failed (greentic-qa-lib): {err}"))
2831}
2832
2833fn question_for_id<'a>(ui: &'a JsonValue, question_id: &str) -> anyhow::Result<&'a JsonValue> {
2834 ui.get("questions")
2835 .and_then(JsonValue::as_array)
2836 .and_then(|questions| {
2837 questions.iter().find(|question| {
2838 question.get("id").and_then(JsonValue::as_str) == Some(question_id)
2839 })
2840 })
2841 .ok_or_else(|| {
2842 anyhow!(
2843 "wizard QA flow failed (greentic-qa-lib): missing question {}",
2844 question_id
2845 )
2846 })
2847}
2848
2849fn prompt_for_wizard_answer(
2850 question_id: &str,
2851 question: &JsonValue,
2852) -> Result<JsonValue, QaLibError> {
2853 let title = question
2854 .get("title")
2855 .and_then(JsonValue::as_str)
2856 .unwrap_or(question_id);
2857 let required = question
2858 .get("required")
2859 .and_then(JsonValue::as_bool)
2860 .unwrap_or(false);
2861 let kind = question
2862 .get("type")
2863 .and_then(JsonValue::as_str)
2864 .unwrap_or("string");
2865
2866 match kind {
2867 "string" => prompt_string_value(title, required),
2868 "enum" => prompt_enum_value(question_id, title, required, question),
2869 "list" => prompt_list_value(question_id, title, required, question),
2870 _ => prompt_string_value(title, required),
2871 }
2872}
2873
2874fn prompt_string_value(title: &str, required: bool) -> Result<JsonValue, QaLibError> {
2875 loop {
2876 print!("{title}: ");
2877 io::stdout()
2878 .flush()
2879 .map_err(|err| QaLibError::Component(err.to_string()))?;
2880 let mut input = String::new();
2881 let read = io::stdin()
2882 .read_line(&mut input)
2883 .map_err(|err| QaLibError::Component(err.to_string()))?;
2884 if read == 0 {
2885 return Err(QaLibError::Component("stdin closed".to_string()));
2886 }
2887 let trimmed = input.trim();
2888 if trimmed.is_empty() {
2889 if required {
2890 println!(
2891 "{}",
2892 operator_i18n::tr("cli.qa.value_required", "value is required")
2893 );
2894 continue;
2895 }
2896 return Ok(JsonValue::Null);
2897 }
2898 return Ok(JsonValue::String(trimmed.to_string()));
2899 }
2900}
2901
2902fn prompt_enum_value(
2903 question_id: &str,
2904 title: &str,
2905 required: bool,
2906 question: &JsonValue,
2907) -> Result<JsonValue, QaLibError> {
2908 let choices = question
2909 .get("choices")
2910 .and_then(JsonValue::as_array)
2911 .ok_or_else(|| QaLibError::MissingField("choices".to_string()))?
2912 .iter()
2913 .filter_map(JsonValue::as_str)
2914 .map(ToString::to_string)
2915 .collect::<Vec<_>>();
2916 if choices.is_empty() {
2917 return Err(QaLibError::MissingField("choices".to_string()));
2918 }
2919 loop {
2920 println!("{title}:");
2921 for (idx, choice) in choices.iter().enumerate() {
2922 println!(" {}. {}", idx + 1, enum_choice_label(question_id, choice));
2923 }
2924 print!(
2925 "{} ",
2926 operator_i18n::tr("cli.qa.select_number_or_value", "Select number or value:")
2927 );
2928 io::stdout()
2929 .flush()
2930 .map_err(|err| QaLibError::Component(err.to_string()))?;
2931 let mut input = String::new();
2932 let read = io::stdin()
2933 .read_line(&mut input)
2934 .map_err(|err| QaLibError::Component(err.to_string()))?;
2935 if read == 0 {
2936 return Err(QaLibError::Component("stdin closed".to_string()));
2937 }
2938 let trimmed = input.trim();
2939 if trimmed.is_empty() {
2940 if required {
2941 println!(
2942 "{}",
2943 operator_i18n::tr("cli.qa.value_required", "value is required")
2944 );
2945 continue;
2946 }
2947 return Ok(JsonValue::Null);
2948 }
2949 if let Ok(n) = trimmed.parse::<usize>()
2950 && n > 0
2951 && n <= choices.len()
2952 {
2953 return Ok(JsonValue::String(choices[n - 1].clone()));
2954 }
2955 if choices.iter().any(|choice| choice == trimmed) {
2956 return Ok(JsonValue::String(trimmed.to_string()));
2957 }
2958 println!(
2959 "{}",
2960 operator_i18n::tr("cli.qa.invalid_choice", "invalid choice")
2961 );
2962 }
2963}
2964
2965fn prompt_list_value(
2966 question_id: &str,
2967 title: &str,
2968 required: bool,
2969 question: &JsonValue,
2970) -> Result<JsonValue, QaLibError> {
2971 let fields = question
2972 .get("list")
2973 .and_then(|value| value.get("fields"))
2974 .and_then(JsonValue::as_array)
2975 .ok_or_else(|| QaLibError::MissingField("list.fields".to_string()))?;
2976
2977 let custom_prompt = custom_list_add_prompt(question_id);
2978 println!("{title}:");
2979 if custom_prompt.is_none() {
2980 println!(
2981 "{}",
2982 operator_i18n::tr(
2983 "cli.qa.list_finish_hint",
2984 "Press Enter on 'Add item?' to finish."
2985 )
2986 );
2987 }
2988 let mut items = Vec::new();
2989 loop {
2990 if let Some((prompt, _default_yes)) = custom_prompt.as_ref() {
2991 print!("{prompt} ");
2992 } else {
2993 print!(
2994 "{} ",
2995 operator_i18n::trf(
2996 "cli.qa.add_item_prompt",
2997 "Add item #{}? [y/N]:",
2998 &[&(items.len() + 1).to_string()]
2999 )
3000 );
3001 }
3002 io::stdout()
3003 .flush()
3004 .map_err(|err| QaLibError::Component(err.to_string()))?;
3005 let mut add = String::new();
3006 let read = io::stdin()
3007 .read_line(&mut add)
3008 .map_err(|err| QaLibError::Component(err.to_string()))?;
3009 if read == 0 {
3010 return Err(QaLibError::Component("stdin closed".to_string()));
3011 }
3012 let add = add.trim().to_ascii_lowercase();
3013 if let Some((_, default_yes)) = custom_prompt.as_ref() {
3014 if add.is_empty() {
3015 if !*default_yes {
3016 break;
3017 }
3018 } else if let Some(value) = parse_yes_no_token(&add) {
3019 if !value {
3020 break;
3021 }
3022 } else {
3023 println!(
3024 "{}",
3025 operator_i18n::tr("cli.common.answer_yes_no", "please answer y or n")
3026 );
3027 continue;
3028 }
3029 } else {
3030 if add.is_empty() {
3031 break;
3032 }
3033 let Some(value) = parse_yes_no_token(&add) else {
3034 println!(
3035 "{}",
3036 operator_i18n::tr("cli.common.answer_yes_no", "please answer y or n")
3037 );
3038 continue;
3039 };
3040 if !value {
3041 break;
3042 }
3043 }
3044
3045 let mut item = JsonMap::new();
3046 for field in fields {
3047 let field_id = field
3048 .get("id")
3049 .and_then(JsonValue::as_str)
3050 .ok_or_else(|| QaLibError::MissingField("id".to_string()))?;
3051 if should_skip_pack_ref_field(field_id, &item) {
3052 continue;
3053 }
3054 let field_title_fallback = field
3055 .get("title")
3056 .and_then(JsonValue::as_str)
3057 .unwrap_or(field_id);
3058 let field_title_owned =
3059 localized_list_field_title(question_id, field_id, field_title_fallback);
3060 let field_title = field_title_owned.as_str();
3061 let field_kind = field
3062 .get("type")
3063 .and_then(JsonValue::as_str)
3064 .unwrap_or("string");
3065 let field_required = field
3066 .get("required")
3067 .and_then(JsonValue::as_bool)
3068 .unwrap_or(false);
3069 let value = if field_id == "make_default_pack" {
3070 prompt_yes_no_value(field_title, false)?
3071 } else {
3072 match field_kind {
3073 "enum" => prompt_enum_value(field_id, field_title, field_required, field)?,
3074 _ => prompt_string_value(field_title, field_required)?,
3075 }
3076 };
3077 if !value.is_null() {
3078 item.insert(field_id.to_string(), value);
3079 }
3080 }
3081 items.push(JsonValue::Object(item));
3082 }
3083
3084 if required && items.is_empty() {
3085 println!(
3086 "{}",
3087 operator_i18n::tr("cli.qa.at_least_one_item", "at least one item is required")
3088 );
3089 return prompt_list_value(question_id, title, required, question);
3090 }
3091 Ok(JsonValue::Array(items))
3092}
3093
3094fn enum_choice_label<'a>(question_id: &str, choice: &'a str) -> Cow<'a, str> {
3095 match (question_id, choice) {
3096 ("access_mode", "all_selected_get_all_packs") => Cow::Owned(operator_i18n::tr(
3097 "cli.qa.choice.access_mode.all_selected_get_all_packs",
3098 "All tenants and teams get access to all packs",
3099 )),
3100 ("access_mode", "per_pack_matrix") => Cow::Owned(operator_i18n::tr(
3101 "cli.qa.choice.access_mode.per_pack_matrix",
3102 "Fine-grained access control",
3103 )),
3104 ("access_scope", "all_tenants") => Cow::Owned(operator_i18n::tr(
3105 "cli.qa.choice.access_scope.all_tenants",
3106 "all tenant",
3107 )),
3108 ("access_scope", "tenant_all_teams") => Cow::Owned(operator_i18n::tr(
3109 "cli.qa.choice.access_scope.tenant_all_teams",
3110 "all teams from a specific tenant",
3111 )),
3112 ("access_scope", "specific_team") => Cow::Owned(operator_i18n::tr(
3113 "cli.qa.choice.access_scope.specific_team",
3114 "specific team for a specific tenant",
3115 )),
3116 _ => Cow::Borrowed(choice),
3117 }
3118}
3119
3120fn should_skip_pack_ref_field(field_id: &str, item: &JsonMap<String, JsonValue>) -> bool {
3121 let scope = item
3122 .get("access_scope")
3123 .and_then(JsonValue::as_str)
3124 .unwrap_or_default();
3125 match field_id {
3126 "tenant_id" => scope != "tenant_all_teams" && scope != "specific_team",
3127 "team_id" => scope != "specific_team",
3128 _ => false,
3129 }
3130}
3131
3132fn localized_list_field_title(question_id: &str, field_id: &str, fallback: &str) -> String {
3133 match (question_id, field_id) {
3134 ("pack_refs", "pack_ref") => operator_i18n::tr(
3135 "cli.qa.pack_ref_field_title",
3136 "Pack reference (e.g. /path/to/app.gtpack, file://..., oci://ghcr.io/..., repo://..., store://...)",
3137 ),
3138 ("custom_provider_refs", "pack_ref") => operator_i18n::tr(
3139 "cli.qa.pack_ref_field_title",
3140 "Pack reference (e.g. /path/to/app.gtpack, file://..., oci://ghcr.io/..., repo://..., store://...)",
3141 ),
3142 ("pack_refs", "access_scope") => operator_i18n::tr(
3143 "cli.qa.pack_ref.access_scope_title",
3144 "Who can access this application?",
3145 ),
3146 ("pack_refs", "tenant_id") => operator_i18n::tr(
3147 "cli.qa.pack_ref.tenant_id_title",
3148 "What is the tenant id who can access this application?",
3149 ),
3150 ("pack_refs", "team_id") => operator_i18n::tr(
3151 "cli.qa.pack_ref.team_id_title",
3152 "What is the team id who can access this application?",
3153 ),
3154 ("pack_refs", "make_default_pack") => operator_i18n::tr(
3155 "cli.qa.pack_ref.make_default_pack_title",
3156 "Is this pack the default pack when no pack is specified?",
3157 ),
3158 _ => fallback.to_string(),
3159 }
3160}
3161
3162fn prompt_yes_no_value(title: &str, default_yes: bool) -> Result<JsonValue, QaLibError> {
3163 loop {
3164 let suffix = if default_yes {
3165 operator_i18n::tr("cli.qa.yes_no_suffix_default_yes", "[Y,n]")
3166 } else {
3167 operator_i18n::tr("cli.qa.yes_no_suffix_default_no", "[y,N]")
3168 };
3169 if title.contains("[y, N]") || title.contains("[Y,n]") || title.contains("[y,N]") {
3170 print!("{title} ");
3171 } else {
3172 print!("{title} {suffix}: ");
3173 }
3174 io::stdout()
3175 .flush()
3176 .map_err(|err| QaLibError::Component(err.to_string()))?;
3177 let mut input = String::new();
3178 let read = io::stdin()
3179 .read_line(&mut input)
3180 .map_err(|err| QaLibError::Component(err.to_string()))?;
3181 if read == 0 {
3182 return Err(QaLibError::Component("stdin closed".to_string()));
3183 }
3184 let normalized = input.trim().to_ascii_lowercase();
3185 let yes = if normalized.is_empty() {
3186 default_yes
3187 } else if let Some(value) = parse_yes_no_token(&normalized) {
3188 value
3189 } else {
3190 println!(
3191 "{}",
3192 operator_i18n::tr("cli.common.answer_yes_no", "please answer y or n")
3193 );
3194 continue;
3195 };
3196 return Ok(JsonValue::String(
3197 if yes { "yes" } else { "no" }.to_string(),
3198 ));
3199 }
3200}
3201
3202fn custom_list_add_prompt(question_id: &str) -> Option<(String, bool)> {
3203 match question_id {
3204 "pack_refs" => Some((
3205 operator_i18n::tr(
3206 "cli.qa.pack_refs.add_prompt",
3207 "Do you want to add an application pack? [Y,n]",
3208 ),
3209 true,
3210 )),
3211 "providers" => Some((
3212 operator_i18n::tr(
3213 "cli.qa.providers.add_prompt",
3214 "Do you want to add providers (e.g. messaging, events, etc)? [Y,n]",
3215 ),
3216 true,
3217 )),
3218 "custom_provider_refs" => Some((
3219 operator_i18n::tr(
3220 "cli.qa.custom_provider_refs.add_prompt",
3221 "Do you want to add a non-well-known provider by pack reference? [y,N]",
3222 ),
3223 false,
3224 )),
3225 _ => None,
3226 }
3227}
3228
3229fn build_prefilled_wizard_answers_from_cli(args: &DemoWizardArgs, locale: &str) -> JsonValue {
3230 let mut map = JsonMap::new();
3231 if let Some(bundle) = args.bundle.as_ref() {
3232 map.insert(
3233 "bundle_path".to_string(),
3234 JsonValue::String(bundle.display().to_string()),
3235 );
3236 }
3237 map.insert("locale".to_string(), JsonValue::String(locale.to_string()));
3238 if !args.pack_refs.is_empty() {
3239 let values = args
3240 .pack_refs
3241 .iter()
3242 .map(|pack_ref| json!({ "pack_ref": pack_ref }))
3243 .collect::<Vec<_>>();
3244 map.insert("pack_refs".to_string(), JsonValue::Array(values));
3245 }
3246 if !args.targets.is_empty() {
3247 let values = args
3248 .targets
3249 .iter()
3250 .filter_map(|target| parse_wizard_target(target).ok())
3251 .map(|(tenant, team)| {
3252 if let Some(team_id) = team {
3253 json!({ "tenant_id": tenant, "team_id": team_id })
3254 } else {
3255 json!({ "tenant_id": tenant })
3256 }
3257 })
3258 .collect::<Vec<_>>();
3259 if !values.is_empty() {
3260 map.insert("targets".to_string(), JsonValue::Array(values));
3261 }
3262 } else {
3263 let mut target = JsonMap::new();
3264 target.insert(
3265 "tenant_id".to_string(),
3266 JsonValue::String(args.tenant.clone()),
3267 );
3268 if let Some(team) = args.team.as_ref() {
3269 target.insert("team_id".to_string(), JsonValue::String(team.clone()));
3270 }
3271 map.insert(
3272 "targets".to_string(),
3273 JsonValue::Array(vec![JsonValue::Object(target)]),
3274 );
3275 }
3276 if args.apply || args.execute {
3277 map.insert(
3278 "execution_mode".to_string(),
3279 JsonValue::String("execute".to_string()),
3280 );
3281 } else if args.validate || args.dry_run {
3282 map.insert(
3283 "execution_mode".to_string(),
3284 JsonValue::String("dry run".to_string()),
3285 );
3286 }
3287 JsonValue::Object(map)
3288}
3289
3290fn merge_cli_overrides_into_wizard_answers(
3291 answers: &mut WizardQaAnswers,
3292 args: &DemoWizardArgs,
3293 locale: &str,
3294) {
3295 if let Some(bundle) = args.bundle.clone() {
3296 answers.bundle = Some(bundle);
3297 }
3298 if !args.catalog_packs.is_empty() {
3299 answers.catalog_packs.extend(
3300 args.catalog_packs
3301 .iter()
3302 .map(|id| WizardCatalogPackAnswer::Id(id.clone())),
3303 );
3304 }
3305 if !args.pack_refs.is_empty() {
3306 answers.pack_refs.extend(
3307 args.pack_refs
3308 .iter()
3309 .map(|pack_ref| WizardPackRefAnswer::Ref(pack_ref.clone())),
3310 );
3311 }
3312 if !args.targets.is_empty() {
3313 answers.targets = args
3314 .targets
3315 .iter()
3316 .map(|target| WizardTargetAnswer::Target(target.clone()))
3317 .collect();
3318 }
3319 if !args.allow_paths.is_empty() {
3320 answers.allow_paths = args.allow_paths.clone();
3321 }
3322 if args.tenant != "demo" || answers.tenant.is_none() {
3323 answers.tenant = Some(args.tenant.clone());
3324 }
3325 if args.team.is_some() {
3326 answers.team = args.team.clone();
3327 }
3328 if let Some(value) = args.locale.as_ref() {
3329 answers.locale = Some(value.clone());
3330 } else if answers.locale.is_none() {
3331 answers.locale = Some(locale.to_string());
3332 }
3333 if args.apply || args.execute {
3334 answers.execution_mode = Some("execute".to_string());
3335 } else if args.validate || args.dry_run {
3336 answers.execution_mode = Some("dry run".to_string());
3337 }
3338}
3339
3340fn detect_system_locale_tag() -> String {
3341 operator_i18n::select_locale(None)
3342}
3343
3344fn parse_local_registry_ref(reference: &str) -> Option<PathBuf> {
3345 if let Some(path) = reference.strip_prefix("file://") {
3346 let trimmed = path.trim();
3347 if trimmed.is_empty() {
3348 return None;
3349 }
3350 return Some(PathBuf::from(trimmed));
3351 }
3352 if reference.contains("://") {
3353 return None;
3354 }
3355 Some(PathBuf::from(reference))
3356}
3357
3358fn normalize_pack_refs(values: &[WizardPackRefAnswer]) -> Vec<String> {
3359 values
3360 .iter()
3361 .map(|value| match value {
3362 WizardPackRefAnswer::Ref(raw) => raw.clone(),
3363 WizardPackRefAnswer::Item { pack_ref, .. } => pack_ref.clone(),
3364 })
3365 .filter(|value| !value.trim().is_empty())
3366 .collect()
3367}
3368
3369fn normalize_default_assignments_from_pack_refs(
3370 values: &[WizardPackRefAnswer],
3371) -> anyhow::Result<Vec<wizard::PackDefaultSelection>> {
3372 let mut out = Vec::new();
3373 for value in values {
3374 let WizardPackRefAnswer::Item {
3375 pack_ref,
3376 access_scope,
3377 make_default_pack,
3378 tenant_id,
3379 team_id,
3380 } = value
3381 else {
3382 continue;
3383 };
3384 let make_default = make_default_pack
3385 .as_deref()
3386 .map(str::trim)
3387 .map(|value| value.eq_ignore_ascii_case("y") || value.eq_ignore_ascii_case("yes"))
3388 .unwrap_or(false);
3389 if !make_default {
3390 continue;
3391 }
3392 let scope = match access_scope.as_deref().map(str::trim) {
3393 None | Some("") | Some("all_tenants") => wizard::PackScope::Global,
3394 Some("tenant_all_teams") => {
3395 let tenant_id = tenant_id
3396 .clone()
3397 .filter(|value| !value.trim().is_empty())
3398 .ok_or_else(|| anyhow!("access_scope=tenant_all_teams requires tenant_id"))?;
3399 wizard::PackScope::Tenant { tenant_id }
3400 }
3401 Some("specific_team") => {
3402 let tenant_id = tenant_id
3403 .clone()
3404 .filter(|value| !value.trim().is_empty())
3405 .ok_or_else(|| anyhow!("access_scope=specific_team requires tenant_id"))?;
3406 let team_id = team_id
3407 .clone()
3408 .filter(|value| !value.trim().is_empty())
3409 .ok_or_else(|| anyhow!("access_scope=specific_team requires team_id"))?;
3410 wizard::PackScope::Team { tenant_id, team_id }
3411 }
3412 Some(other) => return Err(anyhow!("unsupported access_scope {other}")),
3413 };
3414 out.push(wizard::PackDefaultSelection {
3415 pack_identifier: pack_ref.clone(),
3416 scope,
3417 });
3418 }
3419 Ok(out)
3420}
3421
3422fn normalize_access_changes_from_pack_refs(
3423 values: &[WizardPackRefAnswer],
3424 tenants: &[wizard::TenantSelection],
3425) -> anyhow::Result<Vec<wizard::AccessChangeSelection>> {
3426 let mut seen = BTreeSet::new();
3427 let mut out = Vec::new();
3428 for value in values {
3429 let (pack_ref, scope, tenant_id, team_id) = match value {
3430 WizardPackRefAnswer::Ref(pack_ref) => (pack_ref, "all_tenants", None, None),
3431 WizardPackRefAnswer::Item {
3432 pack_ref,
3433 access_scope,
3434 tenant_id,
3435 team_id,
3436 ..
3437 } => (
3438 pack_ref,
3439 access_scope.as_deref().unwrap_or("all_tenants"),
3440 tenant_id.as_deref(),
3441 team_id.as_deref(),
3442 ),
3443 };
3444 let pack_ref = pack_ref.trim();
3445 if pack_ref.is_empty() {
3446 continue;
3447 }
3448 match scope {
3449 "all_tenants" | "" => {
3450 for target in tenants {
3451 let key = (
3452 pack_ref.to_string(),
3453 target.tenant.clone(),
3454 target.team.clone().unwrap_or_default(),
3455 );
3456 if !seen.insert(key) {
3457 continue;
3458 }
3459 out.push(wizard::AccessChangeSelection {
3460 pack_id: pack_ref.to_string(),
3461 operation: wizard::AccessOperation::AllowAdd,
3462 tenant_id: target.tenant.clone(),
3463 team_id: target.team.clone(),
3464 });
3465 }
3466 }
3467 "tenant_all_teams" => {
3468 let tenant_id = tenant_id
3469 .map(str::trim)
3470 .filter(|value| !value.is_empty())
3471 .ok_or_else(|| anyhow!("access_scope=tenant_all_teams requires tenant_id"))?;
3472 let key = (pack_ref.to_string(), tenant_id.to_string(), String::new());
3473 if seen.insert(key) {
3474 out.push(wizard::AccessChangeSelection {
3475 pack_id: pack_ref.to_string(),
3476 operation: wizard::AccessOperation::AllowAdd,
3477 tenant_id: tenant_id.to_string(),
3478 team_id: None,
3479 });
3480 }
3481 }
3482 "specific_team" => {
3483 let tenant_id = tenant_id
3484 .map(str::trim)
3485 .filter(|value| !value.is_empty())
3486 .ok_or_else(|| anyhow!("access_scope=specific_team requires tenant_id"))?;
3487 let team_id = team_id
3488 .map(str::trim)
3489 .filter(|value| !value.is_empty())
3490 .ok_or_else(|| anyhow!("access_scope=specific_team requires team_id"))?;
3491 let key = (
3492 pack_ref.to_string(),
3493 tenant_id.to_string(),
3494 team_id.to_string(),
3495 );
3496 if seen.insert(key) {
3497 out.push(wizard::AccessChangeSelection {
3498 pack_id: pack_ref.to_string(),
3499 operation: wizard::AccessOperation::AllowAdd,
3500 tenant_id: tenant_id.to_string(),
3501 team_id: Some(team_id.to_string()),
3502 });
3503 }
3504 }
3505 other => return Err(anyhow!("unsupported access_scope {other}")),
3506 }
3507 }
3508 Ok(out)
3509}
3510
3511fn normalize_catalog_packs(values: &[WizardCatalogPackAnswer]) -> Vec<String> {
3512 values
3513 .iter()
3514 .map(|value| match value {
3515 WizardCatalogPackAnswer::Id(raw) => raw.clone(),
3516 WizardCatalogPackAnswer::Item { id } => id.clone(),
3517 })
3518 .filter(|value| !value.trim().is_empty())
3519 .collect()
3520}
3521
3522fn normalize_targets(values: &[WizardTargetAnswer]) -> Vec<String> {
3523 values
3524 .iter()
3525 .map(|value| match value {
3526 WizardTargetAnswer::Target(raw) => raw.clone(),
3527 WizardTargetAnswer::Item { tenant_id, team_id } => team_id
3528 .as_ref()
3529 .map(|team| format!("{tenant_id}:{team}"))
3530 .unwrap_or_else(|| tenant_id.clone()),
3531 })
3532 .filter(|value| !value.trim().is_empty())
3533 .collect()
3534}
3535
3536fn normalize_target_selections(values: &[WizardTargetAnswer]) -> Vec<wizard::TenantSelection> {
3537 values
3538 .iter()
3539 .filter_map(|value| match value {
3540 WizardTargetAnswer::Target(raw) => {
3541 parse_wizard_target(raw)
3542 .ok()
3543 .map(|(tenant, team)| wizard::TenantSelection {
3544 tenant,
3545 team,
3546 allow_paths: Vec::new(),
3547 })
3548 }
3549 WizardTargetAnswer::Item { tenant_id, team_id } => {
3550 let tenant = tenant_id.trim();
3551 if tenant.is_empty() {
3552 None
3553 } else {
3554 Some(wizard::TenantSelection {
3555 tenant: tenant.to_string(),
3556 team: team_id.clone().filter(|value| !value.trim().is_empty()),
3557 allow_paths: Vec::new(),
3558 })
3559 }
3560 }
3561 })
3562 .collect()
3563}
3564
3565fn normalize_provider_ids(values: &[WizardProviderAnswer]) -> Vec<String> {
3566 values
3567 .iter()
3568 .filter_map(|value| match value {
3569 WizardProviderAnswer::Id(raw) => Some(raw.clone()),
3570 WizardProviderAnswer::Item { provider_id, id } => provider_id
3571 .clone()
3572 .or_else(|| id.clone())
3573 .filter(|value| !value.trim().is_empty()),
3574 })
3575 .collect()
3576}
3577
3578fn normalize_custom_provider_refs(values: &[WizardCustomProviderRefAnswer]) -> Vec<String> {
3579 values
3580 .iter()
3581 .map(|value| match value {
3582 WizardCustomProviderRefAnswer::Ref(raw) => raw.clone(),
3583 WizardCustomProviderRefAnswer::Item { pack_ref } => pack_ref.clone(),
3584 })
3585 .filter(|value| !value.trim().is_empty())
3586 .collect()
3587}
3588
3589fn normalize_update_ops(values: &[WizardUpdateOpAnswer]) -> BTreeSet<wizard::WizardUpdateOp> {
3590 values
3591 .iter()
3592 .filter_map(|value| match value {
3593 WizardUpdateOpAnswer::Op(raw) => raw.trim().parse::<wizard::WizardUpdateOp>().ok(),
3594 WizardUpdateOpAnswer::Item { op } => op.trim().parse::<wizard::WizardUpdateOp>().ok(),
3595 })
3596 .collect()
3597}
3598
3599fn normalize_remove_targets(
3600 values: &[WizardRemoveTargetAnswer],
3601) -> BTreeSet<wizard::WizardRemoveTarget> {
3602 values
3603 .iter()
3604 .filter_map(|value| match value {
3605 WizardRemoveTargetAnswer::Target(raw) => {
3606 raw.trim().parse::<wizard::WizardRemoveTarget>().ok()
3607 }
3608 WizardRemoveTargetAnswer::Item {
3609 target_type,
3610 target,
3611 } => target_type
3612 .as_ref()
3613 .or(target.as_ref())
3614 .and_then(|raw| raw.trim().parse::<wizard::WizardRemoveTarget>().ok()),
3615 })
3616 .collect()
3617}
3618
3619fn normalize_pack_removes(
3620 values: &[WizardPackRemoveAnswer],
3621) -> anyhow::Result<Vec<wizard::PackRemoveSelection>> {
3622 let mut out = Vec::new();
3623 for value in values {
3624 match value {
3625 WizardPackRemoveAnswer::Pack(raw) => {
3626 let pack_identifier = raw.trim().to_string();
3627 if pack_identifier.is_empty() {
3628 continue;
3629 }
3630 out.push(wizard::PackRemoveSelection {
3631 pack_identifier,
3632 scope: None,
3633 });
3634 }
3635 WizardPackRemoveAnswer::Item {
3636 pack_identifier,
3637 pack_id,
3638 pack_ref,
3639 scope,
3640 tenant_id,
3641 team_id,
3642 } => {
3643 let identifier = pack_identifier
3644 .clone()
3645 .or_else(|| pack_id.clone())
3646 .or_else(|| pack_ref.clone())
3647 .unwrap_or_default();
3648 let pack_identifier = identifier.trim().to_string();
3649 if pack_identifier.is_empty() {
3650 continue;
3651 }
3652 let parsed_scope = match scope.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
3653 None => None,
3654 Some("bundle") => Some(wizard::PackScope::Bundle),
3655 Some("global") => Some(wizard::PackScope::Global),
3656 Some("tenant") => {
3657 let tenant_id = tenant_id
3658 .clone()
3659 .filter(|value| !value.trim().is_empty())
3660 .ok_or_else(|| {
3661 anyhow!("packs_remove scope=tenant requires tenant_id")
3662 })?;
3663 Some(wizard::PackScope::Tenant { tenant_id })
3664 }
3665 Some("team") => {
3666 let tenant_id = tenant_id
3667 .clone()
3668 .filter(|value| !value.trim().is_empty())
3669 .ok_or_else(|| anyhow!("packs_remove scope=team requires tenant_id"))?;
3670 let team_id = team_id
3671 .clone()
3672 .filter(|value| !value.trim().is_empty())
3673 .ok_or_else(|| anyhow!("packs_remove scope=team requires team_id"))?;
3674 Some(wizard::PackScope::Team { tenant_id, team_id })
3675 }
3676 Some(other) => return Err(anyhow!("unsupported packs_remove scope {other}")),
3677 };
3678 out.push(wizard::PackRemoveSelection {
3679 pack_identifier,
3680 scope: parsed_scope,
3681 });
3682 }
3683 }
3684 }
3685 Ok(out)
3686}
3687
3688fn normalize_access_changes(
3689 values: &[WizardAccessChangeAnswer],
3690) -> Vec<wizard::AccessChangeSelection> {
3691 values
3692 .iter()
3693 .filter_map(|value| match value {
3694 WizardAccessChangeAnswer::Item {
3695 pack_id,
3696 pack_ref,
3697 operation,
3698 tenant_id,
3699 team_id,
3700 } => {
3701 let pack_id = pack_id
3702 .clone()
3703 .or_else(|| pack_ref.clone())
3704 .filter(|value| !value.trim().is_empty())?;
3705 let operation = match operation.as_deref().map(str::trim).unwrap_or("allow_add") {
3706 "allow_add" => wizard::AccessOperation::AllowAdd,
3707 "allow_remove" => wizard::AccessOperation::AllowRemove,
3708 _ => wizard::AccessOperation::AllowAdd,
3709 };
3710 Some(wizard::AccessChangeSelection {
3711 pack_id,
3712 operation,
3713 tenant_id: tenant_id.clone(),
3714 team_id: team_id.clone(),
3715 })
3716 }
3717 })
3718 .collect()
3719}
3720
3721fn build_access_changes(
3722 mode: wizard::WizardMode,
3723 access_mode: Option<&str>,
3724 tenants: &[wizard::TenantSelection],
3725 pack_refs: &[String],
3726 existing: Vec<wizard::AccessChangeSelection>,
3727) -> anyhow::Result<Vec<wizard::AccessChangeSelection>> {
3728 if mode != wizard::WizardMode::Create {
3729 return Ok(existing);
3730 }
3731
3732 let normalized_mode = access_mode.map(str::trim).filter(|value| !value.is_empty());
3733 let mut changes = existing;
3734 match normalized_mode {
3735 Some("all_selected_get_all_packs") => {
3736 for tenant in tenants {
3737 for pack_ref in pack_refs {
3738 changes.push(wizard::AccessChangeSelection {
3739 pack_id: pack_ref.clone(),
3740 operation: wizard::AccessOperation::AllowAdd,
3741 tenant_id: tenant.tenant.clone(),
3742 team_id: tenant.team.clone(),
3743 });
3744 }
3745 }
3746 }
3747 Some("per_pack_matrix") => {
3748 if changes.is_empty() {
3749 return Err(anyhow!(
3750 "access_mode=per_pack_matrix requires non-empty access_change entries"
3751 ));
3752 }
3753 }
3754 Some(other) => {
3755 return Err(anyhow!(
3756 "unsupported access_mode {}; expected all_selected_get_all_packs or per_pack_matrix",
3757 other
3758 ));
3759 }
3760 None => {
3761 if changes.is_empty() {
3762 for tenant in tenants {
3763 for pack_ref in pack_refs {
3764 changes.push(wizard::AccessChangeSelection {
3765 pack_id: pack_ref.clone(),
3766 operation: wizard::AccessOperation::AllowAdd,
3767 tenant_id: tenant.tenant.clone(),
3768 team_id: tenant.team.clone(),
3769 });
3770 }
3771 }
3772 }
3773 }
3774 }
3775
3776 let mut dedup = BTreeSet::new();
3777 changes.retain(|change| {
3778 let key = (
3779 change.pack_id.clone(),
3780 match change.operation {
3781 wizard::AccessOperation::AllowAdd => "allow_add",
3782 wizard::AccessOperation::AllowRemove => "allow_remove",
3783 }
3784 .to_string(),
3785 change.tenant_id.clone(),
3786 change.team_id.clone().unwrap_or_default(),
3787 );
3788 dedup.insert(key)
3789 });
3790 Ok(changes)
3791}
3792
3793fn load_wizard_i18n(locale: &str) -> anyhow::Result<ResolvedI18nMap> {
3794 wizard_i18n::load(locale)
3795}
3796
3797fn run_wizard_setup_for_target(
3798 bundle: &Path,
3799 tenant: &str,
3800 team: Option<&str>,
3801 setup_input: Option<&PathBuf>,
3802 allowed_providers: Option<BTreeSet<String>>,
3803 preloaded_setup_answers: Option<SetupInputAnswers>,
3804) -> anyhow::Result<()> {
3805 for domain in [
3806 Domain::Messaging,
3807 Domain::Events,
3808 Domain::Secrets,
3809 Domain::OAuth,
3810 ] {
3811 run_domain_command(DomainRunArgs {
3812 root: bundle.to_path_buf(),
3813 state_root: None,
3814 domain,
3815 action: DomainAction::Setup,
3816 tenant: tenant.to_string(),
3817 team: team.map(|value| value.to_string()),
3818 provider_filter: None,
3819 dry_run: false,
3820 format: PlanFormat::Text,
3821 parallel: 1,
3822 allow_missing_setup: true,
3823 allow_contract_change: false,
3824 backup: false,
3825 online: false,
3826 secrets_env: None,
3827 runner_binary: None,
3828 best_effort: true,
3829 discovered_providers: None,
3830 setup_input: if preloaded_setup_answers.is_some() {
3831 None
3832 } else {
3833 setup_input.cloned()
3834 },
3835 allowed_providers: allowed_providers.clone(),
3836 preloaded_setup_answers: preloaded_setup_answers.clone(),
3837 public_base_url: None,
3838 secrets_manager: None,
3839 })?;
3840 }
3841 Ok(())
3842}
3843
3844fn build_wizard_setup_answers(
3845 bundle: &Path,
3846 packs: &[wizard::ResolvedPackInfo],
3847 allowed: &BTreeSet<String>,
3848 setup_input: Option<&PathBuf>,
3849) -> anyhow::Result<SetupInputAnswers> {
3850 let base_input = if let Some(path) = setup_input {
3851 let raw = load_setup_input(path)?;
3852 Some(SetupInputAnswers::new(raw, allowed.clone())?)
3853 } else {
3854 None
3855 };
3856 let mut map = serde_json::Map::new();
3857 for pack in packs {
3858 if !allowed.contains(&pack.pack_id) {
3859 continue;
3860 }
3861 let pack_path = bundle.join(&pack.output_path);
3862 let answers = collect_setup_answers(
3863 &pack_path,
3864 &pack.pack_id,
3865 base_input.as_ref(),
3866 setup_input.is_none(),
3867 )?;
3868 map.insert(pack.pack_id.clone(), answers);
3869 }
3870 SetupInputAnswers::new(serde_json::Value::Object(map), allowed.clone())
3871}
3872
3873impl DemoSendArgs {
3874 fn run(self) -> anyhow::Result<()> {
3875 let team = if self.team.is_empty() {
3876 None
3877 } else {
3878 Some(self.team.as_str())
3879 };
3880 domains::ensure_cbor_packs(&self.bundle)?;
3881 let pack = resolve_demo_provider_pack(
3882 &self.bundle,
3883 &self.tenant,
3884 team,
3885 &self.provider,
3886 Domain::Messaging,
3887 )?;
3888 let provider_type = primary_provider_type(&pack.path)
3889 .context("failed to determine provider type for demo send")?;
3890 let discovery = discovery::discover_with_options(
3891 &self.bundle,
3892 discovery::DiscoveryOptions { cbor_only: true },
3893 )?;
3894 let provider_map = discovery_map(&discovery.providers);
3895 let provider_id = provider_id_for_pack(&pack.path, &pack.pack_id, Some(&provider_map));
3896
3897 let secrets_handle =
3898 secrets_gate::resolve_secrets_manager(&self.bundle, &self.tenant, team)?;
3899 let runner_host = DemoRunnerHost::new(
3900 self.bundle.clone(),
3901 &discovery,
3902 self.runner_binary.clone(),
3903 secrets_handle.clone(),
3904 false,
3905 )?;
3906
3907 if let Err(e) = crate::capability_bootstrap::try_upgrade_telemetry(
3909 &self.bundle,
3910 &runner_host,
3911 &self.tenant,
3912 team,
3913 None,
3914 None,
3915 ) {
3916 tracing::warn!(error = %e, "telemetry capability bootstrap failed");
3917 }
3918
3919 let env = self.env.clone();
3920 let context = OperatorContext {
3921 tenant: self.tenant.clone(),
3922 team: team.map(|value| value.to_string()),
3923 correlation_id: None,
3924 };
3925
3926 if self.print_required_args {
3927 if let Err(message) = ensure_requirements_flow(&pack) {
3928 eprintln!("{message}");
3929 std::process::exit(2);
3930 }
3931 let input = build_input_payload(
3932 &self.bundle,
3933 Domain::Messaging,
3934 &self.tenant,
3935 team,
3936 Some(&pack.pack_id),
3937 None,
3938 None,
3939 &env,
3940 );
3941 let input_bytes = serde_json::to_vec(&input)?;
3942 let outcome = runner_host.invoke_provider_op(
3943 Domain::Messaging,
3944 &provider_id,
3945 "requirements",
3946 &input_bytes,
3947 &context,
3948 )?;
3949 if !outcome.success {
3950 let message = outcome
3951 .error
3952 .unwrap_or_else(|| "requirements flow failed".to_string());
3953 return Err(anyhow::anyhow!(message));
3954 }
3955 if let Some(value) = outcome.output {
3956 if let Some(rendered) = format_requirements_output(&value) {
3957 println!("{rendered}");
3958 } else {
3959 let json = serde_json::to_string_pretty(&value)?;
3960 println!("{json}");
3961 }
3962 } else if let Some(raw) = outcome.raw {
3963 println!("{raw}");
3964 }
3965 return Ok(());
3966 }
3967
3968 let card_payload = if let Some(path) = &self.card {
3969 let contents = fs::read_to_string(path)
3970 .with_context(|| format!("failed to read card file {}", path.display()))?;
3971 Some(
3972 serde_json::from_str::<JsonValue>(&contents)
3973 .with_context(|| format!("failed to parse card file {}", path.display()))?,
3974 )
3975 } else {
3976 None
3977 };
3978 let mut text_value = self.text.clone();
3979 if text_value.is_none() && card_payload.is_some() {
3980 text_value = Some("adaptive card".to_string());
3981 }
3982 let text_ref = text_value.as_deref();
3983 if text_ref.is_none() && card_payload.is_none() {
3984 return Err(anyhow::anyhow!(
3985 "either --text or --card is required unless --print-required-args"
3986 ));
3987 }
3988 let args = merge_args(self.args_json.as_deref(), &self.args)?;
3989 let mut config_items = Vec::new();
3990 config_items.push(ConfigGateItem::new(
3991 "env",
3992 Some(env.clone()),
3993 ConfigValueSource::Platform("GREENTIC_ENV"),
3994 true,
3995 ));
3996 config_items.push(ConfigGateItem::new(
3997 "tenant",
3998 Some(self.tenant.clone()),
3999 ConfigValueSource::Platform("tenant"),
4000 true,
4001 ));
4002 let team_label = team.unwrap_or("default");
4003 config_items.push(ConfigGateItem::new(
4004 "team",
4005 Some(team_label.to_string()),
4006 ConfigValueSource::Platform("team"),
4007 true,
4008 ));
4009 if let Some(text) = &text_value {
4010 config_items.push(ConfigGateItem::new(
4011 "text",
4012 Some(text.clone()),
4013 ConfigValueSource::Argument("--text"),
4014 true,
4015 ));
4016 }
4017 if let Some(card_path) = &self.card {
4018 config_items.push(ConfigGateItem::new(
4019 "card",
4020 Some(card_path.display().to_string()),
4021 ConfigValueSource::Argument("--card"),
4022 true,
4023 ));
4024 }
4025 let mut arg_entries = args.iter().collect::<Vec<_>>();
4026 arg_entries.sort_by(|(a, _), (b, _)| a.cmp(b));
4027 for (key, value) in arg_entries {
4028 config_items.push(ConfigGateItem::new(
4029 key.as_str(),
4030 Some(config_value_display(value)),
4031 ConfigValueSource::Argument("--arg"),
4032 true,
4033 ));
4034 }
4035 if !self.to.is_empty() {
4036 config_items.push(ConfigGateItem::new(
4037 "to",
4038 Some(self.to.join(",")),
4039 ConfigValueSource::Argument("--to"),
4040 true,
4041 ));
4042 }
4043 if let Some(kind) = self.to_kind.as_ref() {
4044 config_items.push(ConfigGateItem::new(
4045 "to-kind",
4046 Some(kind.clone()),
4047 ConfigValueSource::Argument("--to-kind"),
4048 false,
4049 ));
4050 }
4051 config_gate::log_config_gate(Domain::Messaging, &self.tenant, team, &env, &config_items);
4052 let channel = provider_channel(&self.provider);
4053 let message = build_demo_send_message(DemoSendMessageArgs {
4054 text: text_ref,
4055 args: &args,
4056 tenant: &self.tenant,
4057 team,
4058 destinations: &self.to,
4059 to_kind: self.to_kind.as_deref(),
4060 provider_id: &self.provider,
4061 channel: &channel,
4062 card: card_payload.as_ref(),
4063 });
4064 debug_print_envelope("initial message", &message);
4065
4066 let render_plan_input = egress::build_render_plan_input(message.clone());
4068 let render_plan_input_value = serde_json::to_value(&render_plan_input)?;
4069 let plan_value = run_provider_component_op_json(
4070 &runner_host,
4071 &pack,
4072 &provider_id,
4073 &context,
4074 "render_plan",
4075 render_plan_input_value.clone(),
4076 )
4077 .with_context(|| "render_plan failed")?;
4078 let render_plan_out: RenderPlanOutV1 =
4079 serde_json::from_value(plan_value.clone()).context("render_plan output invalid")?;
4080 debug_print_render_plan_output(&render_plan_out);
4081 if !render_plan_out.ok {
4082 let err = render_plan_out
4083 .error
4084 .unwrap_or_else(|| "render_plan returned error".to_string());
4085 return Err(anyhow::anyhow!(err));
4086 }
4087 let encode_input = egress::build_encode_input(message.clone(), plan_value.clone());
4088 debug_print_encode_input(&encode_input);
4089 let payload_value = run_provider_component_op_json(
4090 &runner_host,
4091 &pack,
4092 &provider_id,
4093 &context,
4094 "encode",
4095 serde_json::to_value(&encode_input)?,
4096 )
4097 .with_context(|| "encode failed")?;
4098 let encode_out: EncodeOutV1 =
4099 serde_json::from_value(payload_value).context("encode output invalid")?;
4100 debug_print_encode_output(&encode_out);
4101 if !encode_out.ok {
4102 let err = encode_out
4103 .error
4104 .unwrap_or_else(|| "encode returned error".to_string());
4105 return Err(anyhow::anyhow!(err));
4106 }
4107 let payload = encode_out
4108 .payload
4109 .ok_or_else(|| anyhow::anyhow!("encode output missing payload"))?;
4110 let send_input = egress::build_send_payload(
4111 payload,
4112 provider_type.clone(),
4113 self.tenant.clone(),
4114 team.map(|value| value.to_string()),
4115 );
4116 let send_value = serde_json::to_value(&send_input)?;
4117 let send_outcome = run_provider_component_op(
4118 &runner_host,
4119 &pack,
4120 &provider_id,
4121 &context,
4122 "send_payload",
4123 send_value,
4124 )
4125 .context("send_payload failed")?;
4126 println!("{}", operator_i18n::tr("cli.common.ok", "ok"));
4127 let status = if send_outcome.success {
4128 operator_i18n::tr("cli.common.success", "success")
4129 } else {
4130 operator_i18n::tr("cli.common.failed", "failed")
4131 };
4132 println!(
4133 "{}",
4134 operator_i18n::trf("cli.demo_send.flow_result", "Flow result: {}", &[&status])
4135 );
4136 if let Some(error) = &send_outcome.error {
4137 println!(
4138 "{}",
4139 operator_i18n::trf("cli.demo_send.flow_error", "Flow error: {}", &[error])
4140 );
4141 }
4142 if let Some(value) = send_outcome.output {
4143 if let Ok(parsed) = serde_json::from_value::<SendPayloadOutV1>(value.clone()) {
4144 debug_print_send_payload_output(&parsed);
4145 } else if demo_debug_enabled() {
4146 if let Ok(body) = serde_json::to_string_pretty(&value) {
4147 println!(
4148 "{}",
4149 operator_i18n::trf(
4150 "cli.demo_send.debug_parse_send_payload_failed",
4151 "[demo] after send_payload output: failed to parse SendPayloadOutV1\n{}",
4152 &[&body]
4153 )
4154 );
4155 } else {
4156 println!(
4157 "{}",
4158 operator_i18n::tr(
4159 "cli.demo_send.debug_invalid_json_output",
4160 "[demo] after send_payload output: invalid JSON output"
4161 )
4162 );
4163 }
4164 }
4165 let missing_uris = if payload_contains_secret_error(&value) {
4166 gather_missing_secret_uris(
4167 &secrets_handle.manager(),
4168 &env,
4169 &self.tenant,
4170 team,
4171 &pack.path,
4172 &provider_id,
4173 secrets_handle.dev_store_path.as_deref(),
4174 secrets_handle.using_env_fallback,
4175 Some(provider_type.as_str()),
4176 )
4177 } else {
4178 Vec::new()
4179 };
4180 if !missing_uris.is_empty() {
4181 println!(
4182 "{}",
4183 operator_i18n::trf(
4184 "cli.demo_send.missing_secret_uris",
4185 "missing secret URIs:\n{}",
4186 &[&missing_uris
4187 .iter()
4188 .map(|uri| format!(" - {uri}"))
4189 .collect::<Vec<_>>()
4190 .join("\n")]
4191 )
4192 );
4193 for uri in &missing_uris {
4194 print_secret_missing_details(
4195 uri,
4196 secrets_handle.dev_store_path.as_deref(),
4197 secrets_handle.using_env_fallback,
4198 &self.bundle,
4199 );
4200 }
4201 }
4202 let enriched = enrich_secret_error_payload(
4203 value,
4204 &context,
4205 &env,
4206 &provider_id,
4207 &pack.pack_id,
4208 &pack.path,
4209 &missing_uris,
4210 &secrets_handle.selection,
4211 secrets_handle.dev_store_path.as_deref(),
4212 );
4213 let json = serde_json::to_string_pretty(&enriched)?;
4214 println!("{json}");
4215 } else if let Some(raw) = send_outcome.raw {
4216 println!("{raw}");
4217 }
4218 Ok(())
4219 }
4220}
4221
4222fn run_provider_component_op(
4223 runner_host: &DemoRunnerHost,
4224 pack: &domains::ProviderPack,
4225 provider_id: &str,
4226 ctx: &OperatorContext,
4227 op: &str,
4228 payload: serde_json::Value,
4229) -> anyhow::Result<FlowOutcome> {
4230 let _span = tracing::info_span!(
4231 "provider_component_op",
4232 provider = %provider_id,
4233 op = %op,
4234 tenant = %ctx.tenant,
4235 )
4236 .entered();
4237 let bytes = serde_json::to_vec(&payload)?;
4238 let outcome = runner_host.invoke_provider_component_op_direct(
4239 Domain::Messaging,
4240 pack,
4241 provider_id,
4242 op,
4243 &bytes,
4244 ctx,
4245 )?;
4246 ensure_provider_op_success(provider_id, op, &outcome)?;
4247 if let Some(value) = &outcome.output
4248 && let Some(card) = detect_adaptive_card_view(value)
4249 {
4250 print_card_summary(&card);
4251 }
4252 Ok(outcome)
4253}
4254
4255fn run_provider_component_op_json(
4256 runner_host: &DemoRunnerHost,
4257 pack: &domains::ProviderPack,
4258 provider_id: &str,
4259 ctx: &OperatorContext,
4260 op: &str,
4261 payload: serde_json::Value,
4262) -> anyhow::Result<serde_json::Value> {
4263 let outcome = run_provider_component_op(runner_host, pack, provider_id, ctx, op, payload)?;
4264 Ok(outcome.output.unwrap_or_else(|| json!({})))
4265}
4266
4267#[allow(clippy::too_many_arguments)]
4268fn enrich_secret_error_payload(
4269 mut payload: serde_json::Value,
4270 ctx: &OperatorContext,
4271 env: &str,
4272 provider_id: &str,
4273 pack_id: &str,
4274 pack_path: &Path,
4275 missing_uris: &[String],
4276 selection: &secrets_manager::SecretsManagerSelection,
4277 dev_store_path: Option<&Path>,
4278) -> serde_json::Value {
4279 let team = secrets_manager::canonical_team(ctx.team.as_deref()).to_string();
4280 let selection_desc = selection.description();
4281 let dev_store_desc = dev_store_path
4282 .map(|path| path.display().to_string())
4283 .unwrap_or_else(|| "<default>".to_string());
4284 let context_suffix = format!(
4285 "env={} tenant={} team={} provider={} pack_id={} pack_path={} secrets_manager={} dev_store={}",
4286 env,
4287 ctx.tenant,
4288 team,
4289 provider_id,
4290 pack_id,
4291 pack_path.display(),
4292 selection_desc,
4293 dev_store_desc
4294 );
4295 if let serde_json::Value::Object(map) = &mut payload {
4296 for key in ["message", "error"] {
4297 if let Some(entry) = map.get_mut(key)
4298 && let Some(text) = entry.as_str()
4299 && text_contains_secret_error(text)
4300 {
4301 let suffix = secret_error_suffix(&context_suffix, missing_uris);
4302 let enriched = format!("{text} ({suffix})");
4303 *entry = serde_json::Value::String(enriched);
4304 }
4305 }
4306 }
4307 payload
4308}
4309
4310fn print_secret_missing_details(
4311 uri: &str,
4312 store_path: Option<&Path>,
4313 using_env_fallback: bool,
4314 bundle_root: &Path,
4315) {
4316 let key = secrets_gate::canonical_secret_store_key(uri)
4317 .unwrap_or_else(|| "<invalid secret uri>".to_string());
4318 let default_store = dev_store_path::default_path(bundle_root);
4319 let store_desc = match (store_path, using_env_fallback) {
4320 (Some(path), _) => path.display().to_string(),
4321 (None, true) => "<env secrets store>".to_string(),
4322 (None, false) => default_store.display().to_string(),
4323 };
4324 println!(
4325 "{}",
4326 operator_i18n::tr("cli.secrets.not_found", "Secret not found:")
4327 );
4328 println!(
4329 "{}",
4330 operator_i18n::trf("cli.secrets.uri", " uri: {}", &[uri])
4331 );
4332 println!(
4333 "{}",
4334 operator_i18n::trf("cli.secrets.key", " key: {}", &[&key])
4335 );
4336 println!(
4337 "{}",
4338 operator_i18n::trf("cli.secrets.store", " store: {}", &[&store_desc])
4339 );
4340 println!(
4341 "{}",
4342 operator_i18n::trf(
4343 "cli.secrets.hint_setup_or_add_key",
4344 "hint: run `greentic-operator setup` or add the key to {}",
4345 &[&default_store.display().to_string()]
4346 )
4347 );
4348}
4349
4350fn payload_contains_secret_error(value: &JsonValue) -> bool {
4351 for key in ["message", "error"] {
4352 if let Some(text) = value.get(key).and_then(JsonValue::as_str)
4353 && text_contains_secret_error(text)
4354 {
4355 return true;
4356 }
4357 }
4358 false
4359}
4360
4361fn text_contains_secret_error(text: &str) -> bool {
4362 let lower = text.to_lowercase();
4363 lower.contains("secret store error") || text.contains("SecretsError")
4364}
4365
4366fn secret_error_suffix(context_suffix: &str, missing_uris: &[String]) -> String {
4367 if missing_uris.is_empty() {
4368 context_suffix.to_string()
4369 } else {
4370 let missing = missing_uris.join(", ");
4371 format!("{context_suffix}; missing secrets: {missing}")
4372 }
4373}
4374
4375#[allow(clippy::too_many_arguments)]
4376fn gather_missing_secret_uris(
4377 manager: &DynSecretsManager,
4378 env: &str,
4379 tenant: &str,
4380 team: Option<&str>,
4381 pack_path: &Path,
4382 provider_id: &str,
4383 store_path: Option<&Path>,
4384 using_env_fallback: bool,
4385 provider_type: Option<&str>,
4386) -> Vec<String> {
4387 match secrets_gate::check_provider_secrets(
4388 manager,
4389 env,
4390 tenant,
4391 team,
4392 pack_path,
4393 provider_id,
4394 provider_type,
4395 store_path,
4396 using_env_fallback,
4397 ) {
4398 Ok(Some(missing)) => missing,
4399 Ok(None) => Vec::new(),
4400 Err(err) => {
4401 operator_log::warn(
4402 module_path!(),
4403 format!(
4404 "failed to check missing secrets for provider {}: {}",
4405 provider_id, err
4406 ),
4407 );
4408 Vec::new()
4409 }
4410 }
4411}
4412
4413fn ensure_provider_op_success(
4414 provider_id: &str,
4415 op: &str,
4416 outcome: &FlowOutcome,
4417) -> anyhow::Result<()> {
4418 if outcome.success {
4419 return Ok(());
4420 }
4421 let message = outcome
4422 .error
4423 .clone()
4424 .or_else(|| outcome.raw.clone())
4425 .unwrap_or_else(|| "unknown error".to_string());
4426 Err(anyhow::anyhow!("{provider_id}.{op} failed: {message}"))
4427}
4428
4429fn print_capability_outcome(outcome: &FlowOutcome) -> anyhow::Result<()> {
4430 println!(
4431 "{}",
4432 operator_i18n::trf(
4433 "cli.capabilities.outcome.success",
4434 "success: {}",
4435 &[&outcome.success.to_string()]
4436 )
4437 );
4438 if let Some(error) = outcome.error.as_ref() {
4439 println!(
4440 "{}",
4441 operator_i18n::trf("cli.capabilities.outcome.error", "error: {}", &[error])
4442 );
4443 }
4444 if let Some(raw) = outcome.raw.as_ref()
4445 && !raw.trim().is_empty()
4446 {
4447 println!(
4448 "{}",
4449 operator_i18n::trf("cli.capabilities.outcome.raw", "raw:\n{}", &[raw])
4450 );
4451 }
4452 if let Some(value) = outcome.output.as_ref() {
4453 println!("{}", serde_json::to_string_pretty(value)?);
4454 }
4455 Ok(())
4456}
4457
4458#[derive(Parser)]
4459#[command(
4460 about = "Send a synthetic HTTP request through the messaging ingress pipeline.",
4461 long_about = "Constructs an HttpInV1 payload, invokes the provider's ingest_http op, and optionally runs the resulting events through the app/outbound flow."
4462)]
4463struct DemoIngressArgs {
4464 #[arg(long)]
4465 bundle: PathBuf,
4466 #[arg(long)]
4467 provider: String,
4468 #[arg(long)]
4469 path: Option<String>,
4470 #[arg(long, value_enum, default_value_t = DemoIngressMethod::Post)]
4471 method: DemoIngressMethod,
4472 #[arg(long = "header")]
4473 headers: Vec<String>,
4474 #[arg(long = "query")]
4475 queries: Vec<String>,
4476 #[arg(long)]
4477 body: Option<String>,
4478 #[arg(long)]
4479 body_json: Option<String>,
4480 #[arg(long)]
4481 body_raw: Option<String>,
4482 #[arg(long)]
4483 binding_id: Option<String>,
4484 #[arg(long, default_value = "demo")]
4485 tenant: String,
4486 #[arg(long, default_value = "default")]
4487 team: String,
4488 #[arg(long)]
4489 runner_binary: Option<PathBuf>,
4490 #[arg(long, value_enum, default_value = "all")]
4491 print: DemoIngressPrintMode,
4492 #[arg(long)]
4493 end_to_end: bool,
4494 #[arg(long)]
4495 app_pack: Option<String>,
4496 #[arg(long, action = ArgAction::SetTrue)]
4497 send: bool,
4498 #[arg(long)]
4499 retries: Option<u32>,
4500 #[arg(long, action = ArgAction::SetTrue)]
4501 dlq_tail: bool,
4502 #[arg(long, default_value_t = true)]
4503 dry_run: bool,
4504 #[arg(long)]
4505 correlation_id: Option<String>,
4506}
4507
4508#[derive(Clone, Copy, Debug, ValueEnum)]
4509enum DemoIngressMethod {
4510 Get,
4511 Post,
4512}
4513
4514impl DemoIngressMethod {
4515 fn as_str(&self) -> &'static str {
4516 match self {
4517 DemoIngressMethod::Get => "GET",
4518 DemoIngressMethod::Post => "POST",
4519 }
4520 }
4521}
4522
4523#[derive(Clone, Copy, Debug, Default, ValueEnum)]
4524enum DemoIngressPrintMode {
4525 Http,
4526 Events,
4527 #[default]
4528 All,
4529}
4530
4531impl DemoIngressPrintMode {
4532 fn should_print_http(&self) -> bool {
4533 matches!(self, DemoIngressPrintMode::Http | DemoIngressPrintMode::All)
4534 }
4535
4536 fn should_print_events(&self) -> bool {
4537 matches!(
4538 self,
4539 DemoIngressPrintMode::Events | DemoIngressPrintMode::All
4540 )
4541 }
4542}
4543
4544impl DemoIngressArgs {
4545 fn run(self) -> anyhow::Result<()> {
4546 ensure_single_body_field(&self)?;
4547 let body_bytes = resolve_ingress_body(
4548 self.body.as_deref(),
4549 self.body_json.as_deref(),
4550 self.body_raw.as_deref(),
4551 )?;
4552 let path = self
4553 .path
4554 .clone()
4555 .unwrap_or_else(|| default_ingress_path(&self.provider, self.binding_id.as_deref()));
4556 let headers = parse_header_pairs(&self.headers)?;
4557 let queries = parse_query_pairs(&self.queries)?;
4558 let route = derive_route_from_path(&path);
4559 let full_path = if path.starts_with('/') {
4560 path.clone()
4561 } else {
4562 format!("/{path}")
4563 };
4564
4565 let request = crate::messaging_universal::ingress::build_ingress_request(
4566 &self.provider,
4567 route,
4568 self.method.as_str(),
4569 &full_path,
4570 headers,
4571 queries,
4572 &body_bytes,
4573 self.binding_id.clone(),
4574 Some(self.tenant.clone()),
4575 Some(self.team.clone()),
4576 );
4577
4578 let team_context = if self.team.is_empty() {
4579 None
4580 } else {
4581 Some(self.team.clone())
4582 };
4583 let context = OperatorContext {
4584 tenant: self.tenant.clone(),
4585 team: team_context,
4586 correlation_id: self.correlation_id.clone(),
4587 };
4588 let secrets_handle = secrets_gate::resolve_secrets_manager(
4589 &self.bundle,
4590 &self.tenant,
4591 context.team.as_deref(),
4592 )?;
4593
4594 let (response, events) = crate::messaging_universal::ingress::run_ingress(
4595 &self.bundle,
4596 &self.provider,
4597 &request,
4598 &context,
4599 self.runner_binary.clone(),
4600 secrets_handle.clone(),
4601 )?;
4602
4603 if self.print.should_print_http() {
4604 print_http_response(&response)?;
4605 }
4606 if self.print.should_print_events() {
4607 print_envelopes(&events)?;
4608 }
4609
4610 if self.end_to_end {
4611 crate::messaging_universal::egress::run_end_to_end(
4612 events,
4613 &self.provider,
4614 &self.bundle,
4615 &context,
4616 self.runner_binary.clone(),
4617 self.app_pack.clone(),
4618 self.send,
4619 self.dry_run,
4620 self.retries.unwrap_or(0),
4621 secrets_handle.clone(),
4622 )?;
4623 }
4624
4625 if self.dlq_tail {
4626 let paths = RuntimePaths::new(self.bundle.join("state"), &self.tenant, &self.team);
4627 println!(
4628 "{}",
4629 operator_i18n::trf(
4630 "cli.ingress.dlq_log_location",
4631 "DLQ log location: {}",
4632 &[&paths.dlq_log_path().display().to_string()]
4633 )
4634 );
4635 }
4636 Ok(())
4637 }
4638}
4639
4640fn ensure_single_body_field(args: &DemoIngressArgs) -> anyhow::Result<()> {
4641 let count =
4642 args.body.is_some() as u8 + args.body_json.is_some() as u8 + args.body_raw.is_some() as u8;
4643 if count > 1 {
4644 Err(anyhow::anyhow!(
4645 "only one of --body, --body-json, or --body-raw can be provided"
4646 ))
4647 } else {
4648 Ok(())
4649 }
4650}
4651
4652fn resolve_ingress_body(
4653 body: Option<&str>,
4654 body_json: Option<&str>,
4655 body_raw: Option<&str>,
4656) -> anyhow::Result<Vec<u8>> {
4657 if let Some(raw) = body_raw {
4658 return Ok(raw.as_bytes().to_vec());
4659 }
4660 if let Some(json) = body_json {
4661 let _ = serde_json::from_str::<serde_json::Value>(json)
4662 .with_context(|| "invalid JSON provided to --body-json")?;
4663 return Ok(json.as_bytes().to_vec());
4664 }
4665 if let Some(path) = body {
4666 let path = path.strip_prefix('@').unwrap_or(path);
4667 let bytes =
4668 std::fs::read(path).with_context(|| format!("failed to read body file at {}", path))?;
4669 return Ok(bytes);
4670 }
4671 Ok(Vec::new())
4672}
4673
4674fn parse_header_pairs(values: &[String]) -> anyhow::Result<Vec<(String, String)>> {
4675 let mut headers = Vec::new();
4676 for raw in values {
4677 let score = raw.splitn(2, ':').collect::<Vec<_>>();
4678 if score.len() != 2 {
4679 return Err(anyhow::anyhow!(
4680 "invalid header '{}'; expected 'Name: value'",
4681 raw
4682 ));
4683 }
4684 headers.push((score[0].trim().to_string(), score[1].trim().to_string()));
4685 }
4686 Ok(headers)
4687}
4688
4689fn parse_query_pairs(values: &[String]) -> anyhow::Result<Vec<(String, String)>> {
4690 let mut queries = Vec::new();
4691 for raw in values {
4692 let mut parts = raw.splitn(2, '=');
4693 let key = parts
4694 .next()
4695 .map(str::trim)
4696 .filter(|value| !value.is_empty())
4697 .ok_or_else(|| anyhow::anyhow!("invalid query '{}'; expected 'k=v'", raw))?;
4698 let value = parts
4699 .next()
4700 .map(str::trim)
4701 .ok_or_else(|| anyhow::anyhow!("invalid query '{}'; expected 'k=v'", raw))?;
4702 queries.push((key.to_string(), value.to_string()));
4703 }
4704 Ok(queries)
4705}
4706
4707fn default_ingress_path(provider: &str, binding_id: Option<&str>) -> String {
4708 if let Some(binding) = binding_id {
4709 format!("/ingress/{}/{}", provider, binding)
4710 } else {
4711 format!("/ingress/{}/webhook", provider)
4712 }
4713}
4714
4715fn derive_route_from_path(path: &str) -> Option<String> {
4716 let segments = path
4717 .trim_start_matches('/')
4718 .split('/')
4719 .filter(|segment| !segment.is_empty())
4720 .collect::<Vec<_>>();
4721 if segments.len() >= 3 && segments[1].eq_ignore_ascii_case("ingress") {
4722 Some(segments[2].to_string())
4723 } else {
4724 None
4725 }
4726}
4727
4728fn print_http_response(
4729 response: &crate::messaging_universal::dto::HttpOutV1,
4730) -> anyhow::Result<()> {
4731 println!(
4732 "{}",
4733 operator_i18n::trf(
4734 "cli.ingress.http_out_status",
4735 "HTTP OUT: status {}",
4736 &[&response.status.to_string()]
4737 )
4738 );
4739 for (name, value) in &response.headers {
4740 println!(
4741 "{}",
4742 operator_i18n::trf("cli.ingress.http_header", " {}: {}", &[name, value])
4743 );
4744 }
4745 if let Some(body_b64) = &response.body_b64 {
4746 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(body_b64) {
4747 if let Ok(text) = std::str::from_utf8(&bytes) {
4748 println!(
4749 "{}",
4750 operator_i18n::trf("cli.ingress.http_body", " body: {}", &[text])
4751 );
4752 } else {
4753 println!(
4754 "{}",
4755 operator_i18n::trf(
4756 "cli.ingress.http_body_base64",
4757 " body (base64): {}",
4758 &[body_b64]
4759 )
4760 );
4761 }
4762 } else {
4763 println!(
4764 "{}",
4765 operator_i18n::trf(
4766 "cli.ingress.http_body_base64",
4767 " body (base64): {}",
4768 &[body_b64]
4769 )
4770 );
4771 }
4772 }
4773 Ok(())
4774}
4775
4776fn print_envelopes(envelopes: &[greentic_types::ChannelMessageEnvelope]) -> anyhow::Result<()> {
4777 for envelope in envelopes {
4778 let formatted = serde_json::to_string_pretty(envelope)?;
4779 println!("{formatted}");
4780 }
4781 Ok(())
4782}
4783
4784impl DemoNewArgs {
4785 fn run(self) -> anyhow::Result<()> {
4786 let base = self
4787 .out
4788 .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
4789 let bundle_path = PathBuf::from(&self.bundle);
4790 let target = if bundle_path.is_absolute() {
4791 bundle_path
4792 } else {
4793 base.join(bundle_path)
4794 };
4795 if target.exists() {
4796 return Err(anyhow::anyhow!(
4797 "bundle path {} already exists",
4798 target.display()
4799 ));
4800 }
4801 create_demo_bundle_structure(&target)?;
4802 println!(
4803 "{}",
4804 operator_i18n::trf(
4805 "cli.demo_new.created_scaffold",
4806 "created demo bundle scaffold at {}",
4807 &[&target.display().to_string()]
4808 )
4809 );
4810 Ok(())
4811 }
4812}
4813
4814#[cfg(test)]
4815#[derive(Clone)]
4816struct DemoProviderInfo {
4817 pack: domains::ProviderPack,
4818}
4819
4820#[cfg(test)]
4821fn select_demo_providers(
4822 providers: &[DemoProviderInfo],
4823 provider_filter: Option<&str>,
4824) -> anyhow::Result<Vec<DemoProviderInfo>> {
4825 if let Some(filter) = provider_filter {
4826 let matches: Vec<_> = providers
4827 .iter()
4828 .filter(|info| provider_filter_matches(&info.pack, filter))
4829 .cloned()
4830 .collect();
4831 match matches.len() {
4832 0 => Err(anyhow::anyhow!(
4833 "No provider packs matched '{}'; try a more specific identifier.",
4834 filter
4835 )),
4836 1 => Ok(matches),
4837 _ => Err(anyhow::anyhow!(
4838 "Multiple provider packs matched '{}'; provide a more specific identifier.",
4839 filter
4840 )),
4841 }
4842 } else {
4843 Ok(providers.to_vec())
4844 }
4845}
4846
4847const DEMO_CONFIG_CONTENT: &str = "version: \"1\"\nproject_root: \"./\"\n";
4848const DEFAULT_DEMO_GMAP: &str = "_ = forbidden\n";
4849
4850fn create_demo_bundle_structure(root: &Path) -> anyhow::Result<()> {
4851 let directories = [
4852 "",
4853 "providers",
4854 "providers/messaging",
4855 "providers/events",
4856 "providers/secrets",
4857 "providers/oauth",
4858 "packs",
4859 "resolved",
4860 "state",
4861 "state/resolved",
4862 "state/runs",
4863 "state/pids",
4864 "state/logs",
4865 "state/runtime",
4866 "state/doctor",
4867 "tenants",
4868 "tenants/default",
4869 "tenants/default/teams",
4870 "tenants/demo",
4871 "tenants/demo/teams",
4872 "tenants/demo/teams/default",
4873 "logs",
4874 ];
4875 for directory in directories {
4876 ensure_dir(&root.join(directory))?;
4877 }
4878 write_if_missing(&root.join("greentic.demo.yaml"), DEMO_CONFIG_CONTENT)?;
4879 write_if_missing(
4880 &root.join("tenants").join("default").join("tenant.gmap"),
4881 DEFAULT_DEMO_GMAP,
4882 )?;
4883 write_if_missing(
4884 &root.join("tenants").join("demo").join("tenant.gmap"),
4885 DEFAULT_DEMO_GMAP,
4886 )?;
4887 write_if_missing(
4888 &root
4889 .join("tenants")
4890 .join("demo")
4891 .join("teams")
4892 .join("default")
4893 .join("team.gmap"),
4894 DEFAULT_DEMO_GMAP,
4895 )?;
4896 Ok(())
4897}
4898
4899fn ensure_dir(path: &Path) -> anyhow::Result<()> {
4900 fs::create_dir_all(path)?;
4901 Ok(())
4902}
4903
4904fn write_if_missing(path: &Path, contents: &str) -> anyhow::Result<()> {
4905 if path.exists() {
4906 return Ok(());
4907 }
4908 if let Some(parent) = path.parent() {
4909 ensure_dir(parent)?;
4910 }
4911 fs::write(path, contents)?;
4912 Ok(())
4913}
4914fn map_restart_target(target: &RestartTarget) -> StartRestartTarget {
4915 match target {
4916 RestartTarget::All => StartRestartTarget::All,
4917 RestartTarget::Cloudflared => StartRestartTarget::Cloudflared,
4918 RestartTarget::Ngrok => StartRestartTarget::Ngrok,
4919 RestartTarget::Nats => StartRestartTarget::Nats,
4920 RestartTarget::Gateway => StartRestartTarget::Gateway,
4921 RestartTarget::Egress => StartRestartTarget::Egress,
4922 RestartTarget::Subscriptions => StartRestartTarget::Subscriptions,
4923 }
4924}
4925
4926impl DemoStatusArgs {
4927 fn run(self) -> anyhow::Result<()> {
4928 let state_dir = resolve_state_dir(self.state_dir, self.bundle.as_ref());
4929 if demo_debug_enabled() {
4930 println!(
4931 "[demo] status state_dir={} tenant={} team={} verbose={}",
4932 state_dir.display(),
4933 self.tenant,
4934 self.team,
4935 self.verbose
4936 );
4937 }
4938 demo::demo_status_runtime(&state_dir, &self.tenant, &self.team, self.verbose)
4939 }
4940}
4941
4942impl DemoLogsArgs {
4943 fn run(self) -> anyhow::Result<()> {
4944 let log_dir = resolve_log_dir(self.log_dir.clone(), self.bundle.as_ref());
4945 let state_dir = resolve_state_dir(None, self.bundle.as_ref());
4946 if demo_debug_enabled() {
4947 println!(
4948 "[demo] logs log_dir={} tenant={} team={} service={} tail={}",
4949 log_dir.display(),
4950 self.tenant,
4951 self.team,
4952 self.service,
4953 self.tail
4954 );
4955 }
4956 demo::demo_logs_runtime(
4957 &state_dir,
4958 &log_dir,
4959 &self.tenant,
4960 &self.team,
4961 &self.service,
4962 self.tail,
4963 )
4964 }
4965}
4966
4967impl DemoDoctorArgs {
4968 fn run(self, _ctx: &AppCtx) -> anyhow::Result<()> {
4969 let config = config::load_operator_config(&self.bundle)?;
4970 let explicit = config::binary_override(config.as_ref(), "greentic-pack", &self.bundle);
4971 let pack_command = bin_resolver::resolve_binary(
4972 "greentic-pack",
4973 &ResolveCtx {
4974 config_dir: self.bundle.clone(),
4975 explicit_path: explicit,
4976 },
4977 )?;
4978 if demo_debug_enabled() {
4979 println!(
4980 "[demo] doctor bundle={} greentic-pack={}",
4981 self.bundle.display(),
4982 pack_command.display()
4983 );
4984 }
4985 demo::demo_doctor(&self.bundle, &pack_command)
4986 }
4987}
4988
4989fn project_root(arg: Option<PathBuf>) -> anyhow::Result<PathBuf> {
4990 Ok(arg.unwrap_or(env::current_dir()?))
4991}
4992
4993fn resolve_state_dir(state_dir: Option<PathBuf>, bundle: Option<&PathBuf>) -> PathBuf {
4994 if let Some(state_dir) = state_dir {
4995 return state_dir;
4996 }
4997 if let Some(bundle) = bundle {
4998 return bundle.join("state");
4999 }
5000 PathBuf::from("state")
5001}
5002
5003fn resolve_log_dir(log_dir: Option<PathBuf>, bundle: Option<&PathBuf>) -> PathBuf {
5004 if let Some(path) = log_dir {
5005 return path;
5006 }
5007 if let Some(bundle) = bundle {
5008 return bundle.join("logs");
5009 }
5010 PathBuf::from("logs")
5011}
5012
5013#[cfg(test)]
5014mod cli_tests {
5015 use super::*;
5016
5017 #[test]
5018 fn resolves_explicit_log_dir() {
5019 let dir = PathBuf::from("/tmp/logs");
5020 assert_eq!(resolve_log_dir(Some(dir.clone()), None), dir);
5021 }
5022
5023 #[test]
5024 fn resolves_bundle_log_dir() {
5025 let bundle = PathBuf::from("/tmp/bundle");
5026 assert_eq!(resolve_log_dir(None, Some(&bundle)), bundle.join("logs"));
5027 }
5028
5029 #[test]
5030 fn resolves_default_log_dir() {
5031 assert_eq!(resolve_log_dir(None, None), PathBuf::from("logs"));
5032 }
5033}
5034
5035fn demo_debug_enabled() -> bool {
5036 matches!(
5037 std::env::var("GREENTIC_OPERATOR_DEMO_DEBUG").as_deref(),
5038 Ok("1") | Ok("true") | Ok("yes")
5039 )
5040}
5041
5042fn demo_provider_files(
5043 root: &Path,
5044 tenant: &str,
5045 team: Option<&str>,
5046 domain: Domain,
5047) -> anyhow::Result<Option<std::collections::BTreeSet<String>>> {
5048 let resolved = demo_resolved_manifest_path(root, tenant, team);
5049 if !resolved.exists() {
5050 return Ok(None);
5051 }
5052 let contents = std::fs::read_to_string(resolved)?;
5053 let manifest: DemoResolvedManifest = serde_yaml_bw::from_str(&contents)?;
5054 let key = match domain {
5055 Domain::Messaging => "messaging",
5056 Domain::Events => "events",
5057 Domain::Secrets => "secrets",
5058 Domain::OAuth => "oauth",
5059 };
5060 let Some(list) = manifest.providers.get(key) else {
5061 return Ok(Some(std::collections::BTreeSet::new()));
5062 };
5063 let mut files = std::collections::BTreeSet::new();
5064 for path in list {
5065 if let Some(name) = Path::new(path).file_name().and_then(|value| value.to_str()) {
5066 files.insert(name.to_string());
5067 }
5068 }
5069 Ok(Some(files))
5070}
5071
5072fn demo_resolved_manifest_path(root: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
5073 root.join("resolved")
5074 .join(resolved_manifest_filename(tenant, team))
5075}
5076
5077fn demo_state_resolved_manifest_path(root: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
5078 root.join("state")
5079 .join("resolved")
5080 .join(resolved_manifest_filename(tenant, team))
5081}
5082
5083fn resolved_manifest_filename(tenant: &str, team: Option<&str>) -> String {
5084 match team {
5085 Some(team) => format!("{tenant}.{team}.yaml"),
5086 None => format!("{tenant}.yaml"),
5087 }
5088}
5089
5090fn demo_bundle_gmap_path(bundle: &Path, tenant: &str, team: Option<&str>) -> PathBuf {
5091 let mut path = bundle.join("tenants").join(tenant);
5092 if let Some(team) = team {
5093 path = path.join("teams").join(team).join("team.gmap");
5094 } else {
5095 path = path.join("tenant.gmap");
5096 }
5097 path
5098}
5099
5100fn copy_resolved_manifest(bundle: &Path, tenant: &str, team: Option<&str>) -> anyhow::Result<()> {
5101 let src = demo_state_resolved_manifest_path(bundle, tenant, team);
5102 if !src.exists() {
5103 return Err(anyhow::anyhow!(
5104 "resolved manifest not found at {}",
5105 src.display()
5106 ));
5107 }
5108 let dst = demo_resolved_manifest_path(bundle, tenant, team);
5109 if let Some(parent) = dst.parent() {
5110 std::fs::create_dir_all(parent)?;
5111 }
5112 std::fs::copy(src, dst)?;
5113 Ok(())
5114}
5115
5116pub(crate) fn discovery_map(
5117 providers: &[discovery::DetectedProvider],
5118) -> std::collections::BTreeMap<PathBuf, discovery::DetectedProvider> {
5119 let mut map = std::collections::BTreeMap::new();
5120 for provider in providers {
5121 map.insert(provider.pack_path.clone(), provider.clone());
5122 }
5123 map
5124}
5125
5126fn provider_filter_matches(pack: &domains::ProviderPack, filter: &str) -> bool {
5127 let file_stem = pack
5128 .file_name
5129 .strip_suffix(".gtpack")
5130 .unwrap_or(&pack.file_name);
5131 pack.pack_id == filter
5132 || pack.file_name == filter
5133 || file_stem == filter
5134 || pack.pack_id.contains(filter)
5135 || pack.file_name.contains(filter)
5136 || file_stem.contains(filter)
5137}
5138
5139pub fn demo_provider_packs(
5140 bundle: &Path,
5141 domain: Domain,
5142) -> anyhow::Result<Vec<domains::ProviderPack>> {
5143 let is_demo_bundle = bundle.join("greentic.demo.yaml").exists();
5144 if is_demo_bundle {
5145 domains::discover_provider_packs_cbor_only(bundle, domain)
5146 } else {
5147 domains::discover_provider_packs(bundle, domain)
5148 }
5149}
5150
5151pub fn demo_provider_pack_by_filter(
5152 bundle: &Path,
5153 domain: Domain,
5154 filter: &str,
5155) -> anyhow::Result<domains::ProviderPack> {
5156 let mut packs = demo_provider_packs(bundle, domain)?;
5157 packs.retain(|pack| provider_filter_matches(pack, filter));
5158 if packs.is_empty() {
5159 return Err(anyhow::anyhow!(
5160 "no provider pack matched {} in {}",
5161 filter,
5162 domains::domain_name(domain)
5163 ));
5164 }
5165 packs.sort_by(|a, b| a.path.cmp(&b.path));
5166 if packs.len() > 1 {
5167 let names = packs
5168 .iter()
5169 .map(|pack| pack.file_name.clone())
5170 .collect::<Vec<_>>();
5171 return Err(anyhow::anyhow!(
5172 "multiple provider packs matched {}; specify a more precise --pack: {}",
5173 filter,
5174 names.join(", ")
5175 ));
5176 }
5177 Ok(packs.remove(0))
5178}
5179
5180pub(crate) fn resolve_demo_provider_pack(
5181 root: &Path,
5182 tenant: &str,
5183 team: Option<&str>,
5184 provider: &str,
5185 domain: Domain,
5186) -> anyhow::Result<domains::ProviderPack> {
5187 let is_demo_bundle = root.join("greentic.demo.yaml").exists();
5188 let mut packs = if is_demo_bundle {
5189 domains::discover_provider_packs_cbor_only(root, domain)?
5190 } else {
5191 domains::discover_provider_packs(root, domain)?
5192 };
5193 if is_demo_bundle && let Some(allowed) = demo_provider_files(root, tenant, team, domain)? {
5194 packs.retain(|pack| allowed.contains(&pack.file_name));
5195 }
5196 packs.retain(|pack| provider_filter_matches(pack, provider));
5197 if packs.is_empty() {
5198 return Err(anyhow::anyhow!(
5199 "No provider packs matched. Try --provider <pack_id>."
5200 ));
5201 }
5202 packs.sort_by(|a, b| a.path.cmp(&b.path));
5203 if packs.len() > 1 {
5204 let names = packs
5205 .iter()
5206 .map(|pack| pack.file_name.clone())
5207 .collect::<Vec<_>>();
5208 return Err(anyhow::anyhow!(
5209 "Multiple provider packs matched: {}. Use a more specific --provider.",
5210 names.join(", ")
5211 ));
5212 }
5213 Ok(packs.remove(0))
5214}
5215
5216fn ensure_requirements_flow(pack: &domains::ProviderPack) -> Result<(), String> {
5217 if pack.entry_flows.iter().any(|flow| flow == "requirements") {
5218 return Ok(());
5219 }
5220 Err(
5221 "requirements flow not found in provider pack; ask the provider pack to include an entry flow named 'requirements'."
5222 .to_string(),
5223 )
5224}
5225
5226#[derive(serde::Deserialize)]
5227struct DemoResolvedManifest {
5228 #[serde(default)]
5229 providers: std::collections::BTreeMap<String, Vec<String>>,
5230}
5231
5232impl From<WizardModeArg> for wizard::WizardMode {
5233 fn from(value: WizardModeArg) -> Self {
5234 match value {
5235 WizardModeArg::Create => wizard::WizardMode::Create,
5236 WizardModeArg::Update => wizard::WizardMode::Update,
5237 WizardModeArg::Remove => wizard::WizardMode::Remove,
5238 }
5239 }
5240}
5241
5242struct DomainRunArgs {
5243 root: PathBuf,
5244 state_root: Option<PathBuf>,
5245 domain: Domain,
5246 action: DomainAction,
5247 tenant: String,
5248 team: Option<String>,
5249 provider_filter: Option<String>,
5250 dry_run: bool,
5251 format: PlanFormat,
5252 parallel: usize,
5253 allow_missing_setup: bool,
5254 allow_contract_change: bool,
5255 backup: bool,
5256 online: bool,
5257 secrets_env: Option<String>,
5258 runner_binary: Option<PathBuf>,
5259 best_effort: bool,
5260 discovered_providers: Option<Vec<discovery::DetectedProvider>>,
5261 setup_input: Option<PathBuf>,
5262 allowed_providers: Option<BTreeSet<String>>,
5263 preloaded_setup_answers: Option<SetupInputAnswers>,
5264 public_base_url: Option<String>,
5265 secrets_manager: Option<DynSecretsManager>,
5266}
5267
5268fn run_domain_command(args: DomainRunArgs) -> anyhow::Result<()> {
5269 let is_demo_bundle = args.root.join("greentic.demo.yaml").exists();
5270 let mut packs = if is_demo_bundle {
5271 domains::discover_provider_packs_cbor_only(&args.root, args.domain)?
5272 } else {
5273 domains::discover_provider_packs(&args.root, args.domain)?
5274 };
5275 let provider_map = args.discovered_providers.as_ref().map(|providers| {
5276 let mut map = std::collections::BTreeMap::new();
5277 for provider in providers {
5278 map.insert(provider.pack_path.clone(), provider.clone());
5279 }
5280 map
5281 });
5282 if let Some(provider_map) = provider_map.as_ref() {
5283 packs.retain(|pack| provider_map.contains_key(&pack.path));
5284 packs.sort_by(|a, b| a.path.cmp(&b.path));
5285 }
5286 if is_demo_bundle
5287 && let Some(allowed) =
5288 demo_provider_files(&args.root, &args.tenant, args.team.as_deref(), args.domain)?
5289 {
5290 packs.retain(|pack| allowed.contains(&pack.file_name));
5291 }
5292 if args.action == DomainAction::Setup {
5293 let setup_flow = domains::config(args.domain).setup_flow;
5294 let missing: Vec<String> = packs
5295 .iter()
5296 .filter(|pack| !pack.entry_flows.iter().any(|flow| flow == setup_flow))
5297 .map(|pack| pack.file_name.clone())
5298 .collect();
5299 if !missing.is_empty() && !args.allow_missing_setup {
5300 if args.best_effort {
5301 println!(
5302 "{}",
5303 operator_i18n::trf(
5304 "cli.domain.best_effort_skipped_missing_setup",
5305 "Best-effort: skipped {} pack(s) missing {}.",
5306 &[&missing.len().to_string(), setup_flow]
5307 )
5308 );
5309 packs.retain(|pack| pack.entry_flows.iter().any(|flow| flow == setup_flow));
5310 } else {
5311 return Err(anyhow::anyhow!(
5312 "missing {setup_flow} in packs: {}",
5313 missing.join(", ")
5314 ));
5315 }
5316 }
5317 }
5318 if packs.is_empty() {
5319 return Ok(());
5320 }
5321 if let Some(allowed) = args.allowed_providers.as_ref() {
5322 let missing = filter_packs_by_allowed(&mut packs, allowed);
5323 if !missing.is_empty() {
5324 println!(
5325 "{}",
5326 operator_i18n::trf(
5327 "cli.domain.warn_skip_missing_packs",
5328 "[warn] skip setup domain={} missing packs: {}",
5329 &[domains::domain_name(args.domain), &missing.join(", ")]
5330 )
5331 );
5332 operator_log::warn(
5333 module_path!(),
5334 format!(
5335 "provider filter domain={} removed packs: {}",
5336 domains::domain_name(args.domain),
5337 missing.join(", ")
5338 ),
5339 );
5340 }
5341 }
5342 operator_log::info(
5343 module_path!(),
5344 format!(
5345 "provider selection domain={} packs={}",
5346 domains::domain_name(args.domain),
5347 packs.len()
5348 ),
5349 );
5350 let setup_answers = if let Some(preloaded) = args.preloaded_setup_answers.clone() {
5351 Some(preloaded)
5352 } else if let Some(path) = args.setup_input.as_ref() {
5353 let provider_keys: BTreeSet<String> =
5354 packs.iter().map(|pack| pack.pack_id.clone()).collect();
5355 Some(SetupInputAnswers::new(
5356 load_setup_input(path)?,
5357 provider_keys,
5358 )?)
5359 } else {
5360 None
5361 };
5362 let interactive = args.setup_input.is_none();
5363 let plan = domains::plan_runs(
5364 args.domain,
5365 args.action,
5366 &packs,
5367 args.provider_filter.as_deref(),
5368 args.allow_missing_setup,
5369 )?;
5370
5371 operator_log::info(
5372 module_path!(),
5373 format!(
5374 "plan domain={} action={:?} items={}",
5375 domains::domain_name(args.domain),
5376 args.action,
5377 plan.len()
5378 ),
5379 );
5380 for item in &plan {
5381 operator_log::debug(
5382 module_path!(),
5383 format!(
5384 "plan item domain={} pack={} flow={}",
5385 domains::domain_name(args.domain),
5386 item.pack.file_name,
5387 item.flow_id
5388 ),
5389 );
5390 }
5391
5392 if plan.is_empty() {
5393 if is_demo_bundle {
5394 println!(
5395 "{}",
5396 operator_i18n::tr(
5397 "cli.domain.no_provider_packs_matched",
5398 "No provider packs matched. Try --provider <pack_id>."
5399 )
5400 );
5401 } else {
5402 println!(
5403 "{}",
5404 operator_i18n::tr(
5405 "cli.domain.no_provider_packs_matched_or_project_root",
5406 "No provider packs matched. Try --provider <pack_id> or --project-root."
5407 )
5408 );
5409 }
5410 operator_log::warn(
5411 module_path!(),
5412 format!(
5413 "no provider packs matched domain={} action={:?}",
5414 domains::domain_name(args.domain),
5415 args.action
5416 ),
5417 );
5418 return Ok(());
5419 }
5420
5421 if args.dry_run {
5422 render_plan(&plan, args.format)?;
5423 return Ok(());
5424 }
5425
5426 let runner_binary = resolve_demo_runner_binary(&args.root, args.runner_binary)?;
5427 let dist_offline = !args.online;
5428 let state_root = args.state_root.as_ref().unwrap_or(&args.root);
5429 run_plan(
5430 &args.root,
5431 state_root,
5432 args.domain,
5433 args.action,
5434 &args.tenant,
5435 args.team.as_deref(),
5436 plan,
5437 args.parallel,
5438 dist_offline,
5439 args.allow_contract_change,
5440 args.backup,
5441 args.secrets_env.as_deref(),
5442 runner_binary,
5443 args.best_effort,
5444 provider_map,
5445 setup_answers,
5446 interactive,
5447 args.public_base_url.clone(),
5448 args.secrets_manager.clone(),
5449 )
5450}
5451
5452fn filter_packs_by_allowed(
5453 packs: &mut Vec<domains::ProviderPack>,
5454 allowed: &BTreeSet<String>,
5455) -> Vec<String> {
5456 let mut seen = BTreeSet::new();
5457 packs.retain(|pack| {
5458 if allowed.contains(&pack.pack_id) {
5459 seen.insert(pack.pack_id.clone());
5460 true
5461 } else {
5462 false
5463 }
5464 });
5465 allowed
5466 .iter()
5467 .filter(|value| !seen.contains(*value))
5468 .cloned()
5469 .collect()
5470}
5471
5472#[allow(clippy::too_many_arguments)]
5473fn run_plan(
5474 root: &Path,
5475 state_root: &Path,
5476 domain: Domain,
5477 action: DomainAction,
5478 tenant: &str,
5479 team: Option<&str>,
5480 plan: Vec<domains::PlannedRun>,
5481 parallel: usize,
5482 dist_offline: bool,
5483 allow_contract_change: bool,
5484 backup: bool,
5485 secrets_env: Option<&str>,
5486 runner_binary: Option<PathBuf>,
5487 best_effort: bool,
5488 provider_map: Option<std::collections::BTreeMap<PathBuf, discovery::DetectedProvider>>,
5489 setup_answers: Option<SetupInputAnswers>,
5490 interactive: bool,
5491 public_base_url: Option<String>,
5492 secrets_manager: Option<DynSecretsManager>,
5493) -> anyhow::Result<()> {
5494 let setup_answers = setup_answers.map(Arc::new);
5495 let plan_public_base_url = public_base_url.map(Arc::new);
5496 let plan_secrets_manager = secrets_manager;
5497 if parallel <= 1 {
5498 let mut errors = Vec::new();
5499 for item in plan {
5500 let result = run_plan_item(
5501 root,
5502 state_root,
5503 domain,
5504 action,
5505 tenant,
5506 team,
5507 &item,
5508 dist_offline,
5509 allow_contract_change,
5510 backup,
5511 secrets_env,
5512 runner_binary.as_deref(),
5513 setup_answers.as_deref(),
5514 provider_map.as_ref(),
5515 interactive,
5516 plan_public_base_url.clone(),
5517 plan_secrets_manager.clone(),
5518 );
5519 if let Err(err) = result {
5520 if best_effort {
5521 errors.push(err);
5522 } else {
5523 return Err(err);
5524 }
5525 }
5526 }
5527 if best_effort && !errors.is_empty() {
5528 println!(
5529 "{}",
5530 operator_i18n::trf(
5531 "cli.domain.best_effort_flows_failed",
5532 "Best-effort: {} flow(s) failed.",
5533 &[&errors.len().to_string()]
5534 )
5535 );
5536 return Ok(());
5537 }
5538 return Ok(());
5539 }
5540
5541 let mut handles = Vec::new();
5542 let plan = std::sync::Arc::new(std::sync::Mutex::new(plan));
5543 let errors = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
5544
5545 for _ in 0..parallel {
5546 let plan = plan.clone();
5547 let errors = errors.clone();
5548 let root = root.to_path_buf();
5549 let state_root = state_root.to_path_buf();
5550 let tenant = tenant.to_string();
5551 let team = team.map(|value| value.to_string());
5552 let secrets_env = secrets_env.map(|value| value.to_string());
5553 let runner_binary = runner_binary.clone();
5554 let provider_map = provider_map.clone();
5555 let setup_answers = setup_answers.clone();
5556 let interactive_flag = interactive;
5557 let thread_public_base_url = plan_public_base_url.clone();
5558 let thread_secrets_manager = plan_secrets_manager.clone();
5559 handles.push(std::thread::spawn(move || {
5560 loop {
5561 let next = {
5562 let mut queue = plan.lock().unwrap();
5563 queue.pop()
5564 };
5565 let Some(item) = next else {
5566 break;
5567 };
5568 let result = run_plan_item(
5569 &root,
5570 &state_root,
5571 domain,
5572 action,
5573 &tenant,
5574 team.as_deref(),
5575 &item,
5576 dist_offline,
5577 allow_contract_change,
5578 backup,
5579 secrets_env.as_deref(),
5580 runner_binary.as_deref(),
5581 setup_answers.as_deref(),
5582 provider_map.as_ref(),
5583 interactive_flag,
5584 thread_public_base_url.clone(),
5585 thread_secrets_manager.clone(),
5586 );
5587 if let Err(err) = result {
5588 errors.lock().unwrap().push(err);
5589 }
5590 }
5591 }));
5592 }
5593
5594 for handle in handles {
5595 let _ = handle.join();
5596 }
5597
5598 let errors = errors.lock().unwrap();
5599 if !errors.is_empty() {
5600 if best_effort {
5601 println!(
5602 "{}",
5603 operator_i18n::trf(
5604 "cli.domain.best_effort_flows_failed",
5605 "Best-effort: {} flow(s) failed.",
5606 &[&errors.len().to_string()]
5607 )
5608 );
5609 return Ok(());
5610 }
5611 return Err(anyhow::anyhow!("{} flow(s) failed.", errors.len()));
5612 }
5613 Ok(())
5614}
5615
5616fn render_plan(plan: &[domains::PlannedRun], format: PlanFormat) -> anyhow::Result<()> {
5617 match format {
5618 PlanFormat::Text => {
5619 println!("{}", operator_i18n::tr("cli.domain.plan_header", "Plan:"));
5620 for item in plan {
5621 println!(
5622 "{}",
5623 operator_i18n::trf(
5624 "cli.domain.plan_item",
5625 " {} -> {}",
5626 &[&item.pack.file_name, &item.flow_id]
5627 )
5628 );
5629 }
5630 Ok(())
5631 }
5632 PlanFormat::Json => {
5633 let json = serde_json::to_string_pretty(plan)?;
5634 println!("{json}");
5635 Ok(())
5636 }
5637 PlanFormat::Yaml => {
5638 let yaml = serde_yaml_bw::to_string(plan)?;
5639 print!("{yaml}");
5640 Ok(())
5641 }
5642 }
5643}
5644
5645#[allow(clippy::too_many_arguments)]
5646fn run_plan_item(
5647 root: &Path,
5648 state_root: &Path,
5649 domain: Domain,
5650 action: DomainAction,
5651 tenant: &str,
5652 team: Option<&str>,
5653 item: &domains::PlannedRun,
5654 dist_offline: bool,
5655 allow_contract_change: bool,
5656 backup: bool,
5657 secrets_env: Option<&str>,
5658 runner_binary: Option<&Path>,
5659 setup_answers: Option<&SetupInputAnswers>,
5660 provider_map: Option<&std::collections::BTreeMap<PathBuf, discovery::DetectedProvider>>,
5661 interactive: bool,
5662 public_base_url: Option<Arc<String>>,
5663 secrets_manager: Option<DynSecretsManager>,
5664) -> anyhow::Result<()> {
5665 let provider_id = provider_id_for_pack(&item.pack.path, &item.pack.pack_id, provider_map);
5666 let env_value = resolve_env(secrets_env);
5667
5668 if domain == Domain::Messaging
5669 && action == DomainAction::Setup
5670 && let Some(manager) = secrets_manager.as_ref()
5671 {
5672 match secrets_gate::check_provider_secrets(
5673 manager,
5674 &env_value,
5675 tenant,
5676 team,
5677 &item.pack.path,
5678 &provider_id,
5679 None,
5680 None,
5681 false,
5682 ) {
5683 Ok(Some(missing)) => {
5684 let formatted = missing
5685 .iter()
5686 .map(|entry| format!(" - {entry}"))
5687 .collect::<Vec<_>>()
5688 .join("\n");
5689 println!(
5690 "{}",
5691 operator_i18n::trf(
5692 "cli.plan.warn_skip_missing_secrets",
5693 "[warn] skip setup domain={} tenant={} provider={}: missing secrets:\n{}",
5694 &[
5695 domains::domain_name(domain),
5696 tenant,
5697 &provider_id,
5698 &formatted
5699 ]
5700 )
5701 );
5702 return Ok(());
5703 }
5704 Ok(None) => {}
5705 Err(err) => {
5706 println!(
5707 "{}",
5708 operator_i18n::trf(
5709 "cli.plan.warn_skip_secrets_check_failed",
5710 "[warn] skip setup domain={} tenant={} provider={}: secrets check failed: {}",
5711 &[
5712 domains::domain_name(domain),
5713 tenant,
5714 &provider_id,
5715 &err.to_string()
5716 ]
5717 )
5718 );
5719 return Ok(());
5720 }
5721 }
5722 }
5723
5724 let (setup_values, qa_form_spec) = if action == DomainAction::Setup {
5725 let (answers, form_spec) = qa_setup_wizard::run_qa_setup(
5726 &item.pack.path,
5727 &item.pack.pack_id,
5728 setup_answers,
5729 interactive,
5730 None, )?;
5732 (Some(answers), form_spec)
5733 } else {
5734 (None, None)
5735 };
5736 let providers_root = state_root
5737 .join("state")
5738 .join("runtime")
5739 .join(tenant)
5740 .join("providers");
5741 if let Err(err) = crate::provider_config_envelope::ensure_contract_compatible(
5742 &providers_root,
5743 &provider_id,
5744 &item.flow_id,
5745 &item.pack.path,
5746 allow_contract_change,
5747 ) {
5748 operator_log::error(module_path!(), err.to_string());
5749 return Err(err);
5750 }
5751 let current_config = crate::provider_config_envelope::read_provider_config_envelope(
5752 &providers_root,
5753 &provider_id,
5754 )?
5755 .map(|envelope| envelope.config);
5756 let qa_mode = if action == DomainAction::Setup {
5757 Some(crate::component_qa_ops::QaMode::Setup)
5758 } else {
5759 crate::component_qa_ops::qa_mode_for_flow(&item.flow_id)
5760 };
5761 let qa_answers = if action == DomainAction::Setup {
5762 setup_values.clone().unwrap_or_else(|| json!({}))
5763 } else {
5764 json!({})
5765 };
5766 let qa_config_override = if let Some(mode) = qa_mode {
5767 if let Err(err) = crate::component_qa_ops::persist_answers_artifacts(
5768 &providers_root,
5769 &provider_id,
5770 mode,
5771 &qa_answers,
5772 ) {
5773 operator_log::warn(
5774 module_path!(),
5775 format!(
5776 "failed to persist qa answers provider={} mode={} flow={}: {err}",
5777 provider_id,
5778 mode.as_str(),
5779 item.flow_id
5780 ),
5781 );
5782 }
5783 match crate::component_qa_ops::apply_answers_via_component_qa(
5784 root,
5785 domain,
5786 tenant,
5787 team,
5788 &item.pack,
5789 &provider_id,
5790 mode,
5791 current_config.as_ref(),
5792 &qa_answers,
5793 ) {
5794 Ok(value) => value,
5795 Err(diag) => {
5796 operator_log::error(
5797 module_path!(),
5798 format!(
5799 "component qa failed provider={} flow={} code={} message={}",
5800 provider_id,
5801 item.flow_id,
5802 diag.code.as_str(),
5803 diag.message
5804 ),
5805 );
5806 return Err(anyhow::anyhow!("{diag}"));
5807 }
5808 }
5809 } else {
5810 None
5811 };
5812
5813 if action == DomainAction::Setup {
5815 if let Some(ref config) = qa_config_override
5817 && let Some(ref form_spec) = qa_form_spec
5818 {
5819 if let Err(err) = crate::qa_persist::persist_qa_config(
5820 &providers_root,
5821 &provider_id,
5822 config,
5823 &item.pack.path,
5824 form_spec,
5825 backup,
5826 ) {
5827 operator_log::warn(
5828 module_path!(),
5829 format!(
5830 "failed to persist qa config provider={}: {err}",
5831 provider_id
5832 ),
5833 );
5834 }
5835 }
5836
5837 let env_value = resolve_env(None);
5842
5843 let mut merged = serde_json::Map::new();
5845 if let Some(ref answers) = setup_values {
5846 if let Some(map) = answers.as_object() {
5847 for (k, v) in map {
5848 merged.insert(k.clone(), v.clone());
5849 }
5850 }
5851 }
5852 if let Some(ref config) = qa_config_override {
5853 if let Some(map) = config.as_object() {
5854 for (k, v) in map {
5855 merged.insert(k.clone(), v.clone());
5856 }
5857 }
5858 }
5859 let merged_value = serde_json::Value::Object(merged);
5860
5861 let pack_path_ref = Some(item.pack.path.as_path());
5862 let persist_result = tokio::runtime::Handle::try_current()
5863 .map(|h| {
5864 h.block_on(crate::qa_persist::persist_all_config_as_secrets(
5865 root,
5866 &env_value,
5867 tenant,
5868 team,
5869 &provider_id,
5870 &merged_value,
5871 pack_path_ref,
5872 ))
5873 })
5874 .unwrap_or_else(|_| {
5875 let rt = tokio::runtime::Runtime::new().expect("persist secrets runtime");
5876 rt.block_on(crate::qa_persist::persist_all_config_as_secrets(
5877 root,
5878 &env_value,
5879 tenant,
5880 team,
5881 &provider_id,
5882 &merged_value,
5883 pack_path_ref,
5884 ))
5885 });
5886 match persist_result {
5887 Ok(saved) if !saved.is_empty() => {
5888 eprintln!(
5889 "persisted {} secret(s) for provider={}: {:?}",
5890 saved.len(),
5891 provider_id,
5892 saved
5893 );
5894 }
5895 Err(err) => {
5896 operator_log::warn(
5897 module_path!(),
5898 format!("failed to persist secrets provider={}: {err}", provider_id),
5899 );
5900 }
5901 _ => {}
5902 }
5903 }
5904
5905 let public_base_url_ref = public_base_url.as_deref().map(|value| value.as_str());
5906 let mut input = build_input_payload(
5907 state_root,
5908 domain,
5909 tenant,
5910 team,
5911 Some(&item.pack.pack_id),
5912 setup_values.as_ref(),
5913 public_base_url_ref,
5914 &env_value,
5915 );
5916 if let Some(config) = qa_config_override.as_ref() {
5917 input["config"] = config.clone();
5918 }
5919 if demo_debug_enabled() {
5920 println!(
5921 "[demo] setup input pack={} flow={} input={}",
5922 item.pack.file_name,
5923 item.flow_id,
5924 serde_json::to_string(&input).unwrap_or_else(|_| "<invalid-json>".to_string())
5925 );
5926 }
5927 if action == DomainAction::Setup
5928 && let Some(config_value) = qa_config_override.as_ref()
5929 {
5930 let setup_path = providers_root.join(format!("{provider_id}.setup.json"));
5931 crate::providers::write_qa_setup_success_record(
5932 &setup_path,
5933 &provider_id,
5934 &item.flow_id,
5935 Some(config_value),
5936 )?;
5937 if let Err(err) = crate::provider_config_envelope::write_provider_config_envelope(
5938 &providers_root,
5939 &provider_id,
5940 &item.flow_id,
5941 config_value,
5942 &item.pack.path,
5943 backup,
5944 ) {
5945 operator_log::warn(
5946 module_path!(),
5947 format!(
5948 "failed to write provider config envelope provider={} flow={}: {err}",
5949 provider_id, item.flow_id
5950 ),
5951 );
5952 }
5953 println!(
5954 "{} {} -> Success (component-qa)",
5955 item.pack.file_name, item.flow_id
5956 );
5957 return Ok(());
5958 }
5959 if let Some(runner_binary) = runner_binary {
5960 let run_dir = state_layout::run_dir(state_root, domain, &item.pack.pack_id, &item.flow_id)?;
5961 std::fs::create_dir_all(&run_dir)?;
5962 let input_path = run_dir.join("input.json");
5963 let input_json = serde_json::to_string_pretty(&input)?;
5964 std::fs::write(&input_path, input_json)?;
5965
5966 let runner_flavor = runner_integration::detect_runner_flavor(runner_binary);
5967 let output = runner_integration::run_flow_with_options(
5968 runner_binary,
5969 &item.pack.path,
5970 &item.flow_id,
5971 &input,
5972 runner_integration::RunFlowOptions {
5973 dist_offline,
5974 tenant: Some(tenant),
5975 team,
5976 artifacts_dir: Some(&run_dir),
5977 runner_flavor,
5978 },
5979 )?;
5980 write_runner_cli_artifacts(&run_dir, &output)?;
5981 if action == DomainAction::Setup {
5982 let setup_path = providers_root.join(format!("{provider_id}.setup.json"));
5983 crate::providers::write_run_output(&setup_path, &provider_id, &item.flow_id, &output)?;
5984 if let Some(config_value) = qa_config_override
5985 .clone()
5986 .or_else(|| extract_config_for_envelope(output.parsed.as_ref()))
5987 && let Err(err) = crate::provider_config_envelope::write_provider_config_envelope(
5988 &providers_root,
5989 &provider_id,
5990 &item.flow_id,
5991 &config_value,
5992 &item.pack.path,
5993 backup,
5994 )
5995 {
5996 operator_log::warn(
5997 module_path!(),
5998 format!(
5999 "failed to write provider config envelope provider={} flow={}: {err}",
6000 provider_id, item.flow_id
6001 ),
6002 );
6003 }
6004 }
6005 let exit = format_runner_exit(&output);
6006 if output.status.success() {
6007 println!(
6008 "{}",
6009 operator_i18n::trf(
6010 "cli.plan.item_result_ok",
6011 "{} {} -> {}",
6012 &[&item.pack.file_name, &item.flow_id, &exit]
6013 )
6014 );
6015 } else if let Some(summary) = summarize_runner_error(&output) {
6016 println!(
6017 "{}",
6018 operator_i18n::trf(
6019 "cli.plan.item_result_error_with_summary",
6020 "{} {} -> {} ({})",
6021 &[&item.pack.file_name, &item.flow_id, &exit, &summary]
6022 )
6023 );
6024 } else {
6025 println!(
6026 "{}",
6027 operator_i18n::trf(
6028 "cli.plan.item_result_error",
6029 "{} {} -> {}",
6030 &[&item.pack.file_name, &item.flow_id, &exit]
6031 )
6032 );
6033 }
6034 } else {
6035 let output = runner_exec::run_provider_pack_flow(runner_exec::RunRequest {
6036 root: state_root.to_path_buf(),
6037 domain,
6038 pack_path: item.pack.path.clone(),
6039 pack_label: item.pack.pack_id.clone(),
6040 flow_id: item.flow_id.clone(),
6041 tenant: tenant.to_string(),
6042 team: team.map(|value| value.to_string()),
6043 input,
6044 dist_offline,
6045 })
6046 .map_err(|err| {
6047 let message = err.to_string();
6048 if message.contains("manifest.cbor is invalid") {
6049 if let Ok(Some(detail)) = domains::manifest_cbor_issue_detail(&item.pack.path) {
6050 return anyhow::anyhow!(
6051 "pack verification failed for {}: {}",
6052 item.pack.path.display(),
6053 detail
6054 );
6055 }
6056 return anyhow::anyhow!(
6057 "pack verification failed for {}: {message}",
6058 item.pack.path.display()
6059 );
6060 }
6061 err
6062 })?;
6063 if action == DomainAction::Setup {
6064 let setup_path = providers_root.join(format!("{provider_id}.setup.json"));
6065 crate::providers::write_run_result(
6066 &setup_path,
6067 &provider_id,
6068 &item.flow_id,
6069 &output.result,
6070 )?;
6071 if let Some(config_value) = qa_config_override.clone().or_else(|| {
6072 extract_config_for_envelope(serde_json::to_value(&output.result).ok().as_ref())
6073 }) && let Err(err) = crate::provider_config_envelope::write_provider_config_envelope(
6074 &providers_root,
6075 &provider_id,
6076 &item.flow_id,
6077 &config_value,
6078 &item.pack.path,
6079 backup,
6080 ) {
6081 operator_log::warn(
6082 module_path!(),
6083 format!(
6084 "failed to write provider config envelope provider={} flow={}: {err}",
6085 provider_id, item.flow_id
6086 ),
6087 );
6088 }
6089 }
6090 println!(
6091 "{} {} -> {:?}",
6092 item.pack.file_name, item.flow_id, output.result.status
6093 );
6094 }
6095
6096 Ok(())
6097}
6098
6099fn resolve_demo_runner_binary(
6100 config_dir: &Path,
6101 runner_binary: Option<PathBuf>,
6102) -> anyhow::Result<Option<PathBuf>> {
6103 let Some(runner_binary) = runner_binary else {
6104 return Ok(None);
6105 };
6106 let runner_str = runner_binary.to_string_lossy();
6107 let (name, explicit) = if looks_like_path_str(&runner_str) {
6108 let name = runner_binary
6109 .file_name()
6110 .and_then(|value| value.to_str())
6111 .unwrap_or("greentic-runner")
6112 .to_string();
6113 (name, Some(runner_binary))
6114 } else {
6115 (runner_str.to_string(), None)
6116 };
6117 let resolved = bin_resolver::resolve_binary(
6118 &name,
6119 &ResolveCtx {
6120 config_dir: config_dir.to_path_buf(),
6121 explicit_path: explicit,
6122 },
6123 )?;
6124 Ok(Some(resolved))
6125}
6126
6127fn write_runner_cli_artifacts(
6128 run_dir: &Path,
6129 output: &runner_integration::RunnerOutput,
6130) -> anyhow::Result<()> {
6131 let run_json = run_dir.join("run.json");
6132 let summary_path = run_dir.join("summary.txt");
6133 let stdout_path = run_dir.join("stdout.txt");
6134 let stderr_path = run_dir.join("stderr.txt");
6135
6136 let json = serde_json::json!({
6137 "status": {
6138 "success": output.status.success(),
6139 "code": output.status.code(),
6140 },
6141 "stdout": output.stdout,
6142 "stderr": output.stderr,
6143 "parsed": output.parsed,
6144 });
6145 let json = serde_json::to_string_pretty(&json)?;
6146 std::fs::write(run_json, json)?;
6147 std::fs::write(stdout_path, &output.stdout)?;
6148 std::fs::write(stderr_path, &output.stderr)?;
6149
6150 let summary = format!(
6151 "success: {}\nexit_code: {}\n",
6152 output.status.success(),
6153 output
6154 .status
6155 .code()
6156 .map(|code| code.to_string())
6157 .unwrap_or_else(|| "signal".to_string())
6158 );
6159 std::fs::write(summary_path, summary)?;
6160 Ok(())
6161}
6162
6163fn format_runner_exit(output: &runner_integration::RunnerOutput) -> String {
6164 if let Some(code) = output.status.code() {
6165 return format!("exit={code}");
6166 }
6167 if output.status.success() {
6168 return "exit=0".to_string();
6169 }
6170 "exit=signal".to_string()
6171}
6172
6173fn summarize_runner_error(output: &runner_integration::RunnerOutput) -> Option<String> {
6174 output
6175 .stderr
6176 .lines()
6177 .map(|line| line.trim())
6178 .find(|line| !line.is_empty())
6179 .map(|line| line.to_string())
6180}
6181
6182fn extract_config_for_envelope(parsed: Option<&JsonValue>) -> Option<JsonValue> {
6183 let value = parsed?;
6184 if let Some(config) = value.get("config") {
6185 return Some(config.clone());
6186 }
6187 Some(value.clone())
6188}
6189
6190pub(crate) fn provider_id_for_pack(
6191 pack_path: &Path,
6192 fallback: &str,
6193 provider_map: Option<&std::collections::BTreeMap<PathBuf, discovery::DetectedProvider>>,
6194) -> String {
6195 provider_map
6196 .and_then(|map| map.get(pack_path))
6197 .map(|provider| provider.provider_id.clone())
6198 .unwrap_or_else(|| fallback.to_string())
6199}
6200
6201fn looks_like_path_str(value: &str) -> bool {
6202 value.contains('/') || value.contains('\\') || Path::new(value).is_absolute()
6203}
6204
6205#[allow(clippy::too_many_arguments)]
6206fn build_input_payload(
6207 root: &Path,
6208 domain: Domain,
6209 tenant: &str,
6210 team: Option<&str>,
6211 pack_id: Option<&str>,
6212 setup_answers: Option<&serde_json::Value>,
6213 public_base_url: Option<&str>,
6214 env: &str,
6215) -> serde_json::Value {
6216 let mut payload = serde_json::json!({
6217 "tenant": tenant,
6218 });
6219 if let Some(team) = team {
6220 payload["team"] = serde_json::Value::String(team.to_string());
6221 }
6222
6223 let resolved_public_base_url = public_base_url.map(|value| value.to_string()).or_else(|| {
6224 if matches!(domain, Domain::Messaging | Domain::Events | Domain::OAuth) {
6225 read_public_base_url(root, tenant, team)
6226 } else {
6227 None
6228 }
6229 });
6230
6231 if matches!(domain, Domain::Messaging | Domain::Events | Domain::OAuth) {
6232 let mut config = serde_json::json!({});
6233 if let Some(url) = resolved_public_base_url.as_ref() {
6234 payload["public_base_url"] = serde_json::Value::String(url.clone());
6235 config["public_base_url"] = serde_json::Value::String(url.clone());
6236 }
6237 payload["config"] = config;
6238 }
6239
6240 if let Some(pack_id) = pack_id
6241 && let Some(config_map) = payload
6242 .get_mut("config")
6243 .and_then(|value| value.as_object_mut())
6244 {
6245 config_map.insert(
6246 "id".to_string(),
6247 serde_json::Value::String(pack_id.to_string()),
6248 );
6249 }
6250 if let Some(pack_id) = pack_id {
6251 payload["id"] = serde_json::Value::String(pack_id.to_string());
6252 }
6253 if let Some(answers) = setup_answers {
6254 payload["setup_answers"] = answers.clone();
6255 if let Ok(json) = serde_json::to_string(answers) {
6256 payload["answers_json"] = serde_json::Value::String(json);
6257 }
6258 }
6259 let mut tenant_ctx = serde_json::json!({
6260 "env": env,
6261 "tenant": tenant,
6262 "tenant_id": tenant,
6263 });
6264 if let Some(team) = team {
6265 tenant_ctx["team"] = serde_json::Value::String(team.to_string());
6266 tenant_ctx["team_id"] = serde_json::Value::String(team.to_string());
6267 }
6268 let msg_id = pack_id
6269 .map(|value| format!("{value}.setup"))
6270 .unwrap_or_else(|| "setup".to_string());
6271 let mut metadata = serde_json::json!({});
6272 if let Some(url) = resolved_public_base_url {
6273 metadata["public_base_url"] = serde_json::Value::String(url);
6274 }
6275 let msg = serde_json::json!({
6276 "id": msg_id,
6277 "tenant": tenant_ctx,
6278 "channel": "setup",
6279 "message": {
6280 "id": pack_id
6281 .map(|value| format!("{value}.setup_default__collect"))
6282 .unwrap_or_else(|| "setup_default__collect".to_string()),
6283 "text": "Collect inputs for setup_default."
6284 },
6285 "session_id": "setup",
6286 "metadata": metadata,
6287 "reply_scope": "",
6288 "text": "Collect inputs for setup_default.",
6289 "user_id": "operator",
6290 });
6291 payload["msg"] = msg;
6292 let payload_id = pack_id
6293 .map(|value| format!("{value}-setup_default"))
6294 .unwrap_or_else(|| "setup_default".to_string());
6295 payload["payload"] = serde_json::json!({
6296 "id": payload_id,
6297 "spec_ref": "assets/setup.yaml"
6298 });
6299 payload
6300}
6301
6302fn read_public_base_url(root: &Path, tenant: &str, team: Option<&str>) -> Option<String> {
6303 let team_id = team.unwrap_or("default");
6304 let paths = crate::runtime_state::RuntimePaths::new(root.join("state"), tenant, team_id);
6305 let path = crate::cloudflared::public_url_path(&paths);
6306 let contents = std::fs::read_to_string(path).ok()?;
6307 crate::cloudflared::parse_public_url(&contents)
6308 .or_else(|| crate::ngrok::parse_public_url(&contents))
6309}
6310
6311fn parse_kv(input: &str) -> anyhow::Result<(String, JsonValue)> {
6312 let mut parts = input.splitn(2, '=');
6313 let key = parts
6314 .next()
6315 .map(str::trim)
6316 .filter(|value| !value.is_empty())
6317 .ok_or_else(|| anyhow::anyhow!("expected key=value"))?;
6318 let value = parts
6319 .next()
6320 .ok_or_else(|| anyhow::anyhow!("expected key=value"))?
6321 .trim();
6322 if value.eq_ignore_ascii_case("true") {
6323 return Ok((key.to_string(), JsonValue::Bool(true)));
6324 }
6325 if value.eq_ignore_ascii_case("false") {
6326 return Ok((key.to_string(), JsonValue::Bool(false)));
6327 }
6328 if let Ok(int_value) = value.parse::<i64>() {
6329 return Ok((key.to_string(), JsonValue::Number(int_value.into())));
6330 }
6331 Ok((key.to_string(), JsonValue::String(value.to_string())))
6332}
6333
6334fn merge_args(
6335 args_json: Option<&str>,
6336 args: &[String],
6337) -> anyhow::Result<JsonMap<String, JsonValue>> {
6338 let mut merged = JsonMap::new();
6339 if let Some(raw) = args_json {
6340 let parsed: JsonValue = serde_json::from_str(raw)?;
6341 let JsonValue::Object(map) = parsed else {
6342 return Err(anyhow::anyhow!("--args-json must be a JSON object"));
6343 };
6344 merged.extend(map);
6345 }
6346 for item in args {
6347 let (key, value) = parse_kv(item)?;
6348 merged.insert(key, value);
6349 }
6350 Ok(merged)
6351}
6352
6353struct DemoSendMessageArgs<'a> {
6354 text: Option<&'a str>,
6355 args: &'a JsonMap<String, JsonValue>,
6356 tenant: &'a str,
6357 team: Option<&'a str>,
6358 destinations: &'a [String],
6359 to_kind: Option<&'a str>,
6360 provider_id: &'a str,
6361 channel: &'a str,
6362 card: Option<&'a JsonValue>,
6363}
6364
6365fn build_demo_send_message(args: DemoSendMessageArgs<'_>) -> JsonValue {
6366 let mut metadata = BTreeMap::new();
6367 if let Some(card_value) = args.card
6368 && let Ok(card_str) = serde_json::to_string(card_value)
6369 {
6370 metadata.insert("adaptive_card".to_string(), card_str);
6371 }
6372 for (key, value) in args.args {
6373 metadata.insert(key.clone(), value.to_string());
6374 }
6375 let env_value = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
6376 let env = EnvId::try_from(env_value.clone())
6377 .unwrap_or_else(|_| EnvId::try_from("local").expect("local env invalid"));
6378 let tenant_id = TenantId::try_from(args.tenant.to_string())
6379 .unwrap_or_else(|_| TenantId::try_from("demo").expect("demo tenant invalid"));
6380 let mut tenant_ctx = TenantCtx::new(env, tenant_id.clone());
6381 if let Some(team_value) = args.team
6382 && let Ok(team_id) = TeamId::try_from(team_value.to_string())
6383 {
6384 tenant_ctx = tenant_ctx.with_team(Some(team_id));
6385 }
6386 tenant_ctx = tenant_ctx
6387 .with_session(Uuid::new_v4().to_string())
6388 .with_flow(Uuid::new_v4().to_string())
6389 .with_node("demo".to_string())
6390 .with_provider(args.provider_id.to_string())
6391 .with_attempt(1);
6392
6393 let to_kind_owned = args.to_kind.map(|value| value.to_string());
6394 let to = args
6395 .destinations
6396 .iter()
6397 .map(|value| Destination {
6398 id: value.clone(),
6399 kind: to_kind_owned.clone(),
6400 })
6401 .collect::<Vec<_>>();
6402 let envelope = ChannelMessageEnvelope {
6403 id: Uuid::new_v4().to_string(),
6404 tenant: tenant_ctx,
6405 channel: args.channel.to_string(),
6406 session_id: Uuid::new_v4().to_string(),
6407 reply_scope: None,
6408 from: None,
6409 to,
6410 correlation_id: None,
6411 text: args.text.map(|value| value.to_string()),
6412 attachments: Vec::new(),
6413 metadata,
6414 };
6415 serde_json::to_value(envelope).unwrap_or(JsonValue::Null)
6416}
6417
6418fn debug_print_envelope(op_label: &str, envelope: &JsonValue) {
6419 if !demo_debug_enabled() {
6420 return;
6421 }
6422 match serde_json::to_string_pretty(envelope) {
6423 Ok(body) => println!(
6424 "{}",
6425 operator_i18n::trf(
6426 "cli.demo.debug.before_envelope",
6427 "[demo] before {} envelope:\n{}",
6428 &[op_label, &body]
6429 )
6430 ),
6431 Err(err) => println!(
6432 "{}",
6433 operator_i18n::trf(
6434 "cli.demo.debug.before_envelope_serialize_failed",
6435 "[demo] before {} envelope: failed to serialize envelope: {}",
6436 &[op_label, &err.to_string()]
6437 )
6438 ),
6439 }
6440}
6441
6442fn debug_print_render_plan_output(output: &RenderPlanOutV1) {
6443 if !demo_debug_enabled() {
6444 return;
6445 }
6446 match serde_json::to_string_pretty(&output) {
6447 Ok(body) => println!(
6448 "{}",
6449 operator_i18n::trf(
6450 "cli.demo.debug.after_render_plan",
6451 "[demo] after render_plan output:\n{}",
6452 &[&body]
6453 )
6454 ),
6455 Err(err) => println!(
6456 "{}",
6457 operator_i18n::trf(
6458 "cli.demo.debug.after_render_plan_serialize_failed",
6459 "[demo] after render_plan output: failed to serialize output: {}",
6460 &[&err.to_string()]
6461 )
6462 ),
6463 }
6464}
6465
6466fn debug_print_encode_input(input: &EncodeInV1) {
6467 if !demo_debug_enabled() {
6468 return;
6469 }
6470 match serde_json::to_string_pretty(&input) {
6471 Ok(body) => println!(
6472 "{}",
6473 operator_i18n::trf(
6474 "cli.demo.debug.encode_input",
6475 "[demo] encode input:\n{}",
6476 &[&body]
6477 )
6478 ),
6479 Err(err) => println!(
6480 "{}",
6481 operator_i18n::trf(
6482 "cli.demo.debug.encode_input_serialize_failed",
6483 "[demo] encode input: failed to serialize input: {}",
6484 &[&err.to_string()]
6485 )
6486 ),
6487 }
6488}
6489
6490fn debug_print_encode_output(output: &EncodeOutV1) {
6491 if !demo_debug_enabled() {
6492 return;
6493 }
6494 match serde_json::to_string_pretty(&output) {
6495 Ok(body) => println!(
6496 "{}",
6497 operator_i18n::trf(
6498 "cli.demo.debug.after_encode",
6499 "[demo] after encode output:\n{}",
6500 &[&body]
6501 )
6502 ),
6503 Err(err) => println!(
6504 "{}",
6505 operator_i18n::trf(
6506 "cli.demo.debug.after_encode_serialize_failed",
6507 "[demo] after encode output: failed to serialize output: {}",
6508 &[&err.to_string()]
6509 )
6510 ),
6511 }
6512}
6513
6514fn debug_print_send_payload_output(output: &SendPayloadOutV1) {
6515 if !demo_debug_enabled() {
6516 return;
6517 }
6518 match serde_json::to_string_pretty(&output) {
6519 Ok(body) => println!(
6520 "{}",
6521 operator_i18n::trf(
6522 "cli.demo.debug.after_send_payload",
6523 "[demo] after send_payload output:\n{}",
6524 &[&body]
6525 )
6526 ),
6527 Err(err) => println!(
6528 "{}",
6529 operator_i18n::trf(
6530 "cli.demo.debug.after_send_payload_serialize_failed",
6531 "[demo] after send_payload output: failed to serialize output: {}",
6532 &[&err.to_string()]
6533 )
6534 ),
6535 }
6536}
6537
6538fn provider_channel(provider: &str) -> String {
6539 if let Some((domain, suffix)) = provider.split_once('-') {
6540 format!("{domain}.{suffix}")
6541 } else {
6542 provider.replace('-', ".")
6543 }
6544}
6545
6546fn config_value_display(value: &JsonValue) -> String {
6547 match value {
6548 JsonValue::String(text) => text.clone(),
6549 JsonValue::Number(number) => number.to_string(),
6550 JsonValue::Bool(flag) => flag.to_string(),
6551 JsonValue::Null => "<null>".to_string(),
6552 other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
6553 }
6554}
6555
6556fn format_requirements_output(value: &JsonValue) -> Option<String> {
6557 let JsonValue::Object(map) = value else {
6558 return None;
6559 };
6560 let has_keys = map.contains_key("required_args")
6561 || map.contains_key("optional_args")
6562 || map.contains_key("examples")
6563 || map.contains_key("notes");
6564 if !has_keys {
6565 return None;
6566 }
6567 let mut output = String::new();
6568 if let Some(required) = map.get("required_args").and_then(JsonValue::as_array) {
6569 output.push_str("Required args:\n");
6570 for item in required {
6571 output.push_str(" - ");
6572 output.push_str(&format_requirements_item(item));
6573 output.push('\n');
6574 }
6575 }
6576 if let Some(optional) = map.get("optional_args").and_then(JsonValue::as_array) {
6577 output.push_str("Optional args:\n");
6578 for item in optional {
6579 output.push_str(" - ");
6580 output.push_str(&format_requirements_item(item));
6581 output.push('\n');
6582 }
6583 }
6584 if let Some(examples) = map.get("examples").and_then(JsonValue::as_array) {
6585 output.push_str("Examples:\n");
6586 for item in examples {
6587 let pretty = serde_json::to_string_pretty(item).unwrap_or_else(|_| item.to_string());
6588 if pretty.contains('\n') {
6589 output.push_str(" -\n");
6590 for line in pretty.lines() {
6591 output.push_str(" ");
6592 output.push_str(line);
6593 output.push('\n');
6594 }
6595 } else {
6596 output.push_str(" - ");
6597 output.push_str(&pretty);
6598 output.push('\n');
6599 }
6600 }
6601 }
6602 if let Some(notes) = map.get("notes").and_then(JsonValue::as_str) {
6603 output.push_str("Notes:\n");
6604 output.push_str(notes);
6605 output.push('\n');
6606 }
6607 Some(output.trim_end().to_string())
6608}
6609
6610fn format_requirements_item(value: &JsonValue) -> String {
6611 if let Some(text) = value.as_str() {
6612 return text.to_string();
6613 }
6614 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
6615}
6616
6617impl From<DomainArg> for Domain {
6618 fn from(value: DomainArg) -> Self {
6619 match value {
6620 DomainArg::Messaging => Domain::Messaging,
6621 DomainArg::Events => Domain::Events,
6622 DomainArg::Secrets => Domain::Secrets,
6623 DomainArg::OAuth => Domain::OAuth,
6624 }
6625 }
6626}
6627
6628#[cfg(test)]
6629mod tests {
6630 use super::*;
6631 use std::{collections::BTreeSet, path::PathBuf};
6632
6633 #[test]
6634 fn parse_kv_infers_basic_types() {
6635 let (key, value) = parse_kv("a=1").unwrap();
6636 assert_eq!(key, "a");
6637 assert_eq!(value, JsonValue::Number(1.into()));
6638
6639 let (key, value) = parse_kv("b=true").unwrap();
6640 assert_eq!(key, "b");
6641 assert_eq!(value, JsonValue::Bool(true));
6642
6643 let (key, value) = parse_kv("c=hello").unwrap();
6644 assert_eq!(key, "c");
6645 assert_eq!(value, JsonValue::String("hello".to_string()));
6646 }
6647
6648 #[test]
6649 fn merge_args_overrides_json() {
6650 let merged = merge_args(
6651 Some(r#"{"chat_id":1,"mode":"x"}"#),
6652 &["chat_id=2".to_string()],
6653 )
6654 .unwrap();
6655 assert_eq!(merged.get("chat_id"), Some(&JsonValue::Number(2.into())));
6656 assert_eq!(
6657 merged.get("mode"),
6658 Some(&JsonValue::String("x".to_string()))
6659 );
6660 }
6661
6662 #[test]
6663 fn requirements_formatting_structured() {
6664 let value = serde_json::json!({
6665 "required_args": ["chat_id"],
6666 "optional_args": ["thread_id"],
6667 "examples": [{"chat_id": 1}],
6668 "notes": "Example note"
6669 });
6670 let rendered = format_requirements_output(&value).unwrap();
6671 assert!(rendered.contains("Required args:"));
6672 assert!(rendered.contains("Optional args:"));
6673 assert!(rendered.contains("Examples:"));
6674 assert!(rendered.contains("Notes:"));
6675 }
6676
6677 #[test]
6678 fn requirements_missing_message() {
6679 let pack = domains::ProviderPack {
6680 pack_id: "demo".to_string(),
6681 file_name: "demo.gtpack".to_string(),
6682 path: PathBuf::from("demo.gtpack"),
6683 entry_flows: vec!["setup_default".to_string()],
6684 };
6685 let error = ensure_requirements_flow(&pack).unwrap_err();
6686 assert!(error.contains("requirements flow not found"));
6687 }
6688
6689 #[test]
6690 fn filter_allowed_providers_moves_missing() {
6691 let mut packs = vec![
6692 domains::ProviderPack {
6693 pack_id: "messaging-telegram".to_string(),
6694 file_name: "telegram.gtpack".to_string(),
6695 path: PathBuf::from("telegram.gtpack"),
6696 entry_flows: vec!["setup_default".to_string()],
6697 },
6698 domains::ProviderPack {
6699 pack_id: "messaging-slack".to_string(),
6700 file_name: "slack.gtpack".to_string(),
6701 path: PathBuf::from("slack.gtpack"),
6702 entry_flows: vec!["setup_default".to_string()],
6703 },
6704 ];
6705 let allowed = vec![
6706 "messaging-telegram".to_string(),
6707 "messaging-email".to_string(),
6708 ]
6709 .into_iter()
6710 .collect::<BTreeSet<_>>();
6711 let missing = filter_packs_by_allowed(&mut packs, &allowed);
6712 assert_eq!(packs.len(), 1);
6713 assert_eq!(packs[0].pack_id, "messaging-telegram");
6714 assert_eq!(missing, vec!["messaging-email".to_string()]);
6715 }
6716
6717 #[test]
6718 fn select_demo_providers_respects_filter() {
6719 let providers = vec![
6720 DemoProviderInfo {
6721 pack: domains::ProviderPack {
6722 pack_id: "messaging-telegram".to_string(),
6723 file_name: "messaging-telegram.gtpack".to_string(),
6724 path: PathBuf::from("messaging-telegram.gtpack"),
6725 entry_flows: Vec::new(),
6726 },
6727 },
6728 DemoProviderInfo {
6729 pack: domains::ProviderPack {
6730 pack_id: "messaging-slack".to_string(),
6731 file_name: "messaging-slack.gtpack".to_string(),
6732 path: PathBuf::from("messaging-slack.gtpack"),
6733 entry_flows: Vec::new(),
6734 },
6735 },
6736 ];
6737 let all = select_demo_providers(&providers, None).unwrap();
6738 assert_eq!(all.len(), providers.len());
6739 let single = select_demo_providers(&providers, Some("messaging-slack")).unwrap();
6740 assert_eq!(single.len(), 1);
6741 assert_eq!(single[0].pack.pack_id, "messaging-slack");
6742 }
6743
6744 #[test]
6745 fn create_per_pack_matrix_requires_entries() {
6746 let tenants = vec![wizard::TenantSelection {
6747 tenant: "demo".to_string(),
6748 team: Some("default".to_string()),
6749 allow_paths: Vec::new(),
6750 }];
6751 let err = build_access_changes(
6752 wizard::WizardMode::Create,
6753 Some("per_pack_matrix"),
6754 &tenants,
6755 &["oci://ghcr.io/greentic/packs/sales@0.6.0".to_string()],
6756 Vec::new(),
6757 )
6758 .unwrap_err()
6759 .to_string();
6760 assert!(err.contains("requires non-empty access_change"));
6761 }
6762
6763 #[test]
6764 fn create_all_selected_expands_matrix() {
6765 let tenants = vec![wizard::TenantSelection {
6766 tenant: "demo".to_string(),
6767 team: Some("default".to_string()),
6768 allow_paths: Vec::new(),
6769 }];
6770 let changes = build_access_changes(
6771 wizard::WizardMode::Create,
6772 Some("all_selected_get_all_packs"),
6773 &tenants,
6774 &[
6775 "oci://ghcr.io/greentic/packs/sales@0.6.0".to_string(),
6776 "oci://ghcr.io/greentic/packs/hr@0.6.0".to_string(),
6777 ],
6778 Vec::new(),
6779 )
6780 .unwrap();
6781 assert_eq!(changes.len(), 2);
6782 }
6783
6784 #[test]
6785 fn parse_yes_no_token_accepts_english_and_dutch() {
6786 assert_eq!(parse_yes_no_token("y"), Some(true));
6787 assert_eq!(parse_yes_no_token("yes"), Some(true));
6788 assert_eq!(parse_yes_no_token("j"), Some(true));
6789 assert_eq!(parse_yes_no_token("ja"), Some(true));
6790 assert_eq!(parse_yes_no_token("n"), Some(false));
6791 assert_eq!(parse_yes_no_token("no"), Some(false));
6792 assert_eq!(parse_yes_no_token("nee"), Some(false));
6793 assert_eq!(parse_yes_no_token("nein"), Some(false));
6794 assert_eq!(parse_yes_no_token("x"), None);
6795 }
6796
6797 #[test]
6798 fn localized_pack_ref_field_title_uses_i18n_key() {
6799 let value = localized_list_field_title("pack_refs", "pack_ref", "fallback");
6800 assert!(!value.is_empty());
6801 assert_ne!(value, "fallback");
6802 }
6803
6804 #[test]
6805 fn normalize_custom_provider_refs_collects_non_empty_values() {
6806 let values = vec![
6807 WizardCustomProviderRefAnswer::Ref("".to_string()),
6808 WizardCustomProviderRefAnswer::Ref("oci://ghcr.io/acme/providers/custom:1".to_string()),
6809 WizardCustomProviderRefAnswer::Item {
6810 pack_ref: "repo://messaging/providers/custom@latest".to_string(),
6811 },
6812 ];
6813 let refs = normalize_custom_provider_refs(&values);
6814 assert_eq!(
6815 refs,
6816 vec![
6817 "oci://ghcr.io/acme/providers/custom:1".to_string(),
6818 "repo://messaging/providers/custom@latest".to_string()
6819 ]
6820 );
6821 }
6822
6823 #[test]
6824 fn demo_up_args_map_runner_binary_into_start_request() {
6825 let args = DemoUpArgs {
6826 bundle: Some(PathBuf::from("./bundle")),
6827 tenant: Some("demo".to_string()),
6828 team: Some("default".to_string()),
6829 no_nats: false,
6830 nats: NatsModeArg::Off,
6831 nats_url: None,
6832 config: None,
6833 cloudflared: CloudflaredModeArg::Off,
6834 cloudflared_binary: None,
6835 ngrok: NgrokModeArg::Off,
6836 ngrok_binary: None,
6837 restart: Vec::new(),
6838 runner_binary: Some(PathBuf::from("/tmp/runner")),
6839 log_dir: None,
6840 verbose: false,
6841 quiet: false,
6842 };
6843
6844 let request = args.to_start_request();
6845 assert_eq!(request.bundle.as_deref(), Some("./bundle"));
6846 assert_eq!(request.tenant.as_deref(), Some("demo"));
6847 assert_eq!(request.team.as_deref(), Some("default"));
6848 assert_eq!(
6849 request.runner_binary.as_deref(),
6850 Some(Path::new("/tmp/runner"))
6851 );
6852 }
6853}