Skip to main content

greentic_setup/engine/
executors.rs

1//! Step executor implementations for the setup engine.
2//!
3//! Each executor handles a specific `SetupStepKind`.
4
5use 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
17/// Execute the CreateBundle step.
18pub 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
26/// Execute the ResolvePacks step.
27pub 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
75/// Execute the AddPacksToBundle step.
76pub 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        // Determine target directory based on pack ID domain prefix
84        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
119/// Determine the target directory for a pack based on its ID.
120///
121/// Packs with domain prefixes (e.g., `messaging-telegram`, `events-webhook`)
122/// go to `providers/<domain>/`. Other packs go to `packs/`.
123pub 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    // Default to packs/ for non-provider packs
141    bundle_path.join("packs")
142}
143
144/// Execute the ApplyPackSetup step.
145pub 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 that are referenced in setup_answers
157    // but not yet present in the bundle.
158    auto_install_provider_packs(bundle_path, metadata);
159
160    // Discover packs so we can find pack_path for secret alias seeding
161    let discovered = if bundle_path.exists() {
162        discovery::discover(bundle_path).ok()
163    } else {
164        None
165    };
166
167    // Persist setup answers to local config files and dev secrets store
168    for (provider_id, answers) in &metadata.setup_answers {
169        // Write answers to provider config directory
170        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        // Persist all answer values to the dev secrets store so that
184        // WASM components can read them via the secrets API at runtime.
185        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        // Seed host-generated runtime secrets the pack declares via
235        // `greentic.generated-secrets.v1` (e.g. the webchat `jwt_signing_key`):
236        // the user is never asked for these, so without this the provider's
237        // runtime endpoints fail with a missing-secret error.
238        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        // Materialize a provider config envelope so runtime/provider ingest
259        // paths can read setup-applied config, not just raw setup answers.
260        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        // Sync OAuth answers to tenant config JSON for webchat-gui providers
283        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        // Sync `skin` answer to tenant config JSON for webchat-gui providers
301        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        // Sync `nav_links_json` answer to tenant config JSON for webchat-gui providers
319        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        // Register webhooks if the provider needs one (e.g. Telegram, Slack, Webex)
337        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    // Print post-setup instructions for providers needing manual steps
365    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
400/// Remove provider artifacts and config directories.
401pub 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
447/// Search sibling bundles for provider packs referenced in setup_answers
448/// and install them into this bundle if missing.
449///
450/// "Missing" is determined by pack_id, not filename: a pack file with any
451/// filename that declares the matching pack_id in its manifest counts as
452/// already installed. Otherwise a custom-named pack (e.g. a tenant-specific
453/// build placed alongside the canonical name) gets clobbered every time
454/// setup runs.
455pub 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        // Determine the provider domain from the ID
480        let domain = domain_from_provider_id(provider_id);
481
482        // Search for the pack in sibling bundles and build output
483        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
507/// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
508pub 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" // default
524}
525
526/// Search known locations for a provider pack file.
527///
528/// Search order:
529/// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
530/// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
531pub 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    // 1. Sibling bundles
540    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    // 2. Build output from greentic-messaging-providers
554    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
568/// Execute the WriteGmapRules step.
569pub 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        // Build gmap content from allow_paths
582        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
598/// Execute the CopyResolvedManifest step.
599pub 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        // Create an empty manifest placeholder if it doesn't exist
613        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
622/// Execute the ValidateBundle step.
623pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
624    bundle::validate_bundle_exists(bundle_path)
625}
626
627/// Execute the BuildFlowIndex step.
628///
629/// Scans all flows in the bundle, builds a TF-IDF index and a routing-compatible
630/// index, and optionally generates intents.md documentation.
631/// Output is written to `bundle/state/indexes/`.
632///
633/// Requires the `fast2flow` feature AND the `fast2flow-bundle` crate wired as a
634/// dependency.  Until `fast2flow-bundle` is published or vendored, this is a
635/// no-op stub that logs a skip message.
636pub 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    /// Regression: a custom-named pack whose manifest declares the matching
678    /// pack_id must satisfy `auto_install_provider_packs`. Filename-only
679    /// detection caused tenant-specific builds (e.g. `*-3aigent.gtpack`) to
680    /// be clobbered by the canonical name on every setup run.
681    #[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}