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