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 #[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 run_teardown_for_uninstall(&config, cat, false).await;
626 assert!(!marker.exists(), "external teardown must be gated");
627
628 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}