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