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