-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathshell.ts
More file actions
339 lines (307 loc) · 8.3 KB
/
Copy pathshell.ts
File metadata and controls
339 lines (307 loc) · 8.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
/**
* Shell detection and configuration utilities.
*
* Provides functions for detecting the current shell, finding config files,
* and modifying PATH in shell configuration.
*/
import { existsSync } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { basename, delimiter, join } from "node:path";
import { logger } from "./logger.js";
import { whichSync } from "./which.js";
const log = logger.withTag("shell");
/** Supported shell types */
export type ShellType = "bash" | "zsh" | "fish" | "sh" | "ash" | "unknown";
/** Result of shell detection */
export type ShellInfo = {
/** Detected shell type */
type: ShellType;
/** Display name for the shell (e.g. "xonsh", "bash"). Derived from $SHELL basename. */
name: string;
/** Path to shell config file, if found */
configFile: string | null;
/** All candidate config files for this shell */
configCandidates: string[];
};
/** Result of PATH modification */
export type PathModificationResult = {
/** Whether modification was performed */
modified: boolean;
/** The config file that was modified, if any */
configFile: string | null;
/** Message describing what happened */
message: string;
/** Command to add manually if auto-modification failed */
manualCommand: string | null;
};
/**
* Detect the current shell from SHELL environment variable.
*/
export function detectShellType(shellPath: string | undefined): ShellType {
if (!shellPath) {
return "unknown";
}
const shellName = basename(shellPath).toLowerCase();
switch (shellName) {
case "bash":
return "bash";
case "zsh":
return "zsh";
case "fish":
return "fish";
case "sh":
return "sh";
case "ash":
return "ash";
default:
return "unknown";
}
}
/**
* Get candidate config files for a shell type.
*
* @param shellType - The shell type
* @param homeDir - User's home directory
* @param xdgConfigHome - XDG_CONFIG_HOME or default
*/
export function getConfigCandidates(
shellType: ShellType,
homeDir: string,
xdgConfigHome?: string
): string[] {
const xdg = xdgConfigHome || join(homeDir, ".config");
switch (shellType) {
case "fish":
return [join(xdg, "fish", "config.fish")];
case "zsh":
return [
join(homeDir, ".zshrc"),
join(homeDir, ".zshenv"),
join(xdg, "zsh", ".zshrc"),
join(xdg, "zsh", ".zshenv"),
];
case "bash":
return [
join(homeDir, ".bashrc"),
join(homeDir, ".bash_profile"),
join(homeDir, ".profile"),
join(xdg, "bash", ".bashrc"),
join(xdg, "bash", ".bash_profile"),
];
case "sh":
case "ash":
return [join(homeDir, ".profile")];
default:
// Fall back to common files for unknown shells
return [
join(homeDir, ".bashrc"),
join(homeDir, ".bash_profile"),
join(homeDir, ".profile"),
];
}
}
/**
* Find the first existing config file from candidates.
*/
export function findExistingConfigFile(candidates: string[]): string | null {
for (const file of candidates) {
if (existsSync(file)) {
return file;
}
}
return null;
}
/**
* Detect shell and find config file.
*/
export function detectShell(
shellPath: string | undefined,
homeDir: string,
xdgConfigHome?: string
): ShellInfo {
const type = detectShellType(shellPath);
const name = shellPath ? basename(shellPath) : type;
const configCandidates = getConfigCandidates(type, homeDir, xdgConfigHome);
const configFile = findExistingConfigFile(configCandidates);
return {
type,
name,
configFile,
configCandidates,
};
}
/**
* Generate the PATH export command for a shell.
*/
export function getPathCommand(
shellType: ShellType,
directory: string
): string {
if (shellType === "fish") {
return `fish_add_path "${directory}"`;
}
return `export PATH="${directory}:$PATH"`;
}
/**
* Check if a directory is in PATH.
*/
export function isInPath(
directory: string,
pathEnv: string | undefined
): boolean {
if (!pathEnv) {
return false;
}
const paths = pathEnv.split(delimiter);
return paths.includes(directory);
}
/**
* Append a shell config line to a config file with idempotency.
*
* Shared implementation for `addToPath` and `addToFpath`. Handles file
* creation, duplicate detection, newline-aware appending, and error fallback.
*
* @param configFile - Path to the shell config file
* @param directory - Directory being configured (used for duplicate check)
* @param command - The full shell command to append (e.g. `export PATH="..."`)
* @param label - Human-readable label for messages (e.g. "PATH", "fpath")
*/
async function addToShellConfig(
configFile: string,
directory: string,
command: string,
label: string
): Promise<PathModificationResult> {
const exists = await access(configFile).then(
() => true,
() => false
);
if (!exists) {
try {
await writeFile(configFile, `# sentry\n${command}\n`, "utf-8");
return {
modified: true,
configFile,
message: `Created ${configFile} with ${label} configuration`,
manualCommand: null,
};
} catch {
return {
modified: false,
configFile: null,
message: `Could not create ${configFile}`,
manualCommand: command,
};
}
}
const content = await readFile(configFile, "utf-8");
if (content.includes(command) || content.includes(`"${directory}"`)) {
return {
modified: false,
configFile,
message: `${label} already configured in ${configFile}`,
manualCommand: null,
};
}
try {
const newContent = content.endsWith("\n")
? `${content}\n# sentry\n${command}\n`
: `${content}\n\n# sentry\n${command}\n`;
await writeFile(configFile, newContent, "utf-8");
return {
modified: true,
configFile,
message: `Added sentry ${label} in ${configFile}`,
manualCommand: null,
};
} catch {
return {
modified: false,
configFile: null,
message: `Could not write to ${configFile}`,
manualCommand: command,
};
}
}
export function addToPath(
configFile: string,
directory: string,
shellType: ShellType
): Promise<PathModificationResult> {
return addToShellConfig(
configFile,
directory,
getPathCommand(shellType, directory),
"PATH"
);
}
/**
* Generate the fpath command for zsh completion directory.
*/
export function getFpathCommand(directory: string): string {
return `fpath=("${directory}" $fpath)`;
}
/**
* Add a directory to zsh's fpath in a shell config file.
*
* @param configFile - Path to the zsh config file (e.g. ~/.zshrc)
* @param directory - Directory to add to fpath
*/
export function addToFpath(
configFile: string,
directory: string
): Promise<PathModificationResult> {
return addToShellConfig(
configFile,
directory,
getFpathCommand(directory),
"fpath"
);
}
/**
* Add to GitHub Actions PATH if running in CI.
*/
export async function addToGitHubPath(
directory: string,
env: NodeJS.ProcessEnv
): Promise<boolean> {
if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_PATH) {
return false;
}
try {
let content = "";
try {
content = await readFile(env.GITHUB_PATH, "utf-8");
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
log.debug(`Failed to read GITHUB_PATH (${code}):`, error);
return false;
}
// File doesn't exist yet — start with empty content
}
if (!content.includes(directory)) {
const newContent = content.endsWith("\n")
? `${content}${directory}\n`
: `${content}\n${directory}\n`;
await writeFile(env.GITHUB_PATH, newContent, "utf-8");
}
return true;
} catch (error) {
log.debug("Failed to update GITHUB_PATH:", error);
return false;
}
}
/**
* Check if bash is available on the system.
*
* Uses `Bun.which` to search PATH for a bash executable.
* Useful as a fallback for unsupported shells — many custom shells
* (xonsh, nushell, etc.) support bash completions.
*
* @param pathEnv - Override PATH for testing. Defaults to the process PATH.
*/
export function isBashAvailable(pathEnv?: string): boolean {
const opts = pathEnv !== undefined ? { PATH: pathEnv } : undefined;
return whichSync("bash", opts) !== null;
}