Skip to main content

cli/apps/
build.rs

1use super::metadata::{self, AppCategory};
2use crate::colors;
3use crate::config::Config;
4use anyhow::{Context, Result, bail};
5use directories::BaseDirs;
6use tokio::fs;
7use tokio::process::Command;
8
9/// Runs the `[artifact].script` declared by an app preset (`shine app artifact apply <app-id>`).
10///
11/// Unlike `post_upgrade` hooks (which are a background side effect of `shine upgrade` and
12/// swallow failures so one broken hook doesn't abort the whole upgrade), this is a single
13/// explicit user action: script failures propagate as a real error, and output streams live
14/// instead of being captured, so the user can see a build fail as it happens.
15pub async fn handle_build(config: &Config, app_id: &str) -> Result<()> {
16    let categories = metadata::load_active_categories(config, Some(app_id)).await?;
17    let cat = categories
18        .iter()
19        .find(|c| c.name == app_id)
20        .ok_or_else(|| anyhow::anyhow!("app preset category not found: {app_id}"))?;
21
22    let Some(artifact) = &cat.artifact else {
23        bail!("app '{app_id}' does not define an artifact script");
24    };
25
26    let mut command = artifact_command(config, app_id, &artifact.script, artifact.runtime).await?;
27    run_artifact_command(&mut command, app_id).await
28}
29
30/// Runs the `[artifact].teardown` script (`shine app artifact remove <app-id>`), the
31/// symmetric reverse of `build`. Like `build` and unlike the implicit teardown
32/// during `uninstall`, this is an explicit user action: it is not gated by
33/// `allow_app_hooks` and a nonzero exit propagates as a real error.
34pub async fn handle_unbuild(config: &Config, app_id: &str) -> Result<()> {
35    let categories = metadata::load_active_categories(config, Some(app_id)).await?;
36    let cat = categories
37        .iter()
38        .find(|c| c.name == app_id)
39        .ok_or_else(|| anyhow::anyhow!("app preset category not found: {app_id}"))?;
40
41    let Some((teardown, runtime)) = cat
42        .artifact
43        .as_ref()
44        .and_then(|a| a.teardown.as_deref().map(|t| (t, a.runtime)))
45    else {
46        bail!("app '{app_id}' does not define an artifact teardown script");
47    };
48
49    let mut command = artifact_command(config, app_id, teardown, runtime).await?;
50    run_artifact_command(&mut command, app_id).await
51}
52
53/// Best-effort teardown run during `shine app uninstall`. Returns immediately
54/// when the category declares no teardown. Unlike the explicit `unbuild`
55/// command it is *implicit*, so — like `post_upgrade`/`post_install` hooks — it
56/// is gated by `allow_app_hooks` for external presets and its failures are
57/// non-fatal (a broken teardown must not block file removal). `dry_run` prints
58/// the intended script without running it.
59pub(crate) async fn run_teardown_for_uninstall(config: &Config, cat: &AppCategory, dry_run: bool) {
60    let Some((teardown, runtime)) = cat
61        .artifact
62        .as_ref()
63        .and_then(|a| a.teardown.as_deref().map(|t| (t, a.runtime)))
64    else {
65        return;
66    };
67    let app_id = &cat.name;
68
69    if config.is_external_presets && !config.allow_app_hooks {
70        println!(
71            "  {} {app_id}: artifact teardown skipped (set allow_app_hooks = true to allow external app hooks; manual: shine app artifact remove {app_id})",
72            colors::symbol("!"),
73        );
74        return;
75    }
76
77    if dry_run {
78        println!(
79            "  {} {app_id}: [dry-run] would run artifact teardown ({teardown})",
80            colors::symbol("!"),
81        );
82        return;
83    }
84
85    let mut command = match artifact_command(config, app_id, teardown, runtime).await {
86        Ok(command) => command,
87        Err(e) => {
88            eprintln!(
89                "  {} {app_id}: artifact teardown skipped: {e:#}",
90                colors::symbol("!"),
91            );
92            return;
93        }
94    };
95    match command.status().await {
96        Ok(status) if status.success() => {
97            println!(
98                "  {} {app_id}: artifact teardown completed",
99                colors::symbol("✓")
100            );
101        }
102        Ok(status) => {
103            eprintln!(
104                "  {} {app_id}: artifact teardown failed: exited with {status}",
105                colors::symbol("!"),
106            );
107        }
108        Err(e) => {
109            eprintln!(
110                "  {} {app_id}: artifact teardown failed: {e}",
111                colors::symbol("!"),
112            );
113        }
114    }
115}
116
117/// Resolves an artifact script (overlay copy wins over the source copy) and
118/// builds a `Command` carrying the full `SHINE_APP_*` env contract plus the
119/// active `[env]` table. Shared by `build` (`script`) and the teardown paths
120/// (`teardown`) so both get identical inputs.
121async fn artifact_command(
122    config: &Config,
123    app_id: &str,
124    script_name: &str,
125    runtime: metadata::ArtifactRuntime,
126) -> Result<Command> {
127    if !config.is_external_presets {
128        crate::presets::extract_prefix(&format!("app/{app_id}"), config.presets_dir(), true)
129            .await?;
130    }
131    let source_dir = config.presets_dir().join("app").join(app_id);
132
133    let overlay_dir = config
134        .active_presets_overlay_dir()
135        .map(|dir| dir.join("app").join(app_id))
136        .filter(|dir| dir.exists());
137
138    let (resolved_app_dir, script_path, external_script) = if let Some(overlay_dir) = &overlay_dir
139        && overlay_dir.join(script_name).exists()
140    {
141        (overlay_dir.clone(), overlay_dir.join(script_name), true)
142    } else {
143        let candidate = source_dir.join(script_name);
144        if !candidate.exists() {
145            bail!("app '{app_id}' artifact script not found: {script_name}");
146        }
147        (source_dir.clone(), candidate, config.is_external_presets)
148    };
149
150    let http_dir = config.shine_dir().join("http").join("app").join(app_id);
151    let cache_dir = BaseDirs::new()
152        .context("resolving system cache directory")?
153        .cache_dir()
154        .join("shine")
155        .join("app")
156        .join(app_id);
157    let state_dir = config.shine_dir().join("state").join("app").join(app_id);
158    for dir in [&http_dir, &cache_dir, &state_dir] {
159        fs::create_dir_all(dir)
160            .await
161            .with_context(|| format!("creating directory: {}", dir.display()))?;
162    }
163
164    // Inject the active `[env]` table so a build/teardown script can read
165    // user-configured values like `SURGE_PROFILE`. Values are passed as stored
166    // (no decryption) — the same as the `template` transform — so building never
167    // triggers a secret decryption prompt (e.g. Touch ID / GPG) for unrelated
168    // `_SECRET` keys. The `SHINE_APP_*` contract vars are set afterwards so they
169    // win on any (unexpected) name collision with a user `[env]` key.
170    let env_config = crate::env::EnvConfig::load_or_init(config).await?;
171
172    let mut command = match runtime {
173        metadata::ArtifactRuntime::Bun => {
174            // Cross-platform: run the script via `bun <script>` (like shine's bun
175            // shell presets). bun is an external prerequisite — fail clearly if
176            // it is missing rather than emitting a raw spawn error.
177            crate::proc::ensure_command("bun").with_context(|| {
178                format!("app '{app_id}' artifact requires Bun (https://bun.sh)")
179            })?;
180            let spec = crate::bun_runtime::resolve(&resolved_app_dir, external_script)?;
181            crate::bun_runtime::command(&script_path, spec)
182        }
183        metadata::ArtifactRuntime::Native => Command::new(&script_path),
184    };
185    command
186        .current_dir(&resolved_app_dir)
187        .envs(env_config.as_map())
188        .env("SHINE_APP_ID", app_id)
189        .env("SHINE_APP_DIR", &resolved_app_dir)
190        .env("SHINE_APP_SOURCE_DIR", &source_dir)
191        .env("SHINE_APP_HTTP_DIR", &http_dir)
192        .env("SHINE_CONFIG_DIR", config.shine_dir())
193        .env("SHINE_CACHE_DIR", &cache_dir)
194        .env("SHINE_STATE_DIR", &state_dir);
195    if let Some(overlay_dir) = &overlay_dir {
196        command.env("SHINE_APP_OVERLAY_DIR", overlay_dir);
197    }
198
199    Ok(command)
200}
201
202/// Runs a prepared artifact `Command` with inherited (live) stdio and turns a
203/// nonzero exit into a real error — the explicit-command semantics shared by
204/// `build` and `unbuild`.
205async fn run_artifact_command(command: &mut Command, app_id: &str) -> Result<()> {
206    let status = command
207        .status()
208        .await
209        .with_context(|| format!("running artifact script for '{app_id}'"))?;
210    if !status.success() {
211        bail!("artifact script for '{app_id}' exited with {status}");
212    }
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::path::{Path, PathBuf};
220    use tokio::fs;
221
222    async fn make_temp_dir() -> PathBuf {
223        crate::test_support::make_temp_dir("shine-apps-build").await
224    }
225
226    #[cfg(unix)]
227    async fn write_sample_category(dir: &Path, script_body: &str) {
228        let cat_dir = dir.join("presets/app/sample");
229        fs::create_dir_all(&cat_dir).await.unwrap();
230        fs::write(
231            cat_dir.join("shine.toml"),
232            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[artifact]\nscript = \"build.sh\"\n\n[[files]]\nsource = \"config.toml\"\n",
233        )
234        .await
235        .unwrap();
236        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
237            .await
238            .unwrap();
239        let script_path = cat_dir.join("build.sh");
240        fs::write(&script_path, script_body).await.unwrap();
241        #[cfg(unix)]
242        {
243            use std::os::unix::fs::PermissionsExt;
244            let mut perms = fs::metadata(&script_path).await.unwrap().permissions();
245            perms.set_mode(perms.mode() | 0o111);
246            fs::set_permissions(&script_path, perms).await.unwrap();
247        }
248    }
249
250    async fn write_bun_category(root: &Path) {
251        fs::create_dir_all(root).await.unwrap();
252        fs::write(root.join("build.ts"), b"console.log('ok')\n")
253            .await
254            .unwrap();
255    }
256
257    fn command_args(command: &Command) -> Vec<String> {
258        command
259            .as_std()
260            .get_args()
261            .map(|arg| arg.to_string_lossy().into_owned())
262            .collect()
263    }
264
265    #[tokio::test]
266    async fn external_bun_artifact_uses_locked_fallback() {
267        let dir = make_temp_dir().await;
268        let category = dir.join("presets/app/sample");
269        write_bun_category(&category).await;
270        fs::write(category.join("package.json"), b"{\"dependencies\":{}}")
271            .await
272            .unwrap();
273        fs::write(category.join("bun.lock"), b"lockfileVersion = 1\n")
274            .await
275            .unwrap();
276        let mut config = Config::new_for_test(&dir);
277        config.is_external_presets = true;
278
279        let command = artifact_command(
280            &config,
281            "sample",
282            "build.ts",
283            metadata::ArtifactRuntime::Bun,
284        )
285        .await
286        .unwrap();
287        assert_eq!(command_args(&command)[0], "--install=fallback");
288        fs::remove_dir_all(dir).await.unwrap();
289    }
290
291    #[tokio::test]
292    async fn overlay_package_without_overlay_script_does_not_enable_builtin_artifact_dependencies()
293    {
294        let dir = make_temp_dir().await;
295        let category = dir.join("presets/app/sample");
296        write_bun_category(&category).await;
297        let overlay = dir.join("overlay/app/sample");
298        fs::create_dir_all(&overlay).await.unwrap();
299        fs::write(overlay.join("package.json"), b"{\"dependencies\":{}}")
300            .await
301            .unwrap();
302        fs::write(overlay.join("bun.lock"), b"lockfileVersion = 1\n")
303            .await
304            .unwrap();
305        let mut config = Config::new_for_test(&dir);
306        config.presets_overlay_dir_override = Some(dir.join("overlay"));
307
308        let command = artifact_command(
309            &config,
310            "sample",
311            "build.ts",
312            metadata::ArtifactRuntime::Bun,
313        )
314        .await
315        .unwrap();
316        assert_eq!(command_args(&command)[0], "--no-install");
317        fs::remove_dir_all(dir).await.unwrap();
318    }
319
320    #[cfg(unix)]
321    #[tokio::test(flavor = "current_thread")]
322    async fn build_bails_when_no_artifact_declared() {
323        let dir = make_temp_dir().await;
324        let cat_dir = dir.join("presets/app/sample");
325        fs::create_dir_all(&cat_dir).await.unwrap();
326        fs::write(
327            cat_dir.join("shine.toml"),
328            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"config.toml\"\n",
329        )
330        .await
331        .unwrap();
332        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
333            .await
334            .unwrap();
335
336        let mut config = Config::new_for_test(&dir);
337        config.is_external_presets = true;
338        fs::create_dir_all(config.shine_dir()).await.unwrap();
339
340        let err = handle_build(&config, "sample").await.unwrap_err();
341        assert!(
342            err.to_string()
343                .contains("does not define an artifact script")
344        );
345
346        fs::remove_dir_all(&dir).await.unwrap();
347    }
348
349    #[cfg(unix)]
350    #[tokio::test(flavor = "current_thread")]
351    async fn build_bails_for_unknown_app_id() {
352        let dir = make_temp_dir().await;
353        let mut config = Config::new_for_test(&dir);
354        config.is_external_presets = true;
355        fs::create_dir_all(config.shine_dir()).await.unwrap();
356
357        let err = handle_build(&config, "doesnotexist").await.unwrap_err();
358        assert!(err.to_string().contains("app preset category not found"));
359
360        fs::remove_dir_all(&dir).await.unwrap();
361    }
362
363    #[cfg(unix)]
364    #[tokio::test(flavor = "current_thread")]
365    async fn build_runs_script_with_contract_env_vars_and_working_directory() {
366        let dir = make_temp_dir().await;
367        let marker = dir.join("marker.txt");
368        write_sample_category(
369            &dir,
370            &format!(
371                "#!/bin/sh\nset -e\npwd > \"{marker}\"\necho \"$SHINE_APP_ID\" >> \"{marker}\"\necho \"$SHINE_APP_HTTP_DIR\" >> \"{marker}\"\ntest -d \"$SHINE_CACHE_DIR\"\ntest -d \"$SHINE_STATE_DIR\"\n",
372                marker = marker.display()
373            ),
374        )
375        .await;
376
377        let mut config = Config::new_for_test(&dir);
378        config.is_external_presets = true;
379        fs::create_dir_all(config.shine_dir()).await.unwrap();
380
381        handle_build(&config, "sample").await.unwrap();
382
383        let content = fs::read_to_string(&marker).await.unwrap();
384        let mut lines = content.lines();
385        let expected_app_dir = std::fs::canonicalize(dir.join("presets/app/sample")).unwrap();
386        assert_eq!(
387            lines.next().unwrap(),
388            expected_app_dir.display().to_string()
389        );
390        assert_eq!(lines.next().unwrap(), "sample");
391        assert_eq!(
392            lines.next().unwrap(),
393            config
394                .shine_dir()
395                .join("http")
396                .join("app")
397                .join("sample")
398                .display()
399                .to_string()
400        );
401
402        fs::remove_dir_all(&dir).await.unwrap();
403    }
404
405    #[cfg(unix)]
406    #[tokio::test(flavor = "current_thread")]
407    async fn build_injects_env_table_into_script() {
408        let dir = make_temp_dir().await;
409        let marker = dir.join("env-marker.txt");
410        write_sample_category(
411            &dir,
412            &format!(
413                "#!/bin/sh\nset -e\nprintf '%s' \"$SURGE_PROFILE\" > \"{marker}\"\n",
414                marker = marker.display()
415            ),
416        )
417        .await;
418
419        let mut config = Config::new_for_test(&dir);
420        config.is_external_presets = true;
421        config
422            .env
423            .insert("SURGE_PROFILE".into(), "/abs/path/Profile.conf".into());
424        fs::create_dir_all(config.shine_dir()).await.unwrap();
425
426        handle_build(&config, "sample").await.unwrap();
427
428        assert_eq!(
429            fs::read_to_string(&marker).await.unwrap(),
430            "/abs/path/Profile.conf"
431        );
432
433        fs::remove_dir_all(&dir).await.unwrap();
434    }
435
436    #[cfg(unix)]
437    #[tokio::test(flavor = "current_thread")]
438    async fn build_prefers_overlay_script_over_source_script() {
439        let dir = make_temp_dir().await;
440        write_sample_category(&dir, "#!/bin/sh\nexit 1\n").await;
441
442        let overlay_dir = dir.join("overlay");
443        let overlay_cat_dir = overlay_dir.join("app/sample");
444        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
445        let marker = dir.join("overlay-ran");
446        let overlay_script = overlay_cat_dir.join("build.sh");
447        fs::write(
448            &overlay_script,
449            format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
450        )
451        .await
452        .unwrap();
453        {
454            use std::os::unix::fs::PermissionsExt;
455            let mut perms = fs::metadata(&overlay_script).await.unwrap().permissions();
456            perms.set_mode(perms.mode() | 0o111);
457            fs::set_permissions(&overlay_script, perms).await.unwrap();
458        }
459
460        let mut config = Config::new_for_test(&dir);
461        config.is_external_presets = true;
462        config.presets_overlay_dir_override = Some(overlay_dir);
463        fs::create_dir_all(config.shine_dir()).await.unwrap();
464
465        handle_build(&config, "sample").await.unwrap();
466
467        assert!(
468            marker.exists(),
469            "overlay build.sh should run instead of the source one"
470        );
471
472        fs::remove_dir_all(&dir).await.unwrap();
473    }
474
475    #[cfg(unix)]
476    #[tokio::test(flavor = "current_thread")]
477    async fn build_falls_back_to_source_script_when_overlay_has_only_content() {
478        let dir = make_temp_dir().await;
479        let marker = dir.join("source-ran");
480        write_sample_category(
481            &dir,
482            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
483        )
484        .await;
485
486        let overlay_dir = dir.join("overlay");
487        let overlay_cat_dir = overlay_dir.join("app/sample");
488        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
489        fs::write(overlay_cat_dir.join("config.toml"), "name = \"overlay\"\n")
490            .await
491            .unwrap();
492
493        let mut config = Config::new_for_test(&dir);
494        config.is_external_presets = true;
495        config.presets_overlay_dir_override = Some(overlay_dir);
496        fs::create_dir_all(config.shine_dir()).await.unwrap();
497
498        handle_build(&config, "sample").await.unwrap();
499
500        assert!(
501            marker.exists(),
502            "source build script should run when the overlay has no artifact script"
503        );
504
505        fs::remove_dir_all(&dir).await.unwrap();
506    }
507
508    #[cfg(unix)]
509    #[tokio::test(flavor = "current_thread")]
510    async fn build_propagates_nonzero_script_exit_as_error() {
511        let dir = make_temp_dir().await;
512        write_sample_category(&dir, "#!/bin/sh\nexit 7\n").await;
513
514        let mut config = Config::new_for_test(&dir);
515        config.is_external_presets = true;
516        fs::create_dir_all(config.shine_dir()).await.unwrap();
517
518        let err = handle_build(&config, "sample").await.unwrap_err();
519        assert!(err.to_string().contains("exited with"));
520
521        fs::remove_dir_all(&dir).await.unwrap();
522    }
523
524    #[cfg(unix)]
525    async fn write_teardown_category(dir: &Path, teardown_body: &str) {
526        let cat_dir = dir.join("presets/app/sample");
527        fs::create_dir_all(&cat_dir).await.unwrap();
528        fs::write(
529            cat_dir.join("shine.toml"),
530            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[artifact]\nscript = \"build.sh\"\nteardown = \"unbuild.sh\"\n\n[[files]]\nsource = \"config.toml\"\n",
531        )
532        .await
533        .unwrap();
534        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
535            .await
536            .unwrap();
537        fs::write(cat_dir.join("build.sh"), "#!/bin/sh\nexit 0\n")
538            .await
539            .unwrap();
540        let script_path = cat_dir.join("unbuild.sh");
541        fs::write(&script_path, teardown_body).await.unwrap();
542        use std::os::unix::fs::PermissionsExt;
543        let mut perms = fs::metadata(&script_path).await.unwrap().permissions();
544        perms.set_mode(perms.mode() | 0o111);
545        fs::set_permissions(&script_path, perms).await.unwrap();
546    }
547
548    #[cfg(unix)]
549    #[tokio::test(flavor = "current_thread")]
550    async fn unbuild_runs_teardown_script() {
551        let dir = make_temp_dir().await;
552        let marker = dir.join("unbuild-ran");
553        write_teardown_category(
554            &dir,
555            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
556        )
557        .await;
558
559        let mut config = Config::new_for_test(&dir);
560        config.is_external_presets = true;
561        fs::create_dir_all(config.shine_dir()).await.unwrap();
562
563        handle_unbuild(&config, "sample").await.unwrap();
564        assert!(marker.exists(), "teardown script should have run");
565
566        fs::remove_dir_all(&dir).await.unwrap();
567    }
568
569    #[cfg(unix)]
570    #[tokio::test(flavor = "current_thread")]
571    async fn unbuild_bails_when_no_teardown_declared() {
572        let dir = make_temp_dir().await;
573        write_sample_category(&dir, "#!/bin/sh\nexit 0\n").await;
574
575        let mut config = Config::new_for_test(&dir);
576        config.is_external_presets = true;
577        fs::create_dir_all(config.shine_dir()).await.unwrap();
578
579        let err = handle_unbuild(&config, "sample").await.unwrap_err();
580        assert!(
581            err.to_string()
582                .contains("does not define an artifact teardown script")
583        );
584
585        fs::remove_dir_all(&dir).await.unwrap();
586    }
587
588    #[cfg(unix)]
589    #[tokio::test(flavor = "current_thread")]
590    async fn unbuild_propagates_nonzero_teardown_exit() {
591        let dir = make_temp_dir().await;
592        write_teardown_category(&dir, "#!/bin/sh\nexit 5\n").await;
593
594        let mut config = Config::new_for_test(&dir);
595        config.is_external_presets = true;
596        fs::create_dir_all(config.shine_dir()).await.unwrap();
597
598        let err = handle_unbuild(&config, "sample").await.unwrap_err();
599        assert!(err.to_string().contains("exited with"));
600
601        fs::remove_dir_all(&dir).await.unwrap();
602    }
603
604    #[cfg(unix)]
605    #[tokio::test(flavor = "current_thread")]
606    async fn teardown_for_uninstall_is_gated_for_external_presets() {
607        let dir = make_temp_dir().await;
608        let marker = dir.join("teardown-ran");
609        write_teardown_category(
610            &dir,
611            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
612        )
613        .await;
614
615        let mut config = Config::new_for_test(&dir);
616        config.is_external_presets = true;
617        fs::create_dir_all(config.shine_dir()).await.unwrap();
618
619        let categories = metadata::load_active_categories(&config, Some("sample"))
620            .await
621            .unwrap();
622        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
623
624        // External preset without the opt-in: teardown must be skipped.
625        run_teardown_for_uninstall(&config, cat, false).await;
626        assert!(!marker.exists(), "external teardown must be gated");
627
628        // Opt in: teardown runs.
629        config.allow_app_hooks = true;
630        run_teardown_for_uninstall(&config, cat, false).await;
631        assert!(marker.exists(), "teardown should run once opted in");
632
633        fs::remove_dir_all(&dir).await.unwrap();
634    }
635
636    #[cfg(unix)]
637    #[tokio::test(flavor = "current_thread")]
638    async fn teardown_for_uninstall_dry_run_does_not_execute() {
639        let dir = make_temp_dir().await;
640        let marker = dir.join("teardown-ran");
641        write_teardown_category(
642            &dir,
643            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
644        )
645        .await;
646
647        let mut config = Config::new_for_test(&dir);
648        config.is_external_presets = true;
649        config.allow_app_hooks = true;
650        fs::create_dir_all(config.shine_dir()).await.unwrap();
651
652        let categories = metadata::load_active_categories(&config, Some("sample"))
653            .await
654            .unwrap();
655        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
656
657        run_teardown_for_uninstall(&config, cat, true).await;
658        assert!(!marker.exists(), "dry-run teardown must not execute");
659
660        fs::remove_dir_all(&dir).await.unwrap();
661    }
662}