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