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
9pub 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
30pub 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
53pub(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
117async 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 let env_config = crate::env::EnvConfig::load_or_init(config).await?;
171
172 let mut command = match runtime {
173 metadata::ArtifactRuntime::Bun => {
174 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
202async 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 run_teardown_for_uninstall(&config, cat, false).await;
625 assert!(!marker.exists(), "external teardown must be gated");
626
627 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}