@nowline/cli 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +32 -9
  2. package/dist/cli/args.d.ts +20 -4
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +20 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +31 -3
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/options.d.ts +74 -0
  10. package/dist/cli/options.d.ts.map +1 -0
  11. package/dist/cli/options.js +209 -0
  12. package/dist/cli/options.js.map +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +2 -3
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/mcp-install.d.ts +18 -0
  17. package/dist/commands/mcp-install.d.ts.map +1 -0
  18. package/dist/commands/mcp-install.js +151 -0
  19. package/dist/commands/mcp-install.js.map +1 -0
  20. package/dist/commands/mcp.d.ts.map +1 -1
  21. package/dist/commands/mcp.js +7 -12
  22. package/dist/commands/mcp.js.map +1 -1
  23. package/dist/commands/render.d.ts +7 -0
  24. package/dist/commands/render.d.ts.map +1 -1
  25. package/dist/commands/render.js +161 -155
  26. package/dist/commands/render.js.map +1 -1
  27. package/dist/commands/serve.d.ts.map +1 -1
  28. package/dist/commands/serve.js +87 -60
  29. package/dist/commands/serve.js.map +1 -1
  30. package/dist/diagnostics/adapt.d.ts +12 -1
  31. package/dist/diagnostics/adapt.d.ts.map +1 -1
  32. package/dist/diagnostics/adapt.js +53 -1
  33. package/dist/diagnostics/adapt.js.map +1 -1
  34. package/dist/diagnostics/model.d.ts +1 -1
  35. package/dist/generated/version.d.ts +1 -1
  36. package/dist/generated/version.d.ts.map +1 -1
  37. package/dist/generated/version.js +2 -2
  38. package/dist/generated/version.js.map +1 -1
  39. package/dist/i18n/locale.d.ts.map +1 -1
  40. package/dist/i18n/locale.js +2 -5
  41. package/dist/i18n/locale.js.map +1 -1
  42. package/dist/index.js +4 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/io/config.d.ts +1 -1
  45. package/dist/io/config.d.ts.map +1 -1
  46. package/dist/io/config.js +1 -1
  47. package/dist/io/config.js.map +1 -1
  48. package/dist/io/log.d.ts +17 -0
  49. package/dist/io/log.d.ts.map +1 -0
  50. package/dist/io/log.js +16 -0
  51. package/dist/io/log.js.map +1 -0
  52. package/dist/mcp-install/clients.d.ts +58 -0
  53. package/dist/mcp-install/clients.d.ts.map +1 -0
  54. package/dist/mcp-install/clients.js +304 -0
  55. package/dist/mcp-install/clients.js.map +1 -0
  56. package/dist/mcp-install/host.d.ts +29 -0
  57. package/dist/mcp-install/host.d.ts.map +1 -0
  58. package/dist/mcp-install/host.js +72 -0
  59. package/dist/mcp-install/host.js.map +1 -0
  60. package/dist/mcp-install/json-config.d.ts +29 -0
  61. package/dist/mcp-install/json-config.d.ts.map +1 -0
  62. package/dist/mcp-install/json-config.js +139 -0
  63. package/dist/mcp-install/json-config.js.map +1 -0
  64. package/dist/mcp-install/server-command.d.ts +26 -0
  65. package/dist/mcp-install/server-command.d.ts.map +1 -0
  66. package/dist/mcp-install/server-command.js +52 -0
  67. package/dist/mcp-install/server-command.js.map +1 -0
  68. package/man/fr/nowline.1 +248 -19
  69. package/man/fr/nowline.5 +819 -45
  70. package/man/nowline.1 +193 -18
  71. package/man/nowline.5 +764 -36
  72. package/package.json +19 -19
  73. package/src/cli/args.ts +55 -5
  74. package/src/cli/help.ts +31 -3
  75. package/src/cli/options.ts +286 -0
  76. package/src/commands/init.ts +2 -3
  77. package/src/commands/mcp-install.ts +211 -0
  78. package/src/commands/mcp.ts +10 -14
  79. package/src/commands/render.ts +198 -195
  80. package/src/commands/serve.ts +102 -74
  81. package/src/diagnostics/adapt.ts +62 -0
  82. package/src/diagnostics/model.ts +1 -1
  83. package/src/generated/version.ts +2 -2
  84. package/src/i18n/locale.ts +2 -6
  85. package/src/index.ts +3 -0
  86. package/src/io/config.ts +0 -1
  87. package/src/io/log.ts +33 -0
  88. package/src/mcp-install/clients.ts +443 -0
  89. package/src/mcp-install/host.ts +106 -0
  90. package/src/mcp-install/json-config.ts +179 -0
  91. package/src/mcp-install/server-command.ts +75 -0
@@ -0,0 +1,443 @@
1
+ import * as path from 'node:path';
2
+ import type { HostEnv } from './host.js';
3
+ import type { ServerEntry } from './server-command.js';
4
+
5
+ export type Scope = 'user' | 'project';
6
+
7
+ /** The name every harness registers the server under, so tools surface as `mcp__nowline__*`. */
8
+ export const SERVER_NAME = 'nowline';
9
+
10
+ /** A JSON / JSONC config file we edit in place. */
11
+ export interface JsonTarget {
12
+ kind: 'json';
13
+ path: string;
14
+ /** Where the entry goes and what it holds, decided after reading the existing document. */
15
+ place(entry: ServerEntry, doc: unknown): { keyPath: string[]; value: unknown };
16
+ /** Top-level keys a newly created file starts with. */
17
+ seed?: Record<string, unknown>;
18
+ /** Shown after a successful install. */
19
+ hint?: string;
20
+ }
21
+
22
+ /**
23
+ * A harness whose config we can't safely edit ourselves (Claude Code's
24
+ * ~/.claude.json, Codex's TOML): run its own `mcp add`, else print it.
25
+ */
26
+ export interface CliTarget {
27
+ kind: 'cli';
28
+ binary: string;
29
+ add: string[];
30
+ remove: string[];
31
+ fallback: PrintTarget;
32
+ }
33
+
34
+ /** Paste-ready instructions, for configs we neither edit nor reach through a CLI. */
35
+ export interface PrintTarget {
36
+ kind: 'print';
37
+ install: { instructions: string; snippet: string };
38
+ uninstall: { instructions: string; snippet?: string };
39
+ }
40
+
41
+ export type Target = JsonTarget | CliTarget | PrintTarget;
42
+
43
+ export interface ClientDescriptor {
44
+ id: string;
45
+ name: string;
46
+ scopes: readonly Scope[];
47
+ /** True when the harness looks installed (its CLI is on PATH or its config dir exists). */
48
+ detect(host: HostEnv): boolean;
49
+ target(scope: Scope, entry: ServerEntry, host: HostEnv): Target;
50
+ }
51
+
52
+ function xdgConfig(host: HostEnv): string {
53
+ return host.env.XDG_CONFIG_HOME || path.join(host.home, '.config');
54
+ }
55
+
56
+ function appData(host: HostEnv): string {
57
+ return host.env.APPDATA || path.join(host.home, 'AppData', 'Roaming');
58
+ }
59
+
60
+ /** Per-OS application config directory (Electron apps: Claude Desktop, VS Code). */
61
+ function appConfigDir(host: HostEnv, name: string): string {
62
+ if (host.platform === 'darwin') {
63
+ return path.join(host.home, 'Library', 'Application Support', name);
64
+ }
65
+ if (host.platform === 'win32') return path.join(appData(host), name);
66
+ return path.join(xdgConfig(host), name);
67
+ }
68
+
69
+ function onPathOrExists(binaries: readonly string[], dirs: (host: HostEnv) => string[]) {
70
+ return (host: HostEnv): boolean =>
71
+ binaries.some((binary) => host.findAllOnPath(binary).length > 0) ||
72
+ dirs(host).some((dir) => host.exists(dir));
73
+ }
74
+
75
+ function plain(entry: ServerEntry) {
76
+ return { command: entry.command, args: entry.args };
77
+ }
78
+
79
+ function stdioTyped(entry: ServerEntry) {
80
+ return { type: 'stdio', command: entry.command, args: entry.args };
81
+ }
82
+
83
+ function underKey(
84
+ filePath: string,
85
+ key: string,
86
+ shape: (entry: ServerEntry) => unknown,
87
+ extra: Partial<JsonTarget> = {},
88
+ ): JsonTarget {
89
+ return {
90
+ kind: 'json',
91
+ path: filePath,
92
+ place: (entry) => ({ keyPath: [key, SERVER_NAME], value: shape(entry) }),
93
+ ...extra,
94
+ };
95
+ }
96
+
97
+ /** POSIX-shell quoting for printed commands; bare when nothing needs escaping. */
98
+ export function shellQuote(arg: string): string {
99
+ return /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
100
+ }
101
+
102
+ export function commandLine(argv: readonly string[]): string {
103
+ return argv.map(shellQuote).join(' ');
104
+ }
105
+
106
+ function cliTarget(
107
+ binary: string,
108
+ add: string[],
109
+ remove: string[],
110
+ fallback?: PrintTarget,
111
+ ): CliTarget {
112
+ return {
113
+ kind: 'cli',
114
+ binary,
115
+ add,
116
+ remove,
117
+ fallback: fallback ?? {
118
+ kind: 'print',
119
+ install: {
120
+ instructions: `${binary} is not on PATH; once it is, run:`,
121
+ snippet: commandLine([binary, ...add]),
122
+ },
123
+ uninstall: {
124
+ instructions: `${binary} is not on PATH; once it is, run:`,
125
+ snippet: commandLine([binary, ...remove]),
126
+ },
127
+ },
128
+ };
129
+ }
130
+
131
+ /** Prefer an existing `.jsonc` sibling, since OpenCode reads either. */
132
+ function jsonOrJsonc(host: HostEnv, base: string): string {
133
+ return host.exists(`${base}.jsonc`) ? `${base}.jsonc` : `${base}.json`;
134
+ }
135
+
136
+ function isRecord(value: unknown): value is Record<string, unknown> {
137
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
138
+ }
139
+
140
+ export const CLIENTS: readonly ClientDescriptor[] = [
141
+ {
142
+ id: 'claude-code',
143
+ name: 'Claude Code',
144
+ scopes: ['user', 'project'],
145
+ detect: onPathOrExists(['claude'], (h) => [path.join(h.home, '.claude')]),
146
+ target: (scope, entry, host) => {
147
+ if (scope === 'project') {
148
+ return underKey(path.join(host.cwd, '.mcp.json'), 'mcpServers', stdioTyped);
149
+ }
150
+ return cliTarget(
151
+ 'claude',
152
+ ['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', entry.command, ...entry.args],
153
+ ['mcp', 'remove', '--scope', 'user', SERVER_NAME],
154
+ );
155
+ },
156
+ },
157
+ {
158
+ id: 'claude-desktop',
159
+ name: 'Claude Desktop',
160
+ scopes: ['user'],
161
+ detect: onPathOrExists([], (h) => [appConfigDir(h, 'Claude')]),
162
+ target: (_scope, _entry, host) =>
163
+ underKey(
164
+ path.join(appConfigDir(host, 'Claude'), 'claude_desktop_config.json'),
165
+ 'mcpServers',
166
+ plain,
167
+ { hint: 'restart Claude Desktop to load it' },
168
+ ),
169
+ },
170
+ {
171
+ id: 'cursor',
172
+ name: 'Cursor',
173
+ scopes: ['user', 'project'],
174
+ detect: onPathOrExists(['cursor'], (h) => [path.join(h.home, '.cursor')]),
175
+ target: (scope, _entry, host) =>
176
+ underKey(
177
+ path.join(scope === 'user' ? host.home : host.cwd, '.cursor', 'mcp.json'),
178
+ 'mcpServers',
179
+ plain,
180
+ ),
181
+ },
182
+ {
183
+ id: 'vscode',
184
+ name: 'VS Code',
185
+ scopes: ['user', 'project'],
186
+ detect: onPathOrExists(['code'], (h) => [path.join(appConfigDir(h, 'Code'), 'User')]),
187
+ target: (scope, _entry, host) =>
188
+ underKey(
189
+ scope === 'user'
190
+ ? path.join(appConfigDir(host, 'Code'), 'User', 'mcp.json')
191
+ : path.join(host.cwd, '.vscode', 'mcp.json'),
192
+ 'servers',
193
+ stdioTyped,
194
+ ),
195
+ },
196
+ {
197
+ id: 'codex',
198
+ name: 'Codex CLI',
199
+ scopes: ['user'],
200
+ detect: onPathOrExists(['codex'], (h) => [h.env.CODEX_HOME || path.join(h.home, '.codex')]),
201
+ target: (_scope, entry, host) => {
202
+ const configPath = path.join(
203
+ host.env.CODEX_HOME || path.join(host.home, '.codex'),
204
+ 'config.toml',
205
+ );
206
+ // JSON string and array literals are valid TOML basic strings and arrays.
207
+ const toml = [
208
+ `[mcp_servers.${SERVER_NAME}]`,
209
+ `command = ${JSON.stringify(entry.command)}`,
210
+ `args = ${JSON.stringify(entry.args)}`,
211
+ ].join('\n');
212
+ return cliTarget(
213
+ 'codex',
214
+ ['mcp', 'add', SERVER_NAME, '--', entry.command, ...entry.args],
215
+ ['mcp', 'remove', SERVER_NAME],
216
+ {
217
+ kind: 'print',
218
+ install: {
219
+ instructions: `codex is not on PATH; add this to ${configPath}:`,
220
+ snippet: toml,
221
+ },
222
+ uninstall: {
223
+ instructions: `codex is not on PATH; delete the [mcp_servers.${SERVER_NAME}] table from ${configPath}.`,
224
+ },
225
+ },
226
+ );
227
+ },
228
+ },
229
+ {
230
+ id: 'gemini',
231
+ name: 'Gemini CLI',
232
+ scopes: ['user', 'project'],
233
+ // Antigravity also lives under ~/.gemini, so look for Gemini's own file.
234
+ detect: onPathOrExists(['gemini'], (h) => [path.join(h.home, '.gemini', 'settings.json')]),
235
+ target: (scope, _entry, host) =>
236
+ underKey(
237
+ path.join(scope === 'user' ? host.home : host.cwd, '.gemini', 'settings.json'),
238
+ 'mcpServers',
239
+ plain,
240
+ ),
241
+ },
242
+ {
243
+ id: 'antigravity',
244
+ name: 'Antigravity',
245
+ scopes: ['user', 'project'],
246
+ detect: onPathOrExists(['agy', 'antigravity'], (h) => [
247
+ path.join(h.home, '.gemini', 'config'),
248
+ path.join(h.home, '.gemini', 'antigravity-cli'),
249
+ ]),
250
+ // The Antigravity app, IDE and CLI share one file.
251
+ target: (scope, _entry, host) =>
252
+ underKey(
253
+ scope === 'user'
254
+ ? path.join(host.home, '.gemini', 'config', 'mcp_config.json')
255
+ : path.join(host.cwd, '.agents', 'mcp_config.json'),
256
+ 'mcpServers',
257
+ plain,
258
+ ),
259
+ },
260
+ {
261
+ id: 'muse',
262
+ name: 'Muse Code',
263
+ scopes: ['user'],
264
+ detect: onPathOrExists(['muse'], (h) => [path.join(h.home, '.config', 'muse')]),
265
+ target: (_scope, _entry, host) =>
266
+ underKey(
267
+ path.join(host.home, '.config', 'muse', 'settings.json'),
268
+ 'mcp_servers',
269
+ // `optional`: a missing nowline warns instead of aborting every Muse run.
270
+ (entry) => ({
271
+ transport: 'stdio',
272
+ command: entry.command,
273
+ args: entry.args,
274
+ mode: 'optional',
275
+ }),
276
+ // Muse refuses to start on a settings file without it.
277
+ { seed: { schema_version: 1 } },
278
+ ),
279
+ },
280
+ {
281
+ id: 'opencode',
282
+ name: 'OpenCode',
283
+ scopes: ['user', 'project'],
284
+ detect: onPathOrExists(['opencode'], (h) => [path.join(xdgConfig(h), 'opencode')]),
285
+ target: (scope, _entry, host) => ({
286
+ kind: 'json',
287
+ path: jsonOrJsonc(
288
+ host,
289
+ scope === 'user'
290
+ ? path.join(xdgConfig(host), 'opencode', 'opencode')
291
+ : path.join(host.cwd, 'opencode'),
292
+ ),
293
+ seed: { $schema: 'https://opencode.ai/config.json' },
294
+ place: (entry, doc) => {
295
+ const command = [entry.command, ...entry.args];
296
+ const mcp = isRecord(doc) ? doc.mcp : undefined;
297
+ // The v2 layout nests servers under `mcp.servers` and drops `enabled`.
298
+ if (isRecord(mcp) && isRecord(mcp.servers)) {
299
+ return {
300
+ keyPath: ['mcp', 'servers', SERVER_NAME],
301
+ value: { type: 'local', command },
302
+ };
303
+ }
304
+ return {
305
+ keyPath: ['mcp', SERVER_NAME],
306
+ value: { type: 'local', command, enabled: true },
307
+ };
308
+ },
309
+ }),
310
+ },
311
+ {
312
+ id: 'copilot',
313
+ name: 'GitHub Copilot CLI',
314
+ scopes: ['user', 'project'],
315
+ detect: onPathOrExists(['copilot'], (h) => [path.join(h.home, '.copilot')]),
316
+ target: (scope, _entry, host) =>
317
+ scope === 'user'
318
+ ? underKey(
319
+ path.join(host.home, '.copilot', 'mcp-config.json'),
320
+ 'mcpServers',
321
+ (entry) => ({
322
+ type: 'local',
323
+ command: entry.command,
324
+ args: entry.args,
325
+ tools: ['*'],
326
+ }),
327
+ )
328
+ : // Same file and shape as Claude Code's project scope; Copilot reads it too.
329
+ underKey(path.join(host.cwd, '.mcp.json'), 'mcpServers', stdioTyped),
330
+ },
331
+ {
332
+ id: 'windsurf',
333
+ name: 'Windsurf',
334
+ scopes: ['user'],
335
+ detect: onPathOrExists(['windsurf'], (h) => [path.join(h.home, '.codeium', 'windsurf')]),
336
+ target: (_scope, _entry, host) =>
337
+ underKey(
338
+ path.join(host.home, '.codeium', 'windsurf', 'mcp_config.json'),
339
+ 'mcpServers',
340
+ plain,
341
+ ),
342
+ },
343
+ {
344
+ id: 'zed',
345
+ name: 'Zed',
346
+ scopes: ['user', 'project'],
347
+ detect: onPathOrExists(['zed'], (h) => [zedConfigDir(h)]),
348
+ target: (scope, _entry, host) =>
349
+ underKey(
350
+ scope === 'user'
351
+ ? path.join(zedConfigDir(host), 'settings.json')
352
+ : path.join(host.cwd, '.zed', 'settings.json'),
353
+ 'context_servers',
354
+ (entry) => ({ command: entry.command, args: entry.args, env: {} }),
355
+ ),
356
+ },
357
+ {
358
+ id: 'amp',
359
+ name: 'Amp',
360
+ scopes: ['user', 'project'],
361
+ detect: onPathOrExists(['amp'], (h) => [path.join(h.home, '.config', 'amp')]),
362
+ target: (scope, entry, host) => {
363
+ if (scope === 'user' && host.platform === 'win32') {
364
+ // Amp documents no Windows settings path; its own CLI knows it.
365
+ return {
366
+ kind: 'print',
367
+ install: {
368
+ instructions: 'run:',
369
+ snippet: commandLine([
370
+ 'amp',
371
+ 'mcp',
372
+ 'add',
373
+ SERVER_NAME,
374
+ '--',
375
+ entry.command,
376
+ ...entry.args,
377
+ ]),
378
+ },
379
+ uninstall: {
380
+ instructions: `remove "${SERVER_NAME}" from "amp.mcpServers" in Amp's settings.json.`,
381
+ },
382
+ };
383
+ }
384
+ return underKey(
385
+ scope === 'user'
386
+ ? path.join(host.home, '.config', 'amp', 'settings.json')
387
+ : path.join(host.cwd, '.amp', 'settings.json'),
388
+ 'amp.mcpServers',
389
+ plain,
390
+ scope === 'project'
391
+ ? { hint: `approve it once with \`amp mcp approve ${SERVER_NAME}\`` }
392
+ : {},
393
+ );
394
+ },
395
+ },
396
+ {
397
+ id: 'goose',
398
+ name: 'Goose',
399
+ scopes: ['user'],
400
+ detect: onPathOrExists(['goose'], (h) => [gooseConfigPath(h, true)]),
401
+ target: (_scope, entry, host) => {
402
+ const configPath = gooseConfigPath(host, false);
403
+ // Goose's config is YAML we'd lose comments in; JSON scalars are valid YAML.
404
+ const yaml = [
405
+ 'extensions:',
406
+ ` ${SERVER_NAME}:`,
407
+ ' enabled: true',
408
+ ` name: ${SERVER_NAME}`,
409
+ ' type: stdio',
410
+ ` cmd: ${JSON.stringify(entry.command)}`,
411
+ ` args: ${JSON.stringify(entry.args)}`,
412
+ ' timeout: 300',
413
+ ].join('\n');
414
+ return {
415
+ kind: 'print',
416
+ install: { instructions: `add this to ${configPath}:`, snippet: yaml },
417
+ uninstall: {
418
+ instructions: `delete extensions.${SERVER_NAME} from ${configPath}.`,
419
+ },
420
+ };
421
+ },
422
+ },
423
+ ];
424
+
425
+ function zedConfigDir(host: HostEnv): string {
426
+ return host.platform === 'win32'
427
+ ? path.join(appData(host), 'Zed')
428
+ : path.join(xdgConfig(host), 'zed');
429
+ }
430
+
431
+ function gooseConfigPath(host: HostEnv, dirOnly: boolean): string {
432
+ const dir =
433
+ host.platform === 'win32'
434
+ ? path.join(appData(host), 'Block', 'goose', 'config')
435
+ : path.join(xdgConfig(host), 'goose');
436
+ return dirOnly ? dir : path.join(dir, 'config.yaml');
437
+ }
438
+
439
+ export const CLIENT_IDS: readonly string[] = CLIENTS.map((c) => c.id);
440
+
441
+ export function findClient(id: string): ClientDescriptor | undefined {
442
+ return CLIENTS.find((c) => c.id === id);
443
+ }
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+
6
+ export interface SpawnResult {
7
+ status: number | null;
8
+ stdout: string;
9
+ stderr: string;
10
+ /** Set when the process couldn't start or timed out. */
11
+ error?: string;
12
+ }
13
+
14
+ /**
15
+ * Everything `--mcp-install` reads from the machine, behind one seam so the
16
+ * client table and server resolution are testable without touching $HOME or
17
+ * spawning real harnesses.
18
+ */
19
+ export interface HostEnv {
20
+ platform: NodeJS.Platform;
21
+ home: string;
22
+ cwd: string;
23
+ env: Readonly<Record<string, string | undefined>>;
24
+ execPath: string;
25
+ argv1: string | undefined;
26
+ /** True for the Bun-compiled standalone binary (Homebrew, .deb, release downloads). */
27
+ isCompiledBinary: boolean;
28
+ exists(p: string): boolean;
29
+ realpath(p: string): string;
30
+ /** Every executable named `name` on PATH, in PATH order. */
31
+ findAllOnPath(name: string): string[];
32
+ spawn(command: string, args: readonly string[]): SpawnResult;
33
+ }
34
+
35
+ const SPAWN_TIMEOUT_MS = 30_000;
36
+
37
+ export function createHostEnv(): HostEnv {
38
+ const platform = process.platform;
39
+ const env = process.env;
40
+ const findAllOnPath = (name: string): string[] => {
41
+ const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean);
42
+ const exts =
43
+ platform === 'win32'
44
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
45
+ : [''];
46
+ const found: string[] = [];
47
+ for (const dir of dirs) {
48
+ for (const ext of exts) {
49
+ const candidate = path.join(dir, name + ext.toLowerCase());
50
+ if (isExecutable(candidate, platform)) found.push(candidate);
51
+ }
52
+ }
53
+ return found;
54
+ };
55
+ const bunVersion = (process.versions as Record<string, string | undefined>).bun;
56
+ return {
57
+ platform,
58
+ home: os.homedir(),
59
+ cwd: process.cwd(),
60
+ env,
61
+ execPath: process.execPath,
62
+ argv1: process.argv[1],
63
+ isCompiledBinary:
64
+ bunVersion !== undefined && !/^bun(\.exe)?$/i.test(path.basename(process.execPath)),
65
+ exists: existsSync,
66
+ realpath: (p) => {
67
+ try {
68
+ return realpathSync(p);
69
+ } catch {
70
+ return path.resolve(p);
71
+ }
72
+ },
73
+ findAllOnPath,
74
+ spawn: (command, args) => {
75
+ const resolved = findAllOnPath(command)[0] ?? command;
76
+ // Node refuses to spawn .cmd/.bat shims without a shell (CVE-2024-27980).
77
+ const needsShell = platform === 'win32' && /\.(cmd|bat)$/i.test(resolved);
78
+ const result = spawnSync(
79
+ needsShell ? `"${resolved}"` : resolved,
80
+ needsShell ? args.map((a) => `"${a.replace(/"/g, '""')}"`) : [...args],
81
+ {
82
+ encoding: 'utf-8',
83
+ // No stdin: a harness that prompts fails fast instead of hanging.
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ timeout: SPAWN_TIMEOUT_MS,
86
+ shell: needsShell,
87
+ },
88
+ );
89
+ return {
90
+ status: result.status,
91
+ stdout: result.stdout ?? '',
92
+ stderr: result.stderr ?? '',
93
+ error: result.error?.message,
94
+ };
95
+ },
96
+ };
97
+ }
98
+
99
+ function isExecutable(candidate: string, platform: NodeJS.Platform): boolean {
100
+ try {
101
+ accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
102
+ return statSync(candidate).isFile();
103
+ } catch {
104
+ return false;
105
+ }
106
+ }