@warlock.js/ai-cli 0.0.0-stage → 5.31.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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +69 -2
- package/cjs/index.cjs +4576 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/adapters/aider.d.mts +41 -0
- package/esm/adapters/aider.d.mts.map +1 -0
- package/esm/adapters/aider.mjs +294 -0
- package/esm/adapters/aider.mjs.map +1 -0
- package/esm/adapters/amp.d.mts +33 -0
- package/esm/adapters/amp.d.mts.map +1 -0
- package/esm/adapters/amp.mjs +119 -0
- package/esm/adapters/amp.mjs.map +1 -0
- package/esm/adapters/antigravity.d.mts +37 -0
- package/esm/adapters/antigravity.d.mts.map +1 -0
- package/esm/adapters/antigravity.mjs +235 -0
- package/esm/adapters/antigravity.mjs.map +1 -0
- package/esm/adapters/claude-code.d.mts +30 -0
- package/esm/adapters/claude-code.d.mts.map +1 -0
- package/esm/adapters/claude-code.mjs +121 -0
- package/esm/adapters/claude-code.mjs.map +1 -0
- package/esm/adapters/claude-stream.d.mts +32 -0
- package/esm/adapters/claude-stream.d.mts.map +1 -0
- package/esm/adapters/claude-stream.mjs +140 -0
- package/esm/adapters/claude-stream.mjs.map +1 -0
- package/esm/adapters/codex.d.mts +37 -0
- package/esm/adapters/codex.d.mts.map +1 -0
- package/esm/adapters/codex.mjs +320 -0
- package/esm/adapters/codex.mjs.map +1 -0
- package/esm/adapters/copilot.d.mts +35 -0
- package/esm/adapters/copilot.d.mts.map +1 -0
- package/esm/adapters/copilot.mjs +244 -0
- package/esm/adapters/copilot.mjs.map +1 -0
- package/esm/adapters/cursor.d.mts +35 -0
- package/esm/adapters/cursor.d.mts.map +1 -0
- package/esm/adapters/cursor.mjs +203 -0
- package/esm/adapters/cursor.mjs.map +1 -0
- package/esm/adapters/droid.d.mts +32 -0
- package/esm/adapters/droid.d.mts.map +1 -0
- package/esm/adapters/droid.mjs +183 -0
- package/esm/adapters/droid.mjs.map +1 -0
- package/esm/adapters/grok.d.mts +32 -0
- package/esm/adapters/grok.d.mts.map +1 -0
- package/esm/adapters/grok.mjs +199 -0
- package/esm/adapters/grok.mjs.map +1 -0
- package/esm/adapters/index.d.mts +16 -0
- package/esm/adapters/index.mjs +18 -0
- package/esm/adapters/kimi.d.mts +34 -0
- package/esm/adapters/kimi.d.mts.map +1 -0
- package/esm/adapters/kimi.mjs +162 -0
- package/esm/adapters/kimi.mjs.map +1 -0
- package/esm/adapters/mistral-vibe.d.mts +32 -0
- package/esm/adapters/mistral-vibe.d.mts.map +1 -0
- package/esm/adapters/mistral-vibe.mjs +252 -0
- package/esm/adapters/mistral-vibe.mjs.map +1 -0
- package/esm/adapters/opencode.d.mts +36 -0
- package/esm/adapters/opencode.d.mts.map +1 -0
- package/esm/adapters/opencode.mjs +219 -0
- package/esm/adapters/opencode.mjs.map +1 -0
- package/esm/adapters/qwen-code.d.mts +30 -0
- package/esm/adapters/qwen-code.d.mts.map +1 -0
- package/esm/adapters/qwen-code.mjs +94 -0
- package/esm/adapters/qwen-code.mjs.map +1 -0
- package/esm/adapters/reasonix.d.mts +31 -0
- package/esm/adapters/reasonix.d.mts.map +1 -0
- package/esm/adapters/reasonix.mjs +250 -0
- package/esm/adapters/reasonix.mjs.map +1 -0
- package/esm/adapters/shared.d.mts +78 -0
- package/esm/adapters/shared.d.mts.map +1 -0
- package/esm/adapters/shared.mjs +161 -0
- package/esm/adapters/shared.mjs.map +1 -0
- package/esm/cli-agent.d.mts +40 -0
- package/esm/cli-agent.d.mts.map +1 -0
- package/esm/cli-agent.mjs +207 -0
- package/esm/cli-agent.mjs.map +1 -0
- package/esm/cli.d.mts +53 -0
- package/esm/cli.d.mts.map +1 -0
- package/esm/cli.mjs +61 -0
- package/esm/cli.mjs.map +1 -0
- package/esm/contracts/adapter.contract.d.mts +154 -0
- package/esm/contracts/adapter.contract.d.mts.map +1 -0
- package/esm/contracts/events.type.d.mts +111 -0
- package/esm/contracts/events.type.d.mts.map +1 -0
- package/esm/contracts/index.d.mts +4 -0
- package/esm/contracts/mcp.type.d.mts +29 -0
- package/esm/contracts/mcp.type.d.mts.map +1 -0
- package/esm/contracts/options.type.d.mts +87 -0
- package/esm/contracts/options.type.d.mts.map +1 -0
- package/esm/errors.d.mts +46 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +32 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/index.d.mts +31 -0
- package/esm/index.mjs +28 -0
- package/esm/model/cli-model.d.mts +48 -0
- package/esm/model/cli-model.d.mts.map +1 -0
- package/esm/model/cli-model.mjs +133 -0
- package/esm/model/cli-model.mjs.map +1 -0
- package/esm/probe/probe.d.mts +18 -0
- package/esm/probe/probe.d.mts.map +1 -0
- package/esm/probe/probe.mjs +148 -0
- package/esm/probe/probe.mjs.map +1 -0
- package/esm/session/aggregate.mjs +86 -0
- package/esm/session/aggregate.mjs.map +1 -0
- package/esm/session/environment.mjs +71 -0
- package/esm/session/environment.mjs.map +1 -0
- package/esm/session/run-directory.mjs +74 -0
- package/esm/session/run-directory.mjs.map +1 -0
- package/esm/session/session.mjs +188 -0
- package/esm/session/session.mjs.map +1 -0
- package/esm/spawn/invocation.d.mts +35 -0
- package/esm/spawn/invocation.d.mts.map +1 -0
- package/esm/spawn/invocation.mjs +76 -0
- package/esm/spawn/invocation.mjs.map +1 -0
- package/esm/spawn/process-tree.mjs +57 -0
- package/esm/spawn/process-tree.mjs.map +1 -0
- package/esm/spawn/resolve-command.d.mts +73 -0
- package/esm/spawn/resolve-command.d.mts.map +1 -0
- package/esm/spawn/resolve-command.mjs +109 -0
- package/esm/spawn/resolve-command.mjs.map +1 -0
- package/esm/stream/event-log.mjs +52 -0
- package/esm/stream/event-log.mjs.map +1 -0
- package/esm/stream/json-lines.d.mts +33 -0
- package/esm/stream/json-lines.d.mts.map +1 -0
- package/esm/stream/json-lines.mjs +65 -0
- package/esm/stream/json-lines.mjs.map +1 -0
- package/esm/stream/line-splitter.mjs +53 -0
- package/esm/stream/line-splitter.mjs.map +1 -0
- package/esm/utils/toml.d.mts +22 -0
- package/esm/utils/toml.d.mts.map +1 -0
- package/esm/utils/toml.mjs +53 -0
- package/esm/utils/toml.mjs.map +1 -0
- package/llms-full.txt +373 -0
- package/llms.txt +10 -0
- package/package.json +39 -4
- package/skills/README.md +13 -0
- package/skills/index.md +22 -0
- package/skills/run-cli-agents/SKILL.md +186 -0
- package/skills/write-a-cli-adapter/SKILL.md +173 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run-directory.mjs","names":[],"sources":["../../../../../../../../packages/ai-cli/src/session/run-directory.ts"],"sourcesContent":["import { chmod, mkdir, mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport path from \"node:path\";\nimport type { CliHome, CliLaunchFile } from \"../contracts\";\n\n/** The directories one run uses. */\nexport type RunDirectories = {\n /** Private per-run directory (0700) for run-scoped files. */\n runDir: string;\n /** The CLI's config home, or `undefined` for `home: \"inherit\"`. */\n home?: string;\n /** Whether `home` lives inside `runDir` (created for this run, deleted after it). */\n homeIsPrivate: boolean;\n /** Remove everything this run created. Never touches a caller-owned home. */\n dispose(): Promise<void>;\n};\n\n/**\n * Create the run's private directory and resolve its config home.\n *\n * The run directory is `mkdtemp`'d under the OS temp dir with mode 0700, so\n * other local users cannot read the MCP config (and the tokens in it)\n * written there. A `\"private\"` home is a `home/` folder inside it.\n */\nexport async function createRunDirectories(home: CliHome, cwd: string): Promise<RunDirectories> {\n const runDir = await mkdtemp(path.join(tmpdir(), \"warlock-cli-\"));\n\n await chmod(runDir, 0o700);\n\n const dispose = async (): Promise<void> => {\n await rm(runDir, { recursive: true, force: true });\n };\n\n if (home === \"inherit\") {\n return { runDir, homeIsPrivate: false, dispose };\n }\n\n if (home === \"private\") {\n const privateHome = path.join(runDir, \"home\");\n\n await mkdir(privateHome, { mode: 0o700 });\n\n return { runDir, home: privateHome, homeIsPrivate: true, dispose };\n }\n\n const ownedHome = path.resolve(cwd, home);\n\n await mkdir(ownedHome, { recursive: true, mode: 0o700 });\n\n return { runDir, home: ownedHome, homeIsPrivate: false, dispose };\n}\n\n/**\n * Write launch files with owner-only permissions (0600; parent directories\n * 0700). Only paths inside the run directory or a private home are\n * accepted, so an adapter can never overwrite a file in a caller-owned\n * home or elsewhere on disk.\n */\nexport async function writeLaunchFiles(\n files: readonly CliLaunchFile[],\n directories: RunDirectories,\n): Promise<void> {\n const roots = [directories.runDir];\n\n for (const file of files) {\n const target = path.resolve(file.path);\n const inside = roots.some((root) => {\n const relative = path.relative(root, target);\n\n return relative.length > 0 && !relative.startsWith(\"..\") && !path.isAbsolute(relative);\n });\n\n if (!inside) {\n throw new Error(\n `Refusing to write ${target}: launch files must live inside the run directory.`,\n );\n }\n\n await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });\n await writeFile(target, file.content, { mode: 0o600 });\n await chmod(target, 0o600);\n }\n}\n"],"mappings":";;;;;;;;;;;;AAwBA,eAAsB,qBAAqB,MAAe,KAAsC;CAC9F,MAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,OAAO,GAAG,cAAc,CAAC;CAEhE,MAAM,MAAM,QAAQ,GAAK;CAEzB,MAAM,UAAU,YAA2B;EACzC,MAAM,GAAG,QAAQ;GAAE,WAAW;GAAM,OAAO;EAAK,CAAC;CACnD;CAEA,IAAI,SAAS,WACX,OAAO;EAAE;EAAQ,eAAe;EAAO;CAAQ;CAGjD,IAAI,SAAS,WAAW;EACtB,MAAM,cAAc,KAAK,KAAK,QAAQ,MAAM;EAE5C,MAAM,MAAM,aAAa,EAAE,MAAM,IAAM,CAAC;EAExC,OAAO;GAAE;GAAQ,MAAM;GAAa,eAAe;GAAM;EAAQ;CACnE;CAEA,MAAM,YAAY,KAAK,QAAQ,KAAK,IAAI;CAExC,MAAM,MAAM,WAAW;EAAE,WAAW;EAAM,MAAM;CAAM,CAAC;CAEvD,OAAO;EAAE;EAAQ,MAAM;EAAW,eAAe;EAAO;CAAQ;AAClE;;;;;;;AAQA,eAAsB,iBACpB,OACA,aACe;CACf,MAAM,QAAQ,CAAC,YAAY,MAAM;CAEjC,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,SAAS,KAAK,QAAQ,KAAK,IAAI;EAOrC,IAAI,CANW,MAAM,MAAM,SAAS;GAClC,MAAM,WAAW,KAAK,SAAS,MAAM,MAAM;GAE3C,OAAO,SAAS,SAAS,KAAK,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,KAAK,WAAW,QAAQ;EACvF,CAEU,GACR,MAAM,IAAI,MACR,qBAAqB,OAAO,mDAC9B;EAGF,MAAM,MAAM,KAAK,QAAQ,MAAM,GAAG;GAAE,WAAW;GAAM,MAAM;EAAM,CAAC;EAClE,MAAM,UAAU,QAAQ,KAAK,SAAS,EAAE,MAAM,IAAM,CAAC;EACrD,MAAM,MAAM,QAAQ,GAAK;CAC3B;AACF"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { killProcessTree } from "../spawn/process-tree.mjs";
|
|
2
|
+
import { EventLog } from "../stream/event-log.mjs";
|
|
3
|
+
import { createLineSplitter } from "../stream/line-splitter.mjs";
|
|
4
|
+
import { ResultAggregator } from "./aggregate.mjs";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
//#region ../../packages/ai-cli/src/session/session.ts
|
|
8
|
+
/**
|
|
9
|
+
* Spawn the CLI and expose it as a {@link CliSession}: stdout lines go
|
|
10
|
+
* through the adapter's parser into a replayable event log, and the
|
|
11
|
+
* process's end becomes the `session.ended` event and the `done()` result.
|
|
12
|
+
*/
|
|
13
|
+
function startSession(input) {
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
const log = new EventLog();
|
|
16
|
+
const aggregator = new ResultAggregator(input.resume);
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
let stderr = "";
|
|
19
|
+
let timedOut = false;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
let closed = false;
|
|
22
|
+
let spawnError;
|
|
23
|
+
const emit = (events) => {
|
|
24
|
+
for (const event of events) {
|
|
25
|
+
aggregator.add(event);
|
|
26
|
+
log.push(event);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const parse = (step) => {
|
|
30
|
+
try {
|
|
31
|
+
emit(step());
|
|
32
|
+
} catch (error) {
|
|
33
|
+
emit([{
|
|
34
|
+
type: "error",
|
|
35
|
+
message: `stream parser failed: ${error instanceof Error ? error.message : String(error)}`
|
|
36
|
+
}]);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const child = spawn(input.invocation.file, input.invocation.args, {
|
|
40
|
+
cwd: input.cwd,
|
|
41
|
+
env: input.env,
|
|
42
|
+
shell: false,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,
|
|
45
|
+
detached: input.platform !== "win32",
|
|
46
|
+
stdio: [
|
|
47
|
+
"pipe",
|
|
48
|
+
"pipe",
|
|
49
|
+
"pipe"
|
|
50
|
+
]
|
|
51
|
+
});
|
|
52
|
+
let cancelPendingKill;
|
|
53
|
+
const kill = () => {
|
|
54
|
+
cancelPendingKill = killProcessTree(child, input.platform, input.killGraceMs);
|
|
55
|
+
};
|
|
56
|
+
const onAbort = () => {
|
|
57
|
+
if (closed || timedOut || cancelled) return;
|
|
58
|
+
cancelled = true;
|
|
59
|
+
kill();
|
|
60
|
+
};
|
|
61
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
62
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
if (input.signal?.aborted) onAbort();
|
|
64
|
+
const timer = input.timeoutMs !== void 0 && input.timeoutMs > 0 ? setTimeout(() => {
|
|
65
|
+
if (cancelled) return;
|
|
66
|
+
timedOut = true;
|
|
67
|
+
kill();
|
|
68
|
+
}, input.timeoutMs) : void 0;
|
|
69
|
+
const splitter = createLineSplitter((line) => parse(() => input.parser.line(line)), () => emit([{
|
|
70
|
+
type: "error",
|
|
71
|
+
message: "dropped an output line longer than 32 MiB"
|
|
72
|
+
}]));
|
|
73
|
+
child.stdout?.on("data", (chunk) => splitter.write(chunk));
|
|
74
|
+
child.stderr?.on("data", (chunk) => {
|
|
75
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-65536);
|
|
76
|
+
});
|
|
77
|
+
child.stdin?.on("error", () => void 0);
|
|
78
|
+
child.stdin?.end(input.stdin ?? "");
|
|
79
|
+
const done = new Promise((resolve) => {
|
|
80
|
+
let settled = false;
|
|
81
|
+
const settle = async (exitCode, signal) => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
settled = true;
|
|
84
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
85
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
86
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
87
|
+
splitter.end();
|
|
88
|
+
parse(() => input.parser.end());
|
|
89
|
+
aggregator.finish();
|
|
90
|
+
const lateRef = input.parser.sessionRef?.();
|
|
91
|
+
if (lateRef !== void 0 && lateRef.length > 0) aggregator.sessionRef = lateRef;
|
|
92
|
+
const status = cancelled ? "cancelled" : timedOut ? "timeout" : spawnError === void 0 && exitCode === 0 && aggregator.fatalError === void 0 ? "completed" : "failed";
|
|
93
|
+
let error;
|
|
94
|
+
if (status === "failed") {
|
|
95
|
+
error = aggregator.fatalError ?? (spawnError ? `could not start ${input.invocation.file}: ${spawnError.message}` : `${input.adapter} exited with ${signal ?? `code ${exitCode}`}` + (stderr.trim() ? `: ${stderr.trim().slice(-2e3)}` : ""));
|
|
96
|
+
if (aggregator.fatalError === void 0) emit([{
|
|
97
|
+
type: "error",
|
|
98
|
+
message: error,
|
|
99
|
+
fatal: true
|
|
100
|
+
}]);
|
|
101
|
+
} else if (status === "timeout") error = `${input.adapter} timed out after ${input.timeoutMs} ms`;
|
|
102
|
+
else if (status === "cancelled") error = `${input.adapter} run was cancelled`;
|
|
103
|
+
const durationMs = Date.now() - startedAt;
|
|
104
|
+
log.push({
|
|
105
|
+
type: "session.ended",
|
|
106
|
+
status,
|
|
107
|
+
exitCode,
|
|
108
|
+
signal,
|
|
109
|
+
sessionRef: aggregator.sessionRef,
|
|
110
|
+
durationMs
|
|
111
|
+
});
|
|
112
|
+
log.close();
|
|
113
|
+
try {
|
|
114
|
+
await input.cleanup();
|
|
115
|
+
} catch {}
|
|
116
|
+
resolve({
|
|
117
|
+
status,
|
|
118
|
+
text: aggregator.text,
|
|
119
|
+
messages: [...aggregator.messages],
|
|
120
|
+
usage: aggregator.usage,
|
|
121
|
+
sessionRef: aggregator.sessionRef,
|
|
122
|
+
exitCode,
|
|
123
|
+
signal,
|
|
124
|
+
error,
|
|
125
|
+
stderr,
|
|
126
|
+
durationMs
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
child.on("error", (error) => {
|
|
130
|
+
if (child.pid === void 0) {
|
|
131
|
+
closed = true;
|
|
132
|
+
spawnError = error;
|
|
133
|
+
settle(null, null);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
child.on("close", (code, signal) => {
|
|
137
|
+
closed = true;
|
|
138
|
+
cancelPendingKill?.();
|
|
139
|
+
settle(code, signal);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
adapter: input.adapter,
|
|
144
|
+
pid: child.pid,
|
|
145
|
+
events: log,
|
|
146
|
+
done: () => done,
|
|
147
|
+
cancel: () => controller.abort()
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A session for a run whose signal was already aborted: nothing is spawned,
|
|
152
|
+
* the stream is a single `session.ended`, and `done()` resolves `cancelled`.
|
|
153
|
+
*/
|
|
154
|
+
function cancelledSession(adapter, resume) {
|
|
155
|
+
const log = new EventLog();
|
|
156
|
+
const error = `${adapter} run was cancelled before it started`;
|
|
157
|
+
log.push({
|
|
158
|
+
type: "session.ended",
|
|
159
|
+
status: "cancelled",
|
|
160
|
+
exitCode: null,
|
|
161
|
+
signal: null,
|
|
162
|
+
sessionRef: resume,
|
|
163
|
+
durationMs: 0
|
|
164
|
+
});
|
|
165
|
+
log.close();
|
|
166
|
+
const result = {
|
|
167
|
+
status: "cancelled",
|
|
168
|
+
text: "",
|
|
169
|
+
messages: [],
|
|
170
|
+
sessionRef: resume,
|
|
171
|
+
exitCode: null,
|
|
172
|
+
signal: null,
|
|
173
|
+
error,
|
|
174
|
+
stderr: "",
|
|
175
|
+
durationMs: 0
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
adapter,
|
|
179
|
+
pid: void 0,
|
|
180
|
+
events: log,
|
|
181
|
+
done: () => Promise.resolve(result),
|
|
182
|
+
cancel: () => void 0
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
//#endregion
|
|
187
|
+
export { cancelledSession, startSession };
|
|
188
|
+
//# sourceMappingURL=session.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.mjs","names":[],"sources":["../../../../../../../../packages/ai-cli/src/session/session.ts"],"sourcesContent":["import { spawn } from \"node:child_process\";\nimport type {\n CliEndStatus,\n CliEvent,\n CliParsedEvent,\n CliResult,\n CliSession,\n CliStreamParser,\n} from \"../contracts\";\nimport type { Invocation } from \"../spawn/invocation\";\nimport { killProcessTree } from \"../spawn/process-tree\";\nimport { EventLog } from \"../stream/event-log\";\nimport { createLineSplitter } from \"../stream/line-splitter\";\nimport { ResultAggregator } from \"./aggregate\";\n\n/** stderr kept for the result (the tail). */\nconst STDERR_LIMIT = 64 * 1024;\n\nexport type StartSessionInput = {\n adapter: string;\n invocation: Invocation;\n cwd: string;\n env: Record<string, string>;\n stdin?: string;\n parser: CliStreamParser;\n signal?: AbortSignal;\n timeoutMs?: number;\n killGraceMs: number;\n platform: NodeJS.Platform;\n resume?: string;\n /** Runs after the process exits and before `done()` resolves (removes the run directory). */\n cleanup: () => Promise<void>;\n};\n\n/**\n * Spawn the CLI and expose it as a {@link CliSession}: stdout lines go\n * through the adapter's parser into a replayable event log, and the\n * process's end becomes the `session.ended` event and the `done()` result.\n */\nexport function startSession(input: StartSessionInput): CliSession {\n const startedAt = Date.now();\n const log = new EventLog<CliEvent>();\n const aggregator = new ResultAggregator(input.resume);\n const controller = new AbortController();\n let stderr = \"\";\n let timedOut = false;\n let cancelled = false;\n // Every pipe has closed: the process group is gone and its id may be\n // reused by an unrelated process, so nothing may signal it any more.\n let closed = false;\n let spawnError: Error | undefined;\n\n const emit = (events: CliParsedEvent[]): void => {\n for (const event of events) {\n aggregator.add(event);\n log.push(event);\n }\n };\n\n const parse = (step: () => CliParsedEvent[]): void => {\n try {\n emit(step());\n } catch (error) {\n emit([\n {\n type: \"error\",\n message: `stream parser failed: ${error instanceof Error ? error.message : String(error)}`,\n },\n ]);\n }\n };\n\n const child = spawn(input.invocation.file, input.invocation.args, {\n cwd: input.cwd,\n env: input.env,\n shell: false,\n windowsHide: true,\n windowsVerbatimArguments: input.invocation.windowsVerbatimArguments,\n // POSIX: own process group, so cancellation reaps the whole tree.\n detached: input.platform !== \"win32\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n\n let cancelPendingKill: (() => void) | undefined;\n\n const kill = (): void => {\n cancelPendingKill = killProcessTree(child, input.platform, input.killGraceMs);\n };\n\n const onAbort = (): void => {\n if (closed || timedOut || cancelled) {\n return;\n }\n\n cancelled = true;\n kill();\n };\n\n input.signal?.addEventListener(\"abort\", onAbort, { once: true });\n controller.signal.addEventListener(\"abort\", onAbort, { once: true });\n\n if (input.signal?.aborted) {\n onAbort();\n }\n\n const timer =\n input.timeoutMs !== undefined && input.timeoutMs > 0\n ? setTimeout(() => {\n if (cancelled) {\n return;\n }\n\n timedOut = true;\n kill();\n }, input.timeoutMs)\n : undefined;\n\n const splitter = createLineSplitter(\n (line) => parse(() => input.parser.line(line)),\n () => emit([{ type: \"error\", message: \"dropped an output line longer than 32 MiB\" }]),\n );\n\n child.stdout?.on(\"data\", (chunk: Buffer) => splitter.write(chunk));\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr = (stderr + chunk.toString(\"utf8\")).slice(-STDERR_LIMIT);\n });\n\n // A CLI that exits before reading its prompt closes the pipe; that EPIPE\n // must not crash the host process.\n child.stdin?.on(\"error\", () => undefined);\n child.stdin?.end(input.stdin ?? \"\");\n\n const done = new Promise<CliResult>((resolve) => {\n let settled = false;\n\n const settle = async (exitCode: number | null, signal: string | null): Promise<void> => {\n if (settled) {\n return;\n }\n\n settled = true;\n\n if (timer !== undefined) {\n clearTimeout(timer);\n }\n\n input.signal?.removeEventListener(\"abort\", onAbort);\n controller.signal.removeEventListener(\"abort\", onAbort);\n splitter.end();\n parse(() => input.parser.end());\n aggregator.finish();\n\n const lateRef = input.parser.sessionRef?.();\n\n if (lateRef !== undefined && lateRef.length > 0) {\n aggregator.sessionRef = lateRef;\n }\n\n const status: CliEndStatus = cancelled\n ? \"cancelled\"\n : timedOut\n ? \"timeout\"\n : spawnError === undefined && exitCode === 0 && aggregator.fatalError === undefined\n ? \"completed\"\n : \"failed\";\n\n let error: string | undefined;\n\n if (status === \"failed\") {\n error =\n aggregator.fatalError ??\n (spawnError\n ? `could not start ${input.invocation.file}: ${spawnError.message}`\n : `${input.adapter} exited with ${signal ?? `code ${exitCode}`}` +\n (stderr.trim() ? `: ${stderr.trim().slice(-2000)}` : \"\"));\n\n if (aggregator.fatalError === undefined) {\n emit([{ type: \"error\", message: error, fatal: true }]);\n }\n } else if (status === \"timeout\") {\n error = `${input.adapter} timed out after ${input.timeoutMs} ms`;\n } else if (status === \"cancelled\") {\n error = `${input.adapter} run was cancelled`;\n }\n\n const durationMs = Date.now() - startedAt;\n\n log.push({\n type: \"session.ended\",\n status,\n exitCode,\n signal,\n sessionRef: aggregator.sessionRef,\n durationMs,\n });\n log.close();\n\n try {\n await input.cleanup();\n } catch {\n // A leftover temp directory must not turn a finished run into a failure.\n }\n\n resolve({\n status,\n text: aggregator.text,\n messages: [...aggregator.messages],\n usage: aggregator.usage,\n sessionRef: aggregator.sessionRef,\n exitCode,\n signal,\n error,\n stderr,\n durationMs,\n });\n };\n\n child.on(\"error\", (error) => {\n // Only a failed spawn ends the session here; any other 'error' (a\n // failed kill) is followed by the real 'close'.\n if (child.pid === undefined) {\n closed = true;\n spawnError = error;\n void settle(null, null);\n }\n });\n\n child.on(\"close\", (code, signal) => {\n // Every pipe is closed: the tree is gone, so the delayed SIGKILL for\n // this process group must not fire, and a later cancel() or abort\n // must not signal it (the pid may already belong to someone else).\n closed = true;\n cancelPendingKill?.();\n void settle(code, signal);\n });\n });\n\n return {\n adapter: input.adapter,\n pid: child.pid,\n events: log,\n done: () => done,\n cancel: () => controller.abort(),\n };\n}\n\n/**\n * A session for a run whose signal was already aborted: nothing is spawned,\n * the stream is a single `session.ended`, and `done()` resolves `cancelled`.\n */\nexport function cancelledSession(adapter: string, resume?: string): CliSession {\n const log = new EventLog<CliEvent>();\n const error = `${adapter} run was cancelled before it started`;\n\n log.push({\n type: \"session.ended\",\n status: \"cancelled\",\n exitCode: null,\n signal: null,\n sessionRef: resume,\n durationMs: 0,\n });\n log.close();\n\n const result: CliResult = {\n status: \"cancelled\",\n text: \"\",\n messages: [],\n sessionRef: resume,\n exitCode: null,\n signal: null,\n error,\n stderr: \"\",\n durationMs: 0,\n };\n\n return {\n adapter,\n pid: undefined,\n events: log,\n done: () => Promise.resolve(result),\n cancel: () => undefined,\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SpawnTarget } from "./resolve-command.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-cli/src/spawn/invocation.d.ts
|
|
4
|
+
/** A ready-to-spawn invocation. */
|
|
5
|
+
type Invocation = {
|
|
6
|
+
file: string;
|
|
7
|
+
args: string[]; /** Set for `cmd.exe` so Node does not re-quote the already-quoted command line. */
|
|
8
|
+
windowsVerbatimArguments: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Quote one argument for a `cmd.exe /s /c "..."` line, or return `null` when
|
|
12
|
+
* it holds a byte cmd cannot quote safely. Inside double quotes `& | < > ^`
|
|
13
|
+
* are literal; trailing backslashes are doubled so the target program's
|
|
14
|
+
* argv parser does not read the closing quote as escaped.
|
|
15
|
+
*/
|
|
16
|
+
declare function quoteForCmd(argument: string): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Build the invocation for a target and argv. A `cmd` target becomes
|
|
19
|
+
* `cmd.exe /d /v:off /s /c ""batch" "arg" …"` — AutoRun disabled, delayed
|
|
20
|
+
* expansion off (so `!` stays literal), each element quoted. Returns a
|
|
21
|
+
* `refused` reason when an argument cannot be quoted safely.
|
|
22
|
+
*/
|
|
23
|
+
declare function buildInvocation(target: SpawnTarget, args: string[], comspec: string): Invocation | {
|
|
24
|
+
refused: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether an invocation's argv fits the platform's limits. Windows counts
|
|
28
|
+
* the whole command line (8191 for `cmd.exe`, 32767 for `CreateProcess`,
|
|
29
|
+
* each argument quoted); POSIX limits a single argument to 128 KiB on Linux
|
|
30
|
+
* and shares `ARG_MAX` with the environment.
|
|
31
|
+
*/
|
|
32
|
+
declare function fitsCommandLine(invocation: Invocation, platform: NodeJS.Platform): boolean;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { Invocation, buildInvocation, fitsCommandLine, quoteForCmd };
|
|
35
|
+
//# sourceMappingURL=invocation.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invocation.d.mts","names":[],"sources":["../../../../../../../../packages/ai-cli/src/spawn/invocation.ts"],"mappings":";;;;KAGY,UAAA;EACV,IAAA;EACA,IAAA;EAEA,wBAAA;AAAA;AA6B0C;AAgB5C;;;;;AAhB4C,iBAA5B,WAAA,CAAY,QAAgB;;;;;;AAoBnB;iBAJT,eAAA,CACd,MAAA,EAAQ,WAAA,EACR,IAAA,YACA,OAAA,WACC,UAAU;EAAK,OAAA;AAAA;;;;;;;iBAqCF,eAAA,CAAgB,UAAA,EAAY,UAAA,EAAY,QAAA,EAAU,MAAA,CAAO,QAAQ"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region ../../packages/ai-cli/src/spawn/invocation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Bytes `cmd.exe` cannot carry inside a double-quoted argument: a quote ends
|
|
4
|
+
* the quoted span, `%` expands variables even inside quotes, and a newline
|
|
5
|
+
* ends the command (the BatBadBut / CVE-2024-24576 class). An argument
|
|
6
|
+
* holding one of them is refused rather than escaped.
|
|
7
|
+
*/
|
|
8
|
+
const CMD_UNSAFE = /["%\r\n\0]/;
|
|
9
|
+
/** `cmd.exe` command-line limit (8191 characters), with headroom for the wrapper. */
|
|
10
|
+
const CMD_LINE_LIMIT = 8e3;
|
|
11
|
+
/** `CreateProcess` command-line limit (32767 characters), with headroom. */
|
|
12
|
+
const WINDOWS_LINE_LIMIT = 32e3;
|
|
13
|
+
/** Linux `MAX_ARG_STRLEN` (128 KiB per argument), with headroom. */
|
|
14
|
+
const POSIX_ARGUMENT_LIMIT = 120 * 1024;
|
|
15
|
+
/** Conservative total argv budget on POSIX (ARG_MAX is shared with the environment). */
|
|
16
|
+
const POSIX_TOTAL_LIMIT = 512 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Quote one argument for a `cmd.exe /s /c "..."` line, or return `null` when
|
|
19
|
+
* it holds a byte cmd cannot quote safely. Inside double quotes `& | < > ^`
|
|
20
|
+
* are literal; trailing backslashes are doubled so the target program's
|
|
21
|
+
* argv parser does not read the closing quote as escaped.
|
|
22
|
+
*/
|
|
23
|
+
function quoteForCmd(argument) {
|
|
24
|
+
if (CMD_UNSAFE.test(argument)) return null;
|
|
25
|
+
return `"${argument}${/\\+$/.exec(argument)?.[0] ?? ""}"`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build the invocation for a target and argv. A `cmd` target becomes
|
|
29
|
+
* `cmd.exe /d /v:off /s /c ""batch" "arg" …"` — AutoRun disabled, delayed
|
|
30
|
+
* expansion off (so `!` stays literal), each element quoted. Returns a
|
|
31
|
+
* `refused` reason when an argument cannot be quoted safely.
|
|
32
|
+
*/
|
|
33
|
+
function buildInvocation(target, args, comspec) {
|
|
34
|
+
if (target.type === "direct") return {
|
|
35
|
+
file: target.file,
|
|
36
|
+
args: [...target.prefixArgs, ...args],
|
|
37
|
+
windowsVerbatimArguments: false
|
|
38
|
+
};
|
|
39
|
+
const quoted = [];
|
|
40
|
+
for (const element of [target.file, ...args]) {
|
|
41
|
+
const safe = quoteForCmd(element);
|
|
42
|
+
if (safe === null) return { refused: "an argument contains \", %, a newline or a NUL byte, which cmd.exe cannot carry safely" };
|
|
43
|
+
quoted.push(safe);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
file: comspec,
|
|
47
|
+
args: [
|
|
48
|
+
"/d",
|
|
49
|
+
"/v:off",
|
|
50
|
+
"/s",
|
|
51
|
+
"/c",
|
|
52
|
+
`"${quoted.join(" ")}"`
|
|
53
|
+
],
|
|
54
|
+
windowsVerbatimArguments: true
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether an invocation's argv fits the platform's limits. Windows counts
|
|
59
|
+
* the whole command line (8191 for `cmd.exe`, 32767 for `CreateProcess`,
|
|
60
|
+
* each argument quoted); POSIX limits a single argument to 128 KiB on Linux
|
|
61
|
+
* and shares `ARG_MAX` with the environment.
|
|
62
|
+
*/
|
|
63
|
+
function fitsCommandLine(invocation, platform) {
|
|
64
|
+
if (platform === "win32") return [invocation.file, ...invocation.args].reduce((total, element) => total + element.length + 3, 0) <= (invocation.windowsVerbatimArguments ? CMD_LINE_LIMIT : WINDOWS_LINE_LIMIT);
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const element of [invocation.file, ...invocation.args]) {
|
|
67
|
+
const bytes = Buffer.byteLength(element, "utf8") + 1;
|
|
68
|
+
if (bytes > 122880) return false;
|
|
69
|
+
total += bytes;
|
|
70
|
+
}
|
|
71
|
+
return total <= POSIX_TOTAL_LIMIT;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
export { buildInvocation, fitsCommandLine, quoteForCmd };
|
|
76
|
+
//# sourceMappingURL=invocation.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invocation.mjs","names":[],"sources":["../../../../../../../../packages/ai-cli/src/spawn/invocation.ts"],"sourcesContent":["import type { SpawnTarget } from \"./resolve-command\";\n\n/** A ready-to-spawn invocation. */\nexport type Invocation = {\n file: string;\n args: string[];\n /** Set for `cmd.exe` so Node does not re-quote the already-quoted command line. */\n windowsVerbatimArguments: boolean;\n};\n\n/**\n * Bytes `cmd.exe` cannot carry inside a double-quoted argument: a quote ends\n * the quoted span, `%` expands variables even inside quotes, and a newline\n * ends the command (the BatBadBut / CVE-2024-24576 class). An argument\n * holding one of them is refused rather than escaped.\n */\nconst CMD_UNSAFE = /[\"%\\r\\n\\0]/;\n\n/** `cmd.exe` command-line limit (8191 characters), with headroom for the wrapper. */\nexport const CMD_LINE_LIMIT = 8000;\n\n/** `CreateProcess` command-line limit (32767 characters), with headroom. */\nexport const WINDOWS_LINE_LIMIT = 32000;\n\n/** Linux `MAX_ARG_STRLEN` (128 KiB per argument), with headroom. */\nexport const POSIX_ARGUMENT_LIMIT = 120 * 1024;\n\n/** Conservative total argv budget on POSIX (ARG_MAX is shared with the environment). */\nexport const POSIX_TOTAL_LIMIT = 512 * 1024;\n\n/**\n * Quote one argument for a `cmd.exe /s /c \"...\"` line, or return `null` when\n * it holds a byte cmd cannot quote safely. Inside double quotes `& | < > ^`\n * are literal; trailing backslashes are doubled so the target program's\n * argv parser does not read the closing quote as escaped.\n */\nexport function quoteForCmd(argument: string): string | null {\n if (CMD_UNSAFE.test(argument)) {\n return null;\n }\n\n const trailing = /\\\\+$/.exec(argument)?.[0] ?? \"\";\n\n return `\"${argument}${trailing}\"`;\n}\n\n/**\n * Build the invocation for a target and argv. A `cmd` target becomes\n * `cmd.exe /d /v:off /s /c \"\"batch\" \"arg\" …\"` — AutoRun disabled, delayed\n * expansion off (so `!` stays literal), each element quoted. Returns a\n * `refused` reason when an argument cannot be quoted safely.\n */\nexport function buildInvocation(\n target: SpawnTarget,\n args: string[],\n comspec: string,\n): Invocation | { refused: string } {\n if (target.type === \"direct\") {\n return {\n file: target.file,\n args: [...target.prefixArgs, ...args],\n windowsVerbatimArguments: false,\n };\n }\n\n const quoted: string[] = [];\n\n for (const element of [target.file, ...args]) {\n const safe = quoteForCmd(element);\n\n if (safe === null) {\n return {\n refused:\n 'an argument contains \", %, a newline or a NUL byte, which cmd.exe cannot carry safely',\n };\n }\n\n quoted.push(safe);\n }\n\n return {\n file: comspec,\n args: [\"/d\", \"/v:off\", \"/s\", \"/c\", `\"${quoted.join(\" \")}\"`],\n windowsVerbatimArguments: true,\n };\n}\n\n/**\n * Whether an invocation's argv fits the platform's limits. Windows counts\n * the whole command line (8191 for `cmd.exe`, 32767 for `CreateProcess`,\n * each argument quoted); POSIX limits a single argument to 128 KiB on Linux\n * and shares `ARG_MAX` with the environment.\n */\nexport function fitsCommandLine(invocation: Invocation, platform: NodeJS.Platform): boolean {\n if (platform === \"win32\") {\n const length = [invocation.file, ...invocation.args].reduce(\n (total, element) => total + element.length + 3,\n 0,\n );\n\n return length <= (invocation.windowsVerbatimArguments ? CMD_LINE_LIMIT : WINDOWS_LINE_LIMIT);\n }\n\n let total = 0;\n\n for (const element of [invocation.file, ...invocation.args]) {\n const bytes = Buffer.byteLength(element, \"utf8\") + 1;\n\n if (bytes > POSIX_ARGUMENT_LIMIT) {\n return false;\n }\n\n total += bytes;\n }\n\n return total <= POSIX_TOTAL_LIMIT;\n}\n"],"mappings":";;;;;;;AAgBA,MAAM,aAAa;;AAGnB,MAAa,iBAAiB;;AAG9B,MAAa,qBAAqB;;AAGlC,MAAa,uBAAuB,MAAM;;AAG1C,MAAa,oBAAoB,MAAM;;;;;;;AAQvC,SAAgB,YAAY,UAAiC;CAC3D,IAAI,WAAW,KAAK,QAAQ,GAC1B,OAAO;CAKT,OAAO,IAAI,WAFM,OAAO,KAAK,QAAQ,CAAC,GAAG,MAAM,GAEhB;AACjC;;;;;;;AAQA,SAAgB,gBACd,QACA,MACA,SACkC;CAClC,IAAI,OAAO,SAAS,UAClB,OAAO;EACL,MAAM,OAAO;EACb,MAAM,CAAC,GAAG,OAAO,YAAY,GAAG,IAAI;EACpC,0BAA0B;CAC5B;CAGF,MAAM,SAAmB,CAAC;CAE1B,KAAK,MAAM,WAAW,CAAC,OAAO,MAAM,GAAG,IAAI,GAAG;EAC5C,MAAM,OAAO,YAAY,OAAO;EAEhC,IAAI,SAAS,MACX,OAAO,EACL,SACE,yFACJ;EAGF,OAAO,KAAK,IAAI;CAClB;CAEA,OAAO;EACL,MAAM;EACN,MAAM;GAAC;GAAM;GAAU;GAAM;GAAM,IAAI,OAAO,KAAK,GAAG,EAAE;EAAE;EAC1D,0BAA0B;CAC5B;AACF;;;;;;;AAQA,SAAgB,gBAAgB,YAAwB,UAAoC;CAC1F,IAAI,aAAa,SAMf,OALe,CAAC,WAAW,MAAM,GAAG,WAAW,IAAI,CAAC,CAAC,QAClD,OAAO,YAAY,QAAQ,QAAQ,SAAS,GAC7C,CAGU,MAAM,WAAW,2BAA2B,iBAAiB;CAG3E,IAAI,QAAQ;CAEZ,KAAK,MAAM,WAAW,CAAC,WAAW,MAAM,GAAG,WAAW,IAAI,GAAG;EAC3D,MAAM,QAAQ,OAAO,WAAW,SAAS,MAAM,IAAI;EAEnD,IAAI,gBACF,OAAO;EAGT,SAAS;CACX;CAEA,OAAO,SAAS;AAClB"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-cli/src/spawn/process-tree.ts
|
|
4
|
+
/**
|
|
5
|
+
* Terminate a CLI and everything it started.
|
|
6
|
+
*
|
|
7
|
+
* Coding CLIs spawn shells, language servers and MCP servers, so signalling
|
|
8
|
+
* the direct child alone would leave grandchildren running.
|
|
9
|
+
*
|
|
10
|
+
* - **POSIX** — the child was spawned `detached` (its own process group), so
|
|
11
|
+
* the whole group gets `SIGTERM`, then `SIGKILL` after `graceMs`.
|
|
12
|
+
* - **Windows** — `taskkill /T /F` walks and kills the PID's tree (console
|
|
13
|
+
* programs ignore a polite close, so there is no grace step).
|
|
14
|
+
*
|
|
15
|
+
* Only the tree this package spawned is ever signalled: the child's own PID
|
|
16
|
+
* (Windows) or its own process group (POSIX) — never a name, port or
|
|
17
|
+
* pattern. The SIGKILL follows even when the leader exits on SIGTERM, so a
|
|
18
|
+
* grandchild that ignores SIGTERM (and keeps the pipes open) is still
|
|
19
|
+
* reaped; a process group id cannot be reused while any member is alive.
|
|
20
|
+
* Call the returned function once the session's pipes have closed (the
|
|
21
|
+
* whole tree is gone) to cancel that pending SIGKILL.
|
|
22
|
+
*/
|
|
23
|
+
function killProcessTree(child, platform, graceMs) {
|
|
24
|
+
const pid = child.pid;
|
|
25
|
+
if (pid === void 0) {
|
|
26
|
+
child.kill("SIGKILL");
|
|
27
|
+
return () => void 0;
|
|
28
|
+
}
|
|
29
|
+
if (platform === "win32") {
|
|
30
|
+
spawn("taskkill", [
|
|
31
|
+
"/pid",
|
|
32
|
+
String(pid),
|
|
33
|
+
"/T",
|
|
34
|
+
"/F"
|
|
35
|
+
], { windowsHide: true }).on("error", () => {
|
|
36
|
+
child.kill("SIGKILL");
|
|
37
|
+
});
|
|
38
|
+
return () => void 0;
|
|
39
|
+
}
|
|
40
|
+
const signalGroup = (signal) => {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(-pid, signal);
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
child.kill(signal);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
signalGroup("SIGTERM");
|
|
50
|
+
const timer = setTimeout(() => signalGroup("SIGKILL"), Math.max(0, graceMs));
|
|
51
|
+
timer.unref();
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
export { killProcessTree };
|
|
57
|
+
//# sourceMappingURL=process-tree.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-tree.mjs","names":[],"sources":["../../../../../../../../packages/ai-cli/src/spawn/process-tree.ts"],"sourcesContent":["import { type ChildProcess, spawn } from \"node:child_process\";\n\n/**\n * Terminate a CLI and everything it started.\n *\n * Coding CLIs spawn shells, language servers and MCP servers, so signalling\n * the direct child alone would leave grandchildren running.\n *\n * - **POSIX** — the child was spawned `detached` (its own process group), so\n * the whole group gets `SIGTERM`, then `SIGKILL` after `graceMs`.\n * - **Windows** — `taskkill /T /F` walks and kills the PID's tree (console\n * programs ignore a polite close, so there is no grace step).\n *\n * Only the tree this package spawned is ever signalled: the child's own PID\n * (Windows) or its own process group (POSIX) — never a name, port or\n * pattern. The SIGKILL follows even when the leader exits on SIGTERM, so a\n * grandchild that ignores SIGTERM (and keeps the pipes open) is still\n * reaped; a process group id cannot be reused while any member is alive.\n * Call the returned function once the session's pipes have closed (the\n * whole tree is gone) to cancel that pending SIGKILL.\n */\nexport function killProcessTree(\n child: ChildProcess,\n platform: NodeJS.Platform,\n graceMs: number,\n): () => void {\n const pid = child.pid;\n\n if (pid === undefined) {\n child.kill(\"SIGKILL\");\n\n return () => undefined;\n }\n\n if (platform === \"win32\") {\n const killer = spawn(\"taskkill\", [\"/pid\", String(pid), \"/T\", \"/F\"], { windowsHide: true });\n\n // A missing taskkill must not crash the process with an unhandled\n // 'error' event; fall back to the direct child.\n killer.on(\"error\", () => {\n child.kill(\"SIGKILL\");\n });\n\n return () => undefined;\n }\n\n const signalGroup = (signal: NodeJS.Signals): void => {\n try {\n process.kill(-pid, signal);\n } catch {\n // The group is already gone, or the child is not a group leader.\n try {\n child.kill(signal);\n } catch {\n // Already exited.\n }\n }\n };\n\n signalGroup(\"SIGTERM\");\n\n const timer = setTimeout(() => signalGroup(\"SIGKILL\"), Math.max(0, graceMs));\n\n timer.unref();\n\n return () => clearTimeout(timer);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,gBACd,OACA,UACA,SACY;CACZ,MAAM,MAAM,MAAM;CAElB,IAAI,QAAQ,QAAW;EACrB,MAAM,KAAK,SAAS;EAEpB,aAAa;CACf;CAEA,IAAI,aAAa,SAAS;EAKxB,AAJe,MAAM,YAAY;GAAC;GAAQ,OAAO,GAAG;GAAG;GAAM;EAAI,GAAG,EAAE,aAAa,KAAK,CAInF,CAAC,CAAC,GAAG,eAAe;GACvB,MAAM,KAAK,SAAS;EACtB,CAAC;EAED,aAAa;CACf;CAEA,MAAM,eAAe,WAAiC;EACpD,IAAI;GACF,QAAQ,KAAK,CAAC,KAAK,MAAM;EAC3B,QAAQ;GAEN,IAAI;IACF,MAAM,KAAK,MAAM;GACnB,QAAQ,CAER;EACF;CACF;CAEA,YAAY,SAAS;CAErB,MAAM,QAAQ,iBAAiB,YAAY,SAAS,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC;CAE3E,MAAM,MAAM;CAEZ,aAAa,aAAa,KAAK;AACjC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region ../../packages/ai-cli/src/spawn/resolve-command.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Host facts the resolver needs, injected so every rule is a pure function
|
|
4
|
+
* that tests can drive for Windows on any OS.
|
|
5
|
+
*/
|
|
6
|
+
type CommandLookup = {
|
|
7
|
+
platform: NodeJS.Platform;
|
|
8
|
+
env: Record<string, string | undefined>; /** Whether a regular, runnable file exists at this absolute path. */
|
|
9
|
+
isFile(filePath: string): boolean; /** Read a small text file (used only for npm `.cmd` shims). */
|
|
10
|
+
readFile(filePath: string): string | undefined; /** Node binary used to run a JavaScript entry behind an npm shim. */
|
|
11
|
+
execPath: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* How to start a resolved command.
|
|
15
|
+
*
|
|
16
|
+
* - `direct` — spawn `file` with `prefixArgs` + the CLI's argv, no shell.
|
|
17
|
+
* - `cmd` — a batch file Node cannot spawn without a shell (Node refuses
|
|
18
|
+
* `.cmd`/`.bat` since CVE-2024-27980); it runs through `cmd.exe` with
|
|
19
|
+
* every argument quoted (see {@link buildInvocation}).
|
|
20
|
+
*/
|
|
21
|
+
type SpawnTarget = {
|
|
22
|
+
type: "direct";
|
|
23
|
+
file: string;
|
|
24
|
+
prefixArgs: string[];
|
|
25
|
+
executable: string;
|
|
26
|
+
} | {
|
|
27
|
+
type: "cmd";
|
|
28
|
+
file: string;
|
|
29
|
+
executable: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Find an executable the way the platform's shell would.
|
|
33
|
+
*
|
|
34
|
+
* POSIX walks `PATH`. Windows walks `PATH` trying each `PATHEXT` extension
|
|
35
|
+
* (`claude` → `claude.cmd`), and also accepts a name that already carries
|
|
36
|
+
* one of those extensions. A command containing a path separator is
|
|
37
|
+
* checked directly instead of searched.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* findExecutable("claude", { platform: "win32", env: { PATH: "C:\\npm", PATHEXT: ".EXE;.CMD" }, ... });
|
|
41
|
+
* // → "C:\\npm\\claude.cmd"
|
|
42
|
+
*/
|
|
43
|
+
declare function findExecutable(command: string, lookup: CommandLookup): string | undefined;
|
|
44
|
+
/** What an npm/pnpm `.cmd` shim launches. */
|
|
45
|
+
type CmdShimTarget = {
|
|
46
|
+
type: "node";
|
|
47
|
+
script: string;
|
|
48
|
+
} | {
|
|
49
|
+
type: "native";
|
|
50
|
+
file: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Read the target of an npm (`cmd-shim`) or pnpm `.cmd` shim.
|
|
54
|
+
*
|
|
55
|
+
* Both generators end the shim with a line that runs the target with `%*`;
|
|
56
|
+
* the target is a quoted path relative to the shim's own directory
|
|
57
|
+
* (`"%dp0%\node_modules\pkg\cli.js"` for npm, `"%~dp0\..\pkg\cli.js"` for
|
|
58
|
+
* pnpm). A JavaScript target is run by Node; an `.exe`/`.com` target is run
|
|
59
|
+
* directly. Anything else returns `undefined` and the caller falls back to
|
|
60
|
+
* `cmd.exe`.
|
|
61
|
+
*/
|
|
62
|
+
declare function parseCmdShim(content: string, shimPath: string): CmdShimTarget | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a command to something `child_process.spawn` can start without a
|
|
65
|
+
* shell. On Windows a `.cmd`/`.bat` npm shim is unwrapped to
|
|
66
|
+
* `node <script>` (or its native `.exe`), so argv reaches the CLI intact
|
|
67
|
+
* with no `cmd.exe` parsing in between; any other batch file falls back
|
|
68
|
+
* to `cmd.exe`.
|
|
69
|
+
*/
|
|
70
|
+
declare function resolveSpawnTarget(command: string, lookup: CommandLookup): SpawnTarget | undefined;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { CmdShimTarget, CommandLookup, SpawnTarget, findExecutable, parseCmdShim, resolveSpawnTarget };
|
|
73
|
+
//# sourceMappingURL=resolve-command.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-command.d.mts","names":[],"sources":["../../../../../../../../packages/ai-cli/src/spawn/resolve-command.ts"],"mappings":";;AAMA;;;KAAY,aAAA;EACV,QAAA,EAAU,MAAA,CAAO,QAAA;EACjB,GAAA,EAAK,MAAM,8BADM;EAGjB,MAAA,CAAO,QAAA,oBAFF;EAIL,QAAA,CAAS,QAAA,+BAFF;EAIP,QAAA;AAAA;;;AAAQ;AAWV;;;;;KAAY,WAAA;EACN,IAAA;EAAgB,IAAA;EAAc,UAAA;EAAsB,UAAA;AAAA;EACpD,IAAA;EAAa,IAAA;EAAc,UAAA;AAAA;;;;;AA0CoC;AA+CrE;;;;;;;iBA/CgB,cAAA,CAAe,OAAA,UAAiB,MAAA,EAAQ,aAAa;;KA+CzD,aAAA;EAAkB,IAAA;EAAc,MAAA;AAAA;EAAqB,IAAA;EAAgB,IAAA;AAAA;;;AAYH;AAuC9E;;;;;;;iBAvCgB,YAAA,CAAa,OAAA,UAAiB,QAAA,WAAmB,aAAa;;AA0ChE;;;;;;iBAHE,kBAAA,CACd,OAAA,UACA,MAAA,EAAQ,aAAA,GACP,WAAW"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region ../../packages/ai-cli/src/spawn/resolve-command.ts
|
|
4
|
+
/** Windows' default `PATHEXT` when the variable is unset. */
|
|
5
|
+
const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
|
|
6
|
+
/** Case-insensitive read on Windows (`Path` vs `PATH`), exact elsewhere. */
|
|
7
|
+
function readEnv(env, name, platform) {
|
|
8
|
+
if (platform !== "win32") return env[name];
|
|
9
|
+
const wanted = name.toUpperCase();
|
|
10
|
+
for (const [key, value] of Object.entries(env)) if (key.toUpperCase() === wanted && value !== void 0) return value;
|
|
11
|
+
}
|
|
12
|
+
function pathApi(platform) {
|
|
13
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Find an executable the way the platform's shell would.
|
|
17
|
+
*
|
|
18
|
+
* POSIX walks `PATH`. Windows walks `PATH` trying each `PATHEXT` extension
|
|
19
|
+
* (`claude` → `claude.cmd`), and also accepts a name that already carries
|
|
20
|
+
* one of those extensions. A command containing a path separator is
|
|
21
|
+
* checked directly instead of searched.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* findExecutable("claude", { platform: "win32", env: { PATH: "C:\\npm", PATHEXT: ".EXE;.CMD" }, ... });
|
|
25
|
+
* // → "C:\\npm\\claude.cmd"
|
|
26
|
+
*/
|
|
27
|
+
function findExecutable(command, lookup) {
|
|
28
|
+
const api = pathApi(lookup.platform);
|
|
29
|
+
const isWindows = lookup.platform === "win32";
|
|
30
|
+
const extensions = isWindows ? (readEnv(lookup.env, "PATHEXT", "win32") ?? DEFAULT_PATHEXT).split(";").map((extension) => extension.trim()).filter((extension) => extension.length > 0) : [];
|
|
31
|
+
const candidatesFor = (base) => {
|
|
32
|
+
if (!isWindows) return [base];
|
|
33
|
+
const hasKnownExtension = extensions.some((extension) => base.toUpperCase().endsWith(extension.toUpperCase()));
|
|
34
|
+
const withExtensions = extensions.map((extension) => base + extension.toLowerCase());
|
|
35
|
+
return hasKnownExtension ? [base, ...withExtensions] : withExtensions;
|
|
36
|
+
};
|
|
37
|
+
if ((isWindows ? /[\\/]/.test(command) : command.includes("/")) || api.isAbsolute(command)) return candidatesFor(api.resolve(command)).find((candidate) => lookup.isFile(candidate));
|
|
38
|
+
const directories = (readEnv(lookup.env, "PATH", lookup.platform) ?? "").split(isWindows ? ";" : ":").map((directory) => directory.trim().replace(/^"(.*)"$/, "$1")).filter((directory) => directory.length > 0);
|
|
39
|
+
for (const directory of directories) for (const candidate of candidatesFor(api.join(directory, command))) if (lookup.isFile(candidate)) return candidate;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the target of an npm (`cmd-shim`) or pnpm `.cmd` shim.
|
|
43
|
+
*
|
|
44
|
+
* Both generators end the shim with a line that runs the target with `%*`;
|
|
45
|
+
* the target is a quoted path relative to the shim's own directory
|
|
46
|
+
* (`"%dp0%\node_modules\pkg\cli.js"` for npm, `"%~dp0\..\pkg\cli.js"` for
|
|
47
|
+
* pnpm). A JavaScript target is run by Node; an `.exe`/`.com` target is run
|
|
48
|
+
* directly. Anything else returns `undefined` and the caller falls back to
|
|
49
|
+
* `cmd.exe`.
|
|
50
|
+
*/
|
|
51
|
+
function parseCmdShim(content, shimPath) {
|
|
52
|
+
const directory = path.win32.dirname(shimPath);
|
|
53
|
+
const line = content.split(/\r?\n/).filter((line) => line.includes("%*")).at(-1);
|
|
54
|
+
if (line === void 0) return;
|
|
55
|
+
const raw = [...line.matchAll(/"(%~?dp0%?[\\/][^"]+)"/gi)].map((match) => match[1] ?? "").filter((target) => !/[\\/]node(\.exe)?$/i.test(target)).at(-1);
|
|
56
|
+
if (raw === void 0) return;
|
|
57
|
+
const relative = raw.replace(/^%~?dp0%?[\\/]/i, "");
|
|
58
|
+
const target = path.win32.resolve(directory, relative);
|
|
59
|
+
if (/\.(c|m)?js$/i.test(target)) return {
|
|
60
|
+
type: "node",
|
|
61
|
+
script: target
|
|
62
|
+
};
|
|
63
|
+
if (/\.(exe|com)$/i.test(target)) return {
|
|
64
|
+
type: "native",
|
|
65
|
+
file: target
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a command to something `child_process.spawn` can start without a
|
|
70
|
+
* shell. On Windows a `.cmd`/`.bat` npm shim is unwrapped to
|
|
71
|
+
* `node <script>` (or its native `.exe`), so argv reaches the CLI intact
|
|
72
|
+
* with no `cmd.exe` parsing in between; any other batch file falls back
|
|
73
|
+
* to `cmd.exe`.
|
|
74
|
+
*/
|
|
75
|
+
function resolveSpawnTarget(command, lookup) {
|
|
76
|
+
const executable = findExecutable(command, lookup);
|
|
77
|
+
if (executable === void 0) return;
|
|
78
|
+
if (lookup.platform !== "win32" || !/\.(cmd|bat)$/i.test(executable)) return {
|
|
79
|
+
type: "direct",
|
|
80
|
+
file: executable,
|
|
81
|
+
prefixArgs: [],
|
|
82
|
+
executable
|
|
83
|
+
};
|
|
84
|
+
const shim = parseCmdShim(lookup.readFile(executable) ?? "", executable);
|
|
85
|
+
if (shim?.type === "node") {
|
|
86
|
+
const bundledNode = path.win32.join(path.win32.dirname(executable), "node.exe");
|
|
87
|
+
return {
|
|
88
|
+
type: "direct",
|
|
89
|
+
file: lookup.isFile(bundledNode) ? bundledNode : lookup.execPath,
|
|
90
|
+
prefixArgs: [shim.script],
|
|
91
|
+
executable
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (shim?.type === "native" && lookup.isFile(shim.file)) return {
|
|
95
|
+
type: "direct",
|
|
96
|
+
file: shim.file,
|
|
97
|
+
prefixArgs: [],
|
|
98
|
+
executable
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
type: "cmd",
|
|
102
|
+
file: executable,
|
|
103
|
+
executable
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
export { findExecutable, parseCmdShim, readEnv, resolveSpawnTarget };
|
|
109
|
+
//# sourceMappingURL=resolve-command.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-command.mjs","names":[],"sources":["../../../../../../../../packages/ai-cli/src/spawn/resolve-command.ts"],"sourcesContent":["import path from \"node:path\";\n\n/**\n * Host facts the resolver needs, injected so every rule is a pure function\n * that tests can drive for Windows on any OS.\n */\nexport type CommandLookup = {\n platform: NodeJS.Platform;\n env: Record<string, string | undefined>;\n /** Whether a regular, runnable file exists at this absolute path. */\n isFile(filePath: string): boolean;\n /** Read a small text file (used only for npm `.cmd` shims). */\n readFile(filePath: string): string | undefined;\n /** Node binary used to run a JavaScript entry behind an npm shim. */\n execPath: string;\n};\n\n/**\n * How to start a resolved command.\n *\n * - `direct` — spawn `file` with `prefixArgs` + the CLI's argv, no shell.\n * - `cmd` — a batch file Node cannot spawn without a shell (Node refuses\n * `.cmd`/`.bat` since CVE-2024-27980); it runs through `cmd.exe` with\n * every argument quoted (see {@link buildInvocation}).\n */\nexport type SpawnTarget =\n | { type: \"direct\"; file: string; prefixArgs: string[]; executable: string }\n | { type: \"cmd\"; file: string; executable: string };\n\n/** Windows' default `PATHEXT` when the variable is unset. */\nconst DEFAULT_PATHEXT = \".COM;.EXE;.BAT;.CMD\";\n\n/** Case-insensitive read on Windows (`Path` vs `PATH`), exact elsewhere. */\nexport function readEnv(\n env: Record<string, string | undefined>,\n name: string,\n platform: NodeJS.Platform,\n): string | undefined {\n if (platform !== \"win32\") {\n return env[name];\n }\n\n const wanted = name.toUpperCase();\n\n for (const [key, value] of Object.entries(env)) {\n if (key.toUpperCase() === wanted && value !== undefined) {\n return value;\n }\n }\n\n return undefined;\n}\n\nfunction pathApi(platform: NodeJS.Platform): typeof path.posix {\n return platform === \"win32\" ? path.win32 : path.posix;\n}\n\n/**\n * Find an executable the way the platform's shell would.\n *\n * POSIX walks `PATH`. Windows walks `PATH` trying each `PATHEXT` extension\n * (`claude` → `claude.cmd`), and also accepts a name that already carries\n * one of those extensions. A command containing a path separator is\n * checked directly instead of searched.\n *\n * @example\n * findExecutable(\"claude\", { platform: \"win32\", env: { PATH: \"C:\\\\npm\", PATHEXT: \".EXE;.CMD\" }, ... });\n * // → \"C:\\\\npm\\\\claude.cmd\"\n */\nexport function findExecutable(command: string, lookup: CommandLookup): string | undefined {\n const api = pathApi(lookup.platform);\n const isWindows = lookup.platform === \"win32\";\n const extensions = isWindows\n ? (readEnv(lookup.env, \"PATHEXT\", \"win32\") ?? DEFAULT_PATHEXT)\n .split(\";\")\n .map((extension) => extension.trim())\n .filter((extension) => extension.length > 0)\n : [];\n\n const candidatesFor = (base: string): string[] => {\n if (!isWindows) {\n return [base];\n }\n\n const hasKnownExtension = extensions.some((extension) =>\n base.toUpperCase().endsWith(extension.toUpperCase()),\n );\n const withExtensions = extensions.map((extension) => base + extension.toLowerCase());\n\n return hasKnownExtension ? [base, ...withExtensions] : withExtensions;\n };\n\n const hasSeparator = isWindows ? /[\\\\/]/.test(command) : command.includes(\"/\");\n\n if (hasSeparator || api.isAbsolute(command)) {\n return candidatesFor(api.resolve(command)).find((candidate) => lookup.isFile(candidate));\n }\n\n const searchPath = readEnv(lookup.env, \"PATH\", lookup.platform) ?? \"\";\n const directories = searchPath\n .split(isWindows ? \";\" : \":\")\n .map((directory) => directory.trim().replace(/^\"(.*)\"$/, \"$1\"))\n .filter((directory) => directory.length > 0);\n\n for (const directory of directories) {\n for (const candidate of candidatesFor(api.join(directory, command))) {\n if (lookup.isFile(candidate)) {\n return candidate;\n }\n }\n }\n\n return undefined;\n}\n\n/** What an npm/pnpm `.cmd` shim launches. */\nexport type CmdShimTarget = { type: \"node\"; script: string } | { type: \"native\"; file: string };\n\n/**\n * Read the target of an npm (`cmd-shim`) or pnpm `.cmd` shim.\n *\n * Both generators end the shim with a line that runs the target with `%*`;\n * the target is a quoted path relative to the shim's own directory\n * (`\"%dp0%\\node_modules\\pkg\\cli.js\"` for npm, `\"%~dp0\\..\\pkg\\cli.js\"` for\n * pnpm). A JavaScript target is run by Node; an `.exe`/`.com` target is run\n * directly. Anything else returns `undefined` and the caller falls back to\n * `cmd.exe`.\n */\nexport function parseCmdShim(content: string, shimPath: string): CmdShimTarget | undefined {\n const directory = path.win32.dirname(shimPath);\n const lines = content.split(/\\r?\\n/).filter((line) => line.includes(\"%*\"));\n const line = lines.at(-1);\n\n if (line === undefined) {\n return undefined;\n }\n\n const quoted = [...line.matchAll(/\"(%~?dp0%?[\\\\/][^\"]+)\"/gi)]\n .map((match) => match[1] ?? \"\")\n .filter((target) => !/[\\\\/]node(\\.exe)?$/i.test(target));\n const raw = quoted.at(-1);\n\n if (raw === undefined) {\n return undefined;\n }\n\n const relative = raw.replace(/^%~?dp0%?[\\\\/]/i, \"\");\n const target = path.win32.resolve(directory, relative);\n\n if (/\\.(c|m)?js$/i.test(target)) {\n return { type: \"node\", script: target };\n }\n\n if (/\\.(exe|com)$/i.test(target)) {\n return { type: \"native\", file: target };\n }\n\n return undefined;\n}\n\n/**\n * Resolve a command to something `child_process.spawn` can start without a\n * shell. On Windows a `.cmd`/`.bat` npm shim is unwrapped to\n * `node <script>` (or its native `.exe`), so argv reaches the CLI intact\n * with no `cmd.exe` parsing in between; any other batch file falls back\n * to `cmd.exe`.\n */\nexport function resolveSpawnTarget(\n command: string,\n lookup: CommandLookup,\n): SpawnTarget | undefined {\n const executable = findExecutable(command, lookup);\n\n if (executable === undefined) {\n return undefined;\n }\n\n if (lookup.platform !== \"win32\" || !/\\.(cmd|bat)$/i.test(executable)) {\n return { type: \"direct\", file: executable, prefixArgs: [], executable };\n }\n\n const shim = parseCmdShim(lookup.readFile(executable) ?? \"\", executable);\n\n if (shim?.type === \"node\") {\n const bundledNode = path.win32.join(path.win32.dirname(executable), \"node.exe\");\n const node = lookup.isFile(bundledNode) ? bundledNode : lookup.execPath;\n\n return { type: \"direct\", file: node, prefixArgs: [shim.script], executable };\n }\n\n if (shim?.type === \"native\" && lookup.isFile(shim.file)) {\n return { type: \"direct\", file: shim.file, prefixArgs: [], executable };\n }\n\n return { type: \"cmd\", file: executable, executable };\n}\n"],"mappings":"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"}
|