1use std::path::{Path, PathBuf};
6
7use anyhow::Context;
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10
11use crate::plan::{ResolvedPackInfo, SetupPlanMetadata};
12use crate::{bundle, bundle_source::BundleSource, discovery};
13
14use super::plan_builders::compute_simple_hash;
15use super::types::SetupConfig;
16
17pub fn execute_create_bundle(
19 bundle_path: &Path,
20 metadata: &SetupPlanMetadata,
21) -> anyhow::Result<()> {
22 bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
23 .context("failed to create bundle structure")
24}
25
26pub fn execute_resolve_packs(
28 _bundle_path: &Path,
29 metadata: &SetupPlanMetadata,
30) -> anyhow::Result<Vec<ResolvedPackInfo>> {
31 let mut resolved = Vec::new();
32 let mut failures = Vec::new();
33
34 for pack_ref in &metadata.pack_refs {
35 match resolve_pack_ref(pack_ref) {
36 Ok(resolved_path) => {
37 let canonical = resolved_path
38 .canonicalize()
39 .unwrap_or(resolved_path.clone());
40 let pack_meta = discovery::read_pack_meta(&canonical)?;
41 resolved.push(ResolvedPackInfo {
42 source_ref: pack_ref.clone(),
43 mapped_ref: canonical.display().to_string(),
44 resolved_digest: compute_file_digest(&canonical)
45 .unwrap_or_else(|_| format!("sha256:{}", compute_simple_hash(pack_ref))),
46 pack_id: pack_meta.map(|meta| meta.pack_id).unwrap_or_else(|| {
47 canonical
48 .file_stem()
49 .and_then(|s| s.to_str())
50 .unwrap_or("unknown")
51 .to_string()
52 }),
53 entry_flows: Vec::new(),
54 cached_path: canonical.clone(),
55 output_path: canonical,
56 });
57 }
58 Err(err) => {
59 failures.push(format!("{pack_ref}: {err}"));
60 }
61 }
62 }
63
64 if !failures.is_empty() {
65 anyhow::bail!(
66 "failed to resolve {} pack ref(s):\n{}",
67 failures.len(),
68 failures.join("\n")
69 );
70 }
71
72 Ok(resolved)
73}
74
75pub fn execute_add_packs_to_bundle(
77 bundle_path: &Path,
78 resolved_packs: &[ResolvedPackInfo],
79) -> anyhow::Result<()> {
80 let mut metadata_entries = Vec::new();
81
82 for pack in resolved_packs {
83 let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
85 std::fs::create_dir_all(&target_dir)?;
86
87 let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
88 if pack.cached_path.exists() && !target_path.exists() {
89 std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
90 format!(
91 "failed to copy pack {} to {}",
92 pack.cached_path.display(),
93 target_path.display()
94 )
95 })?;
96 }
97
98 let reference = target_path
99 .strip_prefix(bundle_path)
100 .unwrap_or(&target_path)
101 .to_string_lossy()
102 .replace('\\', "/");
103 let kind = if reference.starts_with("providers/") {
104 bundle::BundleReferenceKind::ExtensionProvider
105 } else {
106 bundle::BundleReferenceKind::AppPack
107 };
108 metadata_entries.push(bundle::BundleReference {
109 kind,
110 reference,
111 digest: Some(pack.resolved_digest.clone()),
112 });
113 }
114
115 bundle::register_bundle_references(bundle_path, &metadata_entries, None)?;
116 Ok(())
117}
118
119pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
124 const DOMAIN_PREFIXES: &[&str] = &[
125 "messaging-",
126 "events-",
127 "oauth-",
128 "secrets-",
129 "mcp-",
130 "state-",
131 ];
132
133 for prefix in DOMAIN_PREFIXES {
134 if pack_id.starts_with(prefix) {
135 let domain = prefix.trim_end_matches('-');
136 return bundle_path.join("providers").join(domain);
137 }
138 }
139
140 bundle_path.join("packs")
142}
143
144pub fn execute_apply_pack_setup(
146 bundle_path: &Path,
147 metadata: &SetupPlanMetadata,
148 config: &SetupConfig,
149) -> anyhow::Result<usize> {
150 let mut count = 0;
151
152 if !metadata.providers_remove.is_empty() {
153 count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
154 }
155
156 auto_install_provider_packs(bundle_path, metadata);
159
160 let discovered = if bundle_path.exists() {
162 discovery::discover(bundle_path).ok()
163 } else {
164 None
165 };
166
167 for (provider_id, answers) in &metadata.setup_answers {
169 let config_dir = bundle_path.join("state").join("config").join(provider_id);
171 std::fs::create_dir_all(&config_dir)?;
172
173 let config_path = config_dir.join("setup-answers.json");
174 let content =
175 serde_json::to_string_pretty(answers).context("failed to serialize setup answers")?;
176 std::fs::write(&config_path, content).with_context(|| {
177 format!(
178 "failed to write setup answers to: {}",
179 config_path.display()
180 )
181 })?;
182
183 let pack_path = discovered.as_ref().and_then(|d| {
186 d.find_setup_target(provider_id)
187 .map(|p| p.pack_path.as_path())
188 });
189 let env = crate::resolve_env(Some(&config.env));
190 if config.verbose {
191 let team_display = config.team.as_deref().unwrap_or("(none)");
192 println!(
193 " [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
194 config.tenant
195 );
196 let example_uri = crate::canonical_secret_uri(
197 &env,
198 &config.tenant,
199 config.team.as_deref(),
200 provider_id,
201 "_example_key",
202 );
203 println!(" [secrets] URI pattern: {example_uri}");
204 if let Some(config_map) = answers.as_object() {
205 let keys: Vec<&String> = config_map.keys().collect();
206 println!(" [secrets] answer keys: {keys:?}");
207 }
208 }
209 let rt = tokio::runtime::Runtime::new()
210 .context("failed to create tokio runtime for secrets persistence")?;
211 let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
212 bundle_path,
213 &env,
214 &config.tenant,
215 config.team.as_deref(),
216 provider_id,
217 answers,
218 pack_path,
219 ))?;
220 if config.verbose {
221 if persisted.is_empty() {
222 println!(
223 " [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
224 );
225 } else {
226 println!(
227 " [secrets] persisted {} key(s) for {provider_id}: {:?}",
228 persisted.len(),
229 persisted
230 );
231 }
232 }
233
234 if let Some(pack_path) = pack_path {
239 let generated = rt
240 .block_on(crate::qa::persist::seed_generated_secrets(
241 bundle_path,
242 &env,
243 &config.tenant,
244 config.team.as_deref(),
245 provider_id,
246 pack_path,
247 ))
248 .unwrap_or_default();
249 if config.verbose && !generated.is_empty() {
250 println!(
251 " [secrets] generated {} runtime secret(s) for {provider_id}: {:?}",
252 generated.len(),
253 generated
254 );
255 }
256 }
257
258 if let Some(pack_path) = pack_path {
261 crate::config_envelope::write_provider_config_envelope(
262 &bundle_path.join(".providers"),
263 provider_id,
264 "setup-input",
265 answers,
266 pack_path,
267 false,
268 )
269 .with_context(|| {
270 format!(
271 "failed to write provider config envelope for {} using {}",
272 provider_id,
273 pack_path.display()
274 )
275 })?;
276 } else if config.verbose {
277 println!(
278 " [config] WARNING: no resolved pack path for {provider_id}; skipped config envelope write"
279 );
280 }
281
282 match crate::tenant_config::sync_oauth_to_tenant_config(
284 bundle_path,
285 &config.tenant,
286 provider_id,
287 answers,
288 ) {
289 Ok(true) => {
290 if config.verbose {
291 println!(" [oauth] updated tenant config for {provider_id}");
292 }
293 }
294 Ok(false) => {}
295 Err(e) => {
296 println!(" [oauth] WARNING: failed to update tenant config: {e}");
297 }
298 }
299
300 match crate::tenant_config::sync_skin_to_tenant_config(
302 bundle_path,
303 &config.tenant,
304 provider_id,
305 answers,
306 ) {
307 Ok(true) => {
308 if config.verbose {
309 println!(" [skin] updated tenant config for {provider_id}");
310 }
311 }
312 Ok(false) => {}
313 Err(e) => {
314 println!(" [skin] WARNING: failed to update tenant config: {e}");
315 }
316 }
317
318 match crate::tenant_config::sync_nav_links_to_tenant_config(
320 bundle_path,
321 &config.tenant,
322 provider_id,
323 answers,
324 ) {
325 Ok(true) => {
326 if config.verbose {
327 println!(" [nav_links] updated tenant config for {provider_id}");
328 }
329 }
330 Ok(false) => {}
331 Err(e) => {
332 println!(" [nav_links] WARNING: failed to update tenant config: {e}");
333 }
334 }
335
336 if let Some(result) = crate::webhook::register_webhook(
338 provider_id,
339 answers,
340 &config.tenant,
341 config.team.as_deref(),
342 ) {
343 let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
344 if ok {
345 println!(" [webhook] registered for {provider_id}");
346 } else {
347 let err = result
348 .get("error")
349 .and_then(Value::as_str)
350 .unwrap_or("unknown");
351 println!(" [webhook] WARNING: registration failed for {provider_id}: {err}");
352 }
353 }
354
355 count += 1;
356 }
357
358 crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
359 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
360 bundle_path,
361 &metadata.deployment_targets,
362 );
363
364 let provider_configs: Vec<(String, Value)> = metadata
366 .setup_answers
367 .iter()
368 .map(|(id, val)| (id.clone(), val.clone()))
369 .collect();
370 let team = config.team.as_deref().unwrap_or("default");
371 crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
372
373 Ok(count)
374}
375
376fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
377 let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
378 let digest = Sha256::digest(bytes);
379 let encoded = digest
380 .iter()
381 .map(|byte| format!("{byte:02x}"))
382 .collect::<String>();
383 Ok(format!("sha256:{encoded}"))
384}
385
386fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
387 let source = BundleSource::parse(pack_ref)?;
388 let resolved = source.resolve()?;
389
390 if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
391 anyhow::bail!(
392 "resolved pack ref is not a .gtpack file: {}",
393 resolved.display()
394 );
395 }
396
397 Ok(resolved)
398}
399
400pub fn execute_remove_provider_artifacts(
402 bundle_path: &Path,
403 providers_remove: &[String],
404) -> anyhow::Result<usize> {
405 let mut removed = 0usize;
406 let discovered = discovery::discover(bundle_path).ok();
407 for provider_id in providers_remove {
408 if let Some(discovered) = discovered.as_ref()
409 && let Some(provider) = discovered
410 .providers
411 .iter()
412 .find(|provider| provider.provider_id == *provider_id)
413 {
414 if provider.pack_path.exists() {
415 std::fs::remove_file(&provider.pack_path).with_context(|| {
416 format!(
417 "failed to remove provider pack {}",
418 provider.pack_path.display()
419 )
420 })?;
421 }
422 removed += 1;
423 } else {
424 let target_dir = get_pack_target_dir(bundle_path, provider_id);
425 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
426 if target_path.exists() {
427 std::fs::remove_file(&target_path).with_context(|| {
428 format!("failed to remove provider pack {}", target_path.display())
429 })?;
430 removed += 1;
431 }
432 }
433
434 let config_dir = bundle_path.join("state").join("config").join(provider_id);
435 if config_dir.exists() {
436 std::fs::remove_dir_all(&config_dir).with_context(|| {
437 format!(
438 "failed to remove provider config dir {}",
439 config_dir.display()
440 )
441 })?;
442 }
443 }
444 Ok(removed)
445}
446
447pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
456 let bundle_abs =
457 std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
458
459 let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
460 .map(|d| {
461 d.providers
462 .into_iter()
463 .chain(d.app_packs)
464 .map(|p| p.provider_id)
465 .collect()
466 })
467 .unwrap_or_default();
468
469 for provider_id in metadata.setup_answers.keys() {
470 if installed_ids.contains(provider_id) {
471 continue;
472 }
473 let target_dir = get_pack_target_dir(bundle_path, provider_id);
474 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
475 if target_path.exists() {
476 continue;
477 }
478
479 let domain = domain_from_provider_id(provider_id);
481
482 if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
484 if let Err(err) = std::fs::create_dir_all(&target_dir) {
485 eprintln!(
486 " [provider] WARNING: failed to create {}: {err}",
487 target_dir.display()
488 );
489 continue;
490 }
491 match std::fs::copy(&source, &target_path) {
492 Ok(_) => println!(
493 " [provider] installed {provider_id}.gtpack from {}",
494 source.display()
495 ),
496 Err(err) => eprintln!(
497 " [provider] WARNING: failed to copy {}: {err}",
498 source.display()
499 ),
500 }
501 } else {
502 eprintln!(" [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
503 }
504 }
505}
506
507pub fn domain_from_provider_id(provider_id: &str) -> &str {
509 const DOMAIN_PREFIXES: &[&str] = &[
510 "messaging-",
511 "events-",
512 "oauth-",
513 "secrets-",
514 "mcp-",
515 "state-",
516 "telemetry-",
517 ];
518 for prefix in DOMAIN_PREFIXES {
519 if provider_id.starts_with(prefix) {
520 return prefix.trim_end_matches('-');
521 }
522 }
523 "messaging" }
525
526pub fn find_provider_pack_source(
532 provider_id: &str,
533 domain: &str,
534 bundle_abs: &Path,
535) -> Option<PathBuf> {
536 let parent = bundle_abs.parent()?;
537 let filename = format!("{provider_id}.gtpack");
538
539 if let Ok(entries) = std::fs::read_dir(parent) {
541 for entry in entries.flatten() {
542 let sibling = entry.path();
543 if sibling == *bundle_abs || !sibling.is_dir() {
544 continue;
545 }
546 let candidate = sibling.join("providers").join(domain).join(&filename);
547 if candidate.is_file() {
548 return Some(candidate);
549 }
550 }
551 }
552
553 for ancestor in parent.ancestors().take(4) {
555 let candidate = ancestor
556 .join("greentic-messaging-providers")
557 .join("target")
558 .join("packs")
559 .join(&filename);
560 if candidate.is_file() {
561 return Some(candidate);
562 }
563 }
564
565 None
566}
567
568pub fn execute_write_gmap_rules(
570 bundle_path: &Path,
571 metadata: &SetupPlanMetadata,
572) -> anyhow::Result<()> {
573 for tenant_sel in &metadata.tenants {
574 let gmap_path =
575 bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
576
577 if let Some(parent) = gmap_path.parent() {
578 std::fs::create_dir_all(parent)?;
579 }
580
581 let mut content = String::new();
583 if tenant_sel.allow_paths.is_empty() {
584 content.push_str("_ = forbidden\n");
585 } else {
586 for path in &tenant_sel.allow_paths {
587 content.push_str(&format!("{} = allowed\n", path));
588 }
589 content.push_str("_ = forbidden\n");
590 }
591
592 std::fs::write(&gmap_path, content)
593 .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
594 }
595 Ok(())
596}
597
598pub fn execute_copy_resolved_manifests(
600 bundle_path: &Path,
601 metadata: &SetupPlanMetadata,
602) -> anyhow::Result<Vec<PathBuf>> {
603 let mut manifests = Vec::new();
604 let resolved_dir = bundle_path.join("resolved");
605 std::fs::create_dir_all(&resolved_dir)?;
606
607 for tenant_sel in &metadata.tenants {
608 let filename =
609 bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
610 let manifest_path = resolved_dir.join(&filename);
611
612 if !manifest_path.exists() {
614 std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
615 }
616 manifests.push(manifest_path);
617 }
618
619 Ok(manifests)
620}
621
622pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
624 bundle::validate_bundle_exists(bundle_path)
625}
626
627pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
637 tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
638 Ok(())
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use crate::platform_setup::StaticRoutesPolicy;
645 use std::collections::BTreeSet;
646
647 fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
648 SetupPlanMetadata {
649 bundle_name: None,
650 pack_refs,
651 tenants: Vec::new(),
652 default_assignments: Vec::new(),
653 providers: Vec::new(),
654 update_ops: BTreeSet::new(),
655 remove_targets: BTreeSet::new(),
656 packs_remove: Vec::new(),
657 providers_remove: Vec::new(),
658 tenants_remove: Vec::new(),
659 access_changes: Vec::new(),
660 static_routes: StaticRoutesPolicy::default(),
661 deployment_targets: Vec::new(),
662 setup_answers: serde_json::Map::new(),
663 tunnel: None,
664 }
665 }
666
667 #[test]
668 fn resolve_packs_errors_when_any_pack_ref_fails() {
669 let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
670 let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
671 let message = err.to_string();
672
673 assert!(message.contains("failed to resolve 1 pack ref"));
674 assert!(message.contains("/definitely/missing/example.gtpack"));
675 }
676
677 #[test]
682 fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
683 use std::io::Write;
684 use zip::write::{FileOptions, ZipWriter};
685
686 let temp = tempfile::tempdir().expect("tempdir");
687 let bundle = temp.path().join("bundle");
688 let messaging_dir = bundle.join("providers").join("messaging");
689 std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
690
691 let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
692 let file = std::fs::File::create(&custom_pack).expect("create pack file");
693 let mut writer = ZipWriter::new(file);
694 let options: FileOptions<'_, ()> =
695 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
696 writer
697 .start_file("pack.manifest.json", options)
698 .expect("start manifest");
699 writer
700 .write_all(
701 serde_json::json!({
702 "pack_id": "messaging-webchat-gui",
703 "display_name": "WebChat GUI",
704 })
705 .to_string()
706 .as_bytes(),
707 )
708 .expect("write manifest");
709 writer.finish().expect("finish zip");
710
711 let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
712 assert!(!canonical_pack.exists(), "precondition: canonical absent");
713
714 let mut metadata = empty_metadata(vec![]);
715 metadata.setup_answers.insert(
716 "messaging-webchat-gui".to_string(),
717 serde_json::Value::Object(serde_json::Map::new()),
718 );
719
720 auto_install_provider_packs(&bundle, &metadata);
721
722 assert!(
723 custom_pack.exists(),
724 "custom-named pack must be left in place"
725 );
726 assert!(
727 !canonical_pack.exists(),
728 "must not auto-install canonical-named duplicate when pack_id already present"
729 );
730 }
731}