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) = 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 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 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
203async 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 run_teardown_for_uninstall(&config, cat, false).await;
556 assert!(!marker.exists(), "external teardown must be gated");
557
558 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}