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    async fn write_sample_category(dir: &Path, script_body: &str) {
227        let cat_dir = dir.join("presets/app/sample");
228        fs::create_dir_all(&cat_dir).await.unwrap();
229        fs::write(
230            cat_dir.join("shine.toml"),
231            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[artifact]\nscript = \"build.sh\"\n\n[[files]]\nsource = \"config.toml\"\n",
232        )
233        .await
234        .unwrap();
235        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
236            .await
237            .unwrap();
238        let script_path = cat_dir.join("build.sh");
239        fs::write(&script_path, script_body).await.unwrap();
240        #[cfg(unix)]
241        {
242            use std::os::unix::fs::PermissionsExt;
243            let mut perms = fs::metadata(&script_path).await.unwrap().permissions();
244            perms.set_mode(perms.mode() | 0o111);
245            fs::set_permissions(&script_path, perms).await.unwrap();
246        }
247    }
248
249    async fn write_bun_category(root: &Path) {
250        fs::create_dir_all(root).await.unwrap();
251        fs::write(root.join("build.ts"), b"console.log('ok')\n")
252            .await
253            .unwrap();
254    }
255
256    fn command_args(command: &Command) -> Vec<String> {
257        command
258            .as_std()
259            .get_args()
260            .map(|arg| arg.to_string_lossy().into_owned())
261            .collect()
262    }
263
264    #[tokio::test]
265    async fn external_bun_artifact_uses_locked_fallback() {
266        let dir = make_temp_dir().await;
267        let category = dir.join("presets/app/sample");
268        write_bun_category(&category).await;
269        fs::write(category.join("package.json"), b"{\"dependencies\":{}}")
270            .await
271            .unwrap();
272        fs::write(category.join("bun.lock"), b"lockfileVersion = 1\n")
273            .await
274            .unwrap();
275        let mut config = Config::new_for_test(&dir);
276        config.is_external_presets = true;
277
278        let command = artifact_command(
279            &config,
280            "sample",
281            "build.ts",
282            metadata::ArtifactRuntime::Bun,
283        )
284        .await
285        .unwrap();
286        assert_eq!(command_args(&command)[0], "--install=fallback");
287        fs::remove_dir_all(dir).await.unwrap();
288    }
289
290    #[tokio::test]
291    async fn overlay_package_without_overlay_script_does_not_enable_builtin_artifact_dependencies()
292    {
293        let dir = make_temp_dir().await;
294        let category = dir.join("presets/app/sample");
295        write_bun_category(&category).await;
296        let overlay = dir.join("overlay/app/sample");
297        fs::create_dir_all(&overlay).await.unwrap();
298        fs::write(overlay.join("package.json"), b"{\"dependencies\":{}}")
299            .await
300            .unwrap();
301        fs::write(overlay.join("bun.lock"), b"lockfileVersion = 1\n")
302            .await
303            .unwrap();
304        let mut config = Config::new_for_test(&dir);
305        config.presets_overlay_dir_override = Some(dir.join("overlay"));
306
307        let command = artifact_command(
308            &config,
309            "sample",
310            "build.ts",
311            metadata::ArtifactRuntime::Bun,
312        )
313        .await
314        .unwrap();
315        assert_eq!(command_args(&command)[0], "--no-install");
316        fs::remove_dir_all(dir).await.unwrap();
317    }
318
319    #[cfg(unix)]
320    #[tokio::test(flavor = "current_thread")]
321    async fn build_bails_when_no_artifact_declared() {
322        let dir = make_temp_dir().await;
323        let cat_dir = dir.join("presets/app/sample");
324        fs::create_dir_all(&cat_dir).await.unwrap();
325        fs::write(
326            cat_dir.join("shine.toml"),
327            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"config.toml\"\n",
328        )
329        .await
330        .unwrap();
331        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
332            .await
333            .unwrap();
334
335        let mut config = Config::new_for_test(&dir);
336        config.is_external_presets = true;
337        fs::create_dir_all(config.shine_dir()).await.unwrap();
338
339        let err = handle_build(&config, "sample").await.unwrap_err();
340        assert!(
341            err.to_string()
342                .contains("does not define an artifact script")
343        );
344
345        fs::remove_dir_all(&dir).await.unwrap();
346    }
347
348    #[cfg(unix)]
349    #[tokio::test(flavor = "current_thread")]
350    async fn build_bails_for_unknown_app_id() {
351        let dir = make_temp_dir().await;
352        let mut config = Config::new_for_test(&dir);
353        config.is_external_presets = true;
354        fs::create_dir_all(config.shine_dir()).await.unwrap();
355
356        let err = handle_build(&config, "doesnotexist").await.unwrap_err();
357        assert!(err.to_string().contains("app preset category not found"));
358
359        fs::remove_dir_all(&dir).await.unwrap();
360    }
361
362    #[cfg(unix)]
363    #[tokio::test(flavor = "current_thread")]
364    async fn build_runs_script_with_contract_env_vars_and_working_directory() {
365        let dir = make_temp_dir().await;
366        let marker = dir.join("marker.txt");
367        write_sample_category(
368            &dir,
369            &format!(
370                "#!/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",
371                marker = marker.display()
372            ),
373        )
374        .await;
375
376        let mut config = Config::new_for_test(&dir);
377        config.is_external_presets = true;
378        fs::create_dir_all(config.shine_dir()).await.unwrap();
379
380        handle_build(&config, "sample").await.unwrap();
381
382        let content = fs::read_to_string(&marker).await.unwrap();
383        let mut lines = content.lines();
384        let expected_app_dir = std::fs::canonicalize(dir.join("presets/app/sample")).unwrap();
385        assert_eq!(
386            lines.next().unwrap(),
387            expected_app_dir.display().to_string()
388        );
389        assert_eq!(lines.next().unwrap(), "sample");
390        assert_eq!(
391            lines.next().unwrap(),
392            config
393                .shine_dir()
394                .join("http")
395                .join("app")
396                .join("sample")
397                .display()
398                .to_string()
399        );
400
401        fs::remove_dir_all(&dir).await.unwrap();
402    }
403
404    #[cfg(unix)]
405    #[tokio::test(flavor = "current_thread")]
406    async fn build_injects_env_table_into_script() {
407        let dir = make_temp_dir().await;
408        let marker = dir.join("env-marker.txt");
409        write_sample_category(
410            &dir,
411            &format!(
412                "#!/bin/sh\nset -e\nprintf '%s' \"$SURGE_PROFILE\" > \"{marker}\"\n",
413                marker = marker.display()
414            ),
415        )
416        .await;
417
418        let mut config = Config::new_for_test(&dir);
419        config.is_external_presets = true;
420        config
421            .env
422            .insert("SURGE_PROFILE".into(), "/abs/path/Profile.conf".into());
423        fs::create_dir_all(config.shine_dir()).await.unwrap();
424
425        handle_build(&config, "sample").await.unwrap();
426
427        assert_eq!(
428            fs::read_to_string(&marker).await.unwrap(),
429            "/abs/path/Profile.conf"
430        );
431
432        fs::remove_dir_all(&dir).await.unwrap();
433    }
434
435    #[cfg(unix)]
436    #[tokio::test(flavor = "current_thread")]
437    async fn build_prefers_overlay_script_over_source_script() {
438        let dir = make_temp_dir().await;
439        write_sample_category(&dir, "#!/bin/sh\nexit 1\n").await;
440
441        let overlay_dir = dir.join("overlay");
442        let overlay_cat_dir = overlay_dir.join("app/sample");
443        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
444        let marker = dir.join("overlay-ran");
445        let overlay_script = overlay_cat_dir.join("build.sh");
446        fs::write(
447            &overlay_script,
448            format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
449        )
450        .await
451        .unwrap();
452        {
453            use std::os::unix::fs::PermissionsExt;
454            let mut perms = fs::metadata(&overlay_script).await.unwrap().permissions();
455            perms.set_mode(perms.mode() | 0o111);
456            fs::set_permissions(&overlay_script, perms).await.unwrap();
457        }
458
459        let mut config = Config::new_for_test(&dir);
460        config.is_external_presets = true;
461        config.presets_overlay_dir_override = Some(overlay_dir);
462        fs::create_dir_all(config.shine_dir()).await.unwrap();
463
464        handle_build(&config, "sample").await.unwrap();
465
466        assert!(
467            marker.exists(),
468            "overlay build.sh should run instead of the source one"
469        );
470
471        fs::remove_dir_all(&dir).await.unwrap();
472    }
473
474    #[cfg(unix)]
475    #[tokio::test(flavor = "current_thread")]
476    async fn build_falls_back_to_source_script_when_overlay_has_only_content() {
477        let dir = make_temp_dir().await;
478        let marker = dir.join("source-ran");
479        write_sample_category(
480            &dir,
481            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
482        )
483        .await;
484
485        let overlay_dir = dir.join("overlay");
486        let overlay_cat_dir = overlay_dir.join("app/sample");
487        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
488        fs::write(overlay_cat_dir.join("config.toml"), "name = \"overlay\"\n")
489            .await
490            .unwrap();
491
492        let mut config = Config::new_for_test(&dir);
493        config.is_external_presets = true;
494        config.presets_overlay_dir_override = Some(overlay_dir);
495        fs::create_dir_all(config.shine_dir()).await.unwrap();
496
497        handle_build(&config, "sample").await.unwrap();
498
499        assert!(
500            marker.exists(),
501            "source build script should run when the overlay has no artifact script"
502        );
503
504        fs::remove_dir_all(&dir).await.unwrap();
505    }
506
507    #[cfg(unix)]
508    #[tokio::test(flavor = "current_thread")]
509    async fn build_propagates_nonzero_script_exit_as_error() {
510        let dir = make_temp_dir().await;
511        write_sample_category(&dir, "#!/bin/sh\nexit 7\n").await;
512
513        let mut config = Config::new_for_test(&dir);
514        config.is_external_presets = true;
515        fs::create_dir_all(config.shine_dir()).await.unwrap();
516
517        let err = handle_build(&config, "sample").await.unwrap_err();
518        assert!(err.to_string().contains("exited with"));
519
520        fs::remove_dir_all(&dir).await.unwrap();
521    }
522
523    #[cfg(unix)]
524    async fn write_teardown_category(dir: &Path, teardown_body: &str) {
525        let cat_dir = dir.join("presets/app/sample");
526        fs::create_dir_all(&cat_dir).await.unwrap();
527        fs::write(
528            cat_dir.join("shine.toml"),
529            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[artifact]\nscript = \"build.sh\"\nteardown = \"unbuild.sh\"\n\n[[files]]\nsource = \"config.toml\"\n",
530        )
531        .await
532        .unwrap();
533        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
534            .await
535            .unwrap();
536        fs::write(cat_dir.join("build.sh"), "#!/bin/sh\nexit 0\n")
537            .await
538            .unwrap();
539        let script_path = cat_dir.join("unbuild.sh");
540        fs::write(&script_path, teardown_body).await.unwrap();
541        use std::os::unix::fs::PermissionsExt;
542        let mut perms = fs::metadata(&script_path).await.unwrap().permissions();
543        perms.set_mode(perms.mode() | 0o111);
544        fs::set_permissions(&script_path, perms).await.unwrap();
545    }
546
547    #[cfg(unix)]
548    #[tokio::test(flavor = "current_thread")]
549    async fn unbuild_runs_teardown_script() {
550        let dir = make_temp_dir().await;
551        let marker = dir.join("unbuild-ran");
552        write_teardown_category(
553            &dir,
554            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
555        )
556        .await;
557
558        let mut config = Config::new_for_test(&dir);
559        config.is_external_presets = true;
560        fs::create_dir_all(config.shine_dir()).await.unwrap();
561
562        handle_unbuild(&config, "sample").await.unwrap();
563        assert!(marker.exists(), "teardown script should have run");
564
565        fs::remove_dir_all(&dir).await.unwrap();
566    }
567
568    #[cfg(unix)]
569    #[tokio::test(flavor = "current_thread")]
570    async fn unbuild_bails_when_no_teardown_declared() {
571        let dir = make_temp_dir().await;
572        write_sample_category(&dir, "#!/bin/sh\nexit 0\n").await;
573
574        let mut config = Config::new_for_test(&dir);
575        config.is_external_presets = true;
576        fs::create_dir_all(config.shine_dir()).await.unwrap();
577
578        let err = handle_unbuild(&config, "sample").await.unwrap_err();
579        assert!(
580            err.to_string()
581                .contains("does not define an artifact teardown script")
582        );
583
584        fs::remove_dir_all(&dir).await.unwrap();
585    }
586
587    #[cfg(unix)]
588    #[tokio::test(flavor = "current_thread")]
589    async fn unbuild_propagates_nonzero_teardown_exit() {
590        let dir = make_temp_dir().await;
591        write_teardown_category(&dir, "#!/bin/sh\nexit 5\n").await;
592
593        let mut config = Config::new_for_test(&dir);
594        config.is_external_presets = true;
595        fs::create_dir_all(config.shine_dir()).await.unwrap();
596
597        let err = handle_unbuild(&config, "sample").await.unwrap_err();
598        assert!(err.to_string().contains("exited with"));
599
600        fs::remove_dir_all(&dir).await.unwrap();
601    }
602
603    #[cfg(unix)]
604    #[tokio::test(flavor = "current_thread")]
605    async fn teardown_for_uninstall_is_gated_for_external_presets() {
606        let dir = make_temp_dir().await;
607        let marker = dir.join("teardown-ran");
608        write_teardown_category(
609            &dir,
610            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
611        )
612        .await;
613
614        let mut config = Config::new_for_test(&dir);
615        config.is_external_presets = true;
616        fs::create_dir_all(config.shine_dir()).await.unwrap();
617
618        let categories = metadata::load_active_categories(&config, Some("sample"))
619            .await
620            .unwrap();
621        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
622
623        // External preset without the opt-in: teardown must be skipped.
624        run_teardown_for_uninstall(&config, cat, false).await;
625        assert!(!marker.exists(), "external teardown must be gated");
626
627        // Opt in: teardown runs.
628        config.allow_app_hooks = true;
629        run_teardown_for_uninstall(&config, cat, false).await;
630        assert!(marker.exists(), "teardown should run once opted in");
631
632        fs::remove_dir_all(&dir).await.unwrap();
633    }
634
635    #[cfg(unix)]
636    #[tokio::test(flavor = "current_thread")]
637    async fn teardown_for_uninstall_dry_run_does_not_execute() {
638        let dir = make_temp_dir().await;
639        let marker = dir.join("teardown-ran");
640        write_teardown_category(
641            &dir,
642            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
643        )
644        .await;
645
646        let mut config = Config::new_for_test(&dir);
647        config.is_external_presets = true;
648        config.allow_app_hooks = true;
649        fs::create_dir_all(config.shine_dir()).await.unwrap();
650
651        let categories = metadata::load_active_categories(&config, Some("sample"))
652            .await
653            .unwrap();
654        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
655
656        run_teardown_for_uninstall(&config, cat, true).await;
657        assert!(!marker.exists(), "dry-run teardown must not execute");
658
659        fs::remove_dir_all(&dir).await.unwrap();
660    }
661}