1use crate::action::{ActionIrV1, ActionKindV1};
2use crate::env::EnvVarSpec;
3use crate::install::file_ops::{
4 InstallOutcome, UninstallOutcome, install_bytes_with_host, uninstall_entry_with_host,
5};
6use crate::install::{AppEntry, AppInstallStrategy, AppManifest, hash_content};
7use crate::lifecycle::{
8 LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1, LifecycleResultV1, LifecycleStatus,
9};
10use crate::permission::PermissionDeclarationV1;
11use crate::plan::{PermissionV1, PlanActionV1, PlanApprovalV1, PlanV1};
12use crate::runtime::{
13 AppFileInspection, CoreRuntime, FileSystemHost, InspectionChange, InspectionFileStatus,
14 PrivilegedFileSystemHost, ProcessHost, ProcessIo, ProcessRequest, RuntimeEvent,
15 RuntimeInteraction, RuntimeObserver,
16};
17use crate::trust::TrustCapabilityV1;
18use anyhow::{Context, Result, bail};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::Path;
22use std::path::PathBuf;
23use std::time::Duration;
24
25const GENERATOR_TIMEOUT: Duration = Duration::from_secs(30);
26const GENERATOR_STDOUT_LIMIT: usize = 8 * 1024 * 1024;
27const GENERATOR_STDERR_LIMIT: usize = 64 * 1024;
28
29#[derive(Debug, Clone)]
30pub struct AppCategory {
31 pub name: String,
32 pub description: Option<String>,
33 pub destination_root: Option<String>,
34 pub files: Vec<AppFile>,
35 pub list_mode: AppListMode,
36 pub post_upgrade: Vec<AppHook>,
37 pub post_install: Vec<AppHook>,
38 pub uses_metadata: bool,
39 pub has_explicit_files: bool,
40 pub artifact: Option<AppArtifact>,
41 pub permissions: Option<PermissionDeclarationV1>,
42 pub metadata_schema_version: u32,
44 pub metadata_is_overlay: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum AppListMode {
50 Category,
51 Files,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AppHook {
56 pub action: AppHookAction,
57 pub args: Vec<String>,
58 pub show_output: bool,
59 pub env: Vec<EnvVarSpec>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum AppHookAction {
64 Command(String),
65 Script {
66 script: PathBuf,
67 runtime: ArtifactRuntime,
68 },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum ArtifactRuntime {
73 #[default]
74 Native,
75 Bun,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AppArtifact {
80 pub script: String,
81 pub teardown: Option<String>,
82 pub runtime: ArtifactRuntime,
83 pub env: Vec<EnvVarSpec>,
84}
85
86#[derive(Debug, Clone)]
87pub struct AppFile {
88 pub source_rel: PathBuf,
89 pub target_rel: PathBuf,
90 pub destination_root: Option<AppDestinationRoot>,
91 pub description: Option<String>,
92 pub display_name: Option<String>,
93 pub legacy_dest_annotation: Option<String>,
94 pub transforms: Vec<String>,
95 pub install_strategy: AppInstallStrategy,
96 pub requires_admin: bool,
97 pub restart_hint: Option<String>,
98 pub generator: Option<AppGenerator>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum AppDestinationRoot {
103 Path(String),
104 DataDir(PathBuf),
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct AppGenerator {
109 pub script: PathBuf,
110 pub runtime: ArtifactRuntime,
111 pub env: Vec<EnvVarSpec>,
112 pub when_env: String,
113 pub auto: bool,
114}
115
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum AppHookPhase {
118 PostInstall,
119 PostUpgrade,
120}
121
122#[derive(Clone, Debug)]
123pub struct AppHookRequest {
124 pub categories: Vec<AppCategory>,
125 pub changed: BTreeSet<String>,
126 pub phase: AppHookPhase,
127 pub show_success: bool,
128}
129
130#[derive(Clone, Debug)]
131pub struct AppGeneratorRequest {
132 pub category: String,
133 pub source: String,
134 pub generator: AppGenerator,
135 pub explicit: bool,
136}
137
138#[derive(Clone, Debug, Default)]
139pub struct AppInspectionOptions {
140 pub run_generators: bool,
143 pub categories: Vec<String>,
145}
146
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148pub enum AppArtifactAction {
149 Apply,
150 Remove,
151}
152
153#[derive(Clone, Debug)]
154pub struct AppArtifactRequest {
155 pub category: String,
156 pub artifact: AppArtifact,
157 pub action: AppArtifactAction,
158 pub implicit: bool,
159 pub dry_run: bool,
160}
161
162#[derive(Clone, Debug)]
163pub struct AppCacheRequest {
164 pub prefix: String,
165 pub dry_run: bool,
166 pub remove: bool,
167 pub purge: bool,
168 pub overwrite: bool,
169}
170
171#[derive(Clone, Debug, Default, Eq, PartialEq)]
172pub struct AppHookReport {
173 pub outcomes: Vec<LifecycleOutcomeV1>,
174 pub notes: Vec<String>,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq)]
180pub struct AppLifecycleRequest {
181 pub target: Option<String>,
182 pub dry_run: bool,
183 pub force: bool,
184}
185
186#[derive(Clone, Debug, Default, Eq, PartialEq)]
187pub struct AppUninstallLifecycleRequest {
188 pub target: Option<String>,
189 pub dry_run: bool,
190 pub force: bool,
191 pub purge: bool,
192}
193
194#[derive(Clone, Debug, Default, Eq, PartialEq)]
195pub struct AppRefreshRequest {
196 pub category: String,
197 pub file: Option<PathBuf>,
198 pub force: bool,
199}
200
201#[derive(Clone, Debug, Default, Eq, PartialEq)]
202pub struct AppUpgradeRequest {
203 pub category: Option<String>,
204 pub prune_stale: bool,
205 pub prompt_stale: bool,
206 pub show_hook_success: bool,
207}
208
209#[derive(Clone, Debug)]
210pub struct AppUpgradeLifecycleReport {
211 pub files: Vec<AppFileLifecycleReport>,
212 pub updated_categories: Vec<String>,
213 pub skipped: usize,
214 pub failed: usize,
215 pub user_modified: usize,
216 pub restart_hints: BTreeSet<String>,
217 pub lifecycle: LifecycleResultV1,
218}
219
220#[derive(Clone, Debug)]
221pub struct AppFileLifecycleReport {
222 pub category: String,
223 pub source: PathBuf,
224 pub destination: PathBuf,
225 pub transforms: Vec<String>,
226 pub backup: Option<PathBuf>,
227 pub restart_hint: Option<String>,
228 pub generator_error: Option<String>,
229 pub error: Option<String>,
230 pub status: LifecycleStatus,
231 pub action: AppFileAction,
232}
233
234#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235pub enum AppFileAction {
236 Installed,
237 BackedUp,
238 Unchanged,
239 PreviewInstall,
240 GeneratorPreserved,
241 Removed,
242 Restored,
243 ForceRemoved,
244 ForceRestored,
245 Missing,
246 UserModified,
247 PreviewRemove,
248 Failed,
249}
250
251#[derive(Clone, Debug)]
252pub struct AppLifecycleReport {
253 pub categories: Vec<AppCategory>,
254 pub files: Vec<AppFileLifecycleReport>,
255 pub lifecycle: LifecycleResultV1,
256}
257
258#[derive(Clone, Debug)]
259struct AssessedAppFile {
260 category: AppCategory,
261 file: AppFile,
262 destination: PathBuf,
263 content: Option<Vec<u8>>,
264 generator_error: Option<String>,
265 generator_diagnostic: Option<&'static str>,
266}
267
268#[derive(Clone, Copy, Debug, Default)]
269struct AppAssessmentOptions {
270 dry_run: bool,
271 run_generators: bool,
272 explicit_generators: bool,
273 preserve_generator_errors: bool,
274}
275
276struct ApprovedAppInstall<'a> {
277 plan: &'a PlanV1,
278 approval: &'a PlanApprovalV1,
279 action_irs: Vec<ActionIrV1>,
280}
281
282struct ApprovedAppUninstall<'a> {
283 plan: &'a PlanV1,
284 approval: &'a PlanApprovalV1,
285 action_irs: Vec<ActionIrV1>,
286}
287
288fn generator_inspection_status(diagnostic: Option<&'static str>) -> InspectionFileStatus {
289 match diagnostic {
290 Some("app_generator_trust_required") => InspectionFileStatus::GeneratorTrustRequired,
291 Some("app_generator_evaluation_failed") => InspectionFileStatus::GeneratorEvaluationFailed,
292 _ => InspectionFileStatus::GeneratorNotEvaluated,
293 }
294}
295
296impl AppLifecycleReport {
297 fn new(operation: LifecycleOperation, dry_run: bool, categories: Vec<AppCategory>) -> Self {
298 Self {
299 categories,
300 files: Vec::new(),
301 lifecycle: LifecycleResultV1::new(operation, dry_run),
302 }
303 }
304}
305
306impl<H> CoreRuntime<H>
307where
308 H: FileSystemHost + PrivilegedFileSystemHost + ProcessHost,
309{
310 pub(crate) async fn install_apps(
314 &self,
315 request: AppLifecycleRequest,
316 observer: &mut impl RuntimeObserver,
317 interaction: &mut impl RuntimeInteraction,
318 ) -> Result<AppLifecycleReport> {
319 self.install_apps_inner(request, None, observer, interaction)
320 .await
321 }
322
323 pub(crate) async fn install_apps_with_approved_actions(
324 &self,
325 request: AppLifecycleRequest,
326 plan: &PlanV1,
327 approval: &PlanApprovalV1,
328 action_irs: Vec<ActionIrV1>,
329 observer: &mut impl RuntimeObserver,
330 interaction: &mut impl RuntimeInteraction,
331 ) -> Result<AppLifecycleReport> {
332 self.install_apps_inner(
333 request,
334 Some(ApprovedAppInstall {
335 plan,
336 approval,
337 action_irs,
338 }),
339 observer,
340 interaction,
341 )
342 .await
343 }
344
345 async fn install_apps_inner(
346 &self,
347 request: AppLifecycleRequest,
348 mut approved: Option<ApprovedAppInstall<'_>>,
349 observer: &mut impl RuntimeObserver,
350 interaction: &mut impl RuntimeInteraction,
351 ) -> Result<AppLifecycleReport> {
352 let categories = self.app_categories(request.target.as_deref())?;
353 if let Some(target) = &request.target
354 && categories.is_empty()
355 {
356 bail!("app preset category not found: {target}");
357 }
358 validate_app_destinations(self, &categories)?;
359
360 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
363 let mut report = AppLifecycleReport::new(
364 LifecycleOperation::Install,
365 request.dry_run,
366 categories.clone(),
367 );
368 for category in &categories {
369 let cache = if self.context.is_external_presets {
370 LifecycleOutcomeV1::new(
371 format!("app/{}", category.name),
372 Some("preset-cache"),
373 LifecycleStatus::Skipped,
374 [LifecycleEffect::UserResourcePreserved],
375 )
376 .with_diagnostic_code("app_external_preset_cache_preserved")
377 } else {
378 self.reconcile_app_cache(AppCacheRequest {
379 prefix: format!("app/{}", category.name),
380 dry_run: request.dry_run,
381 remove: false,
382 purge: false,
383 overwrite: true,
385 })
386 .await?
387 };
388 report.lifecycle.push(cache);
389 }
390
391 let assessed = self
392 .assess_app_files(
393 &categories,
394 AppAssessmentOptions {
395 dry_run: request.dry_run,
396 run_generators: true,
397 explicit_generators: true,
398 preserve_generator_errors: false,
399 },
400 &manifest,
401 observer,
402 )
403 .await?;
404 let admin_count = assessed
405 .iter()
406 .filter(|assessment| assessment.file.requires_admin)
407 .count();
408 let admin_authorized = request.dry_run
409 || admin_count == 0
410 || self.context.running_as_admin
411 || interaction.authorize_admin(admin_count).await?;
412
413 let mut changed = BTreeSet::new();
414 for assessment in assessed {
415 let source = assessment.file.source_rel.clone();
416 let target = format!("app/{}", assessment.category.name);
417 if assessment.file.requires_admin && !admin_authorized {
418 report.lifecycle.push(
419 LifecycleOutcomeV1::new(
420 &target,
421 Some(source.display().to_string()),
422 LifecycleStatus::Failed,
423 [],
424 )
425 .with_diagnostic_code("app_admin_not_authorized"),
426 );
427 report.files.push(AppFileLifecycleReport {
428 category: assessment.category.name,
429 source,
430 destination: assessment.destination,
431 transforms: assessment.file.transforms,
432 backup: None,
433 restart_hint: assessment.file.restart_hint,
434 generator_error: None,
435 error: Some("administrator permission was not granted".to_string()),
436 status: LifecycleStatus::Failed,
437 action: AppFileAction::Failed,
438 });
439 continue;
440 }
441 if let Some(generator_error) = assessment.generator_error {
442 report.lifecycle.push(
443 LifecycleOutcomeV1::new(
444 &target,
445 Some(source.display().to_string()),
446 LifecycleStatus::Preserved,
447 [LifecycleEffect::ManagedResourcePreserved],
448 )
449 .with_diagnostic_code("app_generator_unavailable"),
450 );
451 report.files.push(AppFileLifecycleReport {
452 category: assessment.category.name,
453 source,
454 destination: assessment.destination,
455 transforms: assessment.file.transforms,
456 backup: None,
457 restart_hint: assessment.file.restart_hint,
458 generator_error: Some(generator_error),
459 error: None,
460 status: LifecycleStatus::Preserved,
461 action: AppFileAction::GeneratorPreserved,
462 });
463 continue;
464 }
465 let content = assessment
466 .content
467 .as_deref()
468 .context("App assessment did not contain install content")?;
469 let previous = manifest.find_by_dest(&assessment.destination).cloned();
470 let resource = source.display().to_string();
471 let action_index = approved.as_ref().and_then(|approved| {
472 approved.action_irs.iter().position(|ir| {
473 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
474 })
475 });
476 let mut journal_execution = None;
477 let outcome = if let Some(index) = action_index {
478 let approved = approved.as_mut().expect("approved App actions");
479 let action_ir = approved.action_irs.remove(index);
480 let is_update = matches!(
481 action_ir.actions.as_slice(),
482 [action] if matches!(action.kind, crate::action::ActionKindV1::UpdateManagedFile { .. })
483 );
484 let is_json = matches!(
485 action_ir.actions.as_slice(),
486 [action] if matches!(action.kind, crate::action::ActionKindV1::MergeManagedJson { .. })
487 );
488 let execution = if is_json {
489 self.execute_app_managed_json_merge_approved(
490 approved.plan,
491 approved.approval,
492 action_ir,
493 content,
494 )
495 .await?
496 } else if is_update {
497 self.execute_app_managed_file_update_approved(
498 approved.plan,
499 approved.approval,
500 action_ir,
501 content,
502 )
503 .await?
504 } else {
505 self.execute_app_managed_file_creation_approved(
506 approved.plan,
507 approved.approval,
508 action_ir,
509 content,
510 )
511 .await?
512 };
513 let backup = (!is_update).then(|| execution.backup.clone()).flatten();
514 journal_execution = Some(execution);
515 let installed_hash = desired_app_hash(&assessment.file, content)?;
516 Ok(match backup {
517 Some(backup) => InstallOutcome::BackedUpAndInstalled {
518 backup,
519 hash: installed_hash,
520 },
521 None => InstallOutcome::Installed {
522 hash: installed_hash,
523 },
524 })
525 } else {
526 self.install_app_content(
527 &assessment.file,
528 content,
529 &assessment.destination,
530 previous.is_some(),
531 request.dry_run,
532 request.force,
533 )
534 .await
535 };
536 let (status, effects, backup, error, action) = match outcome {
537 Ok(InstallOutcome::Installed { hash }) => {
538 if !request.dry_run {
539 manifest.upsert(app_entry(
540 &assessment,
541 hash,
542 previous.as_ref().and_then(|entry| entry.backup.clone()),
543 ));
544 if let Some(execution) = journal_execution {
545 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
549 self.commit_app_managed_file_operation(&execution).await?;
550 }
551 }
552 changed.insert(assessment.category.name.clone());
553 (
554 LifecycleStatus::Changed,
555 vec![
556 LifecycleEffect::ResourceWritten,
557 LifecycleEffect::ReceiptWritten,
558 ],
559 previous.and_then(|entry| entry.backup),
560 None,
561 AppFileAction::Installed,
562 )
563 }
564 Ok(InstallOutcome::BackedUpAndInstalled { backup, hash }) => {
565 if !request.dry_run {
566 manifest.upsert(app_entry(&assessment, hash, Some(backup.clone())));
567 if let Some(execution) = journal_execution {
568 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
569 self.commit_app_managed_file_operation(&execution).await?;
570 }
571 }
572 changed.insert(assessment.category.name.clone());
573 (
574 LifecycleStatus::Changed,
575 vec![
576 LifecycleEffect::BackupCreated,
577 LifecycleEffect::ResourceWritten,
578 LifecycleEffect::ReceiptWritten,
579 ],
580 Some(backup),
581 None,
582 AppFileAction::BackedUp,
583 )
584 }
585 Ok(InstallOutcome::AlreadyManaged) => (
586 LifecycleStatus::Unchanged,
587 Vec::new(),
588 previous.and_then(|entry| entry.backup),
589 None,
590 AppFileAction::Unchanged,
591 ),
592 Ok(InstallOutcome::DryRun) => (
593 LifecycleStatus::Previewed,
594 vec![
595 LifecycleEffect::ResourceWritePreviewed,
596 LifecycleEffect::ReceiptWritePreviewed,
597 ],
598 previous.and_then(|entry| entry.backup),
599 None,
600 AppFileAction::PreviewInstall,
601 ),
602 Err(error) => (
603 LifecycleStatus::Failed,
604 Vec::new(),
605 previous.and_then(|entry| entry.backup),
606 Some(format!("{error:#}")),
607 AppFileAction::Failed,
608 ),
609 };
610 let mut lifecycle = LifecycleOutcomeV1::new(
611 target,
612 Some(source.display().to_string()),
613 status,
614 effects,
615 );
616 if error.is_some() {
617 lifecycle = lifecycle.with_diagnostic_code("app_install_failed");
618 }
619 report.lifecycle.push(lifecycle);
620 report.files.push(AppFileLifecycleReport {
621 category: assessment.category.name,
622 source,
623 destination: assessment.destination,
624 transforms: assessment.file.transforms,
625 backup,
626 restart_hint: assessment.file.restart_hint,
627 generator_error: None,
628 error,
629 status,
630 action,
631 });
632 }
633 if let Some(approved) = approved
634 && !approved.action_irs.is_empty()
635 {
636 bail!("approved App creation actions were not consumed by lifecycle execution");
637 }
638 if !request.dry_run {
639 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
640 let hooks = self
641 .run_app_hooks(
642 AppHookRequest {
643 categories,
644 changed,
645 phase: AppHookPhase::PostInstall,
646 show_success: true,
647 },
648 observer,
649 )
650 .await;
651 report.lifecycle.outcomes.extend(hooks.outcomes);
652 }
653 Ok(report)
654 }
655
656 pub(crate) async fn uninstall_apps(
659 &self,
660 request: AppUninstallLifecycleRequest,
661 observer: &mut impl RuntimeObserver,
662 interaction: &mut impl RuntimeInteraction,
663 ) -> Result<AppLifecycleReport> {
664 self.uninstall_apps_inner(request, None, observer, interaction)
665 .await
666 }
667
668 pub(crate) async fn uninstall_apps_with_approved_actions(
669 &self,
670 request: AppUninstallLifecycleRequest,
671 plan: &PlanV1,
672 approval: &PlanApprovalV1,
673 action_irs: Vec<ActionIrV1>,
674 observer: &mut impl RuntimeObserver,
675 interaction: &mut impl RuntimeInteraction,
676 ) -> Result<AppLifecycleReport> {
677 self.uninstall_apps_inner(
678 request,
679 Some(ApprovedAppUninstall {
680 plan,
681 approval,
682 action_irs,
683 }),
684 observer,
685 interaction,
686 )
687 .await
688 }
689
690 async fn uninstall_apps_inner(
691 &self,
692 request: AppUninstallLifecycleRequest,
693 mut approved: Option<ApprovedAppUninstall<'_>>,
694 observer: &mut impl RuntimeObserver,
695 interaction: &mut impl RuntimeInteraction,
696 ) -> Result<AppLifecycleReport> {
697 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
698 let target_destinations = if let Some(target) = &request.target {
699 self.app_categories(Some(target))?
700 .iter()
701 .flat_map(|category| {
702 category
703 .files
704 .iter()
705 .filter_map(|file| self.app_destination(category, file).ok())
706 })
707 .collect::<BTreeSet<_>>()
708 } else {
709 BTreeSet::new()
710 };
711 let selected = manifest
712 .entries
713 .iter()
714 .filter(|entry| {
715 request.target.as_ref().is_none_or(|category| {
716 entry.source.starts_with(&format!("app/{category}/"))
717 || target_destinations.contains(&entry.destination)
718 })
719 })
720 .cloned()
721 .collect::<Vec<_>>();
722 let category_names = selected
723 .iter()
724 .filter_map(|entry| entry.source.split('/').nth(1).map(str::to_string))
725 .chain(request.target.iter().cloned())
726 .collect::<BTreeSet<_>>();
727 let categories = self
728 .app_categories(None)?
729 .into_iter()
730 .filter(|category| category_names.contains(&category.name))
731 .collect::<Vec<_>>();
732 let mut report = AppLifecycleReport::new(
733 LifecycleOperation::Uninstall,
734 request.dry_run,
735 categories.clone(),
736 );
737 if request.target.is_some() && selected.is_empty() {
738 return Ok(report);
739 }
740
741 let admin_count = if let Some(approved) = &approved {
742 if approved
743 .plan
744 .permissions
745 .required
746 .contains(&PermissionV1::Administrator)
747 {
748 selected
749 .iter()
750 .filter(|entry| {
751 if !entry.requires_admin {
752 return false;
753 }
754 let Some((category, resource)) = app_source_parts(&entry.source) else {
755 return false;
756 };
757 approved.plan.steps.iter().any(|step| {
758 step.target == format!("app/{category}")
759 && step.resource.as_deref() == Some(resource)
760 && step.action == PlanActionV1::Remove
761 })
762 })
763 .count()
764 } else {
765 0
766 }
767 } else {
768 selected.iter().filter(|entry| entry.requires_admin).count()
769 };
770 let admin_authorized = request.dry_run
771 || admin_count == 0
772 || self.context.running_as_admin
773 || interaction.authorize_admin(admin_count).await?;
774
775 for category in &categories {
778 if let Some(artifact) = category.artifact.clone()
779 && artifact.teardown.is_some()
780 {
781 let outcome = self
782 .run_app_artifact(
783 AppArtifactRequest {
784 category: category.name.clone(),
785 artifact,
786 action: AppArtifactAction::Remove,
787 implicit: true,
788 dry_run: request.dry_run,
789 },
790 observer,
791 )
792 .await
793 .unwrap_or_else(|error| {
794 observer.emit(RuntimeEvent::Warning {
795 code: "app_artifact_teardown_failed",
796 target: Some(format!("app/{}", category.name)),
797 detail: format!("{error:#}"),
798 });
799 LifecycleOutcomeV1::new(
800 format!("app/{}", category.name),
801 Some("artifact:teardown"),
802 LifecycleStatus::Failed,
803 [],
804 )
805 .with_diagnostic_code("app_teardown_setup_failed")
806 });
807 report.lifecycle.push(outcome);
808 }
809 }
810
811 for entry in selected {
812 let category = entry.source.split('/').nth(1).unwrap_or("app").to_string();
813 let source = PathBuf::from(
814 entry
815 .source
816 .splitn(3, '/')
817 .nth(2)
818 .unwrap_or(entry.source.as_str()),
819 );
820 if entry.requires_admin && !admin_authorized {
821 report.lifecycle.push(
822 LifecycleOutcomeV1::new(
823 format!("app/{category}"),
824 Some(source.display().to_string()),
825 LifecycleStatus::Failed,
826 [],
827 )
828 .with_diagnostic_code("app_admin_not_authorized"),
829 );
830 report.files.push(AppFileLifecycleReport {
831 category,
832 source,
833 destination: entry.destination,
834 transforms: Vec::new(),
835 backup: entry.backup,
836 restart_hint: None,
837 generator_error: None,
838 error: Some("administrator permission was not granted".to_string()),
839 status: LifecycleStatus::Failed,
840 action: AppFileAction::Failed,
841 });
842 continue;
843 }
844 let target = format!("app/{category}");
845 let resource = source.display().to_string();
846 let action_index = approved.as_ref().and_then(|approved| {
847 approved.action_irs.iter().position(|ir| {
848 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
849 })
850 });
851 let mut journal_execution = None;
852 let outcome = if let Some(index) = action_index {
853 let approved = approved.as_mut().expect("approved App uninstall actions");
854 let action_ir = approved.action_irs.remove(index);
855 let is_json = matches!(
856 action_ir.actions.as_slice(),
857 [action] if matches!(action.kind, crate::action::ActionKindV1::RemoveManagedJson { .. })
858 );
859 let execution = if is_json {
860 self.execute_app_managed_json_removal_approved(
861 approved.plan,
862 approved.approval,
863 action_ir,
864 )
865 .await?
866 } else {
867 self.execute_app_managed_file_removal_approved(
868 approved.plan,
869 approved.approval,
870 action_ir,
871 )
872 .await?
873 };
874 let outcome = match (execution.forced, execution.backup.clone()) {
875 (false, None) => UninstallOutcome::Removed,
876 (false, Some(backup)) => UninstallOutcome::RestoredBackup { backup },
877 (true, None) => UninstallOutcome::ForceRemoved,
878 (true, Some(backup)) => UninstallOutcome::ForceRestoredBackup { backup },
879 };
880 journal_execution = Some(execution);
881 Ok(outcome)
882 } else {
883 self.uninstall_app_entry(&entry, request.dry_run, request.force)
884 .await
885 };
886 let (status, effects, remove_receipt, error, action) = match outcome {
887 Ok(UninstallOutcome::Removed) => (
888 LifecycleStatus::Changed,
889 vec![
890 LifecycleEffect::ResourceRemoved,
891 LifecycleEffect::ReceiptRemoved,
892 ],
893 true,
894 None,
895 AppFileAction::Removed,
896 ),
897 Ok(UninstallOutcome::ForceRemoved) => (
898 LifecycleStatus::Changed,
899 vec![
900 LifecycleEffect::UserModificationOverridden,
901 LifecycleEffect::ResourceRemoved,
902 LifecycleEffect::ReceiptRemoved,
903 ],
904 true,
905 None,
906 AppFileAction::ForceRemoved,
907 ),
908 Ok(UninstallOutcome::RestoredBackup { .. }) => (
909 LifecycleStatus::Changed,
910 vec![
911 LifecycleEffect::BackupRestored,
912 LifecycleEffect::ReceiptRemoved,
913 ],
914 true,
915 None,
916 AppFileAction::Restored,
917 ),
918 Ok(UninstallOutcome::ForceRestoredBackup { .. }) => (
919 LifecycleStatus::Changed,
920 vec![
921 LifecycleEffect::UserModificationOverridden,
922 LifecycleEffect::BackupRestored,
923 LifecycleEffect::ReceiptRemoved,
924 ],
925 true,
926 None,
927 AppFileAction::ForceRestored,
928 ),
929 Ok(UninstallOutcome::NotFound) => (
930 LifecycleStatus::Changed,
931 vec![LifecycleEffect::ReceiptRemoved],
932 true,
933 None,
934 AppFileAction::Missing,
935 ),
936 Ok(UninstallOutcome::UserModified) => (
937 LifecycleStatus::Preserved,
938 vec![LifecycleEffect::UserResourcePreserved],
939 false,
940 None,
941 AppFileAction::UserModified,
942 ),
943 Ok(UninstallOutcome::DryRun) => (
944 LifecycleStatus::Previewed,
945 vec![
946 LifecycleEffect::ResourceRemovePreviewed,
947 LifecycleEffect::ReceiptRemovePreviewed,
948 ],
949 false,
950 None,
951 AppFileAction::PreviewRemove,
952 ),
953 Err(error) => (
954 LifecycleStatus::Failed,
955 Vec::new(),
956 false,
957 Some(format!("{error:#}")),
958 AppFileAction::Failed,
959 ),
960 };
961 if remove_receipt {
962 manifest.remove_by_dest(&entry.destination);
963 if let Some(execution) = journal_execution {
964 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
965 self.commit_app_managed_file_operation(&execution).await?;
966 }
967 }
968 let mut lifecycle = LifecycleOutcomeV1::new(
969 format!("app/{category}"),
970 Some(source.display().to_string()),
971 status,
972 effects,
973 );
974 if error.is_some() {
975 lifecycle = lifecycle.with_diagnostic_code("app_uninstall_failed");
976 }
977 report.lifecycle.push(lifecycle);
978 report.files.push(AppFileLifecycleReport {
979 category,
980 source,
981 destination: entry.destination,
982 transforms: Vec::new(),
983 backup: entry.backup,
984 restart_hint: None,
985 generator_error: None,
986 error,
987 status,
988 action,
989 });
990 }
991 if !request.dry_run {
992 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
993 }
994
995 if !self.context.is_external_presets {
996 let cache_targets = if request.purge && request.target.is_none() {
997 vec!["app".to_string()]
998 } else if let Some(category) = &request.target {
999 vec![format!("app/{category}")]
1000 } else {
1001 category_names
1002 .into_iter()
1003 .map(|category| format!("app/{category}"))
1004 .collect()
1005 };
1006 for target in cache_targets {
1007 report.lifecycle.push(
1008 self.reconcile_app_cache(AppCacheRequest {
1009 prefix: target,
1010 dry_run: request.dry_run,
1011 remove: true,
1012 purge: request.purge,
1013 overwrite: false,
1014 })
1015 .await?,
1016 );
1017 }
1018 } else if request.purge {
1019 report.lifecycle.push(
1020 LifecycleOutcomeV1::new(
1021 request
1022 .target
1023 .as_ref()
1024 .map(|category| format!("app/{category}"))
1025 .unwrap_or_else(|| "app".to_string()),
1026 Some("purge"),
1027 LifecycleStatus::Skipped,
1028 [LifecycleEffect::UserResourcePreserved],
1029 )
1030 .with_diagnostic_code("app_external_preset_cache_preserved"),
1031 );
1032 }
1033 Ok(report)
1034 }
1035
1036 pub(crate) async fn refresh_app_generators(
1039 &self,
1040 request: AppRefreshRequest,
1041 observer: &mut impl RuntimeObserver,
1042 interaction: &mut impl RuntimeInteraction,
1043 ) -> Result<AppLifecycleReport> {
1044 let categories = self.app_categories(Some(&request.category))?;
1045 let category = categories
1046 .first()
1047 .cloned()
1048 .with_context(|| format!("app preset category not found: {}", request.category))?;
1049 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1050 let candidates = if let Some(selector) = &request.file {
1051 let file = category
1052 .files
1053 .iter()
1054 .find(|file| &file.source_rel == selector)
1055 .with_context(|| {
1056 format!(
1057 "app '{}' file not found: {}",
1058 request.category,
1059 selector.display()
1060 )
1061 })?;
1062 if file.generator.is_none() {
1063 bail!(
1064 "app '{}' file is not generated: {}",
1065 request.category,
1066 selector.display()
1067 );
1068 }
1069 vec![file.clone()]
1070 } else {
1071 category
1072 .files
1073 .iter()
1074 .filter(|file| file.generator.is_some())
1075 .cloned()
1076 .collect::<Vec<_>>()
1077 };
1078 if candidates.is_empty() {
1079 bail!("app '{}' has no generated files", request.category);
1080 }
1081
1082 let mut selected = Vec::new();
1083 for file in candidates {
1084 let destination = self.app_destination(&category, &file)?;
1085 let Some(entry) = manifest.find_by_dest(&destination).cloned() else {
1086 if request.file.is_some() {
1087 bail!(
1088 "app '{}' generated file is not installed: {}",
1089 request.category,
1090 file.source_rel.display()
1091 );
1092 }
1093 continue;
1094 };
1095 selected.push((file, destination, entry));
1096 }
1097 if selected.is_empty() {
1098 bail!(
1099 "app '{}' has no installed generated files; run `shine install app/{}` first",
1100 request.category,
1101 request.category
1102 );
1103 }
1104
1105 let admin_count = selected
1106 .iter()
1107 .filter(|(file, _, _)| file.requires_admin)
1108 .count();
1109 if admin_count > 0
1110 && !self.context.running_as_admin
1111 && !interaction.authorize_admin(admin_count).await?
1112 {
1113 bail!("administrator permission was not granted");
1114 }
1115
1116 let mut report =
1117 AppLifecycleReport::new(LifecycleOperation::Update, false, vec![category.clone()]);
1118 let mut changed = BTreeSet::new();
1119 for (file, destination, entry) in selected {
1120 let source = file.source_rel.clone();
1121 let generator = file.generator.clone().expect("selected generated App file");
1122 let result: Result<AppFileAction> = async {
1123 if !self.context.env.contains_key(&generator.when_env) {
1124 bail!(
1125 "app '{}' generator requires config env '{}'",
1126 request.category,
1127 generator.when_env
1128 );
1129 }
1130 let bytes = self
1131 .run_app_generator(
1132 AppGeneratorRequest {
1133 category: request.category.clone(),
1134 source: source.display().to_string(),
1135 generator,
1136 explicit: true,
1137 },
1138 observer,
1139 )
1140 .await?
1141 .context("explicit App generator produced no content")?;
1142 let content =
1143 crate::install::transforms::apply(&file.transforms, &bytes, &self.context.env)?;
1144 let desired_hash = desired_app_hash(&file, &content)?;
1145 let current_hash = match self.host.read(&destination).await {
1146 Ok(bytes) => installed_app_hash(&file, &bytes)?,
1147 Err(error) if error.is_not_found() => None,
1148 Err(error) => {
1149 return Err(error.into_anyhow("reading generated App destination"));
1150 }
1151 };
1152 if current_hash == Some(entry.content_hash) && desired_hash == entry.content_hash {
1153 return Ok(AppFileAction::Unchanged);
1154 }
1155 if current_hash.is_some_and(|hash| hash != entry.content_hash) && !request.force {
1156 return Ok(AppFileAction::UserModified);
1157 }
1158 let outcome = self
1159 .install_app_content(&file, &content, &destination, true, false, true)
1160 .await?;
1161 let hash = match outcome {
1162 InstallOutcome::Installed { hash }
1163 | InstallOutcome::BackedUpAndInstalled { hash, .. } => hash,
1164 InstallOutcome::AlreadyManaged => return Ok(AppFileAction::Unchanged),
1165 InstallOutcome::DryRun => unreachable!("refresh is never dry-run"),
1166 };
1167 manifest.upsert(AppEntry {
1168 source: entry.source.clone(),
1169 destination: destination.clone(),
1170 backup: entry.backup.clone(),
1171 content_hash: hash,
1172 install_strategy: file.install_strategy.clone(),
1173 uses_env: true,
1174 requires_admin: file.requires_admin,
1175 });
1176 Ok(AppFileAction::Installed)
1177 }
1178 .await;
1179 let (action, status, error) = match result {
1180 Ok(AppFileAction::Installed) => {
1181 changed.insert(request.category.clone());
1182 (AppFileAction::Installed, LifecycleStatus::Changed, None)
1183 }
1184 Ok(AppFileAction::Unchanged) => {
1185 (AppFileAction::Unchanged, LifecycleStatus::Unchanged, None)
1186 }
1187 Ok(AppFileAction::UserModified) => (
1188 AppFileAction::UserModified,
1189 LifecycleStatus::Preserved,
1190 None,
1191 ),
1192 Ok(action) => (action, LifecycleStatus::Unchanged, None),
1193 Err(error) => (
1194 AppFileAction::Failed,
1195 LifecycleStatus::Failed,
1196 Some(format!("{error:#}")),
1197 ),
1198 };
1199 let effects = match status {
1200 LifecycleStatus::Changed => {
1201 vec![
1202 LifecycleEffect::ResourceWritten,
1203 LifecycleEffect::ReceiptWritten,
1204 ]
1205 }
1206 LifecycleStatus::Preserved => vec![LifecycleEffect::UserResourcePreserved],
1207 _ => Vec::new(),
1208 };
1209 let mut outcome = LifecycleOutcomeV1::new(
1210 format!("app/{}", request.category),
1211 Some(source.display().to_string()),
1212 status,
1213 effects,
1214 );
1215 if error.is_some() {
1216 outcome = outcome.with_diagnostic_code("app_refresh_failed");
1217 }
1218 report.lifecycle.push(outcome);
1219 report.files.push(AppFileLifecycleReport {
1220 category: request.category.clone(),
1221 source,
1222 destination,
1223 transforms: file.transforms,
1224 backup: entry.backup,
1225 restart_hint: file.restart_hint,
1226 generator_error: None,
1227 error,
1228 status,
1229 action,
1230 });
1231 }
1232 if !changed.is_empty() {
1233 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1234 let hooks = self
1235 .run_app_hooks(
1236 AppHookRequest {
1237 categories: vec![category],
1238 changed,
1239 phase: AppHookPhase::PostUpgrade,
1240 show_success: true,
1241 },
1242 observer,
1243 )
1244 .await;
1245 report.lifecycle.outcomes.extend(hooks.outcomes);
1246 }
1247 Ok(report)
1248 }
1249
1250 pub(crate) async fn upgrade_apps(
1254 &self,
1255 request: AppUpgradeRequest,
1256 plan: &PlanV1,
1257 approval: &PlanApprovalV1,
1258 action_irs: Vec<ActionIrV1>,
1259 observer: &mut impl RuntimeObserver,
1260 interaction: &mut impl RuntimeInteraction,
1261 ) -> Result<AppUpgradeLifecycleReport> {
1262 let mut approved = ApprovedAppInstall {
1263 plan,
1264 approval,
1265 action_irs,
1266 };
1267 let mut manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1268 let mut selected_sources = BTreeSet::new();
1273 let mut selected_entries =
1274 manifest
1275 .entries
1276 .iter()
1277 .rev()
1278 .filter(|entry| {
1279 request.category.as_ref().is_none_or(|category| {
1280 entry.source.starts_with(&format!("app/{category}/"))
1281 }) && selected_sources.insert(entry.source.clone())
1282 })
1283 .cloned()
1284 .collect::<Vec<_>>();
1285 selected_entries.reverse();
1286 if let Some(category) = &request.category
1287 && selected_entries.is_empty()
1288 {
1289 bail!("app preset is not installed: {category}");
1290 }
1291 let installed_categories = selected_entries
1292 .iter()
1293 .filter_map(|entry| {
1294 app_source_parts(&entry.source).map(|(category, _)| category.to_string())
1295 })
1296 .collect::<BTreeSet<_>>();
1297 let mut categories = Vec::new();
1298 for category in &installed_categories {
1299 let prefix = format!("app/{category}/");
1300 if !self
1301 .presets
1302 .files()
1303 .keys()
1304 .any(|logical| logical.starts_with(&prefix))
1305 {
1306 continue;
1307 }
1308 let mut loaded = self.app_categories(Some(category))?;
1309 categories.append(&mut loaded);
1310 if !self.context.is_external_presets {
1311 let _ = self
1312 .reconcile_app_cache(AppCacheRequest {
1313 prefix: format!("app/{category}"),
1314 dry_run: false,
1315 remove: false,
1316 purge: false,
1317 overwrite: true,
1318 })
1319 .await?;
1320 }
1321 }
1322 validate_app_destinations(self, &categories)?;
1323 let mut assessments = self
1324 .assess_app_files(
1325 &categories,
1326 AppAssessmentOptions {
1327 run_generators: true,
1328 ..AppAssessmentOptions::default()
1329 },
1330 &manifest,
1331 observer,
1332 )
1333 .await?
1334 .into_iter()
1335 .map(|assessment| {
1336 (
1337 format!(
1338 "app/{}/{}",
1339 assessment.category.name,
1340 assessment.file.source_rel.display()
1341 ),
1342 assessment,
1343 )
1344 })
1345 .collect::<BTreeMap<_, _>>();
1346 let additional_admin_count = approved
1347 .action_irs
1348 .iter()
1349 .filter(|ir| {
1350 ir.actions.iter().any(|action| {
1351 matches!(
1352 action.kind,
1353 ActionKindV1::RemoveManagedFile {
1354 requires_admin: true,
1355 ..
1356 } | ActionKindV1::RemoveManagedFileWithBackup {
1357 requires_admin: true,
1358 ..
1359 }
1360 ) || matches!(
1361 action.kind,
1362 ActionKindV1::RelocateManagedFile {
1363 previous_requires_admin: true,
1364 desired_requires_admin: false,
1365 previous_present: true,
1366 ..
1367 }
1368 )
1369 })
1370 })
1371 .count();
1372 let admin_count = assessments
1373 .values()
1374 .filter(|assessment| assessment.file.requires_admin)
1375 .count()
1376 + additional_admin_count;
1377 if admin_count > 0
1378 && !self.context.running_as_admin
1379 && !interaction.authorize_admin(admin_count).await?
1380 {
1381 bail!("administrator permission was not granted");
1382 }
1383
1384 let mut report = AppUpgradeLifecycleReport {
1385 files: Vec::new(),
1386 updated_categories: Vec::new(),
1387 skipped: 0,
1388 failed: 0,
1389 user_modified: 0,
1390 restart_hints: BTreeSet::new(),
1391 lifecycle: LifecycleResultV1::new(LifecycleOperation::Upgrade, false),
1392 };
1393 let mut changed = BTreeSet::new();
1394 for entry in selected_entries {
1395 let Some((category_name, file_rel)) = app_source_parts(&entry.source) else {
1396 report.skipped += 1;
1397 report.lifecycle.push(
1398 LifecycleOutcomeV1::new(
1399 "app/unknown",
1400 None::<String>,
1401 LifecycleStatus::Skipped,
1402 [],
1403 )
1404 .with_diagnostic_code("app_manifest_source_invalid"),
1405 );
1406 continue;
1407 };
1408 let assessment = assessments.remove(&entry.source);
1409 let Some(assessment) = assessment else {
1410 let should_remove = request.prune_stale
1411 || (request.prompt_stale && interaction.confirm("app_prune_stale", false)?);
1412 if !should_remove {
1413 report.skipped += 1;
1414 report.lifecycle.push(
1415 LifecycleOutcomeV1::new(
1416 format!("app/{category_name}"),
1417 Some(file_rel.to_string()),
1418 LifecycleStatus::Skipped,
1419 [],
1420 )
1421 .with_diagnostic_code("app_stale_source_preserved"),
1422 );
1423 continue;
1424 }
1425 let target = format!("app/{category_name}");
1426 let resource = file_rel.to_string();
1427 let action_index = approved.action_irs.iter().position(|ir| {
1428 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
1429 });
1430 let approved_remove = approved.plan.steps.iter().any(|step| {
1431 step.target == target
1432 && step.resource.as_deref() == Some(resource.as_str())
1433 && step.action == crate::plan::PlanActionV1::Remove
1434 && step
1435 .diagnostic_codes
1436 .contains(&"app_stale_source_pruned".to_string())
1437 });
1438 let mut journal_execution = None;
1439 let outcome = if let Some(index) = action_index {
1440 let action_ir = approved.action_irs.remove(index);
1441 let is_json = matches!(
1442 action_ir.actions.as_slice(),
1443 [action] if matches!(action.kind, ActionKindV1::RemoveManagedJson { .. })
1444 );
1445 let execution = if is_json {
1446 self.execute_app_managed_json_removal_approved(
1447 approved.plan,
1448 approved.approval,
1449 action_ir,
1450 )
1451 .await?
1452 } else {
1453 self.execute_app_managed_file_removal_approved(
1454 approved.plan,
1455 approved.approval,
1456 action_ir,
1457 )
1458 .await?
1459 };
1460 let outcome = match execution.backup.clone() {
1461 Some(backup) => UninstallOutcome::RestoredBackup { backup },
1462 None => UninstallOutcome::Removed,
1463 };
1464 journal_execution = Some(execution);
1465 outcome
1466 } else if approved_remove {
1467 match self.host.metadata(&entry.destination).await {
1468 Err(error) if error.is_not_found() => UninstallOutcome::NotFound,
1469 Ok(_) => {
1470 bail!("stale App removal destination changed after Plan approval")
1471 }
1472 Err(error) => {
1473 return Err(error.into_anyhow(
1474 "observing stale App removal destination after approval",
1475 ));
1476 }
1477 }
1478 } else {
1479 self.uninstall_app_entry(&entry, false, false).await?
1480 };
1481 let (status, effects, remove, action) = match outcome {
1482 UninstallOutcome::Removed => (
1483 LifecycleStatus::Changed,
1484 vec![
1485 LifecycleEffect::ResourceRemoved,
1486 LifecycleEffect::ReceiptRemoved,
1487 ],
1488 true,
1489 AppFileAction::Removed,
1490 ),
1491 UninstallOutcome::RestoredBackup { .. } => (
1492 LifecycleStatus::Changed,
1493 vec![
1494 LifecycleEffect::BackupRestored,
1495 LifecycleEffect::ReceiptRemoved,
1496 ],
1497 true,
1498 AppFileAction::Restored,
1499 ),
1500 UninstallOutcome::NotFound => (
1501 LifecycleStatus::Changed,
1502 vec![LifecycleEffect::ReceiptRemoved],
1503 true,
1504 AppFileAction::Missing,
1505 ),
1506 UninstallOutcome::UserModified => (
1507 LifecycleStatus::Preserved,
1508 vec![LifecycleEffect::UserResourcePreserved],
1509 false,
1510 AppFileAction::UserModified,
1511 ),
1512 UninstallOutcome::ForceRemoved
1513 | UninstallOutcome::ForceRestoredBackup { .. } => unreachable!(),
1514 UninstallOutcome::DryRun => unreachable!(),
1515 };
1516 if remove {
1517 manifest.remove_by_dest(&entry.destination);
1518 if let Some(execution) = journal_execution {
1519 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1520 self.commit_app_managed_file_operation(&execution).await?;
1521 }
1522 changed.insert(category_name.to_string());
1523 } else {
1524 report.user_modified += 1;
1525 report.skipped += 1;
1526 }
1527 report.lifecycle.push(LifecycleOutcomeV1::new(
1528 format!("app/{category_name}"),
1529 Some(file_rel.to_string()),
1530 status,
1531 effects,
1532 ));
1533 report.files.push(AppFileLifecycleReport {
1534 category: category_name.to_string(),
1535 source: PathBuf::from(file_rel),
1536 destination: entry.destination,
1537 transforms: Vec::new(),
1538 backup: entry.backup,
1539 restart_hint: None,
1540 generator_error: None,
1541 error: None,
1542 status,
1543 action,
1544 });
1545 continue;
1546 };
1547 if assessment
1548 .file
1549 .generator
1550 .as_ref()
1551 .is_some_and(|generator| !generator.auto)
1552 {
1553 report.skipped += 1;
1554 report.lifecycle.push(
1555 LifecycleOutcomeV1::new(
1556 format!("app/{category_name}"),
1557 Some(file_rel.to_string()),
1558 LifecycleStatus::Skipped,
1559 [],
1560 )
1561 .with_diagnostic_code("app_manual_refresh_required"),
1562 );
1563 continue;
1564 }
1565 if let Some(error) = assessment.generator_error.clone() {
1566 report.failed += 1;
1567 report.lifecycle.push(
1568 LifecycleOutcomeV1::new(
1569 format!("app/{category_name}"),
1570 Some(file_rel.to_string()),
1571 LifecycleStatus::Failed,
1572 [LifecycleEffect::ManagedResourcePreserved],
1573 )
1574 .with_diagnostic_code("app_generator_unavailable"),
1575 );
1576 report.files.push(app_upgrade_file_report(
1577 &assessment,
1578 LifecycleStatus::Failed,
1579 AppFileAction::GeneratorPreserved,
1580 Some(error),
1581 ));
1582 continue;
1583 }
1584 let content = assessment
1585 .content
1586 .as_deref()
1587 .context("missing App upgrade content")?;
1588 let desired_hash = desired_app_hash(&assessment.file, content)?;
1589 let desired_destination = assessment.destination.clone();
1590 let relocated = desired_destination != entry.destination;
1591 if relocated
1592 && (manifest.find_by_dest(&desired_destination).is_some()
1593 || self.host.metadata(&desired_destination).await.is_ok())
1594 {
1595 report.user_modified += 1;
1596 report.skipped += 1;
1597 report.lifecycle.push(
1598 LifecycleOutcomeV1::new(
1599 format!("app/{category_name}"),
1600 Some(file_rel.to_string()),
1601 LifecycleStatus::Conflict,
1602 [],
1603 )
1604 .with_diagnostic_code("app_destination_occupied"),
1605 );
1606 continue;
1607 }
1608 let current_hash = match self.host.read(&entry.destination).await {
1609 Ok(bytes) => installed_app_entry_hash(&entry, &bytes)?,
1610 Err(error) if error.is_not_found() => None,
1611 Err(error) => return Err(error.into_anyhow("reading installed App file")),
1612 };
1613 if current_hash.is_some_and(|hash| hash != entry.content_hash) {
1614 report.user_modified += 1;
1615 report.skipped += 1;
1616 report.lifecycle.push(LifecycleOutcomeV1::new(
1617 format!("app/{category_name}"),
1618 Some(file_rel.to_string()),
1619 LifecycleStatus::Preserved,
1620 [LifecycleEffect::UserResourcePreserved],
1621 ));
1622 report.files.push(app_upgrade_file_report(
1623 &assessment,
1624 LifecycleStatus::Preserved,
1625 AppFileAction::UserModified,
1626 None,
1627 ));
1628 continue;
1629 }
1630 if !relocated
1631 && current_hash == Some(entry.content_hash)
1632 && desired_hash == entry.content_hash
1633 {
1634 report.skipped += 1;
1635 report.lifecycle.push(LifecycleOutcomeV1::new(
1636 format!("app/{category_name}"),
1637 Some(file_rel.to_string()),
1638 LifecycleStatus::Unchanged,
1639 [],
1640 ));
1641 report.files.push(app_upgrade_file_report(
1642 &assessment,
1643 LifecycleStatus::Unchanged,
1644 AppFileAction::Unchanged,
1645 None,
1646 ));
1647 continue;
1648 }
1649 let target = format!("app/{category_name}");
1650 let resource = file_rel.to_string();
1651 let action_index = approved.action_irs.iter().position(|ir| {
1652 matches!(ir.actions.as_slice(), [action] if action.target == target && action.resource == resource)
1653 });
1654 let mut journal_execution = None;
1655 let mut journaled_relocation = false;
1656 let installed = if let Some(index) = action_index {
1657 let action_ir = approved.action_irs.remove(index);
1658 let is_json = matches!(
1659 action_ir.actions.as_slice(),
1660 [action] if matches!(action.kind, crate::action::ActionKindV1::MergeManagedJson { .. })
1661 );
1662 let is_relocation = matches!(
1663 action_ir.actions.as_slice(),
1664 [action] if matches!(
1665 action.kind,
1666 crate::action::ActionKindV1::RelocateManagedFile { .. }
1667 | crate::action::ActionKindV1::RelocateManagedJson { .. }
1668 )
1669 );
1670 let execution = if is_relocation {
1671 journaled_relocation = true;
1672 if matches!(
1673 action_ir.actions.as_slice(),
1674 [action] if matches!(
1675 action.kind,
1676 crate::action::ActionKindV1::RelocateManagedJson { .. }
1677 )
1678 ) {
1679 self.execute_app_managed_json_relocation_approved(
1680 approved.plan,
1681 approved.approval,
1682 action_ir,
1683 content,
1684 )
1685 .await?
1686 } else {
1687 self.execute_app_managed_file_relocation_approved(
1688 approved.plan,
1689 approved.approval,
1690 action_ir,
1691 content,
1692 )
1693 .await?
1694 }
1695 } else if is_json {
1696 self.execute_app_managed_json_merge_approved(
1697 approved.plan,
1698 approved.approval,
1699 action_ir,
1700 content,
1701 )
1702 .await?
1703 } else {
1704 self.execute_app_managed_file_update_approved(
1705 approved.plan,
1706 approved.approval,
1707 action_ir,
1708 content,
1709 )
1710 .await?
1711 };
1712 journal_execution = Some(execution);
1713 Ok(InstallOutcome::Installed { hash: desired_hash })
1714 } else {
1715 self.install_app_content(
1716 &assessment.file,
1717 content,
1718 &desired_destination,
1719 !relocated,
1720 false,
1721 true,
1722 )
1723 .await
1724 };
1725 let hash = match installed {
1726 Ok(InstallOutcome::Installed { hash })
1727 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => hash,
1728 Ok(InstallOutcome::AlreadyManaged) => desired_hash,
1729 Ok(InstallOutcome::DryRun) => unreachable!(),
1730 Err(error) => {
1731 report.failed += 1;
1732 report.lifecycle.push(
1733 LifecycleOutcomeV1::new(
1734 format!("app/{category_name}"),
1735 Some(file_rel.to_string()),
1736 LifecycleStatus::Failed,
1737 [],
1738 )
1739 .with_diagnostic_code("app_upgrade_failed"),
1740 );
1741 report.files.push(app_upgrade_file_report(
1742 &assessment,
1743 LifecycleStatus::Failed,
1744 AppFileAction::Failed,
1745 Some(format!("{error:#}")),
1746 ));
1747 continue;
1748 }
1749 };
1750 if relocated && !journaled_relocation {
1751 match self.uninstall_app_entry(&entry, false, false).await {
1752 Ok(UninstallOutcome::Removed)
1753 | Ok(UninstallOutcome::RestoredBackup { .. })
1754 | Ok(UninstallOutcome::NotFound) => {
1755 manifest.remove_by_dest(&entry.destination);
1756 }
1757 _ => {
1758 let rollback = AppEntry {
1759 source: entry.source.clone(),
1760 destination: desired_destination.clone(),
1761 backup: None,
1762 content_hash: hash,
1763 install_strategy: assessment.file.install_strategy.clone(),
1764 uses_env: true,
1765 requires_admin: assessment.file.requires_admin,
1766 };
1767 let _ = self.uninstall_app_entry(&rollback, false, true).await;
1768 report.failed += 1;
1769 report.lifecycle.push(
1770 LifecycleOutcomeV1::new(
1771 format!("app/{category_name}"),
1772 Some(file_rel.to_string()),
1773 LifecycleStatus::Failed,
1774 [],
1775 )
1776 .with_diagnostic_code("app_relocation_rollback"),
1777 );
1778 continue;
1779 }
1780 }
1781 }
1782 manifest.upsert(app_entry(
1783 &assessment,
1784 hash,
1785 if relocated {
1786 None
1787 } else {
1788 entry.backup.clone()
1789 },
1790 ));
1791 if let Some(execution) = journal_execution {
1792 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1793 self.commit_app_managed_file_operation(&execution).await?;
1794 }
1795 changed.insert(category_name.to_string());
1796 if let Some(hint) = &assessment.file.restart_hint {
1797 report.restart_hints.insert(hint.clone());
1798 }
1799 let mut effects = vec![
1800 LifecycleEffect::ResourceWritten,
1801 LifecycleEffect::ReceiptWritten,
1802 ];
1803 if relocated {
1804 effects.push(LifecycleEffect::ResourceRemoved);
1805 }
1806 report.lifecycle.push(LifecycleOutcomeV1::new(
1807 format!("app/{category_name}"),
1808 Some(file_rel.to_string()),
1809 LifecycleStatus::Changed,
1810 effects,
1811 ));
1812 report.files.push(app_upgrade_file_report(
1813 &assessment,
1814 LifecycleStatus::Changed,
1815 AppFileAction::Installed,
1816 None,
1817 ));
1818 }
1819
1820 for (source, assessment) in assessments {
1823 if assessment
1824 .file
1825 .generator
1826 .as_ref()
1827 .is_some_and(|generator| !generator.auto)
1828 || manifest.find_by_source(&source).is_some()
1829 || manifest.find_by_dest(&assessment.destination).is_some()
1830 {
1831 continue;
1832 }
1833 if assessment.file.install_strategy.is_copy()
1834 && self.host.metadata(&assessment.destination).await.is_ok()
1835 {
1836 report.skipped += 1;
1837 report.lifecycle.push(
1838 LifecycleOutcomeV1::new(
1839 format!("app/{}", assessment.category.name),
1840 Some(assessment.file.source_rel.display().to_string()),
1841 LifecycleStatus::Conflict,
1842 [],
1843 )
1844 .with_diagnostic_code("app_destination_occupied"),
1845 );
1846 continue;
1847 }
1848 let Some(content) = assessment.content.as_deref() else {
1849 report.failed += 1;
1850 continue;
1851 };
1852 match self
1853 .install_app_content(
1854 &assessment.file,
1855 content,
1856 &assessment.destination,
1857 false,
1858 false,
1859 true,
1860 )
1861 .await
1862 {
1863 Ok(InstallOutcome::Installed { hash })
1864 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
1865 manifest.upsert(app_entry(&assessment, hash, None));
1866 changed.insert(assessment.category.name.clone());
1867 if let Some(hint) = &assessment.file.restart_hint {
1868 report.restart_hints.insert(hint.clone());
1869 }
1870 report.lifecycle.push(LifecycleOutcomeV1::new(
1871 format!("app/{}", assessment.category.name),
1872 Some(assessment.file.source_rel.display().to_string()),
1873 LifecycleStatus::Changed,
1874 [
1875 LifecycleEffect::ResourceWritten,
1876 LifecycleEffect::ReceiptWritten,
1877 ],
1878 ));
1879 report.files.push(app_upgrade_file_report(
1880 &assessment,
1881 LifecycleStatus::Changed,
1882 AppFileAction::Installed,
1883 None,
1884 ));
1885 }
1886 Ok(InstallOutcome::AlreadyManaged | InstallOutcome::DryRun) => {
1887 report.skipped += 1;
1888 }
1889 Err(error) => {
1890 report.failed += 1;
1891 report.lifecycle.push(
1892 LifecycleOutcomeV1::new(
1893 format!("app/{}", assessment.category.name),
1894 Some(assessment.file.source_rel.display().to_string()),
1895 LifecycleStatus::Failed,
1896 [],
1897 )
1898 .with_diagnostic_code("app_install_failed"),
1899 );
1900 report.files.push(app_upgrade_file_report(
1901 &assessment,
1902 LifecycleStatus::Failed,
1903 AppFileAction::Failed,
1904 Some(format!("{error:#}")),
1905 ));
1906 }
1907 }
1908 }
1909 if !approved.action_irs.is_empty() {
1910 bail!("approved App update actions were not consumed by lifecycle execution");
1911 }
1912 save_manifest(&self.host, &self.context.shine_dir, &manifest).await?;
1913 let hooks = self
1914 .run_app_hooks(
1915 AppHookRequest {
1916 categories,
1917 changed: changed.clone(),
1918 phase: AppHookPhase::PostUpgrade,
1919 show_success: request.show_hook_success,
1920 },
1921 observer,
1922 )
1923 .await;
1924 report.lifecycle.outcomes.extend(hooks.outcomes);
1925 report.updated_categories = changed.into_iter().collect();
1926 Ok(report)
1927 }
1928
1929 pub async fn inspect_apps(
1932 &self,
1933 observer: &mut impl RuntimeObserver,
1934 ) -> Result<Vec<AppFileInspection>> {
1935 self.inspect_apps_with_options(AppInspectionOptions::default(), observer)
1936 .await
1937 }
1938
1939 pub async fn inspect_apps_with_options(
1940 &self,
1941 options: AppInspectionOptions,
1942 observer: &mut impl RuntimeObserver,
1943 ) -> Result<Vec<AppFileInspection>> {
1944 let selected = options.categories.iter().collect::<BTreeSet<_>>();
1945 let categories = self
1946 .app_categories(None)?
1947 .into_iter()
1948 .filter(|category| selected.is_empty() || selected.contains(&category.name))
1949 .collect::<Vec<_>>();
1950 let manifest = load_manifest(&self.host, &self.context.shine_dir).await?;
1951 let assessments = self
1952 .assess_app_files(
1953 &categories,
1954 AppAssessmentOptions {
1955 run_generators: options.run_generators,
1956 explicit_generators: options.run_generators,
1957 preserve_generator_errors: true,
1958 ..AppAssessmentOptions::default()
1959 },
1960 &manifest,
1961 observer,
1962 )
1963 .await?;
1964 let installed_categories = manifest
1965 .entries
1966 .iter()
1967 .filter_map(|entry| {
1968 app_source_parts(&entry.source).map(|(category, _)| category.to_string())
1969 })
1970 .collect::<BTreeSet<_>>();
1971 let mut files = Vec::new();
1972 for assessment in assessments {
1973 let source = format!(
1974 "app/{}/{}",
1975 assessment.category.name,
1976 assessment.file.source_rel.display()
1977 );
1978 let direct_entry = manifest.find_by_dest(&assessment.destination).cloned();
1979 let source_entry = manifest.find_by_source(&source).cloned();
1980 let entry = direct_entry.clone().or_else(|| source_entry.clone());
1981 let current_content = match entry.as_ref() {
1982 Some(entry) => match self.host.read(&entry.destination).await {
1983 Ok(bytes) => Some(bytes),
1984 Err(error) if error.is_not_found() => None,
1985 Err(error) => return Err(error.into_anyhow("reading installed App file")),
1986 },
1987 None => None,
1988 };
1989 let manual_generator = assessment
1990 .file
1991 .generator
1992 .as_ref()
1993 .is_some_and(|generator| !generator.auto);
1994 let mut changes = Vec::new();
1995 let status = if let Some(entry) = direct_entry.as_ref() {
1996 let current_hash = current_content
1997 .as_deref()
1998 .map(|bytes| installed_app_hash(&assessment.file, bytes))
1999 .transpose()?;
2000 match current_hash {
2001 None => InspectionFileStatus::Missing,
2002 Some(None) => InspectionFileStatus::Partial,
2003 Some(Some(hash)) if hash != entry.content_hash => {
2004 InspectionFileStatus::UserModified
2005 }
2006 Some(Some(_)) if assessment.generator_diagnostic.is_some() => {
2007 generator_inspection_status(assessment.generator_diagnostic)
2008 }
2009 Some(Some(_)) => {
2010 let desired = assessment
2011 .content
2012 .as_deref()
2013 .map(|content| desired_app_hash(&assessment.file, content))
2014 .transpose()?;
2015 if desired.is_some_and(|hash| hash != entry.content_hash) {
2016 changes.push(InspectionChange::ContentChanged);
2017 InspectionFileStatus::UpdateAvail
2018 } else {
2019 InspectionFileStatus::UpToDate
2020 }
2021 }
2022 }
2023 } else if let Some(entry) = source_entry.as_ref() {
2024 if assessment.generator_diagnostic.is_some() {
2025 generator_inspection_status(assessment.generator_diagnostic)
2026 } else if manual_generator && !options.run_generators {
2027 let current_hash = current_content
2028 .as_deref()
2029 .map(|bytes| installed_app_hash(&assessment.file, bytes))
2030 .transpose()?;
2031 match current_hash {
2032 None => InspectionFileStatus::Missing,
2033 Some(None) => InspectionFileStatus::Partial,
2034 Some(Some(hash)) if hash != entry.content_hash => {
2035 InspectionFileStatus::UserModified
2036 }
2037 Some(Some(_)) => InspectionFileStatus::UpToDate,
2038 }
2039 } else {
2040 changes.push(InspectionChange::DestinationRelocated {
2041 from: entry.destination.clone(),
2042 to: assessment.destination.clone(),
2043 });
2044 if assessment
2045 .content
2046 .as_deref()
2047 .map(|content| desired_app_hash(&assessment.file, content))
2048 .transpose()?
2049 .is_some_and(|hash| hash != entry.content_hash)
2050 {
2051 changes.push(InspectionChange::ContentChanged);
2052 }
2053 InspectionFileStatus::UpdateAvail
2054 }
2055 } else if installed_categories.contains(&assessment.category.name)
2056 && assessment.generator_diagnostic.is_some()
2057 {
2058 generator_inspection_status(assessment.generator_diagnostic)
2059 } else if installed_categories.contains(&assessment.category.name)
2060 && (!manual_generator || options.run_generators)
2061 && assessment.content.is_some()
2062 {
2063 changes.push(InspectionChange::NewFile {
2064 destination: assessment.destination.clone(),
2065 });
2066 InspectionFileStatus::UpdateAvail
2067 } else {
2068 InspectionFileStatus::NotInstalled
2069 };
2070 let desired_content = assessment.content.clone();
2071 files.push(AppFileInspection {
2072 category: assessment.category,
2073 file: assessment.file,
2074 destination: Some(assessment.destination),
2075 status,
2076 manifest_entry: entry,
2077 desired_content,
2078 current_content,
2079 changes,
2080 assessment_error: assessment.generator_error,
2081 assessment_diagnostic: assessment.generator_diagnostic,
2082 });
2083 }
2084 Ok(files)
2085 }
2086
2087 async fn assess_app_files(
2088 &self,
2089 categories: &[AppCategory],
2090 options: AppAssessmentOptions,
2091 manifest: &AppManifest,
2092 observer: &mut impl RuntimeObserver,
2093 ) -> Result<Vec<AssessedAppFile>> {
2094 let mut assessed = Vec::new();
2095 for category in categories {
2096 for file in &category.files {
2097 let destination = self.app_destination(category, file)?;
2098 let raw = if !options.run_generators && file.generator.is_some() {
2099 assessed.push(AssessedAppFile {
2100 category: category.clone(),
2101 file: file.clone(),
2102 destination,
2103 content: None,
2104 generator_error: None,
2105 generator_diagnostic: Some("app_generator_not_evaluated"),
2106 });
2107 continue;
2108 } else if options.dry_run || file.generator.is_none() {
2109 Some(
2110 self.app_source_bytes(category.name.as_str(), file)?
2111 .to_vec(),
2112 )
2113 } else {
2114 if options.preserve_generator_errors
2115 && let Err(error) =
2116 self.ensure_app_code_allowed(&category.name, "generator")
2117 {
2118 assessed.push(AssessedAppFile {
2119 category: category.clone(),
2120 file: file.clone(),
2121 destination,
2122 content: None,
2123 generator_error: Some(format!("{error:#}")),
2124 generator_diagnostic: Some("app_generator_trust_required"),
2125 });
2126 continue;
2127 }
2128 match self
2129 .run_app_generator(
2130 AppGeneratorRequest {
2131 category: category.name.clone(),
2132 source: file.source_rel.display().to_string(),
2133 generator: file.generator.clone().expect("checked generator"),
2134 explicit: options.explicit_generators,
2135 },
2136 observer,
2137 )
2138 .await
2139 {
2140 Ok(Some(bytes)) => Some(bytes),
2141 Ok(None) => Some(self.app_source_bytes(&category.name, file)?.to_vec()),
2142 Err(error)
2143 if options.preserve_generator_errors
2144 || (manifest.find_by_dest(&destination).is_some()
2145 && self.host.metadata(&destination).await.is_ok()) =>
2146 {
2147 assessed.push(AssessedAppFile {
2148 category: category.clone(),
2149 file: file.clone(),
2150 destination,
2151 content: None,
2152 generator_error: Some(format!("{error:#}")),
2153 generator_diagnostic: Some("app_generator_evaluation_failed"),
2154 });
2155 continue;
2156 }
2157 Err(error) => return Err(error),
2158 }
2159 };
2160 let content = raw
2161 .map(|bytes| {
2162 crate::install::transforms::apply(
2163 &file.transforms,
2164 &bytes,
2165 &self.context.env,
2166 )
2167 .with_context(|| {
2168 format!("transform failed: {}", file.transforms.join(", "))
2169 })
2170 })
2171 .transpose()?;
2172 assessed.push(AssessedAppFile {
2173 category: category.clone(),
2174 file: file.clone(),
2175 destination,
2176 content,
2177 generator_error: None,
2178 generator_diagnostic: None,
2179 });
2180 }
2181 }
2182 Ok(assessed)
2183 }
2184
2185 async fn install_app_content(
2186 &self,
2187 file: &AppFile,
2188 content: &[u8],
2189 destination: &Path,
2190 is_managed: bool,
2191 dry_run: bool,
2192 force: bool,
2193 ) -> Result<InstallOutcome> {
2194 match &file.install_strategy {
2195 AppInstallStrategy::Copy if file.requires_admin => {
2196 install_privileged_bytes(
2197 &self.host,
2198 content,
2199 destination,
2200 is_managed,
2201 dry_run,
2202 force,
2203 )
2204 .await
2205 }
2206 AppInstallStrategy::Copy => {
2207 install_bytes_with_host(
2208 &self.host,
2209 content,
2210 destination,
2211 is_managed,
2212 dry_run,
2213 force,
2214 )
2215 .await
2216 }
2217 AppInstallStrategy::JsonMerge { managed_keys } => {
2218 install_json_merge(&self.host, content, destination, dry_run, managed_keys).await
2219 }
2220 }
2221 }
2222
2223 async fn uninstall_app_entry(
2224 &self,
2225 entry: &AppEntry,
2226 dry_run: bool,
2227 force: bool,
2228 ) -> Result<UninstallOutcome> {
2229 match &entry.install_strategy {
2230 AppInstallStrategy::Copy if entry.requires_admin => {
2231 uninstall_privileged_entry(&self.host, entry, dry_run, force).await
2232 }
2233 AppInstallStrategy::Copy => {
2234 uninstall_entry_with_host(&self.host, entry, dry_run, force).await
2235 }
2236 AppInstallStrategy::JsonMerge { managed_keys } => {
2237 uninstall_json_merge(&self.host, entry, dry_run, force, managed_keys).await
2238 }
2239 }
2240 }
2241}
2242
2243fn validate_app_destinations<H>(
2244 runtime: &CoreRuntime<H>,
2245 categories: &[AppCategory],
2246) -> Result<()> {
2247 let mut destinations = BTreeMap::<PathBuf, String>::new();
2248 for category in categories {
2249 for file in &category.files {
2250 let destination = runtime.app_destination(category, file)?;
2251 let source = format!("app/{}/{}", category.name, file.source_rel.display());
2252 if let Some(previous) = destinations.insert(destination.clone(), source.clone()) {
2253 bail!(
2254 "app destinations conflict: {previous} and {source} both resolve to {}",
2255 destination.display()
2256 );
2257 }
2258 }
2259 }
2260 Ok(())
2261}
2262
2263fn app_entry(assessment: &AssessedAppFile, content_hash: u64, backup: Option<PathBuf>) -> AppEntry {
2264 AppEntry {
2265 source: format!(
2266 "app/{}/{}",
2267 assessment.category.name,
2268 assessment.file.source_rel.display()
2269 ),
2270 destination: assessment.destination.clone(),
2271 backup,
2272 content_hash,
2273 install_strategy: assessment.file.install_strategy.clone(),
2274 uses_env: assessment
2275 .file
2276 .transforms
2277 .iter()
2278 .any(|value| value == "template")
2279 || assessment.file.generator.is_some(),
2280 requires_admin: assessment.file.requires_admin,
2281 }
2282}
2283
2284fn app_source_parts(source: &str) -> Option<(&str, &str)> {
2285 let mut parts = source.splitn(3, '/');
2286 (parts.next()? == "app").then_some((parts.next()?, parts.next()?))
2287}
2288
2289fn app_upgrade_file_report(
2290 assessment: &AssessedAppFile,
2291 status: LifecycleStatus,
2292 action: AppFileAction,
2293 error: Option<String>,
2294) -> AppFileLifecycleReport {
2295 AppFileLifecycleReport {
2296 category: assessment.category.name.clone(),
2297 source: assessment.file.source_rel.clone(),
2298 destination: assessment.destination.clone(),
2299 transforms: assessment.file.transforms.clone(),
2300 backup: None,
2301 restart_hint: assessment.file.restart_hint.clone(),
2302 generator_error: (action == AppFileAction::GeneratorPreserved)
2303 .then(|| error.clone())
2304 .flatten(),
2305 error: (action != AppFileAction::GeneratorPreserved)
2306 .then_some(error)
2307 .flatten(),
2308 status,
2309 action,
2310 }
2311}
2312
2313pub(crate) fn desired_app_hash(file: &AppFile, content: &[u8]) -> Result<u64> {
2314 match &file.install_strategy {
2315 AppInstallStrategy::Copy => Ok(hash_content(content)),
2316 AppInstallStrategy::JsonMerge { managed_keys } => managed_json_hash(content, managed_keys),
2317 }
2318}
2319
2320pub(crate) fn installed_app_hash(file: &AppFile, content: &[u8]) -> Result<Option<u64>> {
2321 match &file.install_strategy {
2322 AppInstallStrategy::Copy => Ok(Some(hash_content(content))),
2323 AppInstallStrategy::JsonMerge { managed_keys } => {
2324 installed_json_hash(content, managed_keys)
2325 }
2326 }
2327}
2328
2329pub(crate) fn installed_app_entry_hash(
2330 entry: &crate::install::AppEntry,
2331 content: &[u8],
2332) -> Result<Option<u64>> {
2333 match &entry.install_strategy {
2334 AppInstallStrategy::Copy => Ok(Some(hash_content(content))),
2335 AppInstallStrategy::JsonMerge { managed_keys } => {
2336 installed_json_hash(content, managed_keys)
2337 }
2338 }
2339}
2340
2341async fn install_privileged_bytes<H>(
2342 host: &H,
2343 content: &[u8],
2344 destination: &Path,
2345 is_managed: bool,
2346 dry_run: bool,
2347 force: bool,
2348) -> Result<InstallOutcome>
2349where
2350 H: FileSystemHost + PrivilegedFileSystemHost,
2351{
2352 if dry_run {
2353 return Ok(InstallOutcome::DryRun);
2354 }
2355 let _guard = host.acquire_privileged_operation().await?;
2356 let hash = hash_content(content);
2357 let exists = match host.metadata(destination).await {
2358 Ok(_) => true,
2359 Err(error) if error.is_not_found() => false,
2360 Err(error) => return Err(error.into_anyhow("inspecting privileged App destination")),
2361 };
2362 if exists && is_managed && !force {
2363 let current = host
2364 .read(destination)
2365 .await
2366 .map_err(|error| error.into_anyhow("reading privileged App destination"))?;
2367 if hash_content(¤t) == hash {
2368 return Ok(InstallOutcome::AlreadyManaged);
2369 }
2370 }
2371 let backup = if exists && !is_managed {
2372 let backup = crate::install::file_ops::backup_path(destination);
2373 host.move_privileged(destination, &backup).await?;
2374 Some(backup)
2375 } else {
2376 None
2377 };
2378 if let Err(error) = host.write_privileged(destination, content).await {
2379 if let Some(backup) = &backup {
2380 let _ = host.move_privileged(backup, destination).await;
2381 }
2382 return Err(error);
2383 }
2384 Ok(match backup {
2385 Some(backup) => InstallOutcome::BackedUpAndInstalled { backup, hash },
2386 None => InstallOutcome::Installed { hash },
2387 })
2388}
2389
2390async fn uninstall_privileged_entry<H>(
2391 host: &H,
2392 entry: &AppEntry,
2393 dry_run: bool,
2394 force: bool,
2395) -> Result<UninstallOutcome>
2396where
2397 H: FileSystemHost + PrivilegedFileSystemHost,
2398{
2399 if dry_run {
2400 return Ok(UninstallOutcome::DryRun);
2401 }
2402 let _guard = host.acquire_privileged_operation().await?;
2403 let current = match host.read(&entry.destination).await {
2404 Ok(bytes) => bytes,
2405 Err(error) if error.is_not_found() => return Ok(UninstallOutcome::NotFound),
2406 Err(error) => return Err(error.into_anyhow("reading privileged App destination")),
2407 };
2408 let user_modified = hash_content(¤t) != entry.content_hash;
2409 if user_modified && !force {
2410 return Ok(UninstallOutcome::UserModified);
2411 }
2412 host.remove_privileged(&entry.destination).await?;
2413 if let Some(backup) = &entry.backup
2414 && host.metadata(backup).await.is_ok()
2415 {
2416 host.move_privileged(backup, &entry.destination).await?;
2417 return Ok(if user_modified {
2418 UninstallOutcome::ForceRestoredBackup {
2419 backup: backup.clone(),
2420 }
2421 } else {
2422 UninstallOutcome::RestoredBackup {
2423 backup: backup.clone(),
2424 }
2425 });
2426 }
2427 Ok(if user_modified {
2428 UninstallOutcome::ForceRemoved
2429 } else {
2430 UninstallOutcome::Removed
2431 })
2432}
2433
2434async fn install_json_merge(
2435 host: &impl FileSystemHost,
2436 source: &[u8],
2437 destination: &Path,
2438 dry_run: bool,
2439 managed_keys: &[String],
2440) -> Result<InstallOutcome> {
2441 if dry_run {
2442 return Ok(InstallOutcome::DryRun);
2443 }
2444 let managed = managed_json_payload(source, managed_keys)?;
2445 let hash = hash_content(&serialize_json_object(&managed)?);
2446 let mut destination_object = match host.read(destination).await {
2447 Ok(existing) => {
2448 if installed_json_hash(&existing, managed_keys)? == Some(hash) {
2449 return Ok(InstallOutcome::AlreadyManaged);
2450 }
2451 parse_json_object(&existing, "json-merge: destination must be a JSON object")?
2452 }
2453 Err(error) if error.is_not_found() => JsonMap::new(),
2454 Err(error) => return Err(error.into_anyhow("reading App JSON destination")),
2455 };
2456 for (key, value) in managed {
2457 destination_object.insert(key, value);
2458 }
2459 host.write_atomic(destination, &serialize_json_object(&destination_object)?)
2460 .await
2461 .map_err(|error| error.into_anyhow("writing merged App JSON"))?;
2462 Ok(InstallOutcome::Installed { hash })
2463}
2464
2465async fn uninstall_json_merge(
2466 host: &impl FileSystemHost,
2467 entry: &AppEntry,
2468 dry_run: bool,
2469 force: bool,
2470 managed_keys: &[String],
2471) -> Result<UninstallOutcome> {
2472 if dry_run {
2473 return Ok(UninstallOutcome::DryRun);
2474 }
2475 let existing = match host.read(&entry.destination).await {
2476 Ok(bytes) => bytes,
2477 Err(error) if error.is_not_found() => return Ok(UninstallOutcome::NotFound),
2478 Err(error) => return Err(error.into_anyhow("reading App JSON destination")),
2479 };
2480 let Some(current_hash) = installed_json_hash(&existing, managed_keys)? else {
2481 return Ok(UninstallOutcome::NotFound);
2482 };
2483 let user_modified = current_hash != entry.content_hash;
2484 if user_modified && !force {
2485 return Ok(UninstallOutcome::UserModified);
2486 }
2487 let mut root = parse_json_object(&existing, "json-merge: destination must be a JSON object")?;
2488 for key in managed_keys {
2489 root.remove(key);
2490 }
2491 host.write_atomic(&entry.destination, &serialize_json_object(&root)?)
2492 .await
2493 .map_err(|error| error.into_anyhow("writing App JSON destination"))?;
2494 Ok(if user_modified {
2495 UninstallOutcome::ForceRemoved
2496 } else {
2497 UninstallOutcome::Removed
2498 })
2499}
2500
2501pub(crate) fn managed_json_hash(source: &[u8], managed_keys: &[String]) -> Result<u64> {
2502 Ok(hash_content(&serialize_json_object(
2503 &managed_json_payload(source, managed_keys)?,
2504 )?))
2505}
2506
2507pub(crate) fn merge_managed_json_bytes(
2508 current: Option<&[u8]>,
2509 source: &[u8],
2510 managed_keys: &[String],
2511) -> Result<Vec<u8>> {
2512 let managed = managed_json_payload(source, managed_keys)?;
2513 let mut root = current
2514 .map(|bytes| parse_json_object(bytes, "json-merge: destination must be a JSON object"))
2515 .transpose()?
2516 .unwrap_or_default();
2517 for (key, value) in managed {
2518 root.insert(key, value);
2519 }
2520 serialize_json_object(&root)
2521}
2522
2523pub(crate) fn remove_managed_json_bytes(
2524 current: &[u8],
2525 managed_keys: &[String],
2526) -> Result<Vec<u8>> {
2527 let mut root = parse_json_object(current, "json-merge: destination must be a JSON object")?;
2528 for key in managed_keys {
2529 root.remove(key);
2530 }
2531 serialize_json_object(&root)
2532}
2533
2534pub(crate) fn restore_managed_json_bytes(
2535 current: &[u8],
2536 original: &[u8],
2537 managed_keys: &[String],
2538) -> Result<Vec<u8>> {
2539 let mut root = parse_json_object(current, "json-merge: destination must be a JSON object")?;
2540 let original = parse_json_object(original, "json-merge: rollback must be a JSON object")?;
2541 for key in managed_keys {
2542 if let Some(value) = original.get(key) {
2543 root.insert(key.clone(), value.clone());
2544 } else {
2545 root.remove(key);
2546 }
2547 }
2548 serialize_json_object(&root)
2549}
2550
2551pub(crate) fn managed_json_keys_match(
2552 left: &[u8],
2553 right: &[u8],
2554 managed_keys: &[String],
2555) -> Result<bool> {
2556 let left = parse_json_object(left, "json-merge: destination must be a JSON object")?;
2557 let right = parse_json_object(right, "json-merge: comparison must be a JSON object")?;
2558 Ok(managed_keys
2559 .iter()
2560 .all(|key| left.get(key) == right.get(key)))
2561}
2562
2563pub(crate) fn managed_json_keys_absent(bytes: &[u8], managed_keys: &[String]) -> Result<bool> {
2564 let root = parse_json_object(bytes, "json-merge: destination must be a JSON object")?;
2565 Ok(managed_keys.iter().all(|key| !root.contains_key(key)))
2566}
2567
2568pub(crate) fn managed_json_payload(
2569 source: &[u8],
2570 managed_keys: &[String],
2571) -> Result<JsonMap<String, JsonValue>> {
2572 let source = parse_json_object(source, "json-merge: source must be a JSON object")?;
2573 managed_keys
2574 .iter()
2575 .map(|key| {
2576 source
2577 .get(key)
2578 .cloned()
2579 .map(|value| (key.clone(), value))
2580 .with_context(|| format!("json-merge: source missing managed key `{key}`"))
2581 })
2582 .collect()
2583}
2584
2585pub(crate) fn installed_json_hash(bytes: &[u8], managed_keys: &[String]) -> Result<Option<u64>> {
2586 let current = parse_json_object(bytes, "json-merge: destination must be a JSON object")?;
2587 let managed = managed_keys
2588 .iter()
2589 .filter_map(|key| current.get(key).cloned().map(|value| (key.clone(), value)))
2590 .collect::<JsonMap<_, _>>();
2591 if managed.is_empty() {
2592 Ok(None)
2593 } else {
2594 Ok(Some(hash_content(&serialize_json_object(&managed)?)))
2595 }
2596}
2597
2598pub(crate) fn parse_json_object(
2599 bytes: &[u8],
2600 context: &'static str,
2601) -> Result<JsonMap<String, JsonValue>> {
2602 let value: JsonValue = serde_json::from_slice(bytes).context(context)?;
2603 let JsonValue::Object(object) = value else {
2604 bail!("{context}");
2605 };
2606 Ok(object)
2607}
2608
2609pub(crate) fn serialize_json_object(object: &JsonMap<String, JsonValue>) -> Result<Vec<u8>> {
2610 let mut bytes =
2611 serde_json::to_vec_pretty(object).context("json-merge: serialization failed")?;
2612 if bytes.last() != Some(&b'\n') {
2613 bytes.push(b'\n');
2614 }
2615 Ok(bytes)
2616}
2617
2618impl<H: FileSystemHost + ProcessHost> CoreRuntime<H> {
2619 pub fn validate_app_category_snapshot(&self, category: &str) -> Result<bool> {
2620 let metadata = format!("app/{category}/shine.toml");
2621 let has_metadata = self.presets.file(&metadata).is_some();
2622 let categories = self.app_categories(Some(category))?;
2623 validate_app_destinations(self, &categories)?;
2624 for category in &categories {
2625 if let Some(artifact) = &category.artifact
2626 && artifact.runtime == ArtifactRuntime::Bun
2627 {
2628 self.bun_dependency_arg(&format!("app/{}/{}", category.name, artifact.script))?;
2629 }
2630 for file in &category.files {
2631 if let Some(generator) = &file.generator
2632 && generator.runtime == ArtifactRuntime::Bun
2633 {
2634 self.bun_dependency_arg(&format!(
2635 "app/{}/{}",
2636 category.name,
2637 generator.script.display()
2638 ))?;
2639 }
2640 }
2641 for hook in category.post_install.iter().chain(&category.post_upgrade) {
2642 if let AppHookAction::Script {
2643 script,
2644 runtime: ArtifactRuntime::Bun,
2645 } = &hook.action
2646 {
2647 self.bun_dependency_arg(&format!(
2648 "app/{}/{}",
2649 category.name,
2650 script.display()
2651 ))?;
2652 }
2653 }
2654 }
2655 Ok(has_metadata)
2656 }
2657
2658 pub async fn run_app_generator(
2659 &self,
2660 request: AppGeneratorRequest,
2661 observer: &mut impl RuntimeObserver,
2662 ) -> Result<Option<Vec<u8>>> {
2663 if !self.context.env.contains_key(&request.generator.when_env) {
2664 return Ok(None);
2665 }
2666 if !request.explicit && !request.generator.auto {
2667 return Ok(None);
2668 }
2669 self.ensure_app_code_allowed(&request.category, "generator")?;
2670 let logical = format!(
2671 "app/{}/{}",
2672 request.category,
2673 request.generator.script.display()
2674 );
2675 let prepared = self
2676 .prepare_app_script(
2677 &request.category,
2678 &logical,
2679 request.generator.runtime,
2680 false,
2681 )
2682 .await?;
2683 let mut env = BTreeMap::new();
2684 for spec in &request.generator.env {
2685 let value = self.context.env.get(&spec.source).ok_or_else(|| {
2686 anyhow::anyhow!(
2687 "app '{}' generator requires config env '{}'",
2688 request.category,
2689 spec.source
2690 )
2691 })?;
2692 env.insert(spec.target.clone(), value.clone());
2693 }
2694 env.extend(self.fixed_app_contract_env(&request.category, &prepared.category_root));
2695 let output = self
2696 .host
2697 .run(ProcessRequest {
2698 program: prepared.program,
2699 args: prepared.args,
2700 cwd: Some(prepared.category_root),
2701 env,
2702 timeout: Some(GENERATOR_TIMEOUT),
2703 stdout_limit: Some(GENERATOR_STDOUT_LIMIT),
2704 stderr_limit: Some(GENERATOR_STDERR_LIMIT),
2705 ..ProcessRequest::default()
2706 })
2707 .await
2708 .with_context(|| format!("running app '{}' generator", request.category))?;
2709 if output.exit_code != Some(0) {
2710 bail!(
2711 "app '{}' generator exited with {} (details redacted)",
2712 request.category,
2713 display_exit_code(output.exit_code)
2714 );
2715 }
2716 let content = String::from_utf8(output.stdout)
2717 .with_context(|| format!("app '{}' generator output is not UTF-8", request.category))?;
2718 let note = String::from_utf8_lossy(&output.stderr).trim().to_string();
2719 if !note.is_empty() {
2720 observer.emit(RuntimeEvent::ProcessOutput {
2721 code: "app_generator_note",
2722 target: format!("app/{}", request.category),
2723 stream: "stderr",
2724 text: note,
2725 });
2726 }
2727 Ok(Some(content.into_bytes()))
2728 }
2729
2730 pub async fn run_app_hooks(
2731 &self,
2732 request: AppHookRequest,
2733 observer: &mut impl RuntimeObserver,
2734 ) -> AppHookReport {
2735 let categories = request
2736 .categories
2737 .into_iter()
2738 .map(|category| (category.name.clone(), category))
2739 .collect::<BTreeMap<_, _>>();
2740 let mut report = AppHookReport::default();
2741 for category_name in request.changed {
2742 let Some(category) = categories.get(&category_name) else {
2743 continue;
2744 };
2745 let hooks = match request.phase {
2746 AppHookPhase::PostInstall => &category.post_install,
2747 AppHookPhase::PostUpgrade => &category.post_upgrade,
2748 };
2749 if hooks.is_empty() {
2750 continue;
2751 }
2752 let resource = match request.phase {
2753 AppHookPhase::PostInstall => "hook:post-install",
2754 AppHookPhase::PostUpgrade => "hook:post-upgrade",
2755 };
2756 if let Err(error) = self.ensure_app_code_allowed(&category_name, resource) {
2757 observer.emit(RuntimeEvent::Warning {
2758 code: "app_hook_permission_required",
2759 target: Some(format!("app/{category_name}")),
2760 detail: error.to_string(),
2761 });
2762 report.outcomes.push(
2763 LifecycleOutcomeV1::new(
2764 format!("app/{category_name}"),
2765 Some(resource),
2766 LifecycleStatus::Skipped,
2767 [],
2768 )
2769 .with_diagnostic_code("app_hook_permission_required"),
2770 );
2771 continue;
2772 }
2773 let mut completed = true;
2774 let mut notes = Vec::new();
2775 for hook in hooks {
2776 let mut env: BTreeMap<String, String> = hook
2777 .env
2778 .iter()
2779 .filter_map(|spec| {
2780 self.context
2781 .env
2782 .get(&spec.source)
2783 .map(|value| (spec.target.clone(), value.clone()))
2784 })
2785 .collect();
2786 let (program, mut args, cwd) = match &hook.action {
2787 AppHookAction::Command(command) => (command.clone(), Vec::new(), None),
2788 AppHookAction::Script { script, runtime } => {
2789 let logical = format!("app/{}/{}", category_name, script.display());
2790 match self
2791 .prepare_app_script(&category_name, &logical, *runtime, true)
2792 .await
2793 {
2794 Ok(prepared) => {
2795 env.extend(self.fixed_app_contract_env(
2796 &category_name,
2797 &prepared.category_root,
2798 ));
2799 (
2800 prepared.program,
2801 prepared.args,
2802 Some(prepared.category_root),
2803 )
2804 }
2805 Err(error) => {
2806 observer.emit(RuntimeEvent::Warning {
2807 code: "app_hook_failed",
2808 target: Some(format!("app/{category_name}")),
2809 detail: format!("{}: {error}", script.display()),
2810 });
2811 completed = false;
2812 break;
2813 }
2814 }
2815 }
2816 };
2817 args.extend(hook.args.clone());
2818 let label = match &hook.action {
2819 AppHookAction::Command(command) => command.clone(),
2820 AppHookAction::Script { script, .. } => script.display().to_string(),
2821 };
2822 let output = self
2823 .host
2824 .run(ProcessRequest {
2825 program,
2826 args,
2827 cwd,
2828 env,
2829 ..ProcessRequest::default()
2830 })
2831 .await;
2832 match output {
2833 Ok(output) if output.exit_code == Some(0) => {
2834 if request.show_success && hook.show_output {
2835 let note = String::from_utf8_lossy(&output.stdout).trim().to_string();
2836 if !note.is_empty() {
2837 notes.push(note);
2838 }
2839 }
2840 }
2841 Ok(output) => {
2842 observer.emit(RuntimeEvent::Warning {
2843 code: "app_hook_failed",
2844 target: Some(format!("app/{category_name}")),
2845 detail: format!(
2846 "{} exited with {}{}",
2847 label,
2848 display_exit_code(output.exit_code),
2849 process_detail(&output)
2850 ),
2851 });
2852 completed = false;
2853 break;
2854 }
2855 Err(error) => {
2856 observer.emit(RuntimeEvent::Warning {
2857 code: "app_hook_failed",
2858 target: Some(format!("app/{category_name}")),
2859 detail: format!("{label}: {error}"),
2860 });
2861 completed = false;
2862 break;
2863 }
2864 }
2865 }
2866 if request.show_success && completed {
2867 observer.emit(RuntimeEvent::Progress {
2868 code: "app_hook_completed",
2869 target: format!("app/{category_name}"),
2870 });
2871 }
2872 for note in ¬es {
2873 observer.emit(RuntimeEvent::ProcessOutput {
2874 code: "app_hook_note",
2875 target: format!("app/{category_name}"),
2876 stream: "stdout",
2877 text: note.clone(),
2878 });
2879 }
2880 report.notes.extend(notes);
2881 report.outcomes.push(if completed {
2882 LifecycleOutcomeV1::new(
2883 format!("app/{category_name}"),
2884 Some(resource),
2885 LifecycleStatus::Changed,
2886 [LifecycleEffect::CodeExecuted],
2887 )
2888 } else {
2889 LifecycleOutcomeV1::new(
2890 format!("app/{category_name}"),
2891 Some(resource),
2892 LifecycleStatus::Failed,
2893 [],
2894 )
2895 .with_diagnostic_code("app_hook_failed")
2896 });
2897 }
2898 report
2899 }
2900
2901 pub(crate) async fn run_app_artifact(
2902 &self,
2903 request: AppArtifactRequest,
2904 observer: &mut impl RuntimeObserver,
2905 ) -> Result<LifecycleOutcomeV1> {
2906 let (script, resource) = match request.action {
2907 AppArtifactAction::Apply => (&request.artifact.script, "artifact:apply"),
2908 AppArtifactAction::Remove => (
2909 request.artifact.teardown.as_ref().ok_or_else(|| {
2910 anyhow::anyhow!(
2911 "app '{}' does not define an artifact teardown script",
2912 request.category
2913 )
2914 })?,
2915 "artifact:teardown",
2916 ),
2917 };
2918 if request.dry_run {
2919 return Ok(LifecycleOutcomeV1::new(
2920 format!("app/{}", request.category),
2921 Some(resource),
2922 LifecycleStatus::Previewed,
2923 [LifecycleEffect::CodeExecutionPreviewed],
2924 ));
2925 }
2926 if request.implicit
2927 && let Err(error) = self.ensure_app_code_allowed(&request.category, resource)
2928 {
2929 observer.emit(RuntimeEvent::Warning {
2930 code: "app_artifact_permission_required",
2931 target: Some(format!("app/{}", request.category)),
2932 detail: error.to_string(),
2933 });
2934 return Ok(LifecycleOutcomeV1::new(
2935 format!("app/{}", request.category),
2936 Some(resource),
2937 LifecycleStatus::Skipped,
2938 [],
2939 )
2940 .with_diagnostic_code("app_artifact_permission_required"));
2941 }
2942 if !request.implicit {
2943 self.ensure_app_code_allowed(&request.category, resource)?;
2944 }
2945 let logical = format!("app/{}/{script}", request.category);
2946 let prepared = self
2947 .prepare_app_script(&request.category, &logical, request.artifact.runtime, true)
2948 .await?;
2949 for directory in [
2950 self.context
2951 .shine_dir
2952 .join("http")
2953 .join("app")
2954 .join(&request.category),
2955 self.context
2956 .cache_dir
2957 .join("shine")
2958 .join("app")
2959 .join(&request.category),
2960 self.context
2961 .shine_dir
2962 .join("state")
2963 .join("app")
2964 .join(&request.category),
2965 ] {
2966 self.host
2967 .create_dir_all(&directory)
2968 .await
2969 .map_err(|error| error.into_anyhow("creating App artifact directory"))?;
2970 }
2971 let output = self
2972 .host
2973 .run(ProcessRequest {
2974 program: prepared.program,
2975 args: prepared.args,
2976 cwd: Some(prepared.category_root.clone()),
2977 env: self.app_artifact_env(
2978 &request.category,
2979 &prepared.category_root,
2980 &request.artifact.env,
2981 ),
2982 io: if request.implicit {
2983 ProcessIo::Captured
2984 } else {
2985 ProcessIo::Inherit
2986 },
2987 ..ProcessRequest::default()
2988 })
2989 .await;
2990 match output {
2991 Ok(output) if output.exit_code == Some(0) => Ok(LifecycleOutcomeV1::new(
2992 format!("app/{}", request.category),
2993 Some(resource),
2994 LifecycleStatus::Changed,
2995 [LifecycleEffect::CodeExecuted],
2996 )),
2997 Ok(output) if request.implicit => {
2998 observer.emit(RuntimeEvent::Warning {
2999 code: "app_artifact_teardown_failed",
3000 target: Some(format!("app/{}", request.category)),
3001 detail: format!(
3002 "artifact script exited with {}",
3003 display_exit_code(output.exit_code)
3004 ),
3005 });
3006 Ok(LifecycleOutcomeV1::new(
3007 format!("app/{}", request.category),
3008 Some(resource),
3009 LifecycleStatus::Failed,
3010 [],
3011 )
3012 .with_diagnostic_code("app_artifact_teardown_failed"))
3013 }
3014 Ok(output) => bail!(
3015 "artifact script for '{}' exited with {}",
3016 request.category,
3017 display_exit_code(output.exit_code)
3018 ),
3019 Err(error) if request.implicit => {
3020 observer.emit(RuntimeEvent::Warning {
3021 code: "app_artifact_teardown_failed",
3022 target: Some(format!("app/{}", request.category)),
3023 detail: error.to_string(),
3024 });
3025 Ok(LifecycleOutcomeV1::new(
3026 format!("app/{}", request.category),
3027 Some(resource),
3028 LifecycleStatus::Failed,
3029 [],
3030 )
3031 .with_diagnostic_code("app_artifact_teardown_failed"))
3032 }
3033 Err(error) => Err(error),
3034 }
3035 }
3036
3037 fn ensure_app_code_allowed(&self, category: &str, capability: &str) -> Result<()> {
3038 let category = self
3039 .app_categories(Some(category))?
3040 .into_iter()
3041 .next()
3042 .with_context(|| format!("app preset category not found: {category}"))?;
3043 let trust_capability = if capability.starts_with("hook:") {
3044 TrustCapabilityV1::AppHook
3045 } else if capability.starts_with("artifact:") {
3046 TrustCapabilityV1::AppArtifact
3047 } else {
3048 TrustCapabilityV1::AppGenerator
3049 };
3050 if !self.app_capability_trusted(&category, trust_capability)? {
3051 bail!(
3052 "app '{}' {capability} requires scoped external-code trust; run `shine trust grant app/{}` after reviewing the active code and permissions",
3053 category.name,
3054 category.name,
3055 );
3056 }
3057 Ok(())
3058 }
3059
3060 async fn prepare_app_script(
3061 &self,
3062 category: &str,
3063 logical: &str,
3064 runtime: ArtifactRuntime,
3065 materialize_category: bool,
3066 ) -> Result<PreparedScript> {
3067 let file = self
3068 .presets
3069 .file(logical)
3070 .with_context(|| format!("app script is missing: {logical}"))?;
3071 let script_path = if let Some(path) = &file.origin.physical_path {
3072 path.clone()
3073 } else if materialize_category {
3074 let prefix = format!("app/{category}/");
3075 for (path, bytes) in self
3076 .presets
3077 .files()
3078 .iter()
3079 .filter(|(path, _)| path.starts_with(&prefix))
3080 {
3081 self.host
3082 .write_atomic(&self.context.presets_dir.join(path), bytes)
3083 .await
3084 .map_err(|error| error.into_anyhow("materializing App artifact category"))?;
3085 }
3086 self.context.presets_dir.join(logical)
3087 } else {
3088 let file_name = Path::new(logical)
3089 .file_name()
3090 .context("app script has no file name")?;
3091 let path = self
3092 .context
3093 .shine_dir
3094 .join("runtime")
3095 .join("app")
3096 .join(category)
3097 .join(file_name);
3098 self.host
3099 .write_atomic(&path, &file.bytes)
3100 .await
3101 .map_err(|error| error.into_anyhow("materializing app script"))?;
3102 path
3103 };
3104 let category_root = (materialize_category && file.origin.physical_path.is_none())
3105 .then(|| self.context.presets_dir.join("app").join(category))
3106 .or_else(|| file.origin.category_root.clone())
3107 .or_else(|| script_path.parent().map(Path::to_path_buf))
3108 .context("app script has no category root")?;
3109 let (program, args) = match runtime {
3110 ArtifactRuntime::Native => (script_path.display().to_string(), Vec::new()),
3111 ArtifactRuntime::Bun => {
3112 let mut args = vec![self.bun_dependency_arg(logical)?];
3113 args.push(script_path.display().to_string());
3114 ("bun".to_string(), args)
3115 }
3116 };
3117 Ok(PreparedScript {
3118 program,
3119 args,
3120 category_root,
3121 })
3122 }
3123
3124 pub(crate) fn bun_dependency_arg(&self, logical: &str) -> Result<String> {
3125 let script = self
3126 .presets
3127 .file(logical)
3128 .context("Bun script disappeared from preset snapshot")?;
3129 if script.origin.source_kind == crate::runtime::PresetSourceKind::Embedded {
3130 return Ok("--no-install".to_string());
3131 }
3132 let mut parts = logical.split('/');
3133 let kind = parts.next().unwrap_or_default();
3134 let category = parts.next().unwrap_or_default();
3135 let package_key = format!("{kind}/{category}/package.json");
3136 let lock_key = format!("{kind}/{category}/bun.lock");
3137 let package = self
3138 .presets
3139 .file(&package_key)
3140 .filter(|candidate| candidate.origin.source_kind == script.origin.source_kind);
3141 let lock = self
3142 .presets
3143 .file(&lock_key)
3144 .filter(|candidate| candidate.origin.source_kind == script.origin.source_kind);
3145 match (package, lock) {
3146 (None, None) => Ok("--no-install".to_string()),
3147 (Some(_), None) | (None, Some(_)) => {
3148 bail!("external Bun preset requires both package.json and bun.lock")
3149 }
3150 (Some(package), Some(_)) => {
3151 let value: serde_json::Value = serde_json::from_slice(&package.bytes)
3152 .context("invalid external Bun package.json")?;
3153 if value.get("trustedDependencies").is_some() {
3154 bail!("external Bun preset must not declare trustedDependencies");
3155 }
3156 Ok("--install=fallback".to_string())
3157 }
3158 }
3159 }
3160
3161 fn app_artifact_env(
3162 &self,
3163 category: &str,
3164 app_dir: &Path,
3165 specs: &[EnvVarSpec],
3166 ) -> BTreeMap<String, String> {
3167 let mut env = BTreeMap::new();
3168 for spec in specs {
3169 if let Some(value) = self.context.env.get(&spec.source) {
3170 env.insert(spec.target.clone(), value.clone());
3171 }
3172 }
3173 env.extend(self.fixed_app_contract_env(category, app_dir));
3174 env
3175 }
3176
3177 fn fixed_app_contract_env(&self, category: &str, app_dir: &Path) -> BTreeMap<String, String> {
3178 let mut env = BTreeMap::new();
3179 let source_dir = self.context.presets_dir.join("app").join(category);
3180 let cache_dir = self
3181 .context
3182 .cache_dir
3183 .join("shine")
3184 .join("app")
3185 .join(category);
3186 let state_dir = self
3187 .context
3188 .shine_dir
3189 .join("state")
3190 .join("app")
3191 .join(category);
3192 let http_dir = self
3193 .context
3194 .shine_dir
3195 .join("http")
3196 .join("app")
3197 .join(category);
3198 for (key, value) in [
3199 ("SHINE_APP_ID", category.to_string()),
3200 ("SHINE_APP_DIR", app_dir.display().to_string()),
3201 ("SHINE_APP_SOURCE_DIR", source_dir.display().to_string()),
3202 ("SHINE_APP_HTTP_DIR", http_dir.display().to_string()),
3203 (
3204 "SHINE_CONFIG_DIR",
3205 self.context.shine_dir.display().to_string(),
3206 ),
3207 ("SHINE_CACHE_DIR", cache_dir.display().to_string()),
3208 ("SHINE_STATE_DIR", state_dir.display().to_string()),
3209 ] {
3210 env.insert(key.to_string(), value);
3211 }
3212 if let Some(overlay) = &self.context.overlay_dir {
3213 env.insert(
3214 "SHINE_APP_OVERLAY_DIR".to_string(),
3215 overlay.join("app").join(category).display().to_string(),
3216 );
3217 }
3218 env
3219 }
3220}
3221
3222impl<H: FileSystemHost> CoreRuntime<H> {
3223 pub async fn reconcile_app_cache(
3224 &self,
3225 request: AppCacheRequest,
3226 ) -> Result<LifecycleOutcomeV1> {
3227 let target = request.prefix.trim_end_matches('/');
3228 let mut changed = false;
3229 let mut receipt_removed = false;
3230 for (logical, bytes) in
3231 self.presets.files().iter().filter(|(logical, _)| {
3232 *logical == target || logical.starts_with(&format!("{target}/"))
3233 })
3234 {
3235 let destination = self.context.presets_dir.join(logical);
3236 if request.remove {
3237 match self.host.metadata(&destination).await {
3238 Ok(_) => {
3239 changed = true;
3240 if !request.dry_run {
3241 self.host
3242 .remove_file(&destination)
3243 .await
3244 .map_err(|error| error.into_anyhow("removing app preset cache"))?;
3245 }
3246 }
3247 Err(error) if error.is_not_found() => {}
3248 Err(error) => return Err(error.into_anyhow("inspecting app preset cache")),
3249 }
3250 } else {
3251 let current = self.host.read(&destination).await;
3252 let differs = match current {
3253 Ok(_) if !request.overwrite => false,
3254 Ok(current) => current != *bytes,
3255 Err(error) if error.is_not_found() => true,
3256 Err(error) => return Err(error.into_anyhow("reading app preset cache")),
3257 };
3258 if differs {
3259 changed = true;
3260 if !request.dry_run {
3261 self.host
3262 .write_atomic(&destination, bytes)
3263 .await
3264 .map_err(|error| error.into_anyhow("writing app preset cache"))?;
3265 }
3266 }
3267 }
3268 }
3269 if request.remove && request.purge {
3270 let root = self.context.presets_dir.join(target);
3271 match self.host.metadata(&root).await {
3272 Ok(_) => {
3273 changed = true;
3274 if !request.dry_run {
3275 self.host
3276 .remove_dir_all(&root)
3277 .await
3278 .map_err(|error| error.into_anyhow("purging app preset cache"))?;
3279 }
3280 }
3281 Err(error) if error.is_not_found() => {}
3282 Err(error) => return Err(error.into_anyhow("inspecting app preset cache root")),
3283 }
3284 if target == "app" {
3285 let manifest = self.context.shine_dir.join("app-manifest.toml");
3286 match self.host.metadata(&manifest).await {
3287 Ok(_) => {
3288 changed = true;
3289 receipt_removed = true;
3290 if !request.dry_run {
3291 self.host
3292 .remove_file(&manifest)
3293 .await
3294 .map_err(|error| error.into_anyhow("purging App manifest"))?;
3295 }
3296 }
3297 Err(error) if error.is_not_found() => {}
3298 Err(error) => return Err(error.into_anyhow("inspecting App manifest")),
3299 }
3300 }
3301 }
3302 let status = match (request.dry_run, changed) {
3303 (true, true) => LifecycleStatus::Previewed,
3304 (false, true) => LifecycleStatus::Changed,
3305 _ => LifecycleStatus::Unchanged,
3306 };
3307 let mut effects = match (request.purge, request.remove, request.dry_run, changed) {
3308 (_, _, _, false) => Vec::new(),
3309 (true, true, true, true) => vec![LifecycleEffect::CacheRemovePreviewed],
3310 (true, true, false, true) => vec![LifecycleEffect::CachePurged],
3311 (false, true, true, true) => vec![LifecycleEffect::CacheRemovePreviewed],
3312 (false, true, false, true) => vec![LifecycleEffect::CacheRemoved],
3313 (_, false, true, true) => vec![LifecycleEffect::CacheWritePreviewed],
3314 (_, false, false, true) => vec![LifecycleEffect::CacheWritten],
3315 };
3316 if receipt_removed {
3317 effects.push(if request.dry_run {
3318 LifecycleEffect::ReceiptRemovePreviewed
3319 } else {
3320 LifecycleEffect::ReceiptRemoved
3321 });
3322 }
3323 Ok(LifecycleOutcomeV1::new(
3324 target.to_string(),
3325 Some(if request.purge {
3326 "purge"
3327 } else {
3328 "preset-cache"
3329 }),
3330 status,
3331 effects,
3332 ))
3333 }
3334}
3335
3336struct PreparedScript {
3337 program: String,
3338 args: Vec<String>,
3339 category_root: PathBuf,
3340}
3341
3342fn display_exit_code(code: Option<i32>) -> String {
3343 code.map_or_else(|| "signal".to_string(), |code| code.to_string())
3344}
3345
3346fn process_detail(output: &crate::runtime::ProcessOutput) -> String {
3347 let stderr = String::from_utf8_lossy(&output.stderr);
3348 let stdout = String::from_utf8_lossy(&output.stdout);
3349 let detail = if stderr.trim().is_empty() {
3350 stdout.trim()
3351 } else {
3352 stderr.trim()
3353 };
3354 if detail.is_empty() {
3355 String::new()
3356 } else {
3357 format!(": {detail}")
3358 }
3359}
3360
3361async fn load_manifest(
3362 host: &impl FileSystemHost,
3363 shine_dir: &std::path::Path,
3364) -> Result<AppManifest> {
3365 let path = shine_dir.join("app-manifest.toml");
3366 let mut manifest = match host.read(&path).await {
3367 Ok(bytes) => toml::from_slice(&bytes).context("failed to parse app manifest")?,
3368 Err(error) if error.is_not_found() => AppManifest::default(),
3369 Err(error) => return Err(error.into_anyhow("failed to read app manifest")),
3370 };
3371 match manifest.schema_version {
3372 0 => manifest.schema_version = crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION,
3373 crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION => {}
3374 version => bail!(
3375 "app manifest schema version {version} is newer than this Shine supports ({})",
3376 crate::install::manifest::APP_MANIFEST_SCHEMA_VERSION
3377 ),
3378 }
3379 Ok(manifest)
3380}
3381
3382async fn save_manifest(
3383 host: &impl FileSystemHost,
3384 shine_dir: &std::path::Path,
3385 manifest: &AppManifest,
3386) -> Result<()> {
3387 let content = toml::to_string_pretty(manifest).context("failed to serialize app manifest")?;
3388 host.write_atomic(&shine_dir.join("app-manifest.toml"), content.as_bytes())
3389 .await
3390 .map_err(|error| error.into_anyhow("failed to write app manifest"))
3391}
3392
3393#[cfg(test)]
3394mod lifecycle_tests {
3395 use super::*;
3396 use crate::runtime::{
3397 FileSystemObservationHost, HostOperation, InMemoryHost, NullObserver, PresetSnapshot,
3398 PresetSourceKind, RuntimeContext, RuntimePlatform,
3399 };
3400 use std::future::Future;
3401 use std::path::Path;
3402 use std::pin::Pin;
3403
3404 fn runtime() -> CoreRuntime<InMemoryHost> {
3405 let home_dir = std::env::temp_dir().join("shine-core-app-lifecycle");
3406 let shine_dir = home_dir.join(".shine");
3407 let presets = PresetSnapshot::builder(PresetSourceKind::External)
3408 .file(
3409 "app/demo/shine.toml",
3410 b"dest = \"~/.config/demo\"\n[[files]]\nsource = \"config\"\n".to_vec(),
3411 )
3412 .file("app/demo/config", b"one".to_vec())
3413 .build();
3414 let mut context = RuntimeContext::isolated(
3415 home_dir,
3416 shine_dir.clone(),
3417 shine_dir.join("presets"),
3418 shine_dir.join("bin"),
3419 RuntimePlatform::Linux,
3420 );
3421 context.is_external_presets = true;
3422 CoreRuntime::new(InMemoryHost::new(), context, presets)
3423 }
3424
3425 struct Interaction;
3426 impl RuntimeInteraction for Interaction {
3427 fn confirm(&mut self, _code: &'static str, default: bool) -> Result<bool> {
3428 Ok(default)
3429 }
3430 fn authorize_admin<'a>(
3431 &'a mut self,
3432 _count: usize,
3433 ) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + 'a>> {
3434 Box::pin(async { Ok(true) })
3435 }
3436 fn select_many(
3437 &mut self,
3438 _code: &'static str,
3439 _choices: &[String],
3440 defaults: &[String],
3441 ) -> Result<Vec<String>> {
3442 Ok(defaults.to_vec())
3443 }
3444 }
3445
3446 #[test]
3447 fn artifact_env_allowlist_cannot_override_fixed_contract_values() {
3448 let mut runtime = runtime();
3449 runtime
3450 .context_mut_for_cli()
3451 .env
3452 .insert("USER_VALUE".to_string(), "override".to_string());
3453 let env = runtime.app_artifact_env(
3454 "demo",
3455 Path::new("/preset/app/demo"),
3456 &[EnvVarSpec {
3457 source: "USER_VALUE".to_string(),
3458 target: "SHINE_APP_ID".to_string(),
3459 }],
3460 );
3461 assert_eq!(env.get("SHINE_APP_ID").map(String::as_str), Some("demo"));
3462 }
3463
3464 #[tokio::test]
3465 async fn bun_script_hook_runs_from_the_parent_lifecycle_and_failure_is_non_fatal() {
3466 let home_dir = std::env::temp_dir().join("shine-core-script-hook");
3467 let shine_dir = home_dir.join(".shine");
3468 let snapshot = PresetSnapshot::builder(PresetSourceKind::Embedded)
3469 .file(
3470 "app/demo/shine.toml",
3471 b"metadata_schema_version = 2\ndest = '~/.config/demo'\npost_upgrade = { script = 'refresh.ts', runtime = 'bun', env = ['TOKEN'] }\n[permissions]\nschema_version = 1\nfilesystem = [{ access = ['execute'], base = 'preset', path = 'refresh.ts' }]\ncommands = ['bun']\nenvironment = [{ name = 'TOKEN', sensitivity = 'plain' }]\n[[files]]\nsource = 'config'\n".to_vec(),
3472 )
3473 .file("app/demo/config", b"one".to_vec())
3474 .file("app/demo/refresh.ts", b"export {};".to_vec())
3475 .build();
3476 let mut context = RuntimeContext::isolated(
3477 home_dir,
3478 shine_dir.clone(),
3479 shine_dir.join("presets"),
3480 shine_dir.join("bin"),
3481 RuntimePlatform::Linux,
3482 );
3483 context
3484 .env
3485 .insert("TOKEN".to_string(), "opaque".to_string());
3486 let runtime = CoreRuntime::new(InMemoryHost::new(), context, snapshot);
3487 let categories = runtime.app_categories(Some("demo")).unwrap();
3488 runtime
3489 .host()
3490 .queue_process_output(Ok(crate::runtime::ProcessOutput {
3491 exit_code: Some(0),
3492 ..Default::default()
3493 }));
3494 let mut observer = NullObserver;
3495 let success = runtime
3496 .run_app_hooks(
3497 AppHookRequest {
3498 categories: categories.clone(),
3499 changed: BTreeSet::from(["demo".to_string()]),
3500 phase: AppHookPhase::PostUpgrade,
3501 show_success: false,
3502 },
3503 &mut observer,
3504 )
3505 .await;
3506 assert_eq!(success.outcomes[0].status, LifecycleStatus::Changed);
3507 assert!(runtime.host().operations().iter().any(|operation| matches!(
3508 operation,
3509 HostOperation::Run { program, args }
3510 if program == "bun" && args.first().is_some_and(|arg| arg == "--no-install")
3511 )));
3512
3513 runtime
3514 .host()
3515 .queue_process_output(Ok(crate::runtime::ProcessOutput {
3516 exit_code: Some(1),
3517 stderr: b"controller unavailable".to_vec(),
3518 ..Default::default()
3519 }));
3520 let failed = runtime
3521 .run_app_hooks(
3522 AppHookRequest {
3523 categories,
3524 changed: BTreeSet::from(["demo".to_string()]),
3525 phase: AppHookPhase::PostUpgrade,
3526 show_success: false,
3527 },
3528 &mut observer,
3529 )
3530 .await;
3531 assert_eq!(failed.outcomes[0].status, LifecycleStatus::Failed);
3532 assert_eq!(failed.outcomes[0].diagnostic_codes, ["app_hook_failed"]);
3533 }
3534
3535 #[tokio::test]
3536 async fn privileged_app_transaction_acquires_host_lock_before_mutation() {
3537 let host = InMemoryHost::new();
3538 install_privileged_bytes(
3539 &host,
3540 b"managed",
3541 Path::new("/etc/demo"),
3542 false,
3543 false,
3544 false,
3545 )
3546 .await
3547 .unwrap();
3548
3549 let operations = host.operations();
3550 let lock = operations
3551 .iter()
3552 .position(|operation| matches!(operation, HostOperation::AcquirePrivilegedOperation))
3553 .unwrap();
3554 let write = operations
3555 .iter()
3556 .position(|operation| matches!(operation, HostOperation::Write(path) if path == Path::new("/etc/demo")))
3557 .unwrap();
3558 assert!(lock < write);
3559 }
3560
3561 #[tokio::test]
3562 async fn app_executor_roundtrip_and_target_isolation_use_in_memory_host() {
3563 let runtime = runtime();
3564 let home_dir = runtime.context().home_dir.clone();
3565 let mut observer = NullObserver;
3566 let mut interaction = Interaction;
3567 let installed = runtime
3568 .install_apps(
3569 AppLifecycleRequest {
3570 target: Some("demo".into()),
3571 dry_run: false,
3572 force: false,
3573 },
3574 &mut observer,
3575 &mut interaction,
3576 )
3577 .await
3578 .unwrap();
3579 assert_eq!(installed.lifecycle.summary().changed, 1);
3580 let unchanged = runtime
3581 .install_apps(
3582 AppLifecycleRequest {
3583 target: Some("demo".into()),
3584 dry_run: false,
3585 force: false,
3586 },
3587 &mut observer,
3588 &mut interaction,
3589 )
3590 .await
3591 .unwrap();
3592 assert_eq!(unchanged.lifecycle.summary().unchanged, 1);
3593
3594 runtime
3595 .host()
3596 .put_file(home_dir.join("other"), b"other".to_vec());
3597 let removed = runtime
3598 .uninstall_apps(
3599 AppUninstallLifecycleRequest {
3600 target: Some("demo".into()),
3601 dry_run: false,
3602 force: false,
3603 purge: false,
3604 },
3605 &mut observer,
3606 &mut interaction,
3607 )
3608 .await
3609 .unwrap();
3610 assert_eq!(removed.lifecycle.summary().changed, 1);
3611 assert!(
3612 runtime
3613 .host()
3614 .read(&home_dir.join(".config/demo/config"))
3615 .await
3616 .is_err()
3617 );
3618 assert_eq!(
3619 runtime.host().read(&home_dir.join("other")).await.unwrap(),
3620 b"other"
3621 );
3622 }
3623}