1mod answers;
8mod executors;
9mod plan_builders;
10mod types;
11
12use std::path::Path;
13
14use anyhow::anyhow;
15
16use crate::plan::*;
17use crate::platform_setup::{
18 persist_static_routes_artifact, persist_telemetry_artifact, persist_tunnel_artifact,
19};
20
21pub use answers::{emit_answers, encrypt_secret_answers, load_answers, prompt_secret_answers};
23pub use executors::{
24 auto_install_provider_packs, domain_from_provider_id, execute_add_packs_to_bundle,
25 execute_apply_pack_setup, execute_build_flow_index, execute_copy_resolved_manifests,
26 execute_create_bundle, execute_remove_provider_artifacts, execute_resolve_packs,
27 execute_validate_bundle, execute_write_gmap_rules, find_provider_pack_source,
28 get_pack_target_dir, invoke_setup_component_operation,
29};
30pub use plan_builders::{
31 apply_create, apply_remove, apply_update, build_metadata, build_metadata_with_ops,
32 compute_simple_hash, dedup_sorted, extract_default_from_help, infer_default_value,
33 infer_update_ops, normalize_tenants, print_plan_summary,
34};
35pub use types::{LoadedAnswers, SetupConfig, SetupRequest};
36
37pub struct SetupEngine {
39 config: SetupConfig,
40}
41
42impl SetupEngine {
43 pub fn new(config: SetupConfig) -> Self {
44 Self { config }
45 }
46
47 pub fn plan(
49 &self,
50 mode: SetupMode,
51 request: &SetupRequest,
52 dry_run: bool,
53 ) -> anyhow::Result<SetupPlan> {
54 match mode {
55 SetupMode::Create => apply_create(request, dry_run),
56 SetupMode::Update => apply_update(request, dry_run),
57 SetupMode::Remove => apply_remove(request, dry_run),
58 }
59 }
60
61 pub fn print_plan(&self, plan: &SetupPlan) {
63 print_plan_summary(plan);
64 }
65
66 pub fn config(&self) -> &SetupConfig {
68 &self.config
69 }
70
71 pub fn execute(&self, plan: &SetupPlan) -> anyhow::Result<SetupExecutionReport> {
76 if plan.dry_run {
77 return Err(anyhow!("cannot execute a dry-run plan"));
78 }
79
80 let bundle = &plan.bundle;
81 let mut report = SetupExecutionReport {
82 bundle: bundle.clone(),
83 resolved_packs: Vec::new(),
84 resolved_manifests: Vec::new(),
85 provider_updates: 0,
86 pending_setup_actions: Vec::new(),
87 warnings: Vec::new(),
88 };
89
90 for step in &plan.steps {
91 match step.kind {
92 SetupStepKind::NoOp => {
93 if self.config.verbose {
94 println!(" [skip] {}", step.description);
95 }
96 }
97 SetupStepKind::CreateBundle => {
98 execute_create_bundle(bundle, &plan.metadata)?;
99 if self.config.verbose {
100 println!(" [done] {}", step.description);
101 }
102 }
103 SetupStepKind::ResolvePacks => {
104 let resolved = execute_resolve_packs(bundle, &plan.metadata)?;
105 report.resolved_packs.extend(resolved);
106 if self.config.verbose {
107 println!(" [done] {}", step.description);
108 }
109 }
110 SetupStepKind::AddPacksToBundle => {
111 execute_add_packs_to_bundle(bundle, &report.resolved_packs)?;
112 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
113 bundle,
114 &plan.metadata.deployment_targets,
115 );
116 if self.config.verbose {
117 println!(" [done] {}", step.description);
118 }
119 }
120 SetupStepKind::ValidateCapabilities => {
121 let cap_report = crate::capabilities::validate_and_upgrade_packs(bundle)?;
122 for warn in &cap_report.warnings {
123 report.warnings.push(warn.message.clone());
124 }
125 if self.config.verbose {
126 println!(
127 " [done] {} (checked={}, upgraded={})",
128 step.description,
129 cap_report.checked,
130 cap_report.upgraded.len()
131 );
132 }
133 }
134 SetupStepKind::ApplyPackSetup => {
135 let setup_report =
136 execute_apply_pack_setup(bundle, &plan.metadata, &self.config)?;
137 report.provider_updates += setup_report.provider_updates;
138 report
139 .pending_setup_actions
140 .extend(setup_report.pending_setup_actions);
141 if self.config.verbose {
142 println!(" [done] {}", step.description);
143 }
144 }
145 SetupStepKind::WriteGmapRules => {
146 execute_write_gmap_rules(bundle, &plan.metadata)?;
147 if self.config.verbose {
148 println!(" [done] {}", step.description);
149 }
150 }
151 SetupStepKind::RunResolver => {
152 if self.config.verbose {
154 println!(" [skip] {} (deferred to runtime)", step.description);
155 }
156 }
157 SetupStepKind::CopyResolvedManifest => {
158 let manifests = execute_copy_resolved_manifests(bundle, &plan.metadata)?;
159 report.resolved_manifests.extend(manifests);
160 if self.config.verbose {
161 println!(" [done] {}", step.description);
162 }
163 }
164 SetupStepKind::ValidateBundle => {
165 execute_validate_bundle(bundle)?;
166 if self.config.verbose {
167 println!(" [done] {}", step.description);
168 }
169 }
170 SetupStepKind::BuildFlowIndex => {
171 execute_build_flow_index(bundle, &self.config)?;
172 if self.config.verbose {
173 println!(" [done] {}", step.description);
174 }
175 }
176 }
177 }
178
179 persist_static_routes_artifact(bundle, &plan.metadata.static_routes)?;
182 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
183 bundle,
184 &plan.metadata.deployment_targets,
185 );
186 if let Some(tunnel) = plan.metadata.tunnel.as_ref() {
187 let _ = persist_tunnel_artifact(bundle, tunnel);
188 }
189 if let Some(telemetry) = plan.metadata.telemetry.as_ref() {
190 let _ = persist_telemetry_artifact(bundle, telemetry);
191 }
192
193 Ok(report)
194 }
195
196 pub fn emit_answers(
201 &self,
202 plan: &SetupPlan,
203 output_path: &Path,
204 key: Option<&str>,
205 interactive: bool,
206 ) -> anyhow::Result<()> {
207 emit_answers(&self.config, plan, output_path, key, interactive)
208 }
209
210 pub fn load_answers(
212 &self,
213 answers_path: &Path,
214 key: Option<&str>,
215 interactive: bool,
216 ) -> anyhow::Result<LoadedAnswers> {
217 load_answers(answers_path, key, interactive)
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::bundle;
225 use crate::platform_setup::{StaticRoutesPolicy, static_routes_artifact_path};
226 use serde_json::json;
227 use std::collections::BTreeSet;
228 use std::path::PathBuf;
229
230 fn empty_request(bundle: PathBuf) -> SetupRequest {
231 SetupRequest {
232 bundle,
233 bundle_name: None,
234 pack_refs: Vec::new(),
235 tenants: vec![TenantSelection {
236 tenant: "demo".to_string(),
237 team: Some("default".to_string()),
238 allow_paths: vec!["packs/default".to_string()],
239 }],
240 default_assignments: Vec::new(),
241 providers: Vec::new(),
242 update_ops: BTreeSet::new(),
243 remove_targets: BTreeSet::new(),
244 packs_remove: Vec::new(),
245 providers_remove: Vec::new(),
246 tenants_remove: Vec::new(),
247 access_changes: Vec::new(),
248 static_routes: StaticRoutesPolicy::default(),
249 setup_answers: serde_json::Map::new(),
250 ..Default::default()
251 }
252 }
253
254 #[test]
255 fn create_plan_is_deterministic() {
256 let req = SetupRequest {
257 bundle: PathBuf::from("bundle"),
258 bundle_name: None,
259 pack_refs: vec![
260 "repo://zeta/pack@1".to_string(),
261 "repo://alpha/pack@1".to_string(),
262 "repo://alpha/pack@1".to_string(),
263 ],
264 tenants: vec![
265 TenantSelection {
266 tenant: "demo".to_string(),
267 team: Some("default".to_string()),
268 allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
269 },
270 TenantSelection {
271 tenant: "alpha".to_string(),
272 team: None,
273 allow_paths: vec!["x".to_string()],
274 },
275 ],
276 default_assignments: Vec::new(),
277 providers: Vec::new(),
278 update_ops: BTreeSet::new(),
279 remove_targets: BTreeSet::new(),
280 packs_remove: Vec::new(),
281 providers_remove: Vec::new(),
282 tenants_remove: Vec::new(),
283 access_changes: Vec::new(),
284 static_routes: StaticRoutesPolicy::default(),
285 setup_answers: serde_json::Map::new(),
286 ..Default::default()
287 };
288 let plan = apply_create(&req, true).unwrap();
289 assert_eq!(
290 plan.metadata.pack_refs,
291 vec![
292 "repo://alpha/pack@1".to_string(),
293 "repo://zeta/pack@1".to_string()
294 ]
295 );
296 assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
297 assert_eq!(
298 plan.metadata.tenants[1].allow_paths,
299 vec!["pack/a".to_string(), "pack/b".to_string()]
300 );
301 }
302
303 #[test]
304 fn dry_run_does_not_create_files() {
305 let bundle = PathBuf::from("/tmp/nonexistent-bundle");
306 let req = empty_request(bundle.clone());
307 let _plan = apply_create(&req, true).unwrap();
308 assert!(!bundle.exists());
309 }
310
311 #[test]
312 fn create_requires_tenants() {
313 let req = SetupRequest {
314 tenants: vec![],
315 ..empty_request(PathBuf::from("x"))
316 };
317 assert!(apply_create(&req, true).is_err());
318 }
319
320 #[test]
321 fn load_answers_reads_platform_setup_and_provider_answers() {
322 let temp = tempfile::tempdir().unwrap();
323 let answers_path = temp.path().join("answers.yaml");
324 std::fs::write(
325 &answers_path,
326 r#"
327bundle_source: ./bundle
328tenant: acme
329team: core
330env: prod
331platform_setup:
332 static_routes:
333 public_web_enabled: true
334 public_base_url: https://example.com/base/
335 deployment_targets:
336 - target: aws
337 provider_pack: packs/aws.gtpack
338 default: true
339setup_answers:
340 messaging-webchat:
341 jwt_signing_key: abc
342"#,
343 )
344 .unwrap();
345
346 let loaded = load_answers(&answers_path, None, false).unwrap();
347 assert_eq!(
348 loaded
349 .platform_setup
350 .static_routes
351 .as_ref()
352 .and_then(|v| v.public_base_url.as_deref()),
353 Some("https://example.com/base/")
354 );
355 assert_eq!(
356 loaded
357 .setup_answers
358 .get("messaging-webchat")
359 .and_then(|v| v.get("jwt_signing_key"))
360 .and_then(serde_json::Value::as_str),
361 Some("abc")
362 );
363 assert_eq!(loaded.tenant.as_deref(), Some("acme"));
364 assert_eq!(loaded.team.as_deref(), Some("core"));
365 assert_eq!(loaded.env.as_deref(), Some("prod"));
366 assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
367 assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
368 }
369
370 #[test]
371 fn emit_answers_includes_platform_setup() {
372 let temp = tempfile::tempdir().unwrap();
373 let bundle_root = temp.path().join("bundle");
374 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
375
376 let engine = SetupEngine::new(SetupConfig {
377 tenant: "demo".into(),
378 team: None,
379 env: "prod".into(),
380 offline: false,
381 verbose: false,
382 });
383 let request = SetupRequest {
384 bundle: bundle_root.clone(),
385 tenants: vec![TenantSelection {
386 tenant: "demo".into(),
387 team: None,
388 allow_paths: Vec::new(),
389 }],
390 static_routes: StaticRoutesPolicy {
391 public_web_enabled: true,
392 public_base_url: Some("https://example.com".into()),
393 public_surface_policy: "enabled".into(),
394 default_route_prefix_policy: "pack_declared".into(),
395 tenant_path_policy: "pack_declared".into(),
396 ..StaticRoutesPolicy::default()
397 },
398 ..Default::default()
399 };
400 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
401 let output = temp.path().join("answers.json");
402 engine.emit_answers(&plan, &output, None, false).unwrap();
403 let emitted: serde_json::Value =
404 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
405 assert_eq!(
406 emitted["platform_setup"]["static_routes"]["public_base_url"],
407 json!("https://example.com")
408 );
409 assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
410 }
411
412 #[test]
413 fn emit_answers_falls_back_to_runtime_public_endpoint() {
414 let temp = tempfile::tempdir().unwrap();
415 let bundle_root = temp.path().join("bundle");
416 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
417 let runtime_dir = bundle_root
418 .join("state")
419 .join("runtime")
420 .join("demo.default");
421 std::fs::create_dir_all(&runtime_dir).unwrap();
422 std::fs::write(
423 runtime_dir.join("endpoints.json"),
424 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
425 )
426 .unwrap();
427
428 let engine = SetupEngine::new(SetupConfig {
429 tenant: "demo".into(),
430 team: Some("default".into()),
431 env: "prod".into(),
432 offline: false,
433 verbose: false,
434 });
435 let request = SetupRequest {
436 bundle: bundle_root.clone(),
437 tenants: vec![TenantSelection {
438 tenant: "demo".into(),
439 team: Some("default".into()),
440 allow_paths: Vec::new(),
441 }],
442 ..Default::default()
443 };
444 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
445 let output = temp.path().join("answers-runtime.json");
446 engine.emit_answers(&plan, &output, None, false).unwrap();
447 let emitted: serde_json::Value =
448 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
449 assert_eq!(
450 emitted["platform_setup"]["static_routes"]["public_base_url"],
451 json!("https://runtime.example.com")
452 );
453 }
454
455 #[test]
456 fn execute_persists_static_routes_artifact() {
457 let temp = tempfile::tempdir().unwrap();
458 let bundle_root = temp.path().join("bundle");
459 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
460
461 let engine = SetupEngine::new(SetupConfig {
462 tenant: "demo".into(),
463 team: None,
464 env: "prod".into(),
465 offline: false,
466 verbose: false,
467 });
468 let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
469 metadata.static_routes = StaticRoutesPolicy {
470 public_web_enabled: true,
471 public_base_url: Some("https://example.com".into()),
472 public_surface_policy: "enabled".into(),
473 default_route_prefix_policy: "pack_declared".into(),
474 tenant_path_policy: "pack_declared".into(),
475 ..StaticRoutesPolicy::default()
476 };
477
478 execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
479 let artifact = static_routes_artifact_path(&bundle_root);
480 assert!(artifact.exists());
481 let stored: serde_json::Value =
482 serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
483 assert_eq!(stored["public_web_enabled"], json!(true));
484 }
485
486 #[test]
487 fn setup_actions_are_persisted_and_stripped_from_provider_config() {
488 let temp = tempfile::tempdir().unwrap();
500 let bundle_root = temp.path().to_path_buf();
501
502 let answers = json!({
503 "bot_token": "secret",
504 "setup_actions": [{
505 "id": "install",
506 "kind": "oauth_install_button",
507 "label": "Add to Example",
508 "authorize_url": "https://example.com/oauth"
509 }]
510 });
511
512 let actions = crate::setup_actions::extract_setup_actions(
513 "messaging-example",
514 "demo",
515 Some("default"),
516 &answers,
517 )
518 .unwrap();
519 assert_eq!(actions.len(), 1);
520 assert_eq!(
521 actions[0].kind,
522 crate::setup_actions::SetupActionKind::OauthInstallButton
523 );
524
525 crate::setup_actions::persist_setup_actions(&bundle_root, &actions).unwrap();
526 let action_path = crate::setup_actions::setup_actions_state_path(
527 &bundle_root,
528 "demo",
529 "default",
530 "messaging-example",
531 );
532 assert!(action_path.exists());
533 let state: crate::setup_actions::SetupActionStateFile =
534 serde_json::from_str(&std::fs::read_to_string(&action_path).unwrap()).unwrap();
535 assert_eq!(state.actions.len(), 1);
536 assert_eq!(state.actions[0].id, "install");
537
538 let persisted = crate::setup_actions::strip_setup_actions(&answers);
540 assert!(persisted.get("setup_actions").is_none());
541 assert_eq!(persisted["bot_token"], json!("secret"));
542 }
543
544 #[test]
545 fn execute_apply_pack_setup_persists_pack_declared_setup_actions() {
546 use std::io::Write;
547 use zip::write::{FileOptions, ZipWriter};
548
549 let temp = tempfile::tempdir().unwrap();
550 let bundle_root = temp.path().join("bundle");
551 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
552 let providers_dir = bundle_root.join("providers/messaging");
553 std::fs::create_dir_all(&providers_dir).unwrap();
554 let pack_path = providers_dir.join("messaging-slack.gtpack");
555 let file = std::fs::File::create(&pack_path).unwrap();
556 let mut writer = ZipWriter::new(file);
557 let options: FileOptions<'_, ()> =
558 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
559 writer.start_file("pack.manifest.json", options).unwrap();
560 writer
561 .write_all(
562 json!({
563 "pack_id": "messaging-slack",
564 "display_name": "Slack"
565 })
566 .to_string()
567 .as_bytes(),
568 )
569 .unwrap();
570 writer.start_file("assets/setup.yaml", options).unwrap();
571 writer
572 .write_all(
573 br#"
574title: Slack
575questions: []
576setup_actions:
577 - id: add_to_slack
578 label: Add to Slack
579 kind: oauth_install_button
580 provider_id: slack
581 authorize_url: https://slack.example/install
582"#,
583 )
584 .unwrap();
585 writer.finish().unwrap();
586
587 let engine = SetupEngine::new(SetupConfig {
588 tenant: "demo".into(),
589 team: Some("default".into()),
590 env: "dev".into(),
591 offline: false,
592 verbose: false,
593 });
594 let request = empty_request(bundle_root.clone());
595 let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
596 assert!(
597 plan.steps
598 .iter()
599 .any(|step| step.kind == crate::plan::SetupStepKind::ApplyPackSetup),
600 "pack-declared setup actions should schedule ApplyPackSetup"
601 );
602 let metadata = build_metadata(&request, Vec::new(), vec![]);
603
604 let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
605 assert_eq!(report.pending_setup_actions.len(), 1);
606 assert_eq!(report.pending_setup_actions[0].id, "add_to_slack");
607 assert_eq!(report.pending_setup_actions[0].label, "Add to Slack");
608 assert_eq!(
609 report.pending_setup_actions[0].provider_id,
610 "messaging-slack"
611 );
612 let action_path = crate::setup_actions::setup_actions_state_path(
613 &bundle_root,
614 "demo",
615 "default",
616 "messaging-slack",
617 );
618 assert!(action_path.exists());
619 }
620
621 #[test]
622 fn execute_apply_pack_setup_hydrates_oauth_install_url_from_answers() {
623 let temp = tempfile::tempdir().unwrap();
624 let bundle_root = temp.path().join("bundle");
625 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
626 write_registration_test_pack(
630 &bundle_root,
631 r#"
632title: Example
633questions:
634 - name: workspace_name
635 kind: string
636"#,
637 json!({"operations": {}}),
638 );
639
640 let engine = SetupEngine::new(SetupConfig {
641 tenant: "demo".into(),
642 team: Some("default".into()),
643 env: "dev".into(),
644 offline: false,
645 verbose: false,
646 });
647 let mut request = empty_request(bundle_root.clone());
648 request.setup_answers.insert(
649 "messaging-example".into(),
650 json!({
651 "slack_client_id": "client-123",
652 "setup_actions": [{
653 "id": "install",
654 "kind": "oauth_install_button",
655 "label": "Add",
656 "authorize_url": "https://slack.com/oauth/v2/authorize",
657 "client_id_field": "slack_client_id",
658 "scopes": ["chat:write", "channels:read"]
659 }]
660 }),
661 );
662 let metadata = build_metadata(&request, Vec::new(), vec![]);
663
664 let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
665 let url = report.pending_setup_actions[0]
666 .authorize_url
667 .as_deref()
668 .unwrap();
669 assert!(url.contains("client_id=client-123"), "{url}");
670 assert!(
671 url.contains("scope=chat%3Awrite%2Cchannels%3Aread"),
672 "{url}"
673 );
674 }
675
676 #[test]
677 fn execute_apply_pack_setup_runs_pack_declared_registration_before_oauth_hydration() {
678 let temp = tempfile::tempdir().unwrap();
679 let bundle_root = temp.path().join("bundle");
680 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
681 write_registration_test_pack(
682 &bundle_root,
683 r#"
684title: Example
685questions:
686 - name: workspace_name
687 kind: string
688setup_actions:
689 - id: install
690 label: Add
691 kind: oauth_install_button
692 authorize_url: https://example.com/oauth
693 client_id_source: registration
694 client_id_field: oauth_client_id
695 registration:
696 component_ref: components/registration.json
697 op: register
698 app_name_field: app_name
699 client_id_output: registered_client_id
700 client_secret_output: registered_client_secret
701 app_id_output: registered_app_id
702"#,
703 json!({
704 "operations": {
705 "register": {
706 "result": {
707 "registered_client_id": "client-from-registration",
708 "registered_client_secret": "secret-from-registration",
709 "registered_app_id": "app-from-registration"
710 }
711 }
712 }
713 }),
714 );
715
716 let engine = SetupEngine::new(SetupConfig {
717 tenant: "demo".into(),
718 team: Some("default".into()),
719 env: "dev".into(),
720 offline: false,
721 verbose: false,
722 });
723 let mut request = empty_request(bundle_root.clone());
724 request
725 .setup_answers
726 .insert("messaging-example".into(), json!({"app_name": "Demo App"}));
727 let metadata = build_metadata(&request, Vec::new(), vec![]);
728
729 let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
730 let url = report.pending_setup_actions[0]
731 .authorize_url
732 .as_deref()
733 .unwrap();
734 assert!(url.contains("client_id=client-from-registration"), "{url}");
741 }
742
743 #[test]
744 fn execute_apply_pack_setup_skips_actions_for_disabled_provider() {
745 let temp = tempfile::tempdir().unwrap();
746 let bundle_root = temp.path().join("bundle");
747 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
748 write_registration_test_pack(
749 &bundle_root,
750 r#"
751title: Example
752questions: []
753setup_actions:
754 - id: install
755 label: Add
756 kind: oauth_install_button
757 authorize_url: https://example.com/oauth
758 client_id_source: registration
759 client_id_field: oauth_client_id
760 registration:
761 component_ref: components/registration.json
762 op: register
763 client_id_output: registered_client_id
764"#,
765 json!({
766 "operations": {
767 "register": {
768 "result": {
769 "registered_client_id": "client-from-registration"
770 }
771 }
772 }
773 }),
774 );
775
776 let engine = SetupEngine::new(SetupConfig {
777 tenant: "demo".into(),
778 team: Some("default".into()),
779 env: "dev".into(),
780 offline: false,
781 verbose: false,
782 });
783 let mut request = empty_request(bundle_root.clone());
784 request
785 .setup_answers
786 .insert("messaging-example".into(), json!({"enabled": false}));
787 let metadata = build_metadata(&request, Vec::new(), vec![]);
788
789 let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
790
791 assert!(report.pending_setup_actions.is_empty());
792 assert!(
793 !bundle_root
794 .join("state/config/setup-actions/demo/default/messaging-example.json")
795 .exists()
796 );
797 let setup_answers_path =
798 bundle_root.join("state/config/messaging-example/setup-answers.json");
799 let stored: serde_json::Value =
800 serde_json::from_str(&std::fs::read_to_string(setup_answers_path).unwrap()).unwrap();
801 assert_eq!(stored["enabled"], json!(false));
802 }
803
804 #[test]
805 fn execute_apply_pack_setup_uses_bundle_name_for_registration_app_name_template() {
806 let temp = tempfile::tempdir().unwrap();
807 let bundle_root = temp.path().join("bundle");
808 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
809 write_registration_test_pack(
810 &bundle_root,
811 r#"
812title: Example
813questions:
814 - name: workspace_name
815 kind: string
816setup_actions:
817 - id: install
818 label: Add
819 kind: oauth_install_button
820 authorize_url: https://example.com/oauth
821 client_id_source: registration
822 client_id_field: oauth_client_id
823 app_name_template: "{{ bundle_name }} Slack"
824 default_app_name: "Greentic Slack"
825 registration:
826 component_ref: components/registration.json
827 op: register
828 app_name_field: slack_app_name
829 config_access_token_field: access_token
830 client_id_output: app_name
831 app_id_output: slack_app_name
832"#,
833 json!({
834 "operations": {
835 "register": {
836 "echo_request": true
837 }
838 }
839 }),
840 );
841
842 let engine = SetupEngine::new(SetupConfig {
843 tenant: "demo".into(),
844 team: Some("default".into()),
845 env: "dev".into(),
846 offline: false,
847 verbose: false,
848 });
849 let mut request = empty_request(bundle_root.clone());
850 request.bundle_name = Some("Acme Support".into());
851 request
852 .setup_answers
853 .insert("messaging-example".into(), json!({"access_token": "token"}));
854 let metadata = build_metadata(&request, Vec::new(), vec![]);
855
856 execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
857
858 use greentic_secrets_lib::SecretsStore as _;
865 let store = crate::secrets::open_dev_store(&bundle_root).expect("open dev store");
866 let rt = tokio::runtime::Runtime::new().unwrap();
867 let env = crate::resolve_env(Some("dev"));
869 let read = |key: &str| -> String {
870 let uri = crate::canonical_secret_uri(
871 &env,
872 "demo",
873 Some("default"),
874 "messaging-example",
875 key,
876 );
877 let bytes = rt
878 .block_on(async { store.get(&uri).await })
879 .unwrap_or_else(|_| panic!("missing dev-store key: {key}"));
880 String::from_utf8(bytes).expect("utf8")
881 };
882 assert_eq!(read("slack_app_name"), "Acme Support Slack");
883 assert_eq!(read("app_name"), "Acme Support Slack");
884 }
885
886 #[test]
887 fn execute_apply_pack_setup_registration_failure_does_not_persist_broken_action() {
888 let temp = tempfile::tempdir().unwrap();
889 let bundle_root = temp.path().join("bundle");
890 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
891 write_registration_test_pack(
892 &bundle_root,
893 r#"
894title: Example
895questions: []
896setup_actions:
897 - id: install
898 label: Add
899 kind: oauth_install_button
900 authorize_url: https://example.com/oauth
901 client_id_source: registration
902 registration:
903 component_ref: components/registration.json
904 op: register
905 config_access_token_field: config_token
906 client_id_output: client_id
907"#,
908 json!({"operations": {}}),
909 );
910
911 let engine = SetupEngine::new(SetupConfig {
912 tenant: "demo".into(),
913 team: Some("default".into()),
914 env: "dev".into(),
915 offline: false,
916 verbose: false,
917 });
918 let mut request = empty_request(bundle_root.clone());
919 request
920 .setup_answers
921 .insert("messaging-example".into(), json!({"config_token": "token"}));
922 let metadata = build_metadata(&request, Vec::new(), vec![]);
923
924 let err = execute_apply_pack_setup(&bundle_root, &metadata, engine.config())
925 .expect_err("registration failure should fail setup");
926 assert!(
927 err.to_string()
928 .contains("failed to run setup action registration"),
929 "{err:#}"
930 );
931 let action_path = crate::setup_actions::setup_actions_state_path(
932 &bundle_root,
933 "demo",
934 "default",
935 "messaging-example",
936 );
937 assert!(!action_path.exists());
938 }
939
940 #[test]
941 fn execute_apply_pack_setup_registration_passes_original_input_field_names() {
942 let temp = tempfile::tempdir().unwrap();
943 let bundle_root = temp.path().join("bundle");
944 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
945 write_registration_test_pack(
946 &bundle_root,
947 r#"
948title: Example
949questions:
950 - name: workspace_name
951 kind: string
952setup_actions:
953 - id: install
954 label: Add
955 kind: oauth_install_button
956 authorize_url: https://example.com/oauth
957 client_id_source: registration
958 registration:
959 component_ref: components/registration.json
960 op: register
961 config_access_token_field: provider_specific_token
962 client_id_output: provider_specific_token
963"#,
964 json!({
965 "operations": {
966 "register": {
967 "echo_request": true
968 }
969 }
970 }),
971 );
972
973 let engine = SetupEngine::new(SetupConfig {
974 tenant: "demo".into(),
975 team: Some("default".into()),
976 env: "dev".into(),
977 offline: false,
978 verbose: false,
979 });
980 let mut request = empty_request(bundle_root.clone());
981 request.setup_answers.insert(
982 "messaging-example".into(),
983 json!({"provider_specific_token": "client-from-original-field"}),
984 );
985 let metadata = build_metadata(&request, Vec::new(), vec![]);
986
987 let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
988 let url = report.pending_setup_actions[0]
989 .authorize_url
990 .as_deref()
991 .unwrap();
992 assert!(
993 url.contains("client_id=client-from-original-field"),
994 "{url}"
995 );
996 }
997
998 fn write_registration_test_pack(
999 bundle_root: &std::path::Path,
1000 setup_yaml: &str,
1001 registration_component: serde_json::Value,
1002 ) {
1003 use std::io::Write;
1004 use zip::write::{FileOptions, ZipWriter};
1005
1006 let providers_dir = bundle_root.join("providers/messaging");
1007 std::fs::create_dir_all(&providers_dir).unwrap();
1008 let pack_path = providers_dir.join("messaging-example.gtpack");
1009 let file = std::fs::File::create(&pack_path).unwrap();
1010 let mut writer = ZipWriter::new(file);
1011 let options: FileOptions<'_, ()> =
1012 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1013 writer.start_file("pack.manifest.json", options).unwrap();
1014 writer
1015 .write_all(
1016 json!({
1017 "pack_id": "messaging-example",
1018 "display_name": "Example"
1019 })
1020 .to_string()
1021 .as_bytes(),
1022 )
1023 .unwrap();
1024 writer.start_file("assets/setup.yaml", options).unwrap();
1025 writer.write_all(setup_yaml.as_bytes()).unwrap();
1026 writer
1027 .start_file("components/registration.json", options)
1028 .unwrap();
1029 writer
1030 .write_all(registration_component.to_string().as_bytes())
1031 .unwrap();
1032 writer.finish().unwrap();
1033 }
1034
1035 #[test]
1036 fn execute_create_persists_platform_metadata_without_provider_steps() {
1037 let temp = tempfile::tempdir().unwrap();
1038 let bundle_root = temp.path().join("bundle");
1039
1040 let engine = SetupEngine::new(SetupConfig {
1041 tenant: "demo".into(),
1042 team: Some("default".into()),
1043 env: "prod".into(),
1044 offline: false,
1045 verbose: false,
1046 });
1047 let request = SetupRequest {
1048 bundle: bundle_root.clone(),
1049 static_routes: StaticRoutesPolicy {
1050 public_web_enabled: true,
1051 public_base_url: Some("https://example.com".into()),
1052 public_surface_policy: "enabled".into(),
1053 default_route_prefix_policy: "pack_declared".into(),
1054 tenant_path_policy: "pack_declared".into(),
1055 ..StaticRoutesPolicy::default()
1056 },
1057 deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
1058 target: "runtime".into(),
1059 provider_pack: None,
1060 default: Some(true),
1061 }],
1062 ..empty_request(bundle_root.clone())
1063 };
1064
1065 let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
1066 engine.execute(&plan).unwrap();
1067
1068 let routes_artifact = static_routes_artifact_path(&bundle_root);
1069 assert!(routes_artifact.exists());
1070
1071 let targets_artifact = bundle_root
1072 .join(".greentic")
1073 .join("deployment-targets.json");
1074 assert!(targets_artifact.exists());
1075 let stored: serde_json::Value =
1076 serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
1077 assert_eq!(stored["targets"][0]["target"], json!("runtime"));
1078 assert_eq!(stored["targets"][0]["default"], json!(true));
1079 }
1080
1081 #[test]
1082 fn remove_execute_deletes_provider_artifact_and_config_dir() {
1083 let temp = tempfile::tempdir().unwrap();
1084 let bundle_root = temp.path().join("bundle");
1085 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1086 let provider_dir = bundle_root.join("providers").join("messaging");
1087 std::fs::create_dir_all(&provider_dir).unwrap();
1088 let provider_pack = provider_dir.join("messaging-webchat.gtpack");
1089 std::fs::copy(
1090 bundle_root.join("packs").join("default.gtpack"),
1091 &provider_pack,
1092 )
1093 .unwrap();
1094 let config_dir = bundle_root
1095 .join("state")
1096 .join("config")
1097 .join("messaging-webchat");
1098 std::fs::create_dir_all(&config_dir).unwrap();
1099 std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
1100
1101 let engine = SetupEngine::new(SetupConfig {
1102 tenant: "demo".into(),
1103 team: None,
1104 env: "prod".into(),
1105 offline: false,
1106 verbose: false,
1107 });
1108 let request = SetupRequest {
1109 bundle: bundle_root.clone(),
1110 providers_remove: vec!["messaging-webchat".into()],
1111 ..Default::default()
1112 };
1113 let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
1114 engine.execute(&plan).unwrap();
1115
1116 assert!(!provider_pack.exists());
1117 assert!(!config_dir.exists());
1118 }
1119
1120 #[test]
1121 fn update_plan_preserves_static_routes_policy() {
1122 let req = SetupRequest {
1123 bundle: PathBuf::from("bundle"),
1124 tenants: vec![TenantSelection {
1125 tenant: "demo".into(),
1126 team: None,
1127 allow_paths: Vec::new(),
1128 }],
1129 static_routes: StaticRoutesPolicy {
1130 public_web_enabled: true,
1131 public_base_url: Some("https://example.com/new".into()),
1132 public_surface_policy: "enabled".into(),
1133 default_route_prefix_policy: "pack_declared".into(),
1134 tenant_path_policy: "pack_declared".into(),
1135 ..StaticRoutesPolicy::default()
1136 },
1137 ..Default::default()
1138 };
1139 let plan = apply_update(&req, true).unwrap();
1140 assert_eq!(
1141 plan.metadata.static_routes.public_base_url.as_deref(),
1142 Some("https://example.com/new")
1143 );
1144 }
1145
1146 #[test]
1147 fn extract_default_from_help_parses_parenthesized() {
1148 let help = "Slack API base URL (default: https://slack.com/api)";
1149 let result = extract_default_from_help(help);
1150 assert_eq!(result, Some("https://slack.com/api".to_string()));
1151 }
1152
1153 #[test]
1154 fn extract_default_from_help_parses_bracketed() {
1155 let help = "Enable feature [default: true]";
1156 let result = extract_default_from_help(help);
1157 assert_eq!(result, Some("true".to_string()));
1158 }
1159
1160 #[test]
1161 fn extract_default_from_help_case_insensitive() {
1162 let help = "Some setting (Default: custom_value)";
1163 let result = extract_default_from_help(help);
1164 assert_eq!(result, Some("custom_value".to_string()));
1165 }
1166
1167 #[test]
1168 fn extract_default_from_help_returns_none_without_default() {
1169 let help = "Just a plain help text with no default";
1170 let result = extract_default_from_help(help);
1171 assert_eq!(result, None);
1172 }
1173
1174 #[test]
1175 fn infer_default_value_uses_explicit_default() {
1176 use crate::setup_input::SetupQuestion;
1177 let question = SetupQuestion {
1178 name: "api_base_url".to_string(),
1179 kind: "string".to_string(),
1180 required: true,
1181 help: Some("Some help (default: wrong_value)".to_string()),
1182 choices: vec![],
1183 default: Some(json!("https://explicit.com")),
1184 secret: false,
1185 title: None,
1186 visible_if: None,
1187 ..Default::default()
1188 };
1189 let result = infer_default_value(&question);
1190 assert_eq!(result, json!("https://explicit.com"));
1191 }
1192
1193 #[test]
1194 fn infer_default_value_extracts_from_help() {
1195 use crate::setup_input::SetupQuestion;
1196 let question = SetupQuestion {
1197 name: "api_base_url".to_string(),
1198 kind: "string".to_string(),
1199 required: true,
1200 help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
1201 choices: vec![],
1202 default: None,
1203 secret: false,
1204 title: None,
1205 visible_if: None,
1206 ..Default::default()
1207 };
1208 let result = infer_default_value(&question);
1209 assert_eq!(result, json!("https://slack.com/api"));
1210 }
1211
1212 #[test]
1213 fn infer_default_value_returns_empty_without_default() {
1214 use crate::setup_input::SetupQuestion;
1215 let question = SetupQuestion {
1216 name: "bot_token".to_string(),
1217 kind: "string".to_string(),
1218 required: true,
1219 help: Some("Your bot token".to_string()),
1220 choices: vec![],
1221 default: None,
1222 secret: true,
1223 title: None,
1224 visible_if: None,
1225 ..Default::default()
1226 };
1227 let result = infer_default_value(&question);
1228 assert_eq!(result, json!(""));
1229 }
1230}