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) = if let Some(overlay_dir) = &overlay_dir
139        && overlay_dir.join(script_name).exists()
140    {
141        (overlay_dir.clone(), overlay_dir.join(script_name))
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)
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 mut command = Command::new("bun");
181            command.arg(&script_path);
182            command
183        }
184        metadata::ArtifactRuntime::Native => Command::new(&script_path),
185    };
186    command
187        .current_dir(&resolved_app_dir)
188        .envs(env_config.as_map())
189        .env("SHINE_APP_ID", app_id)
190        .env("SHINE_APP_DIR", &resolved_app_dir)
191        .env("SHINE_APP_SOURCE_DIR", &source_dir)
192        .env("SHINE_APP_HTTP_DIR", &http_dir)
193        .env("SHINE_CONFIG_DIR", config.shine_dir())
194        .env("SHINE_CACHE_DIR", &cache_dir)
195        .env("SHINE_STATE_DIR", &state_dir);
196    if let Some(overlay_dir) = &overlay_dir {
197        command.env("SHINE_APP_OVERLAY_DIR", overlay_dir);
198    }
199
200    Ok(command)
201}
202
203/// Runs a prepared artifact `Command` with inherited (live) stdio and turns a
204/// nonzero exit into a real error — the explicit-command semantics shared by
205/// `build` and `unbuild`.
206async fn run_artifact_command(command: &mut Command, app_id: &str) -> Result<()> {
207    let status = command
208        .status()
209        .await
210        .with_context(|| format!("running artifact script for '{app_id}'"))?;
211    if !status.success() {
212        bail!("artifact script for '{app_id}' exited with {status}");
213    }
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::path::{Path, PathBuf};
221    use tokio::fs;
222
223    async fn make_temp_dir() -> PathBuf {
224        crate::test_support::make_temp_dir("shine-apps-build").await
225    }
226
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    #[cfg(unix)]
251    #[tokio::test(flavor = "current_thread")]
252    async fn build_bails_when_no_artifact_declared() {
253        let dir = make_temp_dir().await;
254        let cat_dir = dir.join("presets/app/sample");
255        fs::create_dir_all(&cat_dir).await.unwrap();
256        fs::write(
257            cat_dir.join("shine.toml"),
258            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"config.toml\"\n",
259        )
260        .await
261        .unwrap();
262        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
263            .await
264            .unwrap();
265
266        let mut config = Config::new_for_test(&dir);
267        config.is_external_presets = true;
268        fs::create_dir_all(config.shine_dir()).await.unwrap();
269
270        let err = handle_build(&config, "sample").await.unwrap_err();
271        assert!(
272            err.to_string()
273                .contains("does not define an artifact script")
274        );
275
276        fs::remove_dir_all(&dir).await.unwrap();
277    }
278
279    #[cfg(unix)]
280    #[tokio::test(flavor = "current_thread")]
281    async fn build_bails_for_unknown_app_id() {
282        let dir = make_temp_dir().await;
283        let mut config = Config::new_for_test(&dir);
284        config.is_external_presets = true;
285        fs::create_dir_all(config.shine_dir()).await.unwrap();
286
287        let err = handle_build(&config, "doesnotexist").await.unwrap_err();
288        assert!(err.to_string().contains("app preset category not found"));
289
290        fs::remove_dir_all(&dir).await.unwrap();
291    }
292
293    #[cfg(unix)]
294    #[tokio::test(flavor = "current_thread")]
295    async fn build_runs_script_with_contract_env_vars_and_working_directory() {
296        let dir = make_temp_dir().await;
297        let marker = dir.join("marker.txt");
298        write_sample_category(
299            &dir,
300            &format!(
301                "#!/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",
302                marker = marker.display()
303            ),
304        )
305        .await;
306
307        let mut config = Config::new_for_test(&dir);
308        config.is_external_presets = true;
309        fs::create_dir_all(config.shine_dir()).await.unwrap();
310
311        handle_build(&config, "sample").await.unwrap();
312
313        let content = fs::read_to_string(&marker).await.unwrap();
314        let mut lines = content.lines();
315        let expected_app_dir = std::fs::canonicalize(dir.join("presets/app/sample")).unwrap();
316        assert_eq!(
317            lines.next().unwrap(),
318            expected_app_dir.display().to_string()
319        );
320        assert_eq!(lines.next().unwrap(), "sample");
321        assert_eq!(
322            lines.next().unwrap(),
323            config
324                .shine_dir()
325                .join("http")
326                .join("app")
327                .join("sample")
328                .display()
329                .to_string()
330        );
331
332        fs::remove_dir_all(&dir).await.unwrap();
333    }
334
335    #[cfg(unix)]
336    #[tokio::test(flavor = "current_thread")]
337    async fn build_injects_env_table_into_script() {
338        let dir = make_temp_dir().await;
339        let marker = dir.join("env-marker.txt");
340        write_sample_category(
341            &dir,
342            &format!(
343                "#!/bin/sh\nset -e\nprintf '%s' \"$SURGE_PROFILE\" > \"{marker}\"\n",
344                marker = marker.display()
345            ),
346        )
347        .await;
348
349        let mut config = Config::new_for_test(&dir);
350        config.is_external_presets = true;
351        config
352            .env
353            .insert("SURGE_PROFILE".into(), "/abs/path/Profile.conf".into());
354        fs::create_dir_all(config.shine_dir()).await.unwrap();
355
356        handle_build(&config, "sample").await.unwrap();
357
358        assert_eq!(
359            fs::read_to_string(&marker).await.unwrap(),
360            "/abs/path/Profile.conf"
361        );
362
363        fs::remove_dir_all(&dir).await.unwrap();
364    }
365
366    #[cfg(unix)]
367    #[tokio::test(flavor = "current_thread")]
368    async fn build_prefers_overlay_script_over_source_script() {
369        let dir = make_temp_dir().await;
370        write_sample_category(&dir, "#!/bin/sh\nexit 1\n").await;
371
372        let overlay_dir = dir.join("overlay");
373        let overlay_cat_dir = overlay_dir.join("app/sample");
374        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
375        let marker = dir.join("overlay-ran");
376        let overlay_script = overlay_cat_dir.join("build.sh");
377        fs::write(
378            &overlay_script,
379            format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
380        )
381        .await
382        .unwrap();
383        {
384            use std::os::unix::fs::PermissionsExt;
385            let mut perms = fs::metadata(&overlay_script).await.unwrap().permissions();
386            perms.set_mode(perms.mode() | 0o111);
387            fs::set_permissions(&overlay_script, perms).await.unwrap();
388        }
389
390        let mut config = Config::new_for_test(&dir);
391        config.is_external_presets = true;
392        config.presets_overlay_dir_override = Some(overlay_dir);
393        fs::create_dir_all(config.shine_dir()).await.unwrap();
394
395        handle_build(&config, "sample").await.unwrap();
396
397        assert!(
398            marker.exists(),
399            "overlay build.sh should run instead of the source one"
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_falls_back_to_source_script_when_overlay_has_only_content() {
408        let dir = make_temp_dir().await;
409        let marker = dir.join("source-ran");
410        write_sample_category(
411            &dir,
412            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
413        )
414        .await;
415
416        let overlay_dir = dir.join("overlay");
417        let overlay_cat_dir = overlay_dir.join("app/sample");
418        fs::create_dir_all(&overlay_cat_dir).await.unwrap();
419        fs::write(overlay_cat_dir.join("config.toml"), "name = \"overlay\"\n")
420            .await
421            .unwrap();
422
423        let mut config = Config::new_for_test(&dir);
424        config.is_external_presets = true;
425        config.presets_overlay_dir_override = Some(overlay_dir);
426        fs::create_dir_all(config.shine_dir()).await.unwrap();
427
428        handle_build(&config, "sample").await.unwrap();
429
430        assert!(
431            marker.exists(),
432            "source build script should run when the overlay has no artifact script"
433        );
434
435        fs::remove_dir_all(&dir).await.unwrap();
436    }
437
438    #[cfg(unix)]
439    #[tokio::test(flavor = "current_thread")]
440    async fn build_propagates_nonzero_script_exit_as_error() {
441        let dir = make_temp_dir().await;
442        write_sample_category(&dir, "#!/bin/sh\nexit 7\n").await;
443
444        let mut config = Config::new_for_test(&dir);
445        config.is_external_presets = true;
446        fs::create_dir_all(config.shine_dir()).await.unwrap();
447
448        let err = handle_build(&config, "sample").await.unwrap_err();
449        assert!(err.to_string().contains("exited with"));
450
451        fs::remove_dir_all(&dir).await.unwrap();
452    }
453
454    #[cfg(unix)]
455    async fn write_teardown_category(dir: &Path, teardown_body: &str) {
456        let cat_dir = dir.join("presets/app/sample");
457        fs::create_dir_all(&cat_dir).await.unwrap();
458        fs::write(
459            cat_dir.join("shine.toml"),
460            "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[artifact]\nscript = \"build.sh\"\nteardown = \"unbuild.sh\"\n\n[[files]]\nsource = \"config.toml\"\n",
461        )
462        .await
463        .unwrap();
464        fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
465            .await
466            .unwrap();
467        fs::write(cat_dir.join("build.sh"), "#!/bin/sh\nexit 0\n")
468            .await
469            .unwrap();
470        let script_path = cat_dir.join("unbuild.sh");
471        fs::write(&script_path, teardown_body).await.unwrap();
472        use std::os::unix::fs::PermissionsExt;
473        let mut perms = fs::metadata(&script_path).await.unwrap().permissions();
474        perms.set_mode(perms.mode() | 0o111);
475        fs::set_permissions(&script_path, perms).await.unwrap();
476    }
477
478    #[cfg(unix)]
479    #[tokio::test(flavor = "current_thread")]
480    async fn unbuild_runs_teardown_script() {
481        let dir = make_temp_dir().await;
482        let marker = dir.join("unbuild-ran");
483        write_teardown_category(
484            &dir,
485            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
486        )
487        .await;
488
489        let mut config = Config::new_for_test(&dir);
490        config.is_external_presets = true;
491        fs::create_dir_all(config.shine_dir()).await.unwrap();
492
493        handle_unbuild(&config, "sample").await.unwrap();
494        assert!(marker.exists(), "teardown script should have run");
495
496        fs::remove_dir_all(&dir).await.unwrap();
497    }
498
499    #[cfg(unix)]
500    #[tokio::test(flavor = "current_thread")]
501    async fn unbuild_bails_when_no_teardown_declared() {
502        let dir = make_temp_dir().await;
503        write_sample_category(&dir, "#!/bin/sh\nexit 0\n").await;
504
505        let mut config = Config::new_for_test(&dir);
506        config.is_external_presets = true;
507        fs::create_dir_all(config.shine_dir()).await.unwrap();
508
509        let err = handle_unbuild(&config, "sample").await.unwrap_err();
510        assert!(
511            err.to_string()
512                .contains("does not define an artifact teardown script")
513        );
514
515        fs::remove_dir_all(&dir).await.unwrap();
516    }
517
518    #[cfg(unix)]
519    #[tokio::test(flavor = "current_thread")]
520    async fn unbuild_propagates_nonzero_teardown_exit() {
521        let dir = make_temp_dir().await;
522        write_teardown_category(&dir, "#!/bin/sh\nexit 5\n").await;
523
524        let mut config = Config::new_for_test(&dir);
525        config.is_external_presets = true;
526        fs::create_dir_all(config.shine_dir()).await.unwrap();
527
528        let err = handle_unbuild(&config, "sample").await.unwrap_err();
529        assert!(err.to_string().contains("exited with"));
530
531        fs::remove_dir_all(&dir).await.unwrap();
532    }
533
534    #[cfg(unix)]
535    #[tokio::test(flavor = "current_thread")]
536    async fn teardown_for_uninstall_is_gated_for_external_presets() {
537        let dir = make_temp_dir().await;
538        let marker = dir.join("teardown-ran");
539        write_teardown_category(
540            &dir,
541            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
542        )
543        .await;
544
545        let mut config = Config::new_for_test(&dir);
546        config.is_external_presets = true;
547        fs::create_dir_all(config.shine_dir()).await.unwrap();
548
549        let categories = metadata::load_active_categories(&config, Some("sample"))
550            .await
551            .unwrap();
552        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
553
554        // External preset without the opt-in: teardown must be skipped.
555        run_teardown_for_uninstall(&config, cat, false).await;
556        assert!(!marker.exists(), "external teardown must be gated");
557
558        // Opt in: teardown runs.
559        config.allow_app_hooks = true;
560        run_teardown_for_uninstall(&config, cat, false).await;
561        assert!(marker.exists(), "teardown should run once opted in");
562
563        fs::remove_dir_all(&dir).await.unwrap();
564    }
565
566    #[cfg(unix)]
567    #[tokio::test(flavor = "current_thread")]
568    async fn teardown_for_uninstall_dry_run_does_not_execute() {
569        let dir = make_temp_dir().await;
570        let marker = dir.join("teardown-ran");
571        write_teardown_category(
572            &dir,
573            &format!("#!/bin/sh\ntouch \"{}\"\n", marker.display()),
574        )
575        .await;
576
577        let mut config = Config::new_for_test(&dir);
578        config.is_external_presets = true;
579        config.allow_app_hooks = true;
580        fs::create_dir_all(config.shine_dir()).await.unwrap();
581
582        let categories = metadata::load_active_categories(&config, Some("sample"))
583            .await
584            .unwrap();
585        let cat = categories.iter().find(|c| c.name == "sample").unwrap();
586
587        run_teardown_for_uninstall(&config, cat, true).await;
588        assert!(!marker.exists(), "dry-run teardown must not execute");
589
590        fs::remove_dir_all(&dir).await.unwrap();
591    }
592}