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