Skip to main content

shine_core/runtime/
shell.rs

1use super::launcher::{
2    prepare_launcher_resources, prepared_launcher_resource_is_exact,
3    probe_managed_command_with_host,
4};
5use super::shell_action_executor::{
6    ShellCacheRemoval, ShellCacheReplacement, ShellCacheReplacementFile, ShellLauncherCreation,
7    ShellLauncherRemoval, ShellLauncherUpdate, ShellLegacyLauncherRemoval,
8    ShellProfilePreparedFile, ShellProfileReconciliation, ShellRenderedFileRemoval,
9    ShellRenderedFileReplacement, ShellSharedReplacements, ShellSnapshotRemoval,
10    ShellSnapshotReplacement,
11};
12use super::{
13    CoreRuntime, FileKind, FileSystemHost, InspectionChange, InspectionFileStatus, LinkConflict,
14    LinkConflictKind, LinkReport, LinkSpec, PathUpdateStatus, PrivilegedFileSystemHost,
15    ShellConfigUpdate, ShellFileInspection, ShellProfileRemoval, UnlinkReport,
16    command_path_for_name, link_executables_with_host, link_is_current_with_host,
17    unlink_managed_command_with_host,
18};
19use crate::action::{
20    ShellFileIdentityV1, ShellProfileFileOwnershipV1, managed_file_rollback_path,
21    shell_snapshot_rollback_path,
22};
23use crate::lifecycle::{
24    LifecycleEffect, LifecycleOperation, LifecycleOutcomeV1, LifecycleResultV1, LifecycleStatus,
25};
26use crate::permission::PermissionDeclarationV1;
27use crate::plan::PlanApprovalV1;
28use anyhow::{Context, Result, bail};
29use serde::{Deserialize, Serialize};
30use std::collections::{BTreeMap, BTreeSet};
31use std::ffi::OsString;
32use std::path::{Path, PathBuf};
33use std::str::FromStr;
34
35#[derive(Debug, Deserialize)]
36struct ShellCategoryToml {
37    description: Option<String>,
38    files: Option<Vec<ShellFileToml>>,
39}
40
41#[derive(Debug, Deserialize)]
42struct ShellFileToml {
43    source: String,
44    target: Option<String>,
45    description: Option<String>,
46    needs_source: Option<bool>,
47    platforms: Option<Vec<String>>,
48    runtime: Option<String>,
49    transforms: Option<Vec<String>>,
50    env: Option<Vec<String>>,
51    permissions: Option<PermissionDeclarationV1>,
52}
53
54pub const SHELL_MANIFEST_FILE: &str = "shell-manifest.toml";
55pub const SHELL_MANIFEST_SCHEMA_VERSION: u32 = 1;
56
57#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
58#[serde(rename_all = "kebab-case")]
59pub enum ExternalShellMode {
60    #[default]
61    Snapshot,
62    Live,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum LinkRuntime {
67    #[default]
68    Native,
69    Bun,
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum BunDependencyMode {
74    #[default]
75    Disabled,
76    Locked,
77}
78
79impl BunDependencyMode {
80    pub const fn as_manifest_value(self) -> Option<&'static str> {
81        match self {
82            Self::Disabled => None,
83            Self::Locked => Some("locked"),
84        }
85    }
86
87    pub const fn install_arg(self) -> &'static str {
88        match self {
89            Self::Disabled => "--no-install",
90            Self::Locked => "--install=fallback",
91        }
92    }
93}
94
95#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
96pub struct BunRuntimeSpec {
97    pub dependency_mode: BunDependencyMode,
98    pub dependency_hash: Option<u64>,
99}
100
101pub(crate) fn shell_link_spec_from_manifest_entry(entry: &ShellManifestEntry) -> Result<LinkSpec> {
102    let runtime = match entry.runtime.as_str() {
103        "native" => LinkRuntime::Native,
104        "bun" => LinkRuntime::Bun,
105        value => bail!("unsupported Shell launcher runtime in receipt: {value}"),
106    };
107    let bun_dependencies = match entry.bun_dependencies.as_deref() {
108        None => BunDependencyMode::Disabled,
109        Some("locked") => BunDependencyMode::Locked,
110        Some(value) => bail!("unsupported Shell Bun dependency mode in receipt: {value}"),
111    };
112    let source = if entry.transforms.is_empty() {
113        entry.source_path.clone()
114    } else {
115        entry.rendered_path.clone()
116    };
117    let render_target = (entry.mode == ExternalShellMode::Live && !entry.transforms.is_empty())
118        .then(|| format!("shell/{}/{}", entry.category, entry.command));
119    Ok(LinkSpec {
120        source,
121        link_name: OsString::from(&entry.command),
122        runtime,
123        bun_dependencies,
124        env: entry.env.clone(),
125        render_target,
126    })
127}
128
129#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
130pub enum ShellType {
131    Bash,
132    Fish,
133    Zsh,
134    PowerShell,
135    Elvish,
136}
137
138impl FromStr for ShellType {
139    type Err = anyhow::Error;
140
141    fn from_str(value: &str) -> Result<Self> {
142        let shell_name = value
143            .rsplit(['/', '\\'])
144            .next()
145            .unwrap_or(value)
146            .to_ascii_lowercase();
147        match shell_name.trim_end_matches(".exe") {
148            "bash" => Ok(Self::Bash),
149            "fish" => Ok(Self::Fish),
150            "zsh" => Ok(Self::Zsh),
151            "powershell" | "pwsh" => Ok(Self::PowerShell),
152            "elvish" => Ok(Self::Elvish),
153            _ => bail!("Unknown shell item type: {value}"),
154        }
155    }
156}
157
158impl From<ShellType> for &'static str {
159    fn from(value: ShellType) -> Self {
160        match value {
161            ShellType::Bash => "bash",
162            ShellType::Fish => "fish",
163            ShellType::Zsh => "zsh",
164            ShellType::PowerShell => "powershell",
165            ShellType::Elvish => "elvish",
166        }
167    }
168}
169
170impl Default for ShellType {
171    fn default() -> Self {
172        if cfg!(windows) {
173            Self::PowerShell
174        } else {
175            Self::Zsh
176        }
177    }
178}
179
180#[derive(Debug, Clone)]
181pub struct ShellCategory {
182    pub name: String,
183    pub description: Option<String>,
184    pub files: Vec<ShellFile>,
185    pub uses_metadata: bool,
186}
187
188#[derive(Debug, Clone)]
189pub struct ShellFile {
190    pub source_rel: PathBuf,
191    pub command_name: String,
192    pub description: Vec<String>,
193    pub needs_source: bool,
194    pub runtime: LinkRuntime,
195    pub transforms: Vec<String>,
196    pub env: Vec<crate::env::EnvVarSpec>,
197    pub permissions: Option<PermissionDeclarationV1>,
198}
199
200#[derive(Clone, Debug, Eq, PartialEq)]
201pub struct ShellScriptTemplate {
202    pub source_path: PathBuf,
203    pub rendered_path: PathBuf,
204    pub display_name: String,
205    pub transforms: Vec<String>,
206}
207
208#[derive(Clone, Debug, Default, Eq, PartialEq)]
209pub struct ShellTemplateReport {
210    pub updated: Vec<String>,
211}
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214pub enum ShellManifestUpdateScope {
215    Categories,
216    Commands,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct ShellCacheRequest {
221    pub prefix: String,
222    pub dry_run: bool,
223    pub remove: bool,
224    pub overwrite: bool,
225    pub purge: bool,
226}
227
228#[derive(Clone, Debug, Default, Eq, PartialEq)]
229pub struct ShellCacheReport {
230    pub created: Vec<PathBuf>,
231    pub skipped: Vec<PathBuf>,
232    pub overwritten: Vec<PathBuf>,
233    pub removed: Vec<PathBuf>,
234}
235
236#[derive(Clone, Debug, Default, Eq, PartialEq)]
237pub struct ShellLifecycleRequest {
238    pub target: Option<String>,
239    pub dry_run: bool,
240    pub force: bool,
241}
242
243pub struct ShellLifecycleReport {
244    pub categories: Vec<ShellCategory>,
245    pub cache: ShellCacheReport,
246    pub snapshots_updated: usize,
247    pub templates: ShellTemplateReport,
248    pub links: LinkReport,
249    pub profile: Option<ShellConfigUpdate>,
250    pub source_commands: Vec<String>,
251    pub planned_links: Vec<(String, PathBuf, PathBuf)>,
252    pub lifecycle: LifecycleResultV1,
253}
254
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub struct ShellCompletionReport {
257    pub source_commands: Vec<String>,
258    pub profile: ShellConfigUpdate,
259}
260
261#[derive(Clone, Debug, Default, Eq, PartialEq)]
262pub struct ShellUpgradeRequest {
263    pub category: Option<String>,
264}
265
266pub struct ShellUpgradeLifecycleReport {
267    pub runs: Vec<ShellLifecycleReport>,
268    pub updated_targets: Vec<String>,
269    pub updated_categories: Vec<String>,
270    pub lifecycle: LifecycleResultV1,
271}
272
273#[derive(Clone, Debug, Default, Eq, PartialEq)]
274pub struct ShellUninstallRequest {
275    pub target: Option<String>,
276    pub dry_run: bool,
277    pub purge: bool,
278}
279
280pub struct ShellUninstallReport {
281    pub links: UnlinkReport,
282    pub cache: ShellCacheReport,
283    pub profile: Option<ShellProfileRemoval>,
284    pub lifecycle: LifecycleResultV1,
285}
286
287pub(crate) fn has_template_annotation(content: &[u8]) -> bool {
288    let Ok(text) = std::str::from_utf8(content) else {
289        return false;
290    };
291    for line in text.lines() {
292        if line.starts_with("#!") {
293            continue;
294        }
295        let trimmed = line.trim_start();
296        if trimmed == "# shine-template: true" {
297            return true;
298        }
299        if !trimmed.starts_with('#') && !trimmed.is_empty() {
300            break;
301        }
302    }
303    false
304}
305
306fn empty_link_report() -> LinkReport {
307    LinkReport {
308        created: Vec::new(),
309        skipped: Vec::new(),
310        conflicts: Vec::new(),
311        overwritten: Vec::new(),
312    }
313}
314
315fn empty_unlink_report() -> UnlinkReport {
316    UnlinkReport {
317        removed: Vec::new(),
318        skipped: Vec::new(),
319    }
320}
321
322fn merge_shell_cache_report(target: &mut ShellCacheReport, report: ShellCacheReport) {
323    target.created.extend(report.created);
324    target.skipped.extend(report.skipped);
325    target.overwritten.extend(report.overwritten);
326    target.removed.extend(report.removed);
327}
328
329fn embedded_shell_cache_mode(logical: &str) -> Option<u32> {
330    #[cfg(unix)]
331    {
332        Some(if logical.ends_with(".sh") {
333            0o100755
334        } else {
335            0o100644
336        })
337    }
338    #[cfg(not(unix))]
339    {
340        let _ = logical;
341        None
342    }
343}
344
345fn inspection_list(values: &[String]) -> String {
346    if values.is_empty() {
347        "none".to_string()
348    } else {
349        values.join(", ")
350    }
351}
352
353fn push_inspection_change(
354    changes: &mut Vec<InspectionChange>,
355    field: &'static str,
356    from: String,
357    to: String,
358) {
359    if from != to {
360        changes.push(InspectionChange::DeploymentChanged { field, from, to });
361    }
362}
363
364impl<H: FileSystemHost + PrivilegedFileSystemHost> CoreRuntime<H> {
365    pub async fn installed_shell_source_commands(
366        &self,
367        category: Option<&str>,
368    ) -> Result<Vec<String>> {
369        let manifest =
370            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
371        let mut commands = BTreeSet::new();
372        for entry in manifest.entries {
373            if !entry.needs_source || category.is_some_and(|value| value != entry.category) {
374                continue;
375            }
376            let launcher = command_path_for_name(
377                &self.context().bin_dir,
378                std::ffi::OsStr::new(&entry.command),
379            );
380            match self.host().metadata(&launcher).await {
381                Ok(_) => {
382                    commands.insert(entry.command);
383                }
384                Err(error) if error.is_not_found() => {}
385                Err(error) => {
386                    return Err(error.into_anyhow("inspecting installed shell launcher"));
387                }
388            }
389        }
390        Ok(commands.into_iter().collect())
391    }
392
393    pub async fn install_shell_completion(&self, force: bool) -> Result<ShellCompletionReport> {
394        let source_commands = self.installed_shell_source_commands(None).await?;
395        let profile = self
396            .install_shell_profile(&self.context().shell_config_paths, force, &source_commands)
397            .await?;
398        Ok(ShellCompletionReport {
399            source_commands,
400            profile,
401        })
402    }
403
404    pub async fn inspect_shells(&self) -> Result<Vec<ShellFileInspection>> {
405        let categories = self.shell_categories(None)?;
406        let manifest =
407            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
408        let mut files = Vec::new();
409        for category in categories {
410            let snapshot_current = self
411                .shell_snapshot_current(&category.name)
412                .await
413                .unwrap_or(false);
414            for file in &category.files {
415                let desired_path = self.desired_shell_source_path(&category.name, &file.source_rel);
416                let source_path =
417                    self.shell_deployment_source_path(&category.name, &file.source_rel);
418                let rendered_path = self.shell_rendered_path(&category.name, &file.source_rel);
419                let logical_source = format!(
420                    "shell/{}/{}",
421                    category.name,
422                    shell_logical_path(&file.source_rel)
423                );
424                let effective_transforms = if !file.transforms.is_empty() {
425                    file.transforms.clone()
426                } else if self
427                    .presets()
428                    .get(&logical_source)
429                    .is_some_and(has_template_annotation)
430                {
431                    vec!["template".to_string()]
432                } else {
433                    Vec::new()
434                };
435                let effective_source = if effective_transforms.is_empty() {
436                    source_path.clone()
437                } else {
438                    rendered_path.clone()
439                };
440                let desired_content = self
441                    .presets()
442                    .get(&logical_source)
443                    .map(|bytes| {
444                        crate::install::apply_transforms(
445                            &effective_transforms,
446                            bytes,
447                            &self.context().env,
448                        )
449                    })
450                    .transpose()?;
451                let current_content = match self.host().read(&effective_source).await {
452                    Ok(bytes) => Some(bytes),
453                    Err(error) if error.is_not_found() => None,
454                    Err(error) => return Err(error.into_anyhow("reading installed Shell content")),
455                };
456                let link_path = command_path_for_name(
457                    &self.context().bin_dir,
458                    std::ffi::OsStr::new(&file.command_name),
459                );
460                let file_exists = self.host().metadata(&source_path).await.is_ok();
461                let link_metadata = self.host().metadata(&link_path).await.ok();
462                let link_exists = link_metadata.is_some();
463                let link_target = if link_metadata
464                    .as_ref()
465                    .is_some_and(|metadata| metadata.kind == FileKind::Symlink)
466                {
467                    self.host().read_link(&link_path).await.ok()
468                } else {
469                    None
470                };
471                let bun = self.shell_bun_runtime_spec(&category.name, file)?;
472                let runtime_env = file
473                    .env
474                    .iter()
475                    .map(crate::env::EnvVarSpec::to_with_arg)
476                    .collect::<Vec<_>>();
477                let render_target = (self.context().is_external_presets
478                    && self.context().external_shell_mode == ExternalShellMode::Live
479                    && !effective_transforms.is_empty())
480                .then(|| format!("shell/{}/{}", category.name, file.command_name));
481                let link_current = if link_exists {
482                    link_is_current_with_host(
483                        self.host(),
484                        &link_path,
485                        &effective_source,
486                        file.runtime,
487                        bun.dependency_mode,
488                        &runtime_env,
489                        render_target.as_deref(),
490                    )
491                    .await?
492                } else {
493                    false
494                };
495                let canonical = format!("shell/{}/{}", category.name, file.command_name);
496                let entry = manifest.find(&canonical);
497                let launcher_probe = probe_shell_launcher(
498                    self.host(),
499                    self.context(),
500                    &category.name,
501                    &file.command_name,
502                    entry,
503                )
504                .await?;
505                let link_conflict = !launcher_probe.conflicts.is_empty();
506                let installed = entry.is_some()
507                    || link_exists
508                    || link_conflict
509                    || !launcher_probe.resources.is_empty();
510                let source_status = self
511                    .inspect_shell_source(
512                        &category.name,
513                        file,
514                        &source_path,
515                        &rendered_path,
516                        &effective_transforms,
517                    )
518                    .await?;
519                let mut changes = Vec::new();
520                if source_status == InspectionFileStatus::UpdateAvail {
521                    changes.push(InspectionChange::ContentChanged);
522                }
523                let expected_runtime = match file.runtime {
524                    LinkRuntime::Native => "native",
525                    LinkRuntime::Bun => "bun",
526                };
527                let manifest_current = (!self.context().is_external_presets && entry.is_none())
528                    || entry.is_some_and(|entry| {
529                        entry.mode == self.context().external_shell_mode
530                            && entry.source_path == source_path
531                            && entry.runtime == expected_runtime
532                            && entry.bun_dependencies
533                                == bun.dependency_mode.as_manifest_value().map(str::to_string)
534                            && entry.dependency_hash == bun.dependency_hash
535                            && entry.transforms == effective_transforms
536                            && entry.env == runtime_env
537                            && entry.needs_source == file.needs_source
538                    });
539                if let Some(entry) = entry {
540                    if entry.source_path != source_path {
541                        changes.push(InspectionChange::SourceRelocated {
542                            from: entry.source_path.clone(),
543                            to: source_path.clone(),
544                        });
545                    }
546                    if self.context().is_external_presets
547                        && self.context().external_shell_mode == ExternalShellMode::Live
548                        && self
549                            .presets()
550                            .get(&format!(
551                                "shell/{}/{}",
552                                category.name,
553                                shell_logical_path(&file.source_rel)
554                            ))
555                            .is_some_and(|bytes| {
556                                crate::install::hash_content(bytes) != entry.content_hash
557                            })
558                    {
559                        changes.push(InspectionChange::ContentChanged);
560                    }
561                    push_inspection_change(
562                        &mut changes,
563                        "mode",
564                        format!("{:?}", entry.mode).to_lowercase(),
565                        format!("{:?}", self.context().external_shell_mode).to_lowercase(),
566                    );
567                    push_inspection_change(
568                        &mut changes,
569                        "runtime",
570                        entry.runtime.clone(),
571                        expected_runtime.to_string(),
572                    );
573                    push_inspection_change(
574                        &mut changes,
575                        "bun dependencies",
576                        entry
577                            .bun_dependencies
578                            .clone()
579                            .unwrap_or_else(|| "disabled".to_string()),
580                        bun.dependency_mode
581                            .as_manifest_value()
582                            .unwrap_or("disabled")
583                            .to_string(),
584                    );
585                    push_inspection_change(
586                        &mut changes,
587                        "dependency lock",
588                        entry
589                            .dependency_hash
590                            .map(|hash| format!("{hash:016x}"))
591                            .unwrap_or_else(|| "none".to_string()),
592                        bun.dependency_hash
593                            .map(|hash| format!("{hash:016x}"))
594                            .unwrap_or_else(|| "none".to_string()),
595                    );
596                    push_inspection_change(
597                        &mut changes,
598                        "transforms",
599                        inspection_list(&entry.transforms),
600                        inspection_list(&effective_transforms),
601                    );
602                    push_inspection_change(
603                        &mut changes,
604                        "env",
605                        inspection_list(&entry.env),
606                        inspection_list(&runtime_env),
607                    );
608                    push_inspection_change(
609                        &mut changes,
610                        "needs source",
611                        entry.needs_source.to_string(),
612                        file.needs_source.to_string(),
613                    );
614                }
615                if installed && file_exists && !link_exists {
616                    changes.push(InspectionChange::CommandEntryMissing {
617                        path: link_path.clone(),
618                    });
619                }
620                if self.context().is_external_presets && entry.is_none() && link_exists {
621                    changes.push(InspectionChange::ManifestEntryMissing { target: canonical });
622                }
623                if !snapshot_current
624                    && source_status != InspectionFileStatus::UpdateAvail
625                    && self.context().external_shell_mode == ExternalShellMode::Snapshot
626                {
627                    changes.push(InspectionChange::DeploymentChanged {
628                        field: "snapshot",
629                        from: "installed layout".to_string(),
630                        to: "active preset layout".to_string(),
631                    });
632                }
633                let rebuild_explained = changes.iter().any(|change| {
634                    matches!(
635                        change,
636                        InspectionChange::SourceRelocated { .. }
637                            | InspectionChange::DeploymentChanged { .. }
638                            | InspectionChange::CommandEntryMissing { .. }
639                    )
640                });
641                if !link_current && link_exists && !link_conflict && !rebuild_explained {
642                    changes.push(InspectionChange::CommandEntryOutdated {
643                        path: link_path.clone(),
644                    });
645                }
646                if !installed {
647                    changes.clear();
648                }
649                let (status, status_text) = if link_conflict {
650                    (
651                        InspectionFileStatus::UserModified,
652                        "launcher ownership conflict",
653                    )
654                } else if !installed {
655                    (InspectionFileStatus::NotInstalled, "not installed")
656                } else if (installed && !link_exists)
657                    || (link_exists && (!link_current || !manifest_current || !snapshot_current))
658                    || source_status == InspectionFileStatus::UpdateAvail
659                {
660                    (InspectionFileStatus::UpdateAvail, "update available")
661                } else if source_status == InspectionFileStatus::Missing && link_exists {
662                    (InspectionFileStatus::Missing, "rendered script missing")
663                } else if self.context().is_external_presets
664                    && self.context().external_shell_mode == ExternalShellMode::Live
665                    && file_exists
666                    && link_exists
667                {
668                    (
669                        InspectionFileStatus::UpToDate,
670                        if effective_transforms.is_empty() {
671                            "live source"
672                        } else {
673                            "rendered on next run"
674                        },
675                    )
676                } else {
677                    (InspectionFileStatus::UpToDate, "up-to-date")
678                };
679                files.push(ShellFileInspection {
680                    category: category.clone(),
681                    file: file.clone(),
682                    source_path: desired_path,
683                    installed_source_path: source_path,
684                    rendered_path,
685                    link_path,
686                    link_target,
687                    desired_content,
688                    current_content,
689                    status,
690                    status_text,
691                    installed,
692                    link_conflict,
693                    preset_missing: false,
694                    changes,
695                });
696            }
697        }
698        // Receipt-only entries are inspection records, never executable Preset metadata.
699        let present = files
700            .iter()
701            .map(|file| (file.category.name.clone(), file.file.command_name.clone()))
702            .collect::<BTreeSet<_>>();
703        for entry in &manifest.entries {
704            if present.contains(&(entry.category.clone(), entry.command.clone())) {
705                continue;
706            }
707            let probe = probe_shell_launcher(
708                self.host(),
709                self.context(),
710                &entry.category,
711                &entry.command,
712                Some(entry),
713            )
714            .await?;
715            let link_conflict = !probe.conflicts.is_empty();
716            let file = ShellFile {
717                source_rel: entry.source_path.file_name().unwrap_or_default().into(),
718                command_name: entry.command.clone(),
719                description: Vec::new(),
720                needs_source: entry.needs_source,
721                runtime: if entry.runtime == "bun" {
722                    LinkRuntime::Bun
723                } else {
724                    LinkRuntime::Native
725                },
726                transforms: entry.transforms.clone(),
727                env: Vec::new(),
728                permissions: None,
729            };
730            files.push(ShellFileInspection {
731                category: ShellCategory {
732                    name: entry.category.clone(),
733                    description: None,
734                    files: Vec::new(),
735                    uses_metadata: false,
736                },
737                file,
738                source_path: entry.source_path.clone(),
739                installed_source_path: entry.source_path.clone(),
740                rendered_path: entry.rendered_path.clone(),
741                link_path: command_path_for_name(&self.context().bin_dir, entry.command.as_ref()),
742                link_target: None,
743                desired_content: None,
744                current_content: None,
745                status: if link_conflict {
746                    InspectionFileStatus::UserModified
747                } else {
748                    InspectionFileStatus::Missing
749                },
750                status_text: if link_conflict {
751                    "preset missing; launcher ownership conflict"
752                } else {
753                    "preset missing; installed entry preserved"
754                },
755                installed: true,
756                link_conflict,
757                preset_missing: true,
758                changes: Vec::new(),
759            });
760        }
761        Ok(files)
762    }
763
764    async fn inspect_shell_source(
765        &self,
766        category: &str,
767        file: &ShellFile,
768        source_path: &Path,
769        rendered_path: &Path,
770        transforms: &[String],
771    ) -> Result<InspectionFileStatus> {
772        let logical = format!("shell/{category}/{}", shell_logical_path(&file.source_rel));
773        let desired = self
774            .presets()
775            .get(&logical)
776            .context("missing Shell source")?;
777        let current = match self.host().read(source_path).await {
778            Ok(bytes) => bytes,
779            Err(error) if error.is_not_found() => return Ok(InspectionFileStatus::UpdateAvail),
780            Err(error) => return Err(error.into_anyhow("reading deployed Shell source")),
781        };
782        if self.context().is_external_presets
783            && self.context().external_shell_mode == ExternalShellMode::Live
784        {
785            return Ok(InspectionFileStatus::UpToDate);
786        }
787        if current != desired {
788            return Ok(InspectionFileStatus::UpdateAvail);
789        }
790        if transforms.is_empty() {
791            return Ok(InspectionFileStatus::UpToDate);
792        }
793        let expected = crate::install::apply_transforms(transforms, desired, &self.context().env)?;
794        match self.host().read(rendered_path).await {
795            Ok(current) if current == expected => Ok(InspectionFileStatus::UpToDate),
796            Ok(_) => Ok(InspectionFileStatus::UpdateAvail),
797            Err(error) if error.is_not_found() => Ok(InspectionFileStatus::Missing),
798            Err(error) => Err(error.into_anyhow("reading rendered Shell source")),
799        }
800    }
801
802    pub async fn validate_shell_category_snapshot(&self, category: &str) -> Result<bool> {
803        let metadata = format!("shell/{category}/shine.toml");
804        let has_metadata = self.presets().file(&metadata).is_some();
805        let categories = self.shell_categories(Some(category))?;
806        for category in &categories {
807            let mut commands = BTreeSet::new();
808            for file in &category.files {
809                if !commands.insert(file.command_name.clone()) {
810                    bail!(
811                        "shell/{} declares command `{}` more than once",
812                        category.name,
813                        file.command_name
814                    );
815                }
816                if file.runtime == LinkRuntime::Bun {
817                    self.shell_bun_runtime_spec(&category.name, file)?;
818                }
819            }
820        }
821        Ok(has_metadata)
822    }
823
824    pub(crate) async fn install_shells(
825        &self,
826        request: ShellLifecycleRequest,
827    ) -> Result<ShellLifecycleReport> {
828        self.reconcile_shells(request, LifecycleOperation::Install, None)
829            .await
830    }
831
832    pub(crate) async fn install_shells_with_approval(
833        &self,
834        request: ShellLifecycleRequest,
835        approval: &PlanApprovalV1,
836    ) -> Result<ShellLifecycleReport> {
837        self.reconcile_shells(request, LifecycleOperation::Install, Some(approval))
838            .await
839    }
840
841    /// Complete Shell install lifecycle, including immutable target selection,
842    /// cache/snapshot materialization, transforms, launchers, receipt and the
843    /// managed/user profile executor.
844    async fn reconcile_shells(
845        &self,
846        request: ShellLifecycleRequest,
847        operation: LifecycleOperation,
848        approval: Option<&PlanApprovalV1>,
849    ) -> Result<ShellLifecycleReport> {
850        // Future-version rejection is deliberately the first stateful check.
851        let manifest_before =
852            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
853        let selection = request
854            .target
855            .as_deref()
856            .map(parse_shell_lifecycle_target)
857            .transpose()?;
858        let category_filter = selection.as_ref().map(|target| target.category);
859        let mut categories = self.shell_categories(category_filter)?;
860        let category_found = !categories.is_empty();
861        if let Some(target) = &selection
862            && let Some(command) = target.command
863        {
864            for category in &mut categories {
865                category.files.retain(|file| file.command_name == command);
866            }
867            categories.retain(|category| !category.files.is_empty());
868        }
869        if categories.is_empty() {
870            if let Some(target) = &request.target {
871                if category_found
872                    && selection
873                        .as_ref()
874                        .is_some_and(|value| value.command.is_some())
875                {
876                    bail!("shell preset command not found: {target}");
877                }
878                let category = selection
879                    .as_ref()
880                    .map_or(target.as_str(), |value| value.category);
881                bail!("shell preset category not found: {category}");
882            }
883            bail!("no shell preset categories found");
884        }
885        self.validate_shell_snapshot(&categories).await?;
886        let prefix = category_filter.map_or_else(
887            || "shell".to_string(),
888            |category| format!("shell/{category}"),
889        );
890
891        let specs = self.shell_link_specs(&categories).await?;
892        let planned_links = specs
893            .iter()
894            .map(|spec| {
895                let command = spec.link_name.to_string_lossy().to_string();
896                (
897                    command,
898                    command_path_for_name(&self.context().bin_dir, &spec.link_name),
899                    spec.source.clone(),
900                )
901            })
902            .collect::<Vec<_>>();
903        let mut names = BTreeSet::new();
904        for (command, _, _) in &planned_links {
905            if !names.insert(command.clone()) {
906                bail!("duplicate requested shell command: {command}");
907            }
908        }
909
910        if request.dry_run {
911            let mut lifecycle = LifecycleResultV1::new(operation, true);
912            for category in &categories {
913                for file in &category.files {
914                    let mut effects = vec![
915                        LifecycleEffect::ResourceWritePreviewed,
916                        LifecycleEffect::ReceiptWritePreviewed,
917                    ];
918                    if !self.context().is_external_presets
919                        || self.context().external_shell_mode == ExternalShellMode::Snapshot
920                    {
921                        effects.push(LifecycleEffect::CacheWritePreviewed);
922                    }
923                    lifecycle.push(LifecycleOutcomeV1::new(
924                        format!("shell/{}/{}", category.name, file.command_name),
925                        None::<String>,
926                        LifecycleStatus::Previewed,
927                        effects,
928                    ));
929                }
930            }
931            return Ok(ShellLifecycleReport {
932                categories,
933                cache: ShellCacheReport::default(),
934                snapshots_updated: 0,
935                templates: ShellTemplateReport::default(),
936                links: empty_link_report(),
937                profile: None,
938                source_commands: Vec::new(),
939                planned_links,
940                lifecycle,
941            });
942        }
943
944        let (cache_replacements, cache) =
945            if !self.context().is_external_presets && approval.is_some() {
946                self.prepare_shell_cache_replacements(&categories, &manifest_before, request.force)
947                    .await?
948            } else if self.context().is_external_presets {
949                (Vec::new(), ShellCacheReport::default())
950            } else {
951                (
952                    Vec::new(),
953                    self.reconcile_shell_cache(ShellCacheRequest {
954                        prefix,
955                        dry_run: false,
956                        remove: false,
957                        overwrite: request.force,
958                        purge: false,
959                    })
960                    .await?,
961                )
962            };
963        let cache_receipts = cache_replacements
964            .iter()
965            .flat_map(|replacement| replacement.receipt_transitions.iter())
966            .map(|(target, _, desired)| (target.clone(), desired.clone()))
967            .collect::<BTreeMap<_, _>>();
968        let mut transactional_snapshot_categories = BTreeSet::new();
969        if approval.is_some()
970            && self.context().is_external_presets
971            && self.context().external_shell_mode == ExternalShellMode::Snapshot
972        {
973            for category in &categories {
974                let untransformed = category.files.iter().all(|file| {
975                    file.transforms.is_empty()
976                        && self
977                            .presets()
978                            .get(&format!(
979                                "shell/{}/{}",
980                                category.name,
981                                shell_logical_path(&file.source_rel)
982                            ))
983                            .is_none_or(|bytes| !has_template_annotation(bytes))
984                });
985                if untransformed && !self.shell_snapshot_current(&category.name).await? {
986                    transactional_snapshot_categories.insert(category.name.clone());
987                }
988            }
989        }
990        let legacy_snapshot_categories = categories
991            .iter()
992            .filter(|category| !transactional_snapshot_categories.contains(&category.name))
993            .cloned()
994            .collect::<Vec<_>>();
995        let snapshots_updated = self
996            .materialize_shell_snapshots(&legacy_snapshot_categories)
997            .await?
998            + transactional_snapshot_categories.len();
999        let scripts = categories
1000            .iter()
1001            .flat_map(|category| {
1002                category.files.iter().map(|file| ShellScriptTemplate {
1003                    source_path: self
1004                        .shell_deployment_source_path(&category.name, &file.source_rel),
1005                    rendered_path: self.shell_rendered_path(&category.name, &file.source_rel),
1006                    display_name: format!("{}/{}", category.name, file.command_name),
1007                    transforms: file.transforms.clone(),
1008                })
1009            })
1010            .collect::<Vec<_>>();
1011        let mut templates = if approval.is_some() {
1012            ShellTemplateReport::default()
1013        } else {
1014            self.render_shell_templates(&scripts).await?
1015        };
1016        let mut applicable_specs = Vec::new();
1017        let mut foreign_commands = BTreeSet::new();
1018        let mut links = empty_link_report();
1019        if operation == LifecycleOperation::Upgrade {
1020            for spec in &specs {
1021                let command = spec.link_name.to_string_lossy().to_string();
1022                let category = categories
1023                    .iter()
1024                    .find(|category| {
1025                        category
1026                            .files
1027                            .iter()
1028                            .any(|file| file.command_name == command)
1029                    })
1030                    .map(|category| category.name.as_str())
1031                    .unwrap_or_default();
1032                let roots = self.shell_managed_roots(category, None);
1033                let probe = unlink_managed_command_with_host(
1034                    self.host(),
1035                    &self.context().bin_dir,
1036                    &spec.link_name,
1037                    &roots,
1038                    true,
1039                )
1040                .await?;
1041                let link_path = command_path_for_name(&self.context().bin_dir, &spec.link_name);
1042                let stale_symlink = self
1043                    .host()
1044                    .metadata(&link_path)
1045                    .await
1046                    .is_ok_and(|metadata| metadata.kind == FileKind::Symlink);
1047                if !probe.skipped.is_empty() && !stale_symlink {
1048                    foreign_commands.insert(command);
1049                    links.conflicts.push(LinkConflict {
1050                        link_path,
1051                        source: spec.source.clone(),
1052                        kind: LinkConflictKind::ExistingEntry,
1053                    });
1054                } else {
1055                    applicable_specs.push(spec.clone());
1056                }
1057            }
1058        } else {
1059            applicable_specs.extend(specs.iter().cloned());
1060        }
1061        let mut legacy_specs = Vec::new();
1062        let mut launcher_creations = Vec::new();
1063        let mut launcher_updates = Vec::new();
1064        for spec in applicable_specs {
1065            let command = spec.link_name.to_string_lossy().to_string();
1066            let category = categories
1067                .iter()
1068                .find(|category| {
1069                    category
1070                        .files
1071                        .iter()
1072                        .any(|file| file.command_name == command)
1073                })
1074                .context("Shell launcher category disappeared before execution")?;
1075            let file = category
1076                .files
1077                .iter()
1078                .find(|file| file.command_name == command)
1079                .context("Shell launcher command disappeared before execution")?;
1080            let target = format!("shell/{}/{}", category.name, command);
1081            let resources = prepare_launcher_resources(&self.context().bin_dir, &spec);
1082            let desired_receipt = if let Some(receipt) = cache_receipts.get(&target) {
1083                receipt.clone()
1084            } else if transactional_snapshot_categories.contains(&category.name) {
1085                self.desired_shell_manifest_entry(category, file)?
1086            } else {
1087                self.shell_manifest_entry(category, file).await?
1088            };
1089            let all_absent = if operation == LifecycleOperation::Install
1090                && approval.is_some()
1091                && manifest_before.find(&target).is_none()
1092            {
1093                let mut absent = true;
1094                for resource in &resources {
1095                    match self.host().metadata(resource.destination()).await {
1096                        Err(error) if error.is_not_found() => {}
1097                        Ok(_) => absent = false,
1098                        Err(error) => {
1099                            return Err(error.into_anyhow("inspecting Shell launcher creation"));
1100                        }
1101                    }
1102                }
1103                absent
1104            } else {
1105                false
1106            };
1107            if all_absent {
1108                launcher_creations.push((target, spec, desired_receipt));
1109            } else if approval.is_some()
1110                && let Some(previous_receipt) = manifest_before.find(&target)
1111                && *previous_receipt != desired_receipt
1112            {
1113                let previous_spec = shell_link_spec_from_manifest_entry(previous_receipt)?;
1114                let previous_resources =
1115                    prepare_launcher_resources(&self.context().bin_dir, &previous_spec);
1116                let same_shape = previous_resources.len() == resources.len()
1117                    && previous_resources
1118                        .iter()
1119                        .zip(&resources)
1120                        .all(|(previous, desired)| previous.destination() == desired.destination());
1121                let mut exact = same_shape;
1122                let mut changed = false;
1123                let mut rollback_absent = true;
1124                if same_shape {
1125                    for (previous, desired) in previous_resources.iter().zip(&resources) {
1126                        exact &= prepared_launcher_resource_is_exact(self.host(), previous).await?;
1127                        if previous != desired {
1128                            changed = true;
1129                            let rollback = managed_file_rollback_path(previous.destination());
1130                            match self.host().metadata(&rollback).await {
1131                                Err(error) if error.is_not_found() => {}
1132                                Ok(_) => rollback_absent = false,
1133                                Err(error) => {
1134                                    return Err(error
1135                                        .into_anyhow("inspecting Shell launcher rollback path"));
1136                                }
1137                            }
1138                        }
1139                    }
1140                }
1141                if exact && changed && rollback_absent {
1142                    launcher_updates.push((
1143                        target,
1144                        previous_receipt.clone(),
1145                        spec,
1146                        desired_receipt,
1147                    ));
1148                } else {
1149                    legacy_specs.push(spec);
1150                }
1151            } else {
1152                legacy_specs.push(spec);
1153            }
1154        }
1155        let applied = link_executables_with_host(
1156            self.host(),
1157            &self.context().bin_dir,
1158            &legacy_specs,
1159            request.force,
1160        )
1161        .await?;
1162        links.created.extend(applied.created);
1163        links.skipped.extend(applied.skipped);
1164        links.conflicts.extend(applied.conflicts);
1165        links.overwritten.extend(applied.overwritten);
1166        let launcher_creation_refs = launcher_creations
1167            .iter()
1168            .map(|(target, spec, receipt)| ShellLauncherCreation {
1169                target: target.clone(),
1170                spec,
1171                receipt: receipt.clone(),
1172            })
1173            .collect::<Vec<_>>();
1174        let launcher_update_refs = launcher_updates
1175            .iter()
1176            .map(
1177                |(target, previous_receipt, desired_spec, desired_receipt)| ShellLauncherUpdate {
1178                    target: target.clone(),
1179                    previous_receipt: previous_receipt.clone(),
1180                    desired_spec,
1181                    desired_receipt: desired_receipt.clone(),
1182                },
1183            )
1184            .collect::<Vec<_>>();
1185        let scope = if selection
1186            .as_ref()
1187            .is_some_and(|target| target.command.is_some())
1188        {
1189            ShellManifestUpdateScope::Commands
1190        } else {
1191            ShellManifestUpdateScope::Categories
1192        };
1193        let mut manifest_categories = categories.clone();
1194        for category in &mut manifest_categories {
1195            category
1196                .files
1197                .retain(|file| !foreign_commands.contains(&file.command_name));
1198        }
1199        let mut snapshot_replacements = Vec::new();
1200        for category in &manifest_categories {
1201            if !transactional_snapshot_categories.contains(&category.name) {
1202                continue;
1203            }
1204            let prefix = format!("shell/{}/", category.name);
1205            let files = self
1206                .presets()
1207                .files()
1208                .iter()
1209                .filter_map(|(logical, bytes)| {
1210                    logical
1211                        .strip_prefix(&prefix)
1212                        .map(|relative| (PathBuf::from(relative), bytes.clone()))
1213                })
1214                .collect::<Vec<_>>();
1215            let mut receipt_transitions = Vec::new();
1216            for file in &category.files {
1217                let target = format!("shell/{}/{}", category.name, file.command_name);
1218                receipt_transitions.push((
1219                    target.clone(),
1220                    manifest_before.find(&target).cloned(),
1221                    self.desired_shell_manifest_entry(category, file)?,
1222                ));
1223            }
1224            snapshot_replacements.push(ShellSnapshotReplacement {
1225                target: format!("shell/{}", category.name),
1226                destination: self
1227                    .context()
1228                    .shine_dir
1229                    .join("installed/shell")
1230                    .join(&category.name),
1231                files,
1232                receipt_transitions,
1233            });
1234        }
1235        let rendered_replacements = if approval.is_some() {
1236            let (replacements, report) = self
1237                .prepare_shell_rendered_replacements(
1238                    &manifest_categories,
1239                    &manifest_before,
1240                    request.force,
1241                )
1242                .await?;
1243            templates = report;
1244            replacements
1245        } else {
1246            Vec::new()
1247        };
1248        let profile_reconciliations = if approval.is_some() {
1249            let mut planned_manifest = manifest_before.clone();
1250            let mut planned_entries = Vec::new();
1251            for category in &manifest_categories {
1252                for file in &category.files {
1253                    let target = format!("shell/{}/{}", category.name, file.command_name);
1254                    let entry = if let Some(receipt) = cache_receipts.get(&target) {
1255                        receipt.clone()
1256                    } else if transactional_snapshot_categories.contains(&category.name) {
1257                        self.desired_shell_manifest_entry(category, file)?
1258                    } else {
1259                        self.shell_manifest_entry(category, file).await?
1260                    };
1261                    planned_entries.push(entry);
1262                }
1263            }
1264            let selected_categories = manifest_categories
1265                .iter()
1266                .map(|category| category.name.clone())
1267                .collect::<BTreeSet<_>>();
1268            let selected_targets = planned_entries
1269                .iter()
1270                .map(|entry| format!("shell/{}/{}", entry.category, entry.command))
1271                .collect::<BTreeSet<_>>();
1272            match scope {
1273                ShellManifestUpdateScope::Categories => {
1274                    planned_manifest.replace_categories(&selected_categories, planned_entries)
1275                }
1276                ShellManifestUpdateScope::Commands => {
1277                    planned_manifest.replace_targets(&selected_targets, planned_entries)
1278                }
1279            }
1280            self.prepare_shell_profile_reconciliation(
1281                &manifest_before,
1282                &planned_manifest,
1283                false,
1284                operation == LifecycleOperation::Install && request.force,
1285                &[],
1286            )
1287            .await?
1288        } else {
1289            Vec::new()
1290        };
1291        let shell_execution = if let Some(approval) = approval {
1292            self.reconcile_shell_launchers_approved(
1293                ShellSharedReplacements {
1294                    caches: &cache_replacements,
1295                    snapshots: &snapshot_replacements,
1296                    rendered_files: &rendered_replacements,
1297                    rendered_removals: &[],
1298                    cache_removals: &[],
1299                    snapshot_removals: &[],
1300                    profiles: &profile_reconciliations,
1301                },
1302                &launcher_creation_refs,
1303                &launcher_update_refs,
1304                &[],
1305                &[],
1306                approval,
1307            )
1308            .await?
1309        } else {
1310            None
1311        };
1312        links.created.extend(
1313            launcher_creations.iter().map(|(_, spec, _)| {
1314                command_path_for_name(&self.context().bin_dir, &spec.link_name)
1315            }),
1316        );
1317        links
1318            .overwritten
1319            .extend(launcher_updates.iter().map(|(_, _, spec, _)| {
1320                command_path_for_name(&self.context().bin_dir, &spec.link_name)
1321            }));
1322        self.update_shell_manifest(&manifest_categories, scope)
1323            .await?;
1324        if let Some(execution) = &shell_execution {
1325            self.mark_shell_launcher_receipt_committed(execution)
1326                .await?;
1327            self.commit_shell_launcher_operation(execution).await?;
1328        }
1329        let manifest_after =
1330            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
1331        let mut source_commands = manifest_after
1332            .entries
1333            .iter()
1334            .filter(|entry| entry.needs_source)
1335            .map(|entry| entry.command.clone())
1336            .collect::<BTreeSet<_>>()
1337            .into_iter()
1338            .collect::<Vec<_>>();
1339        source_commands.sort();
1340        let profile_force = operation == LifecycleOperation::Install && request.force;
1341        let profile = if approval.is_some() {
1342            let managed =
1343                super::managed_shell_profile_path(&self.context().shine_dir, self.context().shell);
1344            let managed_changed = profile_reconciliations
1345                .iter()
1346                .any(|profile| profile.files.iter().any(|file| file.destination == managed));
1347            let updated_config = profile_reconciliations.iter().find_map(|profile| {
1348                profile
1349                    .files
1350                    .iter()
1351                    .find(|file| file.ownership == ShellProfileFileOwnershipV1::SentinelBlock)
1352                    .map(|file| file.destination.clone())
1353            });
1354            ShellConfigUpdate {
1355                profile_updated: managed_changed,
1356                config_status: updated_config.map_or(
1357                    PathUpdateStatus::AlreadyConfigured,
1358                    PathUpdateStatus::Updated,
1359                ),
1360            }
1361        } else {
1362            self.install_shell_profile(
1363                &self.context().shell_config_paths,
1364                profile_force,
1365                &source_commands,
1366            )
1367            .await?
1368        };
1369        let cache_changed = !cache.created.is_empty() || !cache.overwritten.is_empty();
1370        let profile_changed = profile.profile_updated
1371            || matches!(profile.config_status, PathUpdateStatus::Updated(_));
1372        let mut lifecycle = LifecycleResultV1::new(operation, false);
1373        for category in &categories {
1374            for file in &category.files {
1375                let canonical = format!("shell/{}/{}", category.name, file.command_name);
1376                let link_path = command_path_for_name(
1377                    &self.context().bin_dir,
1378                    std::ffi::OsStr::new(&file.command_name),
1379                );
1380                let conflict = links
1381                    .conflicts
1382                    .iter()
1383                    .any(|value| value.link_path == link_path);
1384                let link_changed = links
1385                    .created
1386                    .iter()
1387                    .chain(&links.overwritten)
1388                    .any(|path| path == &link_path);
1389                let template_changed = templates
1390                    .updated
1391                    .iter()
1392                    .any(|name| name == &format!("{}/{}", category.name, file.command_name));
1393                let receipt_changed = manifest_before.find(&canonical).is_none() || link_changed;
1394                let changed = cache_changed
1395                    || snapshots_updated > 0
1396                    || link_changed
1397                    || template_changed
1398                    || receipt_changed
1399                    || profile_changed;
1400                if conflict {
1401                    lifecycle.push(
1402                        LifecycleOutcomeV1::new(
1403                            canonical,
1404                            None::<String>,
1405                            LifecycleStatus::Conflict,
1406                            [],
1407                        )
1408                        .with_diagnostic_code("shell_command_conflict"),
1409                    );
1410                    continue;
1411                }
1412                let mut effects = Vec::new();
1413                if cache_changed {
1414                    effects.push(LifecycleEffect::CacheWritten);
1415                }
1416                if snapshots_updated > 0 || link_changed || template_changed || profile_changed {
1417                    effects.push(LifecycleEffect::ResourceWritten);
1418                }
1419                if receipt_changed {
1420                    effects.push(LifecycleEffect::ReceiptWritten);
1421                }
1422                lifecycle.push(LifecycleOutcomeV1::new(
1423                    canonical,
1424                    None::<String>,
1425                    if changed {
1426                        LifecycleStatus::Changed
1427                    } else {
1428                        LifecycleStatus::Unchanged
1429                    },
1430                    effects,
1431                ));
1432            }
1433        }
1434        let installed_selected_source_commands = categories
1435            .iter()
1436            .flat_map(|category| category.files.iter())
1437            .filter(|file| file.needs_source)
1438            .map(|file| file.command_name.clone())
1439            .collect::<BTreeSet<_>>()
1440            .into_iter()
1441            .collect();
1442        Ok(ShellLifecycleReport {
1443            categories,
1444            cache,
1445            snapshots_updated,
1446            templates,
1447            links,
1448            profile: Some(profile),
1449            source_commands: installed_selected_source_commands,
1450            planned_links,
1451            lifecycle,
1452        })
1453    }
1454
1455    /// Upgrade only commands recorded as installed. Category-targeted upgrade
1456    /// never widens into uninstalled siblings.
1457    pub(crate) async fn upgrade_shells(
1458        &self,
1459        request: ShellUpgradeRequest,
1460        approval: &PlanApprovalV1,
1461    ) -> Result<ShellUpgradeLifecycleReport> {
1462        let manifest =
1463            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
1464        let selection = request
1465            .category
1466            .as_deref()
1467            .map(parse_shell_lifecycle_target)
1468            .transpose()?;
1469        let mut targets = manifest
1470            .entries
1471            .iter()
1472            .filter(|entry| {
1473                selection.as_ref().is_none_or(|target| {
1474                    entry.category == target.category
1475                        && target
1476                            .command
1477                            .is_none_or(|command| entry.command == command)
1478                })
1479            })
1480            .map(|entry| (entry.category.clone(), entry.command.clone()))
1481            .collect::<BTreeSet<_>>();
1482        // Legacy installs predate the Shell receipt. Recover only launchers
1483        // whose target is inside a captured Shine/preset-managed root.
1484        let available_categories =
1485            self.shell_categories_or_missing(selection.as_ref().map(|target| target.category))?;
1486        let available = available_categories
1487            .iter()
1488            .flat_map(|category| {
1489                category
1490                    .files
1491                    .iter()
1492                    .map(|file| (category.name.clone(), file.command_name.clone()))
1493            })
1494            .collect::<BTreeSet<_>>();
1495        for category in available_categories {
1496            for file in category.files {
1497                if selection
1498                    .as_ref()
1499                    .and_then(|target| target.command)
1500                    .is_some_and(|command| command != file.command_name)
1501                {
1502                    continue;
1503                }
1504                let roots = self.shell_managed_roots(&category.name, None);
1505                let probe = unlink_managed_command_with_host(
1506                    self.host(),
1507                    &self.context().bin_dir,
1508                    std::ffi::OsStr::new(&file.command_name),
1509                    &roots,
1510                    true,
1511                )
1512                .await?;
1513                if !probe.removed.is_empty() {
1514                    targets.insert((category.name.clone(), file.command_name));
1515                }
1516            }
1517        }
1518        if let Some(category) = &request.category
1519            && targets.is_empty()
1520        {
1521            bail!("shell preset is not installed: {category}");
1522        }
1523        let mut report = ShellUpgradeLifecycleReport {
1524            runs: Vec::new(),
1525            updated_targets: Vec::new(),
1526            updated_categories: Vec::new(),
1527            lifecycle: LifecycleResultV1::new(LifecycleOperation::Upgrade, false),
1528        };
1529        let mut updated_categories = BTreeSet::new();
1530        for (category, command) in std::mem::take(&mut targets) {
1531            let target = format!("{category}/{command}");
1532            if !available.contains(&(category.clone(), command.clone())) {
1533                report.lifecycle.push(
1534                    LifecycleOutcomeV1::new(
1535                        format!("shell/{target}"),
1536                        None::<String>,
1537                        LifecycleStatus::Preserved,
1538                        vec![LifecycleEffect::ManagedResourcePreserved],
1539                    )
1540                    .with_diagnostic_code("shell_preset_missing"),
1541                );
1542                continue;
1543            }
1544            let run = self
1545                .reconcile_shells(
1546                    ShellLifecycleRequest {
1547                        target: Some(target.clone()),
1548                        dry_run: false,
1549                        force: true,
1550                    },
1551                    LifecycleOperation::Upgrade,
1552                    Some(approval),
1553                )
1554                .await?;
1555            let canonical = format!("shell/{target}");
1556            if run.lifecycle.outcomes.iter().any(|outcome| {
1557                outcome.target == canonical && outcome.status == LifecycleStatus::Changed
1558            }) {
1559                report.updated_targets.push(target);
1560                updated_categories.insert(category);
1561            }
1562            report
1563                .lifecycle
1564                .outcomes
1565                .extend(run.lifecycle.outcomes.iter().cloned());
1566            report.runs.push(run);
1567        }
1568        report.updated_targets.sort();
1569        report.updated_categories = updated_categories.into_iter().collect();
1570        Ok(report)
1571    }
1572
1573    /// Complete command/category/all Shell uninstall. Shared category state is
1574    /// removed only after the last installed sibling is selected.
1575    pub(crate) async fn uninstall_shells(
1576        &self,
1577        request: ShellUninstallRequest,
1578    ) -> Result<ShellUninstallReport> {
1579        self.uninstall_shells_with_approval(request, None).await
1580    }
1581
1582    pub(crate) async fn uninstall_shells_with_approval(
1583        &self,
1584        request: ShellUninstallRequest,
1585        approval: Option<&PlanApprovalV1>,
1586    ) -> Result<ShellUninstallReport> {
1587        let mut manifest =
1588            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
1589        let selection = request
1590            .target
1591            .as_deref()
1592            .map(parse_shell_lifecycle_target)
1593            .transpose()?;
1594        let mut targets = manifest
1595            .entries
1596            .iter()
1597            .filter(|entry| {
1598                selection.as_ref().is_none_or(|target| {
1599                    entry.category == target.category
1600                        && target
1601                            .command
1602                            .is_none_or(|command| entry.command == command)
1603                })
1604            })
1605            .map(|entry| (entry.category.clone(), entry.command.clone()))
1606            .collect::<BTreeSet<_>>();
1607        if targets.is_empty() {
1608            let mut categories =
1609                self.shell_categories(selection.as_ref().map(|target| target.category))?;
1610            if let Some(command) = selection.as_ref().and_then(|target| target.command) {
1611                for category in &mut categories {
1612                    category.files.retain(|file| file.command_name == command);
1613                }
1614            }
1615            for category in categories {
1616                for file in category.files {
1617                    let roots = self.shell_managed_roots(&category.name, None);
1618                    let probe = probe_managed_command_with_host(
1619                        self.host(),
1620                        &self.context().bin_dir,
1621                        std::ffi::OsStr::new(&file.command_name),
1622                        &roots,
1623                    )
1624                    .await?;
1625                    if !probe.resources.is_empty() || !probe.conflicts.is_empty() {
1626                        targets.insert((category.name.clone(), file.command_name));
1627                    }
1628                }
1629            }
1630        }
1631        if let Some(target) = &request.target
1632            && targets.is_empty()
1633        {
1634            bail!("shell command is not installed: {target}");
1635        }
1636
1637        let selected = targets.clone();
1638        let categories_removed = targets
1639            .iter()
1640            .map(|(category, _)| category.clone())
1641            .filter(|category| {
1642                !manifest.entries.iter().any(|entry| {
1643                    entry.category == *category
1644                        && !selected.contains(&(entry.category.clone(), entry.command.clone()))
1645                })
1646            })
1647            .collect::<BTreeSet<_>>();
1648        let mut launcher_removals = Vec::new();
1649        if approval.is_some() && !request.dry_run {
1650            for (category, command) in &targets {
1651                let target = format!("shell/{category}/{command}");
1652                let Some(entry) = manifest.find(&target).cloned() else {
1653                    continue;
1654                };
1655                let spec = shell_link_spec_from_manifest_entry(&entry)?;
1656                let resources = prepare_launcher_resources(&self.context().bin_dir, &spec);
1657                let mut exact = true;
1658                let mut rollback_absent = true;
1659                for resource in &resources {
1660                    exact &= prepared_launcher_resource_is_exact(self.host(), resource).await?;
1661                    let rollback = managed_file_rollback_path(resource.destination());
1662                    match self.host().metadata(&rollback).await {
1663                        Err(error) if error.is_not_found() => {}
1664                        Ok(_) => rollback_absent = false,
1665                        Err(error) => {
1666                            return Err(
1667                                error.into_anyhow("inspecting Shell launcher rollback path")
1668                            );
1669                        }
1670                    }
1671                }
1672                if exact && rollback_absent {
1673                    launcher_removals.push(ShellLauncherRemoval {
1674                        target,
1675                        previous_receipt: entry,
1676                    });
1677                }
1678            }
1679        }
1680        let transactional_targets = launcher_removals
1681            .iter()
1682            .map(|removal| removal.target.clone())
1683            .collect::<BTreeSet<_>>();
1684        let mut legacy_launcher_removals = Vec::new();
1685        for (category, command) in &targets {
1686            let canonical = format!("shell/{category}/{command}");
1687            if manifest.find(&canonical).is_some() {
1688                continue;
1689            }
1690            let roots = self.shell_managed_roots(category, None);
1691            let probe = probe_managed_command_with_host(
1692                self.host(),
1693                &self.context().bin_dir,
1694                std::ffi::OsStr::new(command),
1695                &roots,
1696            )
1697            .await?;
1698            if !probe.conflicts.is_empty() {
1699                continue;
1700            }
1701            if !probe.resources.is_empty() {
1702                legacy_launcher_removals.push(ShellLegacyLauncherRemoval {
1703                    target: canonical,
1704                    resources: probe.resources,
1705                });
1706            }
1707        }
1708        let legacy_targets = legacy_launcher_removals
1709            .iter()
1710            .map(|removal| removal.target.clone())
1711            .collect::<Vec<_>>();
1712        let mut rendered_removals = Vec::new();
1713        if approval.is_some() && !request.dry_run {
1714            let rendered_root = self.context().shine_dir.join("rendered/shell");
1715            let selected_rendered_paths = manifest
1716                .entries
1717                .iter()
1718                .filter(|entry| targets.contains(&(entry.category.clone(), entry.command.clone())))
1719                .map(|entry| entry.rendered_path.clone())
1720                .collect::<BTreeSet<_>>();
1721            for destination in selected_rendered_paths {
1722                if !destination.starts_with(&rendered_root) {
1723                    continue;
1724                }
1725                let consumers = manifest
1726                    .entries
1727                    .iter()
1728                    .filter(|entry| entry.rendered_path == destination)
1729                    .collect::<Vec<_>>();
1730                if consumers.iter().any(|entry| {
1731                    !targets.contains(&(entry.category.clone(), entry.command.clone()))
1732                }) {
1733                    continue;
1734                }
1735                let rollback = managed_file_rollback_path(&destination);
1736                match self.host().metadata(&rollback).await {
1737                    Err(error) if error.is_not_found() => {}
1738                    Ok(_) => bail!(
1739                        "Shell rendered-file rollback path is occupied: {}",
1740                        rollback.display()
1741                    ),
1742                    Err(error) => {
1743                        return Err(error
1744                            .into_anyhow("inspecting Shell rendered-file removal rollback path"));
1745                    }
1746                }
1747                let metadata = match self.host().metadata(&destination).await {
1748                    Err(error) if error.is_not_found() => continue,
1749                    Ok(metadata) if metadata.kind == FileKind::File => metadata,
1750                    Ok(_) => bail!("Shell rendered-file removal target is not a regular file"),
1751                    Err(error) => {
1752                        return Err(
1753                            error.into_anyhow("inspecting Shell rendered-file removal target")
1754                        );
1755                    }
1756                };
1757                let previous = ShellFileIdentityV1 {
1758                    content_hash: crate::install::hash_content(
1759                        &self.host().read(&destination).await.map_err(|error| {
1760                            error.into_anyhow("reading Shell rendered-file removal target")
1761                        })?,
1762                    ),
1763                    unix_mode: metadata.unix_mode,
1764                };
1765                let previous_receipts = consumers
1766                    .into_iter()
1767                    .map(|entry| {
1768                        (
1769                            format!("shell/{}/{}", entry.category, entry.command),
1770                            entry.clone(),
1771                        )
1772                    })
1773                    .collect::<Vec<_>>();
1774                let target = previous_receipts
1775                    .first()
1776                    .map(|(target, _)| target.clone())
1777                    .context("Shell rendered-file removal has no receipt consumer")?;
1778                rendered_removals.push(ShellRenderedFileRemoval {
1779                    target,
1780                    destination,
1781                    previous,
1782                    previous_receipts,
1783                });
1784            }
1785        }
1786        let mut cache_removals = Vec::new();
1787        let mut snapshot_removals = Vec::new();
1788        if approval.is_some() && !request.dry_run {
1789            let receipt_removals_for = |category: Option<&str>| {
1790                manifest
1791                    .entries
1792                    .iter()
1793                    .filter(|entry| {
1794                        category.is_none_or(|category| entry.category == category)
1795                            && selected.contains(&(entry.category.clone(), entry.command.clone()))
1796                    })
1797                    .map(|entry| {
1798                        (
1799                            format!("shell/{}/{}", entry.category, entry.command),
1800                            entry.clone(),
1801                        )
1802                    })
1803                    .collect::<Vec<_>>()
1804            };
1805            if !self.context().is_external_presets {
1806                if request.purge && selection.is_none() {
1807                    let root = self.context().presets_dir.join("shell");
1808                    let mut files = Vec::new();
1809                    if let Some(tree) = super::shell_action_executor::collect_shell_tree_for_action(
1810                        self.host(),
1811                        &root,
1812                    )
1813                    .await?
1814                    {
1815                        for file in tree {
1816                            let destination = root.join(&file.relative_path);
1817                            let metadata =
1818                                self.host().metadata(&destination).await.map_err(|error| {
1819                                    error.into_anyhow("inspecting Shell cache purge file")
1820                                })?;
1821                            files.push((
1822                                destination,
1823                                ShellFileIdentityV1 {
1824                                    content_hash: file.content_hash,
1825                                    unix_mode: metadata.unix_mode,
1826                                },
1827                            ));
1828                        }
1829                    }
1830                    if !files.is_empty() {
1831                        cache_removals.push(ShellCacheRemoval {
1832                            target: "shell".to_string(),
1833                            files,
1834                            previous_receipts: receipt_removals_for(None),
1835                        });
1836                    }
1837                } else {
1838                    for category in &categories_removed {
1839                        let prefix = format!("shell/{category}/");
1840                        let mut files = Vec::new();
1841                        for logical in self
1842                            .presets()
1843                            .files()
1844                            .keys()
1845                            .filter(|logical| logical.starts_with(&prefix))
1846                        {
1847                            let destination = self.context().presets_dir.join(logical);
1848                            let metadata = match self.host().metadata(&destination).await {
1849                                Ok(metadata) if metadata.kind == FileKind::File => metadata,
1850                                Ok(_) => bail!(
1851                                    "Shell cache removal target is not a regular file: {}",
1852                                    destination.display()
1853                                ),
1854                                Err(error) if error.is_not_found() => continue,
1855                                Err(error) => {
1856                                    return Err(
1857                                        error.into_anyhow("inspecting Shell cache removal target")
1858                                    );
1859                                }
1860                            };
1861                            let bytes = self.host().read(&destination).await.map_err(|error| {
1862                                error.into_anyhow("reading Shell cache removal target")
1863                            })?;
1864                            files.push((
1865                                destination,
1866                                ShellFileIdentityV1 {
1867                                    content_hash: crate::install::hash_content(&bytes),
1868                                    unix_mode: metadata.unix_mode,
1869                                },
1870                            ));
1871                        }
1872                        if !files.is_empty() {
1873                            cache_removals.push(ShellCacheRemoval {
1874                                target: format!("shell/{category}"),
1875                                files,
1876                                previous_receipts: receipt_removals_for(Some(category)),
1877                            });
1878                        }
1879                    }
1880                }
1881            }
1882            for category in &categories_removed {
1883                let destination = self
1884                    .context()
1885                    .shine_dir
1886                    .join("installed/shell")
1887                    .join(category);
1888                let rollback = shell_snapshot_rollback_path(&destination);
1889                match self.host().metadata(&rollback).await {
1890                    Err(error) if error.is_not_found() => {}
1891                    Ok(_) => bail!(
1892                        "Shell snapshot removal rollback path is occupied: {}",
1893                        rollback.display()
1894                    ),
1895                    Err(error) => {
1896                        return Err(error.into_anyhow("inspecting Shell snapshot removal rollback"));
1897                    }
1898                }
1899                if let Some(previous_files) =
1900                    super::shell_action_executor::collect_shell_tree_for_action(
1901                        self.host(),
1902                        &destination,
1903                    )
1904                    .await?
1905                {
1906                    snapshot_removals.push(ShellSnapshotRemoval {
1907                        target: format!("shell/{category}"),
1908                        destination,
1909                        previous_files,
1910                        previous_receipts: receipt_removals_for(Some(category)),
1911                    });
1912                }
1913            }
1914        }
1915        let profile_reconciliations = if approval.is_some() && !request.dry_run {
1916            let mut planned_manifest = manifest.clone();
1917            for (category, command) in &targets {
1918                planned_manifest.remove_target(category, command);
1919            }
1920            self.prepare_shell_profile_reconciliation(
1921                &manifest,
1922                &planned_manifest,
1923                selection.is_none(),
1924                false,
1925                &legacy_targets,
1926            )
1927            .await?
1928        } else {
1929            Vec::new()
1930        };
1931        let shell_execution = if let Some(approval) = approval {
1932            self.reconcile_shell_launchers_approved(
1933                ShellSharedReplacements {
1934                    caches: &[],
1935                    snapshots: &[],
1936                    rendered_files: &[],
1937                    rendered_removals: &rendered_removals,
1938                    cache_removals: &cache_removals,
1939                    snapshot_removals: &snapshot_removals,
1940                    profiles: &profile_reconciliations,
1941                },
1942                &[],
1943                &[],
1944                &launcher_removals,
1945                &legacy_launcher_removals,
1946                approval,
1947            )
1948            .await?
1949        } else {
1950            None
1951        };
1952        let mut links = empty_unlink_report();
1953        let mut target_states = Vec::new();
1954        for (category, command) in &targets {
1955            let canonical = format!("shell/{category}/{command}");
1956            let entry = manifest.find(&canonical).cloned();
1957            let (managed, foreign) = if transactional_targets.contains(&canonical) {
1958                let entry = entry
1959                    .as_ref()
1960                    .context("transactional Shell launcher receipt disappeared")?;
1961                let spec = shell_link_spec_from_manifest_entry(entry)?;
1962                links.removed.extend(
1963                    prepare_launcher_resources(&self.context().bin_dir, &spec)
1964                        .into_iter()
1965                        .map(|resource| resource.destination().to_path_buf()),
1966                );
1967                (true, false)
1968            } else if legacy_targets.contains(&canonical) {
1969                links.removed.extend(
1970                    legacy_launcher_removals
1971                        .iter()
1972                        .find(|removal| removal.target == canonical)
1973                        .into_iter()
1974                        .flat_map(|removal| removal.resources.iter())
1975                        .map(|resource| resource.destination().to_path_buf()),
1976                );
1977                (true, false)
1978            } else if approval.is_some() && entry.is_some() {
1979                let spec = shell_link_spec_from_manifest_entry(
1980                    entry
1981                        .as_ref()
1982                        .context("planned Shell launcher receipt disappeared")?,
1983                )?;
1984                links.skipped.extend(
1985                    prepare_launcher_resources(&self.context().bin_dir, &spec)
1986                        .into_iter()
1987                        .map(|resource| resource.destination().to_path_buf()),
1988                );
1989                (false, true)
1990            } else {
1991                let roots = self.shell_managed_roots(category, entry.as_ref());
1992                let report = unlink_managed_command_with_host(
1993                    self.host(),
1994                    &self.context().bin_dir,
1995                    std::ffi::OsStr::new(command),
1996                    &roots,
1997                    request.dry_run,
1998                )
1999                .await?;
2000                let managed = !report.removed.is_empty();
2001                let foreign = !report.skipped.is_empty();
2002                links.removed.extend(report.removed);
2003                links.skipped.extend(report.skipped);
2004                (managed, foreign)
2005            };
2006            target_states.push((category.clone(), command.clone(), managed, foreign));
2007
2008            if !request.dry_run {
2009                manifest.remove_target(category, command);
2010            }
2011        }
2012
2013        if !request.dry_run {
2014            save_shell_manifest_with_host(self.host(), &self.context().shine_dir, &manifest)
2015                .await?;
2016            if let Some(execution) = &shell_execution {
2017                self.mark_shell_launcher_receipt_committed(execution)
2018                    .await?;
2019                self.commit_shell_launcher_operation(execution).await?;
2020            }
2021        }
2022        let mut cache = ShellCacheReport::default();
2023        if approval.is_none() && !self.context().is_external_presets {
2024            for category in &categories_removed {
2025                let report = self
2026                    .reconcile_shell_cache(ShellCacheRequest {
2027                        prefix: format!("shell/{category}"),
2028                        dry_run: request.dry_run,
2029                        remove: true,
2030                        overwrite: false,
2031                        purge: request.purge,
2032                    })
2033                    .await?;
2034                merge_shell_cache_report(&mut cache, report);
2035            }
2036            if request.purge && selection.is_none() {
2037                let report = self
2038                    .reconcile_shell_cache(ShellCacheRequest {
2039                        prefix: "shell".to_string(),
2040                        dry_run: request.dry_run,
2041                        remove: true,
2042                        overwrite: false,
2043                        purge: true,
2044                    })
2045                    .await?;
2046                merge_shell_cache_report(&mut cache, report);
2047            }
2048        }
2049        if approval.is_none() && !request.dry_run {
2050            for category in &categories_removed {
2051                self.remove_shell_snapshot_tree(category).await?;
2052            }
2053            if request.purge && !self.context().is_external_presets {
2054                self.remove_empty_shell_roots(&categories_removed).await?;
2055            }
2056        } else if approval.is_some() && !request.dry_run {
2057            cache.removed.extend(
2058                cache_removals
2059                    .iter()
2060                    .flat_map(|removal| removal.files.iter().map(|(path, _)| path.clone())),
2061            );
2062            if request.purge && !self.context().is_external_presets {
2063                self.remove_empty_shell_roots(&categories_removed).await?;
2064            }
2065        }
2066        let profile = if request.dry_run {
2067            None
2068        } else if approval.is_some() {
2069            if selection.is_none() {
2070                let managed = super::managed_shell_profile_path(
2071                    &self.context().shine_dir,
2072                    self.context().shell,
2073                );
2074                Some(ShellProfileRemoval {
2075                    config_paths: profile_reconciliations
2076                        .iter()
2077                        .flat_map(|profile| profile.files.iter())
2078                        .filter(|file| file.ownership == ShellProfileFileOwnershipV1::SentinelBlock)
2079                        .map(|file| file.destination.clone())
2080                        .collect(),
2081                    managed_profile: profile_reconciliations
2082                        .iter()
2083                        .flat_map(|profile| profile.files.iter())
2084                        .any(|file| file.destination == managed)
2085                        .then_some(managed),
2086                })
2087            } else {
2088                None
2089            }
2090        } else if selection.is_none() {
2091            Some(
2092                self.remove_shell_profile(&self.context().shell_config_paths)
2093                    .await?,
2094            )
2095        } else {
2096            let source_commands = manifest
2097                .entries
2098                .iter()
2099                .filter(|entry| entry.needs_source)
2100                .map(|entry| entry.command.clone())
2101                .collect::<BTreeSet<_>>()
2102                .into_iter()
2103                .collect::<Vec<_>>();
2104            self.write_shell_profile(&source_commands).await?;
2105            None
2106        };
2107
2108        let mut lifecycle = LifecycleResultV1::new(LifecycleOperation::Uninstall, request.dry_run);
2109        for (category, command, managed, foreign) in target_states {
2110            let category_removed = categories_removed.contains(&category);
2111            let mut effects = Vec::new();
2112            if managed {
2113                effects.push(if request.dry_run {
2114                    LifecycleEffect::ResourceRemovePreviewed
2115                } else {
2116                    LifecycleEffect::ResourceRemoved
2117                });
2118            }
2119            if foreign {
2120                effects.push(LifecycleEffect::UserResourcePreserved);
2121            }
2122            effects.push(if request.dry_run {
2123                LifecycleEffect::ReceiptRemovePreviewed
2124            } else {
2125                LifecycleEffect::ReceiptRemoved
2126            });
2127            if category_removed {
2128                effects.push(if request.dry_run {
2129                    LifecycleEffect::CacheRemovePreviewed
2130                } else {
2131                    LifecycleEffect::CacheRemoved
2132                });
2133            }
2134            let status = if foreign {
2135                LifecycleStatus::Conflict
2136            } else if request.dry_run {
2137                LifecycleStatus::Previewed
2138            } else {
2139                LifecycleStatus::Changed
2140            };
2141            let outcome = LifecycleOutcomeV1::new(
2142                format!("shell/{category}/{command}"),
2143                None::<String>,
2144                status,
2145                effects,
2146            );
2147            lifecycle.push(if foreign {
2148                outcome.with_diagnostic_code("shell_command_conflict")
2149            } else {
2150                outcome
2151            });
2152        }
2153        Ok(ShellUninstallReport {
2154            links,
2155            cache,
2156            profile,
2157            lifecycle,
2158        })
2159    }
2160
2161    fn shell_managed_roots(
2162        &self,
2163        category: &str,
2164        entry: Option<&ShellManifestEntry>,
2165    ) -> Vec<PathBuf> {
2166        shell_managed_roots(self.context(), category, entry)
2167    }
2168
2169    async fn shell_link_specs(&self, categories: &[ShellCategory]) -> Result<Vec<LinkSpec>> {
2170        let mut specs = Vec::new();
2171        for category in categories {
2172            for file in &category.files {
2173                let source = self.shell_deployment_source_path(&category.name, &file.source_rel);
2174                let logical = format!(
2175                    "shell/{}/{}",
2176                    category.name,
2177                    shell_logical_path(&file.source_rel)
2178                );
2179                let annotated = self
2180                    .presets()
2181                    .get(&logical)
2182                    .is_some_and(has_template_annotation);
2183                let transforms = !file.transforms.is_empty() || annotated;
2184                let effective = if transforms {
2185                    self.shell_rendered_path(&category.name, &file.source_rel)
2186                } else {
2187                    source
2188                };
2189                let bun = self.shell_bun_runtime_spec(&category.name, file)?;
2190                specs.push(LinkSpec {
2191                    source: effective,
2192                    link_name: OsString::from(&file.command_name),
2193                    runtime: file.runtime,
2194                    bun_dependencies: bun.dependency_mode,
2195                    env: file
2196                        .env
2197                        .iter()
2198                        .map(crate::env::EnvVarSpec::to_with_arg)
2199                        .collect(),
2200                    render_target: (self.context().is_external_presets
2201                        && self.context().external_shell_mode == ExternalShellMode::Live
2202                        && transforms)
2203                        .then(|| format!("shell/{}/{}", category.name, file.command_name)),
2204                });
2205            }
2206        }
2207        Ok(specs)
2208    }
2209}
2210
2211pub(super) async fn probe_shell_launcher(
2212    host: &impl super::FileSystemObservationHost,
2213    context: &super::RuntimeContext,
2214    category: &str,
2215    command: &str,
2216    entry: Option<&ShellManifestEntry>,
2217) -> Result<super::launcher::ManagedLauncherProbe> {
2218    let mut roots = shell_managed_roots(context, category, entry);
2219    let probe =
2220        probe_managed_command_with_host(host, &context.bin_dir, command.as_ref(), &roots).await?;
2221    // Retain upgrade repair of receipt-backed legacy symlinks inside Shine's own
2222    // roots. Do not broaden the ownership proof for regular launcher files.
2223    if entry.is_some() && !probe.conflicts.is_empty() {
2224        let mut only_symlinks = true;
2225        for path in &probe.conflicts {
2226            only_symlinks &= host
2227                .metadata(path)
2228                .await
2229                .is_ok_and(|metadata| metadata.kind == FileKind::Symlink);
2230        }
2231        if only_symlinks {
2232            roots.push(context.shine_dir.clone());
2233            roots.push(context.presets_dir.clone());
2234            return probe_managed_command_with_host(
2235                host,
2236                &context.bin_dir,
2237                command.as_ref(),
2238                &roots,
2239            )
2240            .await;
2241        }
2242    }
2243    Ok(probe)
2244}
2245
2246pub(super) fn shell_managed_roots(
2247    context: &super::RuntimeContext,
2248    category: &str,
2249    entry: Option<&ShellManifestEntry>,
2250) -> Vec<PathBuf> {
2251    let mut roots = planned_shell_managed_roots(context, category);
2252    if let Some(entry) = entry {
2253        roots.push(entry.source_path.clone());
2254        roots.push(entry.rendered_path.clone());
2255    }
2256    roots
2257}
2258
2259pub(super) fn planned_shell_managed_roots(
2260    context: &super::RuntimeContext,
2261    category: &str,
2262) -> Vec<PathBuf> {
2263    let mut roots = vec![
2264        context.presets_dir.join("shell").join(category),
2265        context.shine_dir.join("rendered/shell").join(category),
2266        context.shine_dir.join("installed/shell").join(category),
2267    ];
2268    if let Some(overlay) = &context.overlay_dir {
2269        roots.push(overlay.join("shell").join(category));
2270    }
2271    roots
2272}
2273
2274impl<H> CoreRuntime<H> {
2275    pub fn desired_shell_source_path(&self, category: &str, source_rel: &Path) -> PathBuf {
2276        let logical = format!("shell/{category}/{}", shell_logical_path(source_rel));
2277        self.presets()
2278            .origin(&logical)
2279            .and_then(|origin| origin.physical_path.clone())
2280            .unwrap_or_else(|| self.context().presets_dir.join(logical))
2281    }
2282
2283    pub fn shell_deployment_source_path(&self, category: &str, source_rel: &Path) -> PathBuf {
2284        if self.context().is_external_presets
2285            && self.context().external_shell_mode == ExternalShellMode::Snapshot
2286        {
2287            self.context()
2288                .shine_dir
2289                .join("installed/shell")
2290                .join(category)
2291                .join(source_rel)
2292        } else {
2293            self.desired_shell_source_path(category, source_rel)
2294        }
2295    }
2296
2297    pub fn shell_rendered_path(&self, category: &str, source_rel: &Path) -> PathBuf {
2298        self.context()
2299            .shine_dir
2300            .join("rendered/shell")
2301            .join(category)
2302            .join(source_rel)
2303    }
2304
2305    pub fn shell_bun_runtime_spec(
2306        &self,
2307        category: &str,
2308        file: &ShellFile,
2309    ) -> Result<BunRuntimeSpec> {
2310        if file.runtime != LinkRuntime::Bun {
2311            return Ok(BunRuntimeSpec::default());
2312        }
2313        let logical = format!("shell/{category}/{}", shell_logical_path(&file.source_rel));
2314        let Some(source) = self.presets().file(&logical) else {
2315            if !self.context().is_external_presets {
2316                return Ok(BunRuntimeSpec::default());
2317            }
2318            bail!("Bun shell source missing from snapshot");
2319        };
2320        if source.origin.source_kind == super::PresetSourceKind::Embedded {
2321            return Ok(BunRuntimeSpec::default());
2322        }
2323        let package_key = format!("shell/{category}/package.json");
2324        let lock_key = format!("shell/{category}/bun.lock");
2325        let package = self
2326            .presets()
2327            .file(&package_key)
2328            .filter(|file| file.origin.source_kind == source.origin.source_kind);
2329        let lock = self
2330            .presets()
2331            .file(&lock_key)
2332            .filter(|file| file.origin.source_kind == source.origin.source_kind);
2333        match (package, lock) {
2334            (None, None) => Ok(BunRuntimeSpec::default()),
2335            (Some(_), None) => bail!(
2336                "external Bun preset dependency declaration requires bun.lock beside package.json"
2337            ),
2338            (None, Some(_)) => {
2339                bail!("external Bun preset dependency lock requires package.json beside bun.lock")
2340            }
2341            (Some(package), Some(lock)) => {
2342                let parsed: serde_json::Value =
2343                    serde_json::from_slice(&package.bytes).context("parsing Bun preset package")?;
2344                if parsed.get("trustedDependencies").is_some() {
2345                    bail!("external Bun preset package must not declare trustedDependencies");
2346                }
2347                let mut bytes = package.bytes.clone();
2348                bytes.push(0);
2349                bytes.extend_from_slice(&lock.bytes);
2350                Ok(BunRuntimeSpec {
2351                    dependency_mode: BunDependencyMode::Locked,
2352                    dependency_hash: Some(crate::install::hash_content(&bytes)),
2353                })
2354            }
2355        }
2356    }
2357}
2358
2359impl<H: FileSystemHost> CoreRuntime<H> {
2360    pub async fn effective_shell_transforms(
2361        &self,
2362        file: &ShellFile,
2363        source: &Path,
2364    ) -> Result<Vec<String>> {
2365        if !file.transforms.is_empty() {
2366            return Ok(file.transforms.clone());
2367        }
2368        let bytes = self
2369            .host()
2370            .read(source)
2371            .await
2372            .map_err(|error| error.into_anyhow("reading shell source"))?;
2373        Ok(if has_template_annotation(&bytes) {
2374            vec!["template".to_string()]
2375        } else {
2376            Vec::new()
2377        })
2378    }
2379
2380    pub async fn reconcile_shell_cache(
2381        &self,
2382        request: ShellCacheRequest,
2383    ) -> Result<ShellCacheReport> {
2384        let prefix = request.prefix.trim_end_matches('/');
2385        let mut report = ShellCacheReport::default();
2386        for (logical, bytes) in self
2387            .presets()
2388            .files()
2389            .iter()
2390            .filter(|(path, _)| *path == prefix || path.starts_with(&format!("{prefix}/")))
2391        {
2392            let destination = self.context().presets_dir.join(logical);
2393            if request.remove {
2394                match self.host().metadata(&destination).await {
2395                    Ok(_) => {
2396                        report.removed.push(destination.clone());
2397                        if !request.dry_run {
2398                            self.host()
2399                                .remove_file(&destination)
2400                                .await
2401                                .map_err(|error| error.into_anyhow("removing Shell cache"))?;
2402                        }
2403                    }
2404                    Err(error) if error.is_not_found() => report.skipped.push(destination),
2405                    Err(error) => return Err(error.into_anyhow("inspecting Shell cache")),
2406                }
2407            } else {
2408                let (exists, differs) = match self.host().read(&destination).await {
2409                    Ok(current) => (true, current != *bytes),
2410                    Err(error) if error.is_not_found() => (false, true),
2411                    Err(error) => return Err(error.into_anyhow("reading Shell cache")),
2412                };
2413                if exists && !request.overwrite {
2414                    report.skipped.push(destination);
2415                    continue;
2416                }
2417                if differs {
2418                    if exists {
2419                        report.overwritten.push(destination.clone());
2420                    } else {
2421                        report.created.push(destination.clone());
2422                    }
2423                    if !request.dry_run {
2424                        self.host()
2425                            .write_atomic(&destination, bytes)
2426                            .await
2427                            .map_err(|error| error.into_anyhow("writing Shell cache"))?;
2428                        if logical.ends_with(".sh") {
2429                            self.host()
2430                                .set_executable(&destination)
2431                                .await
2432                                .map_err(|error| {
2433                                    error.into_anyhow("setting Shell cache executable mode")
2434                                })?;
2435                        }
2436                    }
2437                } else {
2438                    report.skipped.push(destination);
2439                }
2440            }
2441        }
2442        if request.remove && request.purge {
2443            let root = self.context().presets_dir.join(prefix);
2444            match self.host().metadata(&root).await {
2445                Ok(_) => {
2446                    report.removed.push(root.clone());
2447                    if !request.dry_run {
2448                        self.host()
2449                            .remove_dir_all(&root)
2450                            .await
2451                            .map_err(|error| error.into_anyhow("purging Shell cache"))?;
2452                    }
2453                }
2454                Err(error) if error.is_not_found() => {}
2455                Err(error) => return Err(error.into_anyhow("inspecting Shell cache root")),
2456            }
2457        }
2458        Ok(report)
2459    }
2460
2461    pub async fn validate_shell_snapshot(&self, categories: &[ShellCategory]) -> Result<()> {
2462        if !self.context().is_external_presets
2463            || self.context().external_shell_mode != ExternalShellMode::Snapshot
2464        {
2465            return Ok(());
2466        }
2467        for category in categories {
2468            for file in &category.files {
2469                let source = self.desired_shell_source_path(&category.name, &file.source_rel);
2470                let transforms = self.effective_shell_transforms(file, &source).await?;
2471                if !transforms.is_empty() {
2472                    let bytes = self
2473                        .host()
2474                        .read(&source)
2475                        .await
2476                        .map_err(|error| error.into_anyhow("reading desired Shell source"))?;
2477                    crate::install::apply_transforms(&transforms, &bytes, &self.context().env)
2478                        .with_context(|| {
2479                            format!("validating transformed shell source: {}", source.display())
2480                        })?;
2481                }
2482            }
2483        }
2484        Ok(())
2485    }
2486
2487    pub async fn materialize_shell_snapshots(&self, categories: &[ShellCategory]) -> Result<usize> {
2488        if !self.context().is_external_presets
2489            || self.context().external_shell_mode != ExternalShellMode::Snapshot
2490        {
2491            return Ok(0);
2492        }
2493        let mut changed = 0;
2494        for category in categories {
2495            let prefix = format!("shell/{}/", category.name);
2496            let destination = self
2497                .context()
2498                .shine_dir
2499                .join("installed/shell")
2500                .join(&category.name);
2501            if self.shell_snapshot_current(&category.name).await? {
2502                continue;
2503            }
2504            let stage = self
2505                .context()
2506                .shine_dir
2507                .join("installed/shell")
2508                .join(format!(".{}-{}", category.name, uuid::Uuid::new_v4()));
2509            for (logical, bytes) in self
2510                .presets()
2511                .files()
2512                .iter()
2513                .filter(|(path, _)| path.starts_with(&prefix))
2514            {
2515                let relative = logical.strip_prefix(&prefix).unwrap_or_default();
2516                self.host()
2517                    .write_atomic(&stage.join(relative), bytes)
2518                    .await
2519                    .map_err(|error| error.into_anyhow("staging Shell snapshot"))?;
2520            }
2521            let backup = self
2522                .context()
2523                .shine_dir
2524                .join("installed/shell")
2525                .join(format!(
2526                    ".{}-backup-{}",
2527                    category.name,
2528                    uuid::Uuid::new_v4()
2529                ));
2530            let had_destination = match self.host().metadata(&destination).await {
2531                Ok(_) => {
2532                    self.host()
2533                        .rename(&destination, &backup)
2534                        .await
2535                        .map_err(|error| error.into_anyhow("backing up prior Shell snapshot"))?;
2536                    true
2537                }
2538                Err(error) if error.is_not_found() => false,
2539                Err(error) => return Err(error.into_anyhow("inspecting Shell snapshot")),
2540            };
2541            if let Err(error) = self.host().rename(&stage, &destination).await {
2542                if had_destination {
2543                    let _ = self.host().rename(&backup, &destination).await;
2544                }
2545                return Err(error.into_anyhow("installing Shell snapshot"));
2546            }
2547            if had_destination {
2548                self.host()
2549                    .remove_dir_all(&backup)
2550                    .await
2551                    .map_err(|error| error.into_anyhow("removing prior Shell snapshot backup"))?;
2552            }
2553            changed += 1;
2554        }
2555        Ok(changed)
2556    }
2557
2558    pub async fn shell_snapshot_current(&self, category: &str) -> Result<bool> {
2559        if !self.context().is_external_presets
2560            || self.context().external_shell_mode != ExternalShellMode::Snapshot
2561        {
2562            return Ok(true);
2563        }
2564        let prefix = format!("shell/{category}/");
2565        let expected = self
2566            .presets()
2567            .files()
2568            .iter()
2569            .filter_map(|(path, bytes)| {
2570                path.strip_prefix(&prefix)
2571                    .map(|relative| (PathBuf::from(relative), bytes))
2572            })
2573            .collect::<BTreeMap<_, _>>();
2574        let root = self
2575            .context()
2576            .shine_dir
2577            .join("installed/shell")
2578            .join(category);
2579        let actual = collect_host_files(self.host(), &root).await?;
2580        if expected.keys().cloned().collect::<BTreeSet<_>>() != actual {
2581            return Ok(false);
2582        }
2583        for (relative, bytes) in expected {
2584            if self
2585                .host()
2586                .read(&root.join(relative))
2587                .await
2588                .map_or(true, |current| current != *bytes)
2589            {
2590                return Ok(false);
2591            }
2592        }
2593        Ok(true)
2594    }
2595
2596    pub async fn update_shell_manifest(
2597        &self,
2598        categories: &[ShellCategory],
2599        scope: ShellManifestUpdateScope,
2600    ) -> Result<()> {
2601        let mut manifest =
2602            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
2603        let previous = manifest.clone();
2604        let selected = categories
2605            .iter()
2606            .map(|category| category.name.clone())
2607            .collect::<BTreeSet<_>>();
2608        let targets = categories
2609            .iter()
2610            .flat_map(|category| {
2611                category
2612                    .files
2613                    .iter()
2614                    .map(|file| format!("shell/{}/{}", category.name, file.command_name))
2615            })
2616            .collect::<BTreeSet<_>>();
2617        let mut entries = Vec::new();
2618        for category in categories {
2619            for file in &category.files {
2620                let entry = self.shell_manifest_entry(category, file).await?;
2621                let transforms = &entry.transforms;
2622                let effective_source = if transforms.is_empty() {
2623                    entry.source_path.as_path()
2624                } else {
2625                    entry.rendered_path.as_path()
2626                };
2627                let bun = self.shell_bun_runtime_spec(&category.name, file)?;
2628                let render_target = (self.context().is_external_presets
2629                    && self.context().external_shell_mode == ExternalShellMode::Live
2630                    && !transforms.is_empty())
2631                .then(|| format!("shell/{}/{}", category.name, file.command_name));
2632                let link = super::command_path_for_name(
2633                    &self.context().bin_dir,
2634                    std::ffi::OsStr::new(&file.command_name),
2635                );
2636                if !link_is_current_with_host(
2637                    self.host(),
2638                    &link,
2639                    effective_source,
2640                    file.runtime,
2641                    bun.dependency_mode,
2642                    &entry.env,
2643                    render_target.as_deref(),
2644                )
2645                .await?
2646                {
2647                    continue;
2648                }
2649                entries.push(entry);
2650            }
2651        }
2652        match scope {
2653            ShellManifestUpdateScope::Categories => manifest.replace_categories(&selected, entries),
2654            ShellManifestUpdateScope::Commands => manifest.replace_targets(&targets, entries),
2655        }
2656        if manifest == previous {
2657            return Ok(());
2658        }
2659        save_shell_manifest_with_host(self.host(), &self.context().shine_dir, &manifest).await
2660    }
2661
2662    async fn shell_manifest_entry(
2663        &self,
2664        category: &ShellCategory,
2665        file: &ShellFile,
2666    ) -> Result<ShellManifestEntry> {
2667        let source_path = self.shell_deployment_source_path(&category.name, &file.source_rel);
2668        let bytes = self
2669            .host()
2670            .read(&source_path)
2671            .await
2672            .map_err(|error| error.into_anyhow("reading installed shell source"))?;
2673        let transforms = self.effective_shell_transforms(file, &source_path).await?;
2674        self.shell_manifest_entry_for_content(category, file, source_path, transforms, &bytes)
2675    }
2676
2677    fn desired_shell_manifest_entry(
2678        &self,
2679        category: &ShellCategory,
2680        file: &ShellFile,
2681    ) -> Result<ShellManifestEntry> {
2682        let source_path = self.shell_deployment_source_path(&category.name, &file.source_rel);
2683        let logical = format!(
2684            "shell/{}/{}",
2685            category.name,
2686            shell_logical_path(&file.source_rel)
2687        );
2688        let bytes = self
2689            .presets()
2690            .get(&logical)
2691            .context("missing desired Shell source")?;
2692        let transforms = if !file.transforms.is_empty() {
2693            file.transforms.clone()
2694        } else if has_template_annotation(bytes) {
2695            vec!["template".to_string()]
2696        } else {
2697            Vec::new()
2698        };
2699        self.shell_manifest_entry_for_content(category, file, source_path, transforms, bytes)
2700    }
2701
2702    fn shell_manifest_entry_for_content(
2703        &self,
2704        category: &ShellCategory,
2705        file: &ShellFile,
2706        source_path: PathBuf,
2707        transforms: Vec<String>,
2708        bytes: &[u8],
2709    ) -> Result<ShellManifestEntry> {
2710        let rendered_path = self.shell_rendered_path(&category.name, &file.source_rel);
2711        let env = file
2712            .env
2713            .iter()
2714            .map(crate::env::EnvVarSpec::to_with_arg)
2715            .collect::<Vec<_>>();
2716        let bun = self.shell_bun_runtime_spec(&category.name, file)?;
2717        Ok(ShellManifestEntry {
2718            category: category.name.clone(),
2719            command: file.command_name.clone(),
2720            mode: if self.context().is_external_presets {
2721                self.context().external_shell_mode
2722            } else {
2723                ExternalShellMode::Snapshot
2724            },
2725            source_path,
2726            rendered_path,
2727            runtime: if file.runtime == LinkRuntime::Bun {
2728                "bun"
2729            } else {
2730                "native"
2731            }
2732            .to_string(),
2733            bun_dependencies: bun.dependency_mode.as_manifest_value().map(str::to_string),
2734            dependency_hash: bun.dependency_hash,
2735            transforms,
2736            env,
2737            needs_source: file.needs_source,
2738            content_hash: crate::install::hash_content(bytes),
2739        })
2740    }
2741
2742    pub async fn render_live_shell(&self, target: &str) -> Result<()>
2743    where
2744        H: PrivilegedFileSystemHost,
2745    {
2746        let _guard = self.host().acquire_privileged_operation().await?;
2747        if self.shell_operation_journal_bytes().await?.is_some() {
2748            bail!("an interrupted Shell operation requires explicit recovery");
2749        }
2750        let manifest =
2751            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
2752        let entry = manifest
2753            .find(target)
2754            .with_context(|| format!("live shell command is not installed: {target}"))?;
2755        if entry.mode != ExternalShellMode::Live {
2756            bail!("shell command is not installed in live mode: {target}");
2757        }
2758        if entry.transforms.is_empty() {
2759            return Ok(());
2760        }
2761        let rendered_root = self.context().shine_dir.join("rendered");
2762        if !entry.rendered_path.starts_with(&rendered_root) {
2763            bail!("invalid live rendered path recorded for {target}");
2764        }
2765        let source = self
2766            .host()
2767            .read(&entry.source_path)
2768            .await
2769            .map_err(|error| error.into_anyhow("reading live source"))?;
2770        let rendered =
2771            crate::install::apply_transforms(&entry.transforms, &source, &self.context().env)
2772                .with_context(|| format!("live transform failed for {target}"))?;
2773        if self
2774            .host()
2775            .read(&entry.rendered_path)
2776            .await
2777            .is_ok_and(|current| current == rendered)
2778        {
2779            return Ok(());
2780        }
2781        self.host()
2782            .write_atomic(&entry.rendered_path, &rendered)
2783            .await
2784            .map_err(|error| error.into_anyhow("writing live rendered shell source"))?;
2785        let mode = self
2786            .host()
2787            .metadata(&entry.source_path)
2788            .await
2789            .ok()
2790            .and_then(|metadata| metadata.unix_mode)
2791            .unwrap_or(0o755);
2792        self.host()
2793            .set_mode(&entry.rendered_path, mode)
2794            .await
2795            .map_err(|error| error.into_anyhow("setting live rendered shell mode"))?;
2796        Ok(())
2797    }
2798
2799    pub async fn remove_shell_manifest_entries(
2800        &self,
2801        category: Option<&str>,
2802        command: Option<&str>,
2803    ) -> Result<()> {
2804        let mut manifest =
2805            load_shell_manifest_with_host(self.host(), &self.context().shine_dir).await?;
2806        match (category, command) {
2807            (Some(category), Some(command)) => manifest.remove_target(category, command),
2808            (Some(category), None) => manifest.remove_category(category),
2809            (None, None) => manifest.entries.clear(),
2810            (None, Some(_)) => bail!("shell command removal requires a category"),
2811        }
2812        save_shell_manifest_with_host(self.host(), &self.context().shine_dir, &manifest).await
2813    }
2814
2815    pub async fn remove_shell_snapshot_tree(&self, category: &str) -> Result<()> {
2816        let path = self
2817            .context()
2818            .shine_dir
2819            .join("installed/shell")
2820            .join(category);
2821        match self.host().metadata(&path).await {
2822            Ok(_) => self
2823                .host()
2824                .remove_dir_all(&path)
2825                .await
2826                .map_err(|error| error.into_anyhow("removing managed Shell snapshot tree"))?,
2827            Err(error) if error.is_not_found() => {}
2828            Err(error) => {
2829                return Err(error.into_anyhow("inspecting managed Shell snapshot tree"));
2830            }
2831        }
2832        Ok(())
2833    }
2834
2835    pub async fn remove_empty_shell_roots(&self, categories: &BTreeSet<String>) -> Result<()> {
2836        let shell_root = self.context().presets_dir.join("shell");
2837        for category in categories {
2838            let path = shell_root.join(category);
2839            if super::shell_action_executor::collect_shell_tree_for_action(self.host(), &path)
2840                .await?
2841                .is_some_and(|files| files.is_empty())
2842            {
2843                self.host()
2844                    .remove_dir_all(&path)
2845                    .await
2846                    .map_err(|error| error.into_anyhow("removing empty Shell category"))?;
2847            }
2848        }
2849        if super::shell_action_executor::collect_shell_tree_for_action(self.host(), &shell_root)
2850            .await?
2851            .is_some_and(|files| files.is_empty())
2852        {
2853            self.host()
2854                .remove_dir_all(&shell_root)
2855                .await
2856                .map_err(|error| error.into_anyhow("removing empty Shell preset root"))?;
2857        }
2858        let bin_dir = &self.context().bin_dir;
2859        match self.host().read_dir(bin_dir).await {
2860            Ok(entries) if entries.is_empty() => self
2861                .host()
2862                .remove_dir_all(bin_dir)
2863                .await
2864                .map_err(|error| error.into_anyhow("removing empty Shell root"))?,
2865            Ok(_) => {}
2866            Err(error) if error.is_not_found() => {}
2867            Err(error) => return Err(error.into_anyhow("inspecting empty Shell root")),
2868        }
2869        Ok(())
2870    }
2871}
2872
2873impl<H> CoreRuntime<H> {
2874    pub fn shell_categories(&self, filter: Option<&str>) -> Result<Vec<ShellCategory>> {
2875        let categories = self.shell_categories_or_missing(filter)?;
2876        if filter.is_some() && categories.is_empty() && self.context().is_external_presets {
2877            bail!(
2878                "shell preset category not found: {}",
2879                filter.unwrap_or_default()
2880            );
2881        }
2882        Ok(categories)
2883    }
2884
2885    pub(super) fn shell_categories_or_missing(
2886        &self,
2887        filter: Option<&str>,
2888    ) -> Result<Vec<ShellCategory>> {
2889        let prefix = "shell/";
2890        let names = self
2891            .presets()
2892            .files()
2893            .keys()
2894            .filter_map(|path| path.strip_prefix(prefix))
2895            .filter_map(|rest| rest.split_once('/').map(|(category, _)| category))
2896            .filter(|category| filter.is_none_or(|filter| filter == *category))
2897            .map(str::to_string)
2898            .collect::<BTreeSet<_>>();
2899        names
2900            .into_iter()
2901            .map(|name| self.parse_shell_category(&name))
2902            .collect()
2903    }
2904
2905    pub(crate) fn effective_shell_cache_logicals(
2906        &self,
2907        category: &ShellCategory,
2908    ) -> Result<BTreeSet<String>> {
2909        let prefix = format!("shell/{}/", category.name);
2910        let mut selected = self
2911            .presets()
2912            .files()
2913            .keys()
2914            .filter(|logical| logical.starts_with(&prefix))
2915            .cloned()
2916            .collect::<BTreeSet<_>>();
2917        let metadata_path = format!("{prefix}shine.toml");
2918        let active_sources = category
2919            .files
2920            .iter()
2921            .map(|file| format!("{prefix}{}", shell_logical_path(&file.source_rel)))
2922            .collect::<BTreeSet<_>>();
2923        let declared_files = self
2924            .presets()
2925            .get(&metadata_path)
2926            .map(|metadata| {
2927                toml::from_slice::<ShellCategoryToml>(metadata)
2928                    .with_context(|| format!("failed to parse {metadata_path}"))
2929                    .map(|parsed| parsed.files)
2930            })
2931            .transpose()?
2932            .flatten();
2933        if let Some(entries) = declared_files {
2934            for entry in entries {
2935                let runtime = match entry.runtime.as_deref() {
2936                    None | Some("native") => LinkRuntime::Native,
2937                    Some("bun") => LinkRuntime::Bun,
2938                    Some(other) => bail!("unsupported runtime `{other}` (expected `bun`)"),
2939                };
2940                let source = normalize_shell_metadata_source(&entry.source, runtime)
2941                    .with_context(|| format!("invalid source in {metadata_path}"))?;
2942                let logical = format!("{prefix}{}", shell_logical_path(&source));
2943                if !active_sources.contains(&logical) {
2944                    selected.remove(&logical);
2945                }
2946            }
2947        } else {
2948            selected.retain(|logical| {
2949                let relative = logical.strip_prefix(&prefix).unwrap_or(logical);
2950                !is_native_shell_script(Path::new(relative)) || active_sources.contains(logical)
2951            });
2952        }
2953        Ok(selected)
2954    }
2955
2956    fn parse_shell_category(&self, name: &str) -> Result<ShellCategory> {
2957        let prefix = format!("shell/{name}/");
2958        let metadata_path = format!("{prefix}shine.toml");
2959        let metadata = self.presets().get(&metadata_path);
2960        let parsed = metadata
2961            .map(|bytes| {
2962                toml::from_slice::<ShellCategoryToml>(bytes)
2963                    .with_context(|| format!("failed to parse {metadata_path}"))
2964            })
2965            .transpose()?;
2966        let mut files = Vec::new();
2967        if let Some(entries) = parsed.as_ref().and_then(|parsed| parsed.files.as_ref()) {
2968            for entry in entries {
2969                if !shell_platform_matches(
2970                    entry.platforms.as_deref(),
2971                    self.context().platform,
2972                    &metadata_path,
2973                )? {
2974                    continue;
2975                }
2976                let runtime = match entry.runtime.as_deref() {
2977                    None | Some("native") => LinkRuntime::Native,
2978                    Some("bun") => LinkRuntime::Bun,
2979                    Some(other) => bail!("unsupported runtime `{other}` (expected `bun`)"),
2980                };
2981                let source_rel = normalize_shell_metadata_source(&entry.source, runtime)
2982                    .with_context(|| format!("invalid source in {metadata_path}"))?;
2983                if !shell_source_matches(runtime, self.context().shell, &source_rel) {
2984                    continue;
2985                }
2986                let command_name = shell_command_name(&source_rel, entry.target.as_deref())?;
2987                let needs_source = entry.needs_source.unwrap_or(false);
2988                if runtime == LinkRuntime::Bun && needs_source {
2989                    bail!(
2990                        "{metadata_path}: `runtime = \"bun\"` cannot be combined with `needs_source = true`"
2991                    );
2992                }
2993                let transforms = entry.transforms.clone().unwrap_or_default();
2994                crate::install::transforms::validate(&transforms)
2995                    .with_context(|| format!("invalid transforms in {metadata_path}"))?;
2996                let env = crate::env::parse_env_specs(entry.env.as_deref().unwrap_or_default())
2997                    .with_context(|| format!("invalid env in {metadata_path}"))?;
2998                if runtime != LinkRuntime::Bun && !env.is_empty() {
2999                    bail!("{metadata_path}: `env` is only valid when `runtime = \"bun\"`");
3000                }
3001                if let Some(permissions) = &entry.permissions {
3002                    permissions
3003                        .validate()
3004                        .with_context(|| format!("invalid permissions in {metadata_path}"))?;
3005                }
3006                let logical = format!("{prefix}{}", shell_logical_path(&source_rel));
3007                let bytes = self.presets().get(&logical).with_context(|| {
3008                    format!(
3009                        "shell/{name}/shine.toml references missing file: {}",
3010                        source_rel.display()
3011                    )
3012                })?;
3013                let description = entry.description.clone().map_or_else(
3014                    || shell_description(bytes, runtime),
3015                    |description| vec![description],
3016                );
3017                files.push(ShellFile {
3018                    source_rel,
3019                    command_name,
3020                    description,
3021                    needs_source,
3022                    runtime,
3023                    transforms,
3024                    env,
3025                    permissions: entry.permissions.clone(),
3026                });
3027            }
3028        } else {
3029            for path in self.presets().files().keys() {
3030                let Some(relative) = path.strip_prefix(&prefix) else {
3031                    continue;
3032                };
3033                if relative == "shine.toml" || !is_native_shell_script(Path::new(relative)) {
3034                    continue;
3035                }
3036                let source_rel = normalize_shell_metadata_source(relative, LinkRuntime::Native)?;
3037                if !shell_source_matches(LinkRuntime::Native, self.context().shell, &source_rel) {
3038                    continue;
3039                }
3040                let bytes = self.presets().get(path).unwrap_or_default();
3041                files.push(ShellFile {
3042                    command_name: shell_command_name(&source_rel, None)?,
3043                    description: shell_description(bytes, LinkRuntime::Native),
3044                    needs_source: false,
3045                    runtime: LinkRuntime::Native,
3046                    transforms: Vec::new(),
3047                    env: Vec::new(),
3048                    permissions: None,
3049                    source_rel,
3050                });
3051            }
3052        }
3053        files.sort_by(|left, right| left.command_name.cmp(&right.command_name));
3054        let mut commands = BTreeSet::new();
3055        for file in &files {
3056            if !commands.insert(file.command_name.clone()) {
3057                bail!(
3058                    "shell/{name} declares command `{}` more than once",
3059                    file.command_name
3060                );
3061            }
3062        }
3063        Ok(ShellCategory {
3064            name: name.to_string(),
3065            description: parsed.and_then(|parsed| parsed.description),
3066            files,
3067            uses_metadata: metadata.is_some(),
3068        })
3069    }
3070}
3071
3072impl<H: FileSystemHost> CoreRuntime<H> {
3073    async fn prepare_shell_profile_reconciliation(
3074        &self,
3075        manifest_before: &ShellManifest,
3076        manifest_after: &ShellManifest,
3077        remove_all: bool,
3078        _force: bool,
3079        legacy_targets: &[String],
3080    ) -> Result<Vec<ShellProfileReconciliation>> {
3081        let mut files = Vec::new();
3082        let source_commands = manifest_after
3083            .entries
3084            .iter()
3085            .filter(|entry| entry.needs_source)
3086            .map(|entry| entry.command.clone())
3087            .collect::<BTreeSet<_>>()
3088            .into_iter()
3089            .collect::<Vec<_>>();
3090        let managed_profile =
3091            super::managed_shell_profile_path(&self.context().shine_dir, self.context().shell);
3092        let desired_profile = (!remove_all).then(|| {
3093            super::managed_profile_snippet(
3094                self.context().shell,
3095                &self.context().bin_dir,
3096                &self.context().home_dir,
3097                &source_commands,
3098            )
3099            .into_bytes()
3100        });
3101        let current_profile = match self.host().read(&managed_profile).await {
3102            Ok(bytes) => Some(bytes),
3103            Err(error) if error.is_not_found() => None,
3104            Err(error) => return Err(error.into_anyhow("reading managed Shell profile")),
3105        };
3106        if current_profile != desired_profile {
3107            let mode = self
3108                .host()
3109                .metadata(&managed_profile)
3110                .await
3111                .ok()
3112                .and_then(|metadata| metadata.unix_mode)
3113                .or_else(|| cfg!(unix).then_some(0o644));
3114            files.push(ShellProfilePreparedFile {
3115                destination: managed_profile.clone(),
3116                desired: desired_profile,
3117                unix_mode: mode,
3118                ownership: ShellProfileFileOwnershipV1::WholeFile,
3119                previous_block_hash: None,
3120                desired_block_hash: None,
3121            });
3122        }
3123
3124        if remove_all || !manifest_after.entries.is_empty() {
3125            let profile = managed_profile.clone();
3126            let snippet = super::profile::shell_config_snippet(
3127                self.context().shell,
3128                &profile,
3129                &self.context().home_dir,
3130            );
3131            for path in &self.context().shell_config_paths {
3132                let existing = match self.host().read(path).await {
3133                    Ok(bytes) => {
3134                        String::from_utf8(bytes).context("Shell configuration is not UTF-8")?
3135                    }
3136                    Err(error) if error.is_not_found() => String::new(),
3137                    Err(error) => {
3138                        return Err(error.into_anyhow("reading Shell configuration"));
3139                    }
3140                };
3141                let previous_block_hash = super::profile::shell_sentinel_block(&existing)
3142                    .map(|block| crate::install::hash_content(block.as_bytes()));
3143                let desired = if remove_all {
3144                    if previous_block_hash.is_none() {
3145                        continue;
3146                    }
3147                    super::profile::remove_shell_sentinel(&existing)
3148                } else {
3149                    if super::profile::shell_sentinel_block(&existing)
3150                        == Some(snippet.trim_end_matches('\n'))
3151                    {
3152                        continue;
3153                    }
3154                    let cleaned = super::profile::remove_shell_sentinel(&existing);
3155                    format!("{cleaned}\n{snippet}")
3156                };
3157                let desired_block_hash = super::profile::shell_sentinel_block(&desired)
3158                    .map(|block| crate::install::hash_content(block.as_bytes()));
3159                let mode = self
3160                    .host()
3161                    .metadata(path)
3162                    .await
3163                    .ok()
3164                    .and_then(|metadata| metadata.unix_mode)
3165                    .or_else(|| cfg!(unix).then_some(0o644));
3166                files.push(ShellProfilePreparedFile {
3167                    destination: path.clone(),
3168                    desired: Some(desired.into_bytes()),
3169                    unix_mode: mode,
3170                    ownership: ShellProfileFileOwnershipV1::SentinelBlock,
3171                    previous_block_hash,
3172                    desired_block_hash,
3173                });
3174            }
3175        }
3176        if files.is_empty() {
3177            return Ok(Vec::new());
3178        }
3179
3180        let before = manifest_before
3181            .entries
3182            .iter()
3183            .map(|entry| (format!("shell/{}/{}", entry.category, entry.command), entry))
3184            .collect::<BTreeMap<_, _>>();
3185        let after = manifest_after
3186            .entries
3187            .iter()
3188            .map(|entry| (format!("shell/{}/{}", entry.category, entry.command), entry))
3189            .collect::<BTreeMap<_, _>>();
3190        let mut receipt_transitions = Vec::new();
3191        let mut receipt_removals = Vec::new();
3192        for target in before
3193            .keys()
3194            .chain(after.keys())
3195            .cloned()
3196            .collect::<BTreeSet<_>>()
3197        {
3198            match (before.get(&target), after.get(&target)) {
3199                (previous, Some(desired)) => receipt_transitions.push((
3200                    target,
3201                    previous.map(|entry| (*entry).clone()),
3202                    (*desired).clone(),
3203                )),
3204                (Some(previous), None) => {
3205                    receipt_removals.push((target, (*previous).clone()));
3206                }
3207                (None, None) => unreachable!(),
3208            }
3209        }
3210        Ok(vec![ShellProfileReconciliation {
3211            target: "shell/profile".to_string(),
3212            files,
3213            receipt_transitions,
3214            receipt_removals,
3215            legacy_targets: legacy_targets.to_vec(),
3216        }])
3217    }
3218
3219    async fn planned_embedded_shell_source(
3220        &self,
3221        category: &ShellCategory,
3222        file: &ShellFile,
3223        overwrite: bool,
3224    ) -> Result<(Vec<u8>, Option<u32>)> {
3225        let logical = format!(
3226            "shell/{}/{}",
3227            category.name,
3228            shell_logical_path(&file.source_rel)
3229        );
3230        let desired = self
3231            .presets()
3232            .get(&logical)
3233            .context("missing desired embedded Shell source")?;
3234        let source_path = self.shell_deployment_source_path(&category.name, &file.source_rel);
3235        match self.host().metadata(&source_path).await {
3236            Ok(metadata) if metadata.kind == FileKind::File => {
3237                let current =
3238                    self.host().read(&source_path).await.map_err(|error| {
3239                        error.into_anyhow("reading embedded Shell cache source")
3240                    })?;
3241                if overwrite && current != *desired {
3242                    Ok((desired.to_vec(), embedded_shell_cache_mode(&logical)))
3243                } else {
3244                    Ok((current, metadata.unix_mode))
3245                }
3246            }
3247            Ok(_) => bail!(
3248                "embedded Shell cache source is not a regular file: {}",
3249                source_path.display()
3250            ),
3251            Err(error) if error.is_not_found() => {
3252                Ok((desired.to_vec(), embedded_shell_cache_mode(&logical)))
3253            }
3254            Err(error) => Err(error.into_anyhow("inspecting embedded Shell cache source")),
3255        }
3256    }
3257
3258    async fn planned_embedded_shell_manifest_entry(
3259        &self,
3260        category: &ShellCategory,
3261        file: &ShellFile,
3262        overwrite: bool,
3263    ) -> Result<ShellManifestEntry> {
3264        let source_path = self.shell_deployment_source_path(&category.name, &file.source_rel);
3265        let (bytes, _) = self
3266            .planned_embedded_shell_source(category, file, overwrite)
3267            .await?;
3268        let transforms = if !file.transforms.is_empty() {
3269            file.transforms.clone()
3270        } else if has_template_annotation(&bytes) {
3271            vec!["template".to_string()]
3272        } else {
3273            Vec::new()
3274        };
3275        self.shell_manifest_entry_for_content(category, file, source_path, transforms, &bytes)
3276    }
3277
3278    async fn prepare_shell_cache_replacements(
3279        &self,
3280        categories: &[ShellCategory],
3281        manifest_before: &ShellManifest,
3282        overwrite: bool,
3283    ) -> Result<(Vec<ShellCacheReplacement>, ShellCacheReport)> {
3284        let mut replacements = Vec::new();
3285        let mut report = ShellCacheReport::default();
3286        for category in categories {
3287            let prefix = format!("shell/{}/", category.name);
3288            let effective_logicals = self.effective_shell_cache_logicals(category)?;
3289            let mut files = Vec::new();
3290            for (logical, bytes) in self.presets().files().iter().filter(|(logical, _)| {
3291                logical.starts_with(&prefix) && effective_logicals.contains(*logical)
3292            }) {
3293                let destination = self.context().presets_dir.join(logical);
3294                let previous = match self.host().metadata(&destination).await {
3295                    Ok(metadata) if metadata.kind == FileKind::File => {
3296                        let current = self.host().read(&destination).await.map_err(|error| {
3297                            error.into_anyhow("reading embedded Shell cache file")
3298                        })?;
3299                        if current == *bytes || !overwrite {
3300                            report.skipped.push(destination);
3301                            continue;
3302                        }
3303                        report.overwritten.push(destination.clone());
3304                        Some(ShellFileIdentityV1 {
3305                            content_hash: crate::install::hash_content(&current),
3306                            unix_mode: metadata.unix_mode,
3307                        })
3308                    }
3309                    Ok(_) => bail!(
3310                        "embedded Shell cache destination is not a regular file: {}",
3311                        destination.display()
3312                    ),
3313                    Err(error) if error.is_not_found() => {
3314                        report.created.push(destination.clone());
3315                        None
3316                    }
3317                    Err(error) => {
3318                        return Err(error.into_anyhow("inspecting embedded Shell cache file"));
3319                    }
3320                };
3321                let rollback = managed_file_rollback_path(&destination);
3322                match self.host().metadata(&rollback).await {
3323                    Err(error) if error.is_not_found() => {}
3324                    Ok(_) => bail!(
3325                        "embedded Shell cache rollback path is occupied: {}",
3326                        rollback.display()
3327                    ),
3328                    Err(error) => {
3329                        return Err(error.into_anyhow("inspecting embedded Shell cache rollback"));
3330                    }
3331                }
3332                let unix_mode = embedded_shell_cache_mode(logical);
3333                let desired = ShellFileIdentityV1 {
3334                    content_hash: crate::install::hash_content(bytes),
3335                    unix_mode,
3336                };
3337                if previous.as_ref() == Some(&desired) {
3338                    report.skipped.push(destination);
3339                    continue;
3340                }
3341                files.push(ShellCacheReplacementFile {
3342                    destination,
3343                    bytes: bytes.clone(),
3344                    unix_mode,
3345                });
3346            }
3347            if files.is_empty() {
3348                continue;
3349            }
3350            let mut receipt_transitions = Vec::new();
3351            for file in &category.files {
3352                let target = format!("shell/{}/{}", category.name, file.command_name);
3353                receipt_transitions.push((
3354                    target.clone(),
3355                    manifest_before.find(&target).cloned(),
3356                    self.planned_embedded_shell_manifest_entry(category, file, overwrite)
3357                        .await?,
3358                ));
3359            }
3360            replacements.push(ShellCacheReplacement {
3361                target: format!("shell/{}", category.name),
3362                files,
3363                receipt_transitions,
3364            });
3365        }
3366        Ok((replacements, report))
3367    }
3368
3369    async fn prepare_shell_rendered_replacements(
3370        &self,
3371        categories: &[ShellCategory],
3372        manifest_before: &ShellManifest,
3373        overwrite_embedded: bool,
3374    ) -> Result<(Vec<ShellRenderedFileReplacement>, ShellTemplateReport)> {
3375        let mut replacements = BTreeMap::<PathBuf, ShellRenderedFileReplacement>::new();
3376        let mut report = ShellTemplateReport::default();
3377        for category in categories {
3378            for file in &category.files {
3379                let source = self.shell_deployment_source_path(&category.name, &file.source_rel);
3380                let (content, source_mode, transforms) = if self.context().is_external_presets {
3381                    let logical = format!(
3382                        "shell/{}/{}",
3383                        category.name,
3384                        shell_logical_path(&file.source_rel)
3385                    );
3386                    let desired = self
3387                        .presets()
3388                        .get(&logical)
3389                        .context("missing desired Shell rendered-file source")?;
3390                    let transforms = if !file.transforms.is_empty() {
3391                        file.transforms.clone()
3392                    } else if has_template_annotation(desired) {
3393                        vec!["template".to_string()]
3394                    } else {
3395                        continue;
3396                    };
3397                    let content =
3398                        self.host().read(&source).await.map_err(|error| {
3399                            error.into_anyhow("reading Shell rendered-file source")
3400                        })?;
3401                    let mode = self
3402                        .host()
3403                        .metadata(&source)
3404                        .await
3405                        .ok()
3406                        .and_then(|metadata| metadata.unix_mode);
3407                    (content, mode, transforms)
3408                } else {
3409                    let (content, mode) = self
3410                        .planned_embedded_shell_source(category, file, overwrite_embedded)
3411                        .await?;
3412                    let transforms = if !file.transforms.is_empty() {
3413                        file.transforms.clone()
3414                    } else if has_template_annotation(&content) {
3415                        vec!["template".to_string()]
3416                    } else {
3417                        continue;
3418                    };
3419                    (content, mode, transforms)
3420                };
3421                let rendered =
3422                    crate::install::apply_transforms(&transforms, &content, &self.context().env)
3423                        .with_context(|| {
3424                            format!("template substitution failed for {}", source.display())
3425                        })?;
3426                let destination = self.shell_rendered_path(&category.name, &file.source_rel);
3427                let unix_mode = source_mode.or_else(|| cfg!(unix).then_some(0o755));
3428                let current = match self.host().metadata(&destination).await {
3429                    Ok(metadata) if metadata.kind == FileKind::File => {
3430                        let bytes = self.host().read(&destination).await.map_err(|error| {
3431                            error.into_anyhow("reading current Shell rendered file")
3432                        })?;
3433                        bytes == rendered && metadata.unix_mode == unix_mode
3434                    }
3435                    Ok(_) => false,
3436                    Err(error) if error.is_not_found() => false,
3437                    Err(error) => {
3438                        return Err(error.into_anyhow("inspecting Shell rendered file"));
3439                    }
3440                };
3441                if current {
3442                    continue;
3443                }
3444                let target = format!("shell/{}/{}", category.name, file.command_name);
3445                let desired_receipt = if self.context().is_external_presets {
3446                    self.shell_manifest_entry(category, file).await?
3447                } else {
3448                    self.planned_embedded_shell_manifest_entry(category, file, overwrite_embedded)
3449                        .await?
3450                };
3451                let transition = (
3452                    target.clone(),
3453                    manifest_before.find(&target).cloned(),
3454                    desired_receipt,
3455                );
3456                if let Some(existing) = replacements.get_mut(&destination) {
3457                    if existing.bytes != rendered || existing.unix_mode != unix_mode {
3458                        bail!(
3459                            "Shell commands sharing rendered path {} produce different output",
3460                            destination.display()
3461                        );
3462                    }
3463                    existing.receipt_transitions.push(transition);
3464                } else {
3465                    replacements.insert(
3466                        destination.clone(),
3467                        ShellRenderedFileReplacement {
3468                            target,
3469                            destination,
3470                            bytes: rendered,
3471                            unix_mode,
3472                            receipt_transitions: vec![transition],
3473                        },
3474                    );
3475                }
3476                report
3477                    .updated
3478                    .push(format!("{}/{}", category.name, file.command_name));
3479            }
3480        }
3481        report.updated.sort();
3482        report.updated.dedup();
3483        Ok((replacements.into_values().collect(), report))
3484    }
3485
3486    pub async fn render_shell_templates(
3487        &self,
3488        scripts: &[ShellScriptTemplate],
3489    ) -> Result<ShellTemplateReport> {
3490        let mut report = ShellTemplateReport::default();
3491        for script in scripts {
3492            let content = match self.host().read(&script.source_path).await {
3493                Ok(bytes) => bytes,
3494                Err(error) if error.is_not_found() => continue,
3495                Err(error) => return Err(error.into_anyhow("reading shell template source")),
3496            };
3497            let transforms = if !script.transforms.is_empty() {
3498                script.transforms.clone()
3499            } else if has_template_annotation(&content) {
3500                vec!["template".to_string()]
3501            } else {
3502                continue;
3503            };
3504            let rendered =
3505                crate::install::apply_transforms(&transforms, &content, &self.context().env)
3506                    .with_context(|| {
3507                        format!(
3508                            "template substitution failed for {}",
3509                            script.source_path.display()
3510                        )
3511                    })?;
3512            let changed = match self.host().read(&script.rendered_path).await {
3513                Ok(current) => current != rendered,
3514                Err(_) => true,
3515            };
3516            if let Some(parent) = script.rendered_path.parent() {
3517                self.host()
3518                    .create_dir_all(parent)
3519                    .await
3520                    .map_err(|error| error.into_anyhow("creating rendered script directory"))?;
3521            }
3522            self.host()
3523                .write_atomic(&script.rendered_path, &rendered)
3524                .await
3525                .map_err(|error| error.into_anyhow("writing rendered shell script"))?;
3526            let mode = self
3527                .host()
3528                .metadata(&script.source_path)
3529                .await
3530                .ok()
3531                .and_then(|metadata| metadata.unix_mode)
3532                .unwrap_or(0o755);
3533            self.host()
3534                .set_mode(&script.rendered_path, mode)
3535                .await
3536                .map_err(|error| error.into_anyhow("setting rendered shell script permissions"))?;
3537            if changed {
3538                report.updated.push(script.display_name.clone());
3539            }
3540        }
3541        Ok(report)
3542    }
3543}
3544
3545pub(crate) async fn load_shell_manifest_with_host(
3546    host: &impl super::FileSystemObservationHost,
3547    shine_dir: &Path,
3548) -> Result<ShellManifest> {
3549    let path = shine_dir.join(SHELL_MANIFEST_FILE);
3550    let mut manifest = match host.read(&path).await {
3551        Ok(bytes) => toml::from_slice(&bytes).context("failed to parse shell manifest")?,
3552        Err(error) if error.is_not_found() => ShellManifest::default(),
3553        Err(error) => return Err(error.into_anyhow("failed to read shell manifest")),
3554    };
3555    match manifest.schema_version {
3556        0 => manifest.schema_version = SHELL_MANIFEST_SCHEMA_VERSION,
3557        SHELL_MANIFEST_SCHEMA_VERSION => {}
3558        version => bail!(
3559            "shell manifest schema version {version} is newer than this Shine supports ({SHELL_MANIFEST_SCHEMA_VERSION})"
3560        ),
3561    }
3562    Ok(manifest)
3563}
3564
3565async fn save_shell_manifest_with_host(
3566    host: &impl FileSystemHost,
3567    shine_dir: &Path,
3568    manifest: &ShellManifest,
3569) -> Result<()> {
3570    if manifest.schema_version != SHELL_MANIFEST_SCHEMA_VERSION {
3571        bail!(
3572            "cannot write shell manifest schema version {}; expected {SHELL_MANIFEST_SCHEMA_VERSION}",
3573            manifest.schema_version
3574        );
3575    }
3576    let bytes = toml::to_string_pretty(manifest).context("failed to serialize shell manifest")?;
3577    host.write_atomic(&shine_dir.join(SHELL_MANIFEST_FILE), bytes.as_bytes())
3578        .await
3579        .map_err(|error| error.into_anyhow("failed to write shell manifest"))
3580}
3581
3582async fn collect_host_files(host: &impl FileSystemHost, root: &Path) -> Result<BTreeSet<PathBuf>> {
3583    let mut result = BTreeSet::new();
3584    let mut pending = vec![root.to_path_buf()];
3585    while let Some(directory) = pending.pop() {
3586        let entries = match host.read_dir(&directory).await {
3587            Ok(entries) => entries,
3588            Err(error) if error.is_not_found() => return Ok(result),
3589            Err(error) => return Err(error.into_anyhow("reading Shell snapshot")),
3590        };
3591        for path in entries {
3592            match host.metadata(&path).await {
3593                Ok(metadata) if metadata.kind == super::FileKind::Directory => pending.push(path),
3594                Ok(metadata) if metadata.kind == super::FileKind::File => {
3595                    result.insert(
3596                        path.strip_prefix(root)
3597                            .context("Shell snapshot escaped root")?
3598                            .to_path_buf(),
3599                    );
3600                }
3601                Ok(_) => bail!(
3602                    "Shell snapshot contains unsupported symlink: {}",
3603                    path.display()
3604                ),
3605                Err(error) => return Err(error.into_anyhow("inspecting Shell snapshot")),
3606            }
3607        }
3608    }
3609    Ok(result)
3610}
3611
3612fn shell_platform_matches(
3613    platforms: Option<&[String]>,
3614    current: super::RuntimePlatform,
3615    context: &str,
3616) -> Result<bool> {
3617    let Some(platforms) = platforms else {
3618        return Ok(true);
3619    };
3620    if platforms.is_empty() {
3621        bail!(
3622            "{context} platforms must not be empty; expected `macos`, `linux`, `windows`, or `unix`"
3623        );
3624    }
3625    let mut matches = false;
3626    for platform in platforms {
3627        match platform.trim().to_ascii_lowercase().as_str() {
3628            "macos" => matches |= current == super::RuntimePlatform::Macos,
3629            "linux" => matches |= current == super::RuntimePlatform::Linux,
3630            "windows" => matches |= current == super::RuntimePlatform::Windows,
3631            "unix" => matches |= current.is_unix(),
3632            _ => bail!(
3633                "{context} has unsupported platform `{platform}`; expected `macos`, `linux`, `windows`, or `unix`"
3634            ),
3635        }
3636    }
3637    Ok(matches)
3638}
3639
3640fn normalize_shell_metadata_source(value: &str, runtime: LinkRuntime) -> Result<PathBuf> {
3641    let path = Path::new(value);
3642    if path.as_os_str().is_empty() || path.is_absolute() {
3643        bail!("source path must be a non-empty relative path");
3644    }
3645    let mut normalized = PathBuf::new();
3646    for component in path.components() {
3647        match component {
3648            std::path::Component::Normal(value) => normalized.push(value),
3649            std::path::Component::CurDir => {}
3650            _ => bail!("source path must be relative and must not contain '..'"),
3651        }
3652    }
3653    if normalized.file_name().and_then(|value| value.to_str()) == Some("shine.toml") {
3654        bail!("source path must not point to shine.toml");
3655    }
3656    let valid = match runtime {
3657        LinkRuntime::Native => is_native_shell_script(&normalized),
3658        LinkRuntime::Bun => matches!(
3659            normalized.extension().and_then(|value| value.to_str()),
3660            Some("ts" | "js" | "mts" | "mjs")
3661        ),
3662    };
3663    if !valid {
3664        bail!("source path extension is incompatible with the declared runtime");
3665    }
3666    Ok(normalized)
3667}
3668
3669fn shell_command_name(source: &Path, target: Option<&str>) -> Result<String> {
3670    let command = target
3671        .map(str::to_string)
3672        .unwrap_or_else(|| super::link_stem(source).to_string_lossy().to_string());
3673    let trimmed = command.trim();
3674    let path = Path::new(trimmed);
3675    if trimmed.is_empty() || matches!(trimmed, "." | "..") || path.components().count() != 1 {
3676        bail!("command name must be a plain filename");
3677    }
3678    Ok(trimmed.to_string())
3679}
3680
3681fn is_native_shell_script(path: &Path) -> bool {
3682    matches!(
3683        path.extension().and_then(|value| value.to_str()),
3684        Some("sh" | "ps1")
3685    )
3686}
3687
3688fn shell_source_matches(runtime: LinkRuntime, shell: ShellType, source: &Path) -> bool {
3689    if runtime == LinkRuntime::Bun {
3690        return true;
3691    }
3692    let is_powershell = source.extension().and_then(|value| value.to_str()) == Some("ps1");
3693    is_powershell == (shell == ShellType::PowerShell)
3694}
3695
3696fn shell_logical_path(path: &Path) -> String {
3697    path.components()
3698        .map(|component| component.as_os_str().to_string_lossy())
3699        .collect::<Vec<_>>()
3700        .join("/")
3701}
3702
3703fn shell_description(bytes: &[u8], runtime: LinkRuntime) -> Vec<String> {
3704    let Ok(text) = std::str::from_utf8(bytes) else {
3705        return Vec::new();
3706    };
3707    let leader = if runtime == LinkRuntime::Bun {
3708        "//"
3709    } else {
3710        "#"
3711    };
3712    let mut description = Vec::new();
3713    for line in text.lines() {
3714        if line.starts_with("#!") {
3715            continue;
3716        }
3717        let trimmed = line.trim_start();
3718        if let Some(value) = trimmed.strip_prefix(leader) {
3719            let value = value.strip_prefix(' ').unwrap_or(value);
3720            if !value.starts_with("shine-") {
3721                description.push(value.to_string());
3722            }
3723        } else if !trimmed.is_empty() {
3724            break;
3725        }
3726    }
3727    while description.last().is_some_and(String::is_empty) {
3728        description.pop();
3729    }
3730    description
3731}
3732
3733#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3734pub struct ShellTarget<'a> {
3735    pub category: &'a str,
3736    pub command: Option<&'a str>,
3737}
3738
3739pub fn parse_shell_lifecycle_target(target: &str) -> Result<ShellTarget<'_>> {
3740    let target = target.trim();
3741    if target.is_empty() {
3742        bail!("shell preset target must not be empty");
3743    }
3744    let mut parts = target.split('/');
3745    let category = parts.next().unwrap_or_default();
3746    let command = parts.next();
3747    if category.is_empty() || command.is_some_and(str::is_empty) || parts.next().is_some() {
3748        bail!(
3749            "invalid shell preset target `{target}`; expected <category> or <category>/<command>"
3750        );
3751    }
3752    Ok(ShellTarget { category, command })
3753}
3754
3755#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3756pub struct ShellManifestEntry {
3757    pub category: String,
3758    pub command: String,
3759    pub mode: ExternalShellMode,
3760    pub source_path: PathBuf,
3761    pub rendered_path: PathBuf,
3762    pub runtime: String,
3763    #[serde(default, skip_serializing_if = "Option::is_none")]
3764    pub bun_dependencies: Option<String>,
3765    #[serde(default, skip_serializing_if = "Option::is_none")]
3766    pub dependency_hash: Option<u64>,
3767    #[serde(default)]
3768    pub transforms: Vec<String>,
3769    #[serde(default)]
3770    pub env: Vec<String>,
3771    #[serde(default)]
3772    pub needs_source: bool,
3773    pub content_hash: u64,
3774}
3775
3776#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
3777pub struct ShellManifest {
3778    #[serde(default = "legacy_manifest_schema_version")]
3779    pub schema_version: u32,
3780    #[serde(default)]
3781    pub entries: Vec<ShellManifestEntry>,
3782}
3783
3784fn legacy_manifest_schema_version() -> u32 {
3785    0
3786}
3787
3788impl Default for ShellManifest {
3789    fn default() -> Self {
3790        Self {
3791            schema_version: SHELL_MANIFEST_SCHEMA_VERSION,
3792            entries: Vec::new(),
3793        }
3794    }
3795}
3796
3797impl ShellManifest {
3798    pub async fn load(
3799        host: &impl super::FileSystemObservationHost,
3800        shine_dir: &(impl AsRef<Path> + ?Sized),
3801    ) -> Result<Self> {
3802        load_shell_manifest_with_host(host, shine_dir.as_ref()).await
3803    }
3804
3805    pub async fn save(
3806        &self,
3807        host: &impl FileSystemHost,
3808        shine_dir: &(impl AsRef<Path> + ?Sized),
3809    ) -> Result<()> {
3810        save_shell_manifest_with_host(host, shine_dir.as_ref(), self).await
3811    }
3812
3813    pub fn find(&self, target: &str) -> Option<&ShellManifestEntry> {
3814        self.entries
3815            .iter()
3816            .find(|entry| canonical_target(entry) == target)
3817    }
3818
3819    pub fn replace_categories(
3820        &mut self,
3821        categories: &BTreeSet<String>,
3822        entries: Vec<ShellManifestEntry>,
3823    ) {
3824        self.entries
3825            .retain(|entry| !categories.contains(&entry.category));
3826        self.entries.extend(entries);
3827        self.entries.sort_by_key(canonical_target);
3828    }
3829
3830    pub fn remove_category(&mut self, category: &str) {
3831        self.entries.retain(|entry| entry.category != category);
3832    }
3833
3834    pub fn remove_target(&mut self, category: &str, command: &str) {
3835        self.entries
3836            .retain(|entry| entry.category != category || entry.command != command);
3837    }
3838
3839    pub fn replace_targets(
3840        &mut self,
3841        targets: &BTreeSet<String>,
3842        entries: Vec<ShellManifestEntry>,
3843    ) {
3844        self.entries
3845            .retain(|entry| !targets.contains(&canonical_target(entry)));
3846        self.entries.extend(entries);
3847        self.entries.sort_by_key(canonical_target);
3848    }
3849}
3850
3851fn canonical_target(entry: &ShellManifestEntry) -> String {
3852    format!("shell/{}/{}", entry.category, entry.command)
3853}
3854
3855#[cfg(test)]
3856mod tests {
3857    use super::*;
3858    use crate::runtime::{
3859        FileSystemObservationHost, InMemoryHost, PresetSnapshot, PresetSourceKind, RealHost,
3860        RuntimeContext, RuntimePlatform,
3861    };
3862
3863    #[tokio::test]
3864    async fn in_memory_shell_lifecycle_covers_cache_launcher_profile_and_receipt() {
3865        let host = InMemoryHost::new();
3866        let home_dir = std::env::temp_dir().join("shine-core-shell-lifecycle");
3867        let shine_dir = home_dir.join(".shine");
3868        let bin_dir = shine_dir.join("bin");
3869        let context = RuntimeContext::isolated(
3870            home_dir,
3871            shine_dir.clone(),
3872            shine_dir.join("presets"),
3873            bin_dir.clone(),
3874            RuntimePlatform::Linux,
3875        );
3876        let snapshot = PresetSnapshot::builder(PresetSourceKind::Embedded)
3877            .file(
3878                "shell/tools/shine.toml",
3879                b"description = \"tools\"\n[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\nneeds_source = true\n"
3880                    .to_vec(),
3881            )
3882            .file("shell/tools/tool.sh", b"#!/bin/sh\necho tool\n".to_vec())
3883            .build();
3884        let runtime = CoreRuntime::new(host.clone(), context, snapshot);
3885        let launcher_path = command_path_for_name(&bin_dir, std::ffi::OsStr::new("tool"));
3886
3887        let installed = runtime
3888            .install_shells(ShellLifecycleRequest {
3889                target: Some("tools/tool".to_string()),
3890                dry_run: false,
3891                force: false,
3892            })
3893            .await
3894            .unwrap();
3895        assert_eq!(installed.source_commands, vec!["tool"]);
3896        assert_eq!(installed.links.created.len(), 1);
3897        assert!(host.metadata(&launcher_path).await.is_ok());
3898        assert!(
3899            host.read(&shine_dir.join("shell-manifest.toml"))
3900                .await
3901                .unwrap()
3902                .starts_with(b"schema_version = 1")
3903        );
3904        assert_eq!(
3905            runtime.installed_shell_source_commands(None).await.unwrap(),
3906            vec!["tool"]
3907        );
3908
3909        let removed = runtime
3910            .uninstall_shells(ShellUninstallRequest {
3911                target: None,
3912                dry_run: false,
3913                purge: true,
3914            })
3915            .await
3916            .unwrap();
3917        assert_eq!(removed.links.removed.len(), 1);
3918        assert!(host.metadata(&launcher_path).await.is_err());
3919    }
3920
3921    #[cfg(unix)]
3922    #[tokio::test]
3923    async fn approved_uninstall_removes_receiptless_legacy_launcher_and_reconciles_profile() {
3924        let host = InMemoryHost::new();
3925        let home_dir = std::env::temp_dir().join("shine-core-legacy-shell-uninstall");
3926        let shine_dir = home_dir.join(".shine");
3927        let presets_dir = shine_dir.join("presets");
3928        let bin_dir = shine_dir.join("bin");
3929        let context = RuntimeContext::isolated(
3930            home_dir,
3931            shine_dir.clone(),
3932            presets_dir.clone(),
3933            bin_dir.clone(),
3934            RuntimePlatform::Linux,
3935        );
3936        let snapshot = PresetSnapshot::builder(PresetSourceKind::Embedded)
3937            .file(
3938                "shell/legacy/shine.toml",
3939                b"[[files]]\nsource = 'tool.sh'\ntarget = 'tool'\n[files.permissions]\nschema_version = 1\n"
3940                    .to_vec(),
3941            )
3942            .file("shell/legacy/tool.sh", b"#!/bin/sh\n".to_vec())
3943            .build();
3944        let runtime = CoreRuntime::new(host.clone(), context, snapshot);
3945        let legacy_source = presets_dir.join("shell/legacy/tool.sh");
3946        let launcher = command_path_for_name(&bin_dir, std::ffi::OsStr::new("tool"));
3947        host.symlink(&legacy_source, &launcher).await.unwrap();
3948        let managed_profile =
3949            super::super::managed_shell_profile_path(&shine_dir, runtime.context().shell);
3950        host.put_file(&managed_profile, b"legacy profile\n".to_vec());
3951
3952        let plan = runtime
3953            .plan_shells(super::super::ShellPlanRequest {
3954                operation: LifecycleOperation::Uninstall,
3955                target: Some("legacy".to_string()),
3956                force: false,
3957                purge: false,
3958                input_versions: super::super::PlanningInputVersions::default(),
3959            })
3960            .await
3961            .unwrap();
3962        assert!(plan.is_ready());
3963        assert!(plan.steps.iter().any(|step| {
3964            step.target == "shell/legacy/tool"
3965                && step.action == crate::plan::PlanActionV1::Remove
3966                && step
3967                    .diagnostic_codes
3968                    .contains(&"shell_legacy_launcher_remove_transaction".to_string())
3969        }));
3970        assert!(plan.steps.iter().any(|step| step.target == "shell/profile"));
3971
3972        let approval = PlanApprovalV1::for_reviewed_plan(&plan).unwrap();
3973        runtime
3974            .uninstall_shells_with_approval(
3975                ShellUninstallRequest {
3976                    target: Some("legacy".to_string()),
3977                    dry_run: false,
3978                    purge: false,
3979                },
3980                Some(&approval),
3981            )
3982            .await
3983            .unwrap();
3984
3985        assert!(host.metadata(&launcher).await.is_err());
3986        assert!(
3987            host.metadata(&shine_dir.join(super::super::SHELL_OPERATION_JOURNAL_FILE))
3988                .await
3989                .is_err()
3990        );
3991        assert_ne!(
3992            host.read(&managed_profile).await.unwrap(),
3993            b"legacy profile\n"
3994        );
3995        assert!(
3996            host.read(&shine_dir.join(SHELL_MANIFEST_FILE))
3997                .await
3998                .unwrap()
3999                .starts_with(b"schema_version = 1")
4000        );
4001    }
4002
4003    #[cfg(not(unix))]
4004    #[tokio::test]
4005    async fn approved_uninstall_removes_receiptless_legacy_windows_launcher_pair() {
4006        let host = InMemoryHost::new();
4007        let home_dir = std::env::temp_dir().join("shine-core-legacy-windows-shell-uninstall");
4008        let shine_dir = home_dir.join(".shine");
4009        let presets_dir = shine_dir.join("presets");
4010        let bin_dir = shine_dir.join("bin");
4011        let context = RuntimeContext::isolated(
4012            home_dir,
4013            shine_dir.clone(),
4014            presets_dir.clone(),
4015            bin_dir.clone(),
4016            RuntimePlatform::Windows,
4017        );
4018        let snapshot = PresetSnapshot::builder(PresetSourceKind::Embedded)
4019            .file(
4020                "shell/legacy/shine.toml",
4021                b"[[files]]\nsource = 'tool.ps1'\ntarget = 'tool'\n[files.permissions]\nschema_version = 1\n"
4022                    .to_vec(),
4023            )
4024            .file("shell/legacy/tool.ps1", b"Write-Output 'legacy'\n".to_vec())
4025            .build();
4026        let runtime = CoreRuntime::new(host.clone(), context, snapshot);
4027        let legacy_source = presets_dir.join("shell/legacy/tool.ps1");
4028        let launcher = command_path_for_name(&bin_dir, std::ffi::OsStr::new("tool"));
4029        let marker = format!(
4030            "# shine-managed\r\n# shine-target: {}\r\n",
4031            legacy_source.display()
4032        );
4033        host.put_file(&launcher, marker.as_bytes().to_vec());
4034        host.put_file(&launcher.with_extension("cmd"), marker.as_bytes().to_vec());
4035
4036        let plan = runtime
4037            .plan_shells(super::super::ShellPlanRequest {
4038                operation: LifecycleOperation::Uninstall,
4039                target: Some("legacy".to_string()),
4040                force: false,
4041                purge: false,
4042                input_versions: super::super::PlanningInputVersions::default(),
4043            })
4044            .await
4045            .unwrap();
4046        assert!(plan.is_ready());
4047        assert!(plan.steps.iter().any(|step| {
4048            step.target == "shell/legacy/tool"
4049                && step
4050                    .diagnostic_codes
4051                    .contains(&"shell_legacy_launcher_remove_transaction".to_string())
4052        }));
4053
4054        let approval = PlanApprovalV1::for_reviewed_plan(&plan).unwrap();
4055        let report = runtime
4056            .uninstall_shells_with_approval(
4057                ShellUninstallRequest {
4058                    target: Some("legacy".to_string()),
4059                    dry_run: false,
4060                    purge: false,
4061                },
4062                Some(&approval),
4063            )
4064            .await
4065            .unwrap();
4066
4067        assert_eq!(report.links.removed.len(), 2);
4068        assert!(host.metadata(&launcher).await.is_err());
4069        assert!(
4070            host.metadata(&launcher.with_extension("cmd"))
4071                .await
4072                .is_err()
4073        );
4074        assert!(
4075            host.metadata(&shine_dir.join(super::super::SHELL_OPERATION_JOURNAL_FILE))
4076                .await
4077                .is_err()
4078        );
4079    }
4080
4081    #[tokio::test]
4082    async fn legacy_and_future_versions_are_gated_in_core() {
4083        let root =
4084            std::env::temp_dir().join(format!("shine-shell-manifest-{}", uuid::Uuid::new_v4()));
4085        tokio::fs::create_dir_all(&root).await.unwrap();
4086        let path = root.join(SHELL_MANIFEST_FILE);
4087        tokio::fs::write(&path, "entries = []\n").await.unwrap();
4088
4089        let legacy = ShellManifest::load(&RealHost, &root).await.unwrap();
4090        assert_eq!(legacy.schema_version, SHELL_MANIFEST_SCHEMA_VERSION);
4091        assert!(
4092            !tokio::fs::read_to_string(&path)
4093                .await
4094                .unwrap()
4095                .contains("schema_version")
4096        );
4097        legacy.save(&RealHost, &root).await.unwrap();
4098        assert!(
4099            tokio::fs::read_to_string(&path)
4100                .await
4101                .unwrap()
4102                .contains("schema_version = 1")
4103        );
4104
4105        tokio::fs::write(&path, "schema_version = 2\nentries = []\n")
4106            .await
4107            .unwrap();
4108        assert!(
4109            ShellManifest::load(&RealHost, &root)
4110                .await
4111                .unwrap_err()
4112                .to_string()
4113                .contains("newer")
4114        );
4115        tokio::fs::remove_dir_all(root).await.unwrap();
4116    }
4117}