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