| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "runtime" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/config" |
| 9 | "reasonix/internal/control" |
| 10 | "reasonix/internal/sandbox" |
| 11 | ) |
| 12 | |
| 13 | // Shell support discovery and repair guidance. The Git.Git winget manifest may |
| 14 | // elevate even with user scope, so Reasonix never launches that installer and |
| 15 | // Windows exposes only the official manual link. |
| 16 | |
| 17 | // shellInstallActionGitForWindows is the single install action id hosts may |
| 18 | // request today; unknown ids are rejected as errors rather than no-ops so a |
| 19 | // frontend typo cannot silently do nothing. |
| 20 | const shellInstallActionGitForWindows = "git-for-windows" |
| 21 | |
| 22 | // GitForWindowsManualURL is the official download page handed to Windows users. |
| 23 | const GitForWindowsManualURL = "https://git-scm.com/download/win" |
| 24 | |
| 25 | // Structured outcomes retained by the desktop bridge contract. Invalid action ids remain |
| 26 | // errors; supported Windows requests always return manual_required. |
| 27 | const ( |
| 28 | shellInstallStatusManualRequired = "manual_required" |
| 29 | shellInstallStatusUnsupported = "unsupported_platform" |
| 30 | ) |
| 31 | |
| 32 | // ShellInstallResult is the structured outcome of InstallShellSupport. |
| 33 | type ShellInstallResult struct { |
| 34 | Status string `json:"status"` |
| 35 | Path string `json:"path,omitempty"` |
| 36 | Reason string `json:"reason,omitempty"` |
| 37 | ManualURL string `json:"manualUrl,omitempty"` |
| 38 | } |
| 39 | |
| 40 | // ShellCapabilityView is one discovered interpreter for the settings surface: |
| 41 | // whether it is usable, where it lives, how it was found, and why not when |
| 42 | // unavailable. Purely informational — resolution goes through ResolveShell. |
| 43 | type ShellCapabilityView struct { |
| 44 | ID string `json:"id"` |
| 45 | Variant string `json:"variant,omitempty"` |
| 46 | Available bool `json:"available"` |
| 47 | Path string `json:"path,omitempty"` |
| 48 | Source string `json:"source,omitempty"` |
| 49 | Reason string `json:"reason,omitempty"` |
| 50 | } |
| 51 | |
| 52 | // ShellInstallActionView is retained in the bridge shape for older desktop |
| 53 | // clients. Current settings views leave it nil because Git Bash is no longer a |
| 54 | // Windows Agent runtime or a Shell settings repair target. |
| 55 | type ShellInstallActionView struct { |
| 56 | ID string `json:"id"` |
| 57 | Mode string `json:"mode"` |
| 58 | Available bool `json:"available"` |
| 59 | ManualURL string `json:"manualUrl,omitempty"` |
| 60 | } |
| 61 | |
| 62 | // SandboxView is the Settings panel's sandbox surface. The shell fields |
| 63 | // separate three states: the configured preference (Shell), what the live |
| 64 | // controller bound (EffectiveShell), and what a reload would pick now |
| 65 | // (ResolvedShell) — ShellReloadRequired marks the divergence. |
| 66 | type SandboxView struct { |
| 67 | Bash string `json:"bash"` |
| 68 | Network bool `json:"network"` |
| 69 | WorkspaceRoot string `json:"workspaceRoot"` |
| 70 | AllowWrite []string `json:"allowWrite"` |
| 71 | EffectiveWorkspaceRoot string `json:"effectiveWorkspaceRoot"` |
| 72 | EffectiveWriteRoots []string `json:"effectiveWriteRoots"` |
| 73 | Shell string `json:"shell"` // [tools.shell] prefer: auto|bash|powershell|pwsh |
| 74 | EffectiveShell string `json:"effectiveShell,omitempty"` |
| 75 | ResolvedShell string `json:"resolvedShell,omitempty"` |
| 76 | ShellReloadRequired bool `json:"shellReloadRequired"` |
| 77 | ShellCapabilities []ShellCapabilityView `json:"shellCapabilities"` |
| 78 | GitCapability *ShellCapabilityView `json:"gitCapability,omitempty"` |
| 79 | ShellInstallAction *ShellInstallActionView `json:"shellInstallAction,omitempty"` |
| 80 | ShellRepairGuidance *RepairGuidanceView `json:"shellRepairGuidance,omitempty"` |
| 81 | GitRepairGuidance *RepairGuidanceView `json:"gitRepairGuidance,omitempty"` |
| 82 | } |
| 83 | |
| 84 | // InstallShellSupport retains the existing bridge method while enforcing the |
| 85 | // manual-only policy. It never probes for or launches a package manager. |
| 86 | func (a *App) InstallShellSupport(id string) (ShellInstallResult, error) { |
| 87 | return installShellSupportForGOOS(runtime.GOOS, id) |
| 88 | } |
| 89 | |
| 90 | func installShellSupportForGOOS(goos, id string) (ShellInstallResult, error) { |
| 91 | if strings.TrimSpace(id) != shellInstallActionGitForWindows { |
| 92 | return ShellInstallResult{}, fmt.Errorf("unknown shell support action %q", id) |
| 93 | } |
| 94 | if goos != "windows" { |
| 95 | return ShellInstallResult{ |
| 96 | Status: shellInstallStatusUnsupported, |
| 97 | Reason: "shell helper install is only available on Windows", |
| 98 | }, nil |
| 99 | } |
| 100 | return ShellInstallResult{ |
| 101 | Status: shellInstallStatusManualRequired, |
| 102 | Reason: "automatic installation is disabled because Git for Windows cannot reliably honor user scope", |
| 103 | ManualURL: GitForWindowsManualURL, |
| 104 | }, nil |
| 105 | } |
| 106 | |
| 107 | // CancelShellInstall is retained as an idempotent compatibility no-op. No |
| 108 | // installer can be running under the manual-only policy. |
| 109 | func (a *App) CancelShellInstall() {} |
| 110 | |
| 111 | // SetShellPreference updates only [tools.shell] prefer, preserving the |
| 112 | // configured shell path and every other sandbox field, so switching the |
| 113 | // interpreter never rewrites unrelated settings. |
| 114 | func (a *App) SetShellPreference(prefer string) error { |
| 115 | prefer = strings.TrimSpace(prefer) |
| 116 | switch strings.ToLower(prefer) { |
| 117 | case "", "auto", "bash", "powershell", "pwsh": |
| 118 | default: |
| 119 | return fmt.Errorf("invalid shell preference %q (use auto, bash, powershell, or pwsh)", prefer) |
| 120 | } |
| 121 | return a.applyConfigChange(func(c *config.Config) error { |
| 122 | c.Tools.Shell.Prefer = prefer |
| 123 | return nil |
| 124 | }) |
| 125 | } |
| 126 | |
| 127 | // shellInstallActionViewForGOOS exposes the official manual link on Windows. |
| 128 | // Other platforms use their native detect-and-guide policy. |
| 129 | func shellInstallActionViewForGOOS(goos string) *ShellInstallActionView { |
| 130 | if goos != "windows" { |
| 131 | return nil |
| 132 | } |
| 133 | return &ShellInstallActionView{ |
| 134 | ID: shellInstallActionGitForWindows, |
| 135 | Mode: "manual", |
| 136 | Available: false, |
| 137 | ManualURL: GitForWindowsManualURL, |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // sandboxViewFor builds the Settings panel's SandboxView shell surface. |
| 142 | // effectiveShell is what the live controller actually bound at build time; |
| 143 | // resolvedShell is what a reload would pick from the current config and |
| 144 | // machine state. They can diverge after a manual repair or an unreloaded config |
| 145 | // edit, so the surface shows both plus an explicit reload button. |
| 146 | func (a *App) sandboxViewFor(cfg *config.Config, ctrl control.SessionAPI, writeRoots []string, effectiveWorkspaceRoot string) SandboxView { |
| 147 | shell := cfg.Tools.Shell.Prefer |
| 148 | if shell == "" { |
| 149 | shell = "auto" |
| 150 | } |
| 151 | resolved := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, nil) |
| 152 | bound := resolved |
| 153 | if ctrl != nil { |
| 154 | if sh := ctrl.BoundShell(); sh.Path != "" { |
| 155 | bound = sh |
| 156 | } |
| 157 | } |
| 158 | return SandboxView{ |
| 159 | Bash: cfg.BashMode(), Network: cfg.Sandbox.Network, |
| 160 | WorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite), |
| 161 | EffectiveWorkspaceRoot: effectiveWorkspaceRoot, EffectiveWriteRoots: nonNil(writeRoots), |
| 162 | Shell: shell, EffectiveShell: sandboxEffectiveShellView(bound), |
| 163 | ResolvedShell: sandboxEffectiveShellView(resolved), |
| 164 | ShellReloadRequired: bound != resolved, |
| 165 | ShellCapabilities: sandboxCapabilityViews(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path), |
| 166 | GitCapability: gitCapabilityView(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path), |
| 167 | ShellRepairGuidance: shellRepairGuidanceForGOOS(runtime.GOOS), |
| 168 | GitRepairGuidance: gitRepairGuidanceForGOOS(runtime.GOOS), |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func sandboxEffectiveShellView(sh sandbox.Shell) string { |
| 173 | if sh.Kind == sandbox.ShellZsh { |
| 174 | return "zsh" |
| 175 | } |
| 176 | if sh.Kind == sandbox.ShellSh { |
| 177 | return "sh" |
| 178 | } |
| 179 | if sh.Kind == sandbox.ShellPowerShell { |
| 180 | if sh.SupportsChaining() { |
| 181 | return "pwsh" |
| 182 | } |
| 183 | return "powershell" |
| 184 | } |
| 185 | path := strings.ToLower(strings.ReplaceAll(sh.Path, "\\", "/")) |
| 186 | if strings.Contains(path, "/git/") && strings.HasSuffix(path, "bash.exe") { |
| 187 | return "git-bash" |
| 188 | } |
| 189 | return "bash" |
| 190 | } |
| 191 | |
| 192 | func gitCapabilityView(prefer, configPath string) *ShellCapabilityView { |
| 193 | capability := sandbox.GitCapabilityForConfig(prefer, configPath) |
| 194 | return &ShellCapabilityView{ |
| 195 | ID: capability.ID, Available: capability.Available, Path: capability.Path, |
| 196 | Source: capability.Source, Reason: capability.Reason, |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // sandboxCapabilityViews projects the discovered shell inventory for the |
| 201 | // settings surface. The slice is never nil so the desktop binding always |
| 202 | // encodes a JSON array, not null. |
| 203 | func sandboxCapabilityViews(prefer, configPath string) []ShellCapabilityView { |
| 204 | caps := sandbox.ShellCapabilitiesForConfig(prefer, configPath) |
| 205 | out := make([]ShellCapabilityView, 0, len(caps)) |
| 206 | for _, cap := range caps { |
| 207 | out = append(out, ShellCapabilityView{ |
| 208 | ID: cap.ID, |
| 209 | Variant: cap.Variant, |
| 210 | Available: cap.Available, |
| 211 | Path: cap.Path, |
| 212 | Source: cap.Source, |
| 213 | Reason: cap.Reason, |
| 214 | }) |
| 215 | } |
| 216 | return out |
| 217 | } |
| 218 |