Skip to main content

greentic_operator/
cli.rs

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    /// Per-provider setup answers that get seeded as secrets during bundle creation.
630    /// Format: `{ "messaging-webchat": { "public_base_url": "...", ... }, ... }`
631    #[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            // set_var is unsafe in this codebase, so wrap it accordingly.
1577            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            // set_var is unsafe in this codebase, so wrap it accordingly.
1794            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        // 1. Collect answers via card wizard
2061        let answers = qa_setup_wizard::run_interactive_card_wizard(&self.pack, &provider_id)?;
2062
2063        // 2. Build input payload with collected answers
2064        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        // 3. Resolve secrets manager
2082        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        // 4. Run the setup flow via DemoRunner
2090        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        // Convert provider labels back to IDs (wizard displays labels, but we need IDs internally)
2188        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
2601/// Load wizard answers from a local file path or an https:// URL.
2602fn 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        // --- Telemetry capability bootstrap ---
3908        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        // Compose a message plan and encode payload directly against the provider component (no flow resolution).
4067        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, // no pre-built FormSpec; will try setup.yaml fallback
5731        )?;
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    // Persist secrets and config from QA results
5814    if action == DomainAction::Setup {
5815        // Persist config envelope when FormSpec is available
5816        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        // Persist setup-input answers AND QA config output as secrets.
5838        // WASM components read all values (both secret and non-secret) via
5839        // the secrets API, so we must persist everything.
5840        // Use GREENTIC_ENV (not CLI --env) to match the runtime's secret URI scope.
5841        let env_value = resolve_env(None);
5842
5843        // Merge: start with setup input answers, overlay with QA config output
5844        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}