Skip to main content

shine_core/runtime/
app.rs

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