@devmarketplacenpm/devmp 0.1.1-beta.5
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/README.md +208 -0
- package/bin/devmp.js +245 -0
- package/lib/api.js +231 -0
- package/lib/browser.js +21 -0
- package/lib/checkpoints.js +292 -0
- package/lib/command-runner.js +368 -0
- package/lib/commands.js +597 -0
- package/lib/completion.js +190 -0
- package/lib/config.js +172 -0
- package/lib/diff.js +216 -0
- package/lib/executor.js +208 -0
- package/lib/git.js +39 -0
- package/lib/instructions.js +69 -0
- package/lib/interactive-tunnel.js +420 -0
- package/lib/interactive.js +1269 -0
- package/lib/markdown.js +201 -0
- package/lib/mentions.js +68 -0
- package/lib/prompt.js +140 -0
- package/lib/routes.js +27 -0
- package/lib/session.js +107 -0
- package/lib/status.js +249 -0
- package/lib/tty/ansi.js +171 -0
- package/lib/tty/composer.js +680 -0
- package/lib/tty/screen.js +167 -0
- package/lib/ui.js +174 -0
- package/lib/version.js +134 -0
- package/lib/workspace.js +608 -0
- package/lib/ws-run.js +142 -0
- package/package.json +40 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { createHash, randomUUID } = require("crypto");
|
|
7
|
+
const { resolveInside, isUnsafeFileTarget } = require("./workspace");
|
|
8
|
+
|
|
9
|
+
const AUTH_DIR = ".devmarketplace";
|
|
10
|
+
const CHECKPOINT_FILE = "cli-checkpoints.json";
|
|
11
|
+
const MAX_CHECKPOINTS = 8;
|
|
12
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const MAX_CHECKPOINT_BYTES = 12 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function checkpointPath() {
|
|
16
|
+
return (
|
|
17
|
+
process.env.DEVMP_CLI_CHECKPOINTS ||
|
|
18
|
+
path.join(os.homedir(), AUTH_DIR, CHECKPOINT_FILE)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hash(content) {
|
|
23
|
+
return createHash("sha256").update(content).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readDocument(file) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
29
|
+
return parsed?.workspaces ? parsed : { version: 1, workspaces: {} };
|
|
30
|
+
} catch {
|
|
31
|
+
return { version: 1, workspaces: {} };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeDocument(file, document) {
|
|
36
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
37
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
38
|
+
await fs.writeFile(temp, `${JSON.stringify(document, null, 2)}\n`, {
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
mode: 0o600,
|
|
41
|
+
});
|
|
42
|
+
await fs.rename(temp, file);
|
|
43
|
+
await fs.chmod(file, 0o600).catch(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizedPath(rootDir, relPath) {
|
|
47
|
+
const target = resolveInside(rootDir, relPath);
|
|
48
|
+
if (!target) throw new Error(`refused ${relPath} (outside workspace)`);
|
|
49
|
+
return path.relative(path.resolve(rootDir), target).split(path.sep).join("/");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function snapshotFile(rootDir, relPath) {
|
|
53
|
+
const normalized = normalizedPath(rootDir, relPath);
|
|
54
|
+
const target = resolveInside(rootDir, normalized);
|
|
55
|
+
if (await isUnsafeFileTarget(rootDir, target)) {
|
|
56
|
+
throw new Error(`Cannot safely checkpoint non-regular file: ${normalized}`);
|
|
57
|
+
}
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = await fs.lstat(target);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "ENOENT") {
|
|
63
|
+
return { path: normalized, exists: false, content: null, hash: null };
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
68
|
+
throw new Error(`Cannot safely checkpoint non-regular file: ${normalized}`);
|
|
69
|
+
}
|
|
70
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Cannot safely checkpoint ${normalized} (${stat.size} bytes; limit ${MAX_FILE_BYTES}).`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const content = await fs.readFile(target, "utf8");
|
|
76
|
+
return {
|
|
77
|
+
path: normalized,
|
|
78
|
+
exists: true,
|
|
79
|
+
content,
|
|
80
|
+
hash: hash(content),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sameSnapshot(a, b) {
|
|
85
|
+
return Boolean(a?.exists) === Boolean(b?.exists) && a?.hash === b?.hash;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function restoreSnapshot(rootDir, snapshot) {
|
|
89
|
+
const target = resolveInside(rootDir, snapshot.path);
|
|
90
|
+
if (!target) throw new Error(`refused ${snapshot.path} (outside workspace)`);
|
|
91
|
+
if (await isUnsafeFileTarget(rootDir, target)) {
|
|
92
|
+
throw new Error(`refused ${snapshot.path} (unsafe file target)`);
|
|
93
|
+
}
|
|
94
|
+
if (!snapshot.exists) {
|
|
95
|
+
await fs.unlink(target).catch((error) => {
|
|
96
|
+
if (error?.code !== "ENOENT") throw error;
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
101
|
+
await fs.writeFile(target, snapshot.content, "utf8");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deriveChanges(checkpoint) {
|
|
105
|
+
const files = checkpoint?.files || {};
|
|
106
|
+
const changed = new Map();
|
|
107
|
+
for (const [filePath, snapshots] of Object.entries(files)) {
|
|
108
|
+
const before = snapshots.before;
|
|
109
|
+
const after = snapshots.after;
|
|
110
|
+
if (!after || sameSnapshot(before, after)) continue;
|
|
111
|
+
changed.set(filePath, {
|
|
112
|
+
path: filePath,
|
|
113
|
+
kind: !before.exists ? "created" : !after.exists ? "deleted" : "modified",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const op of checkpoint.operations || []) {
|
|
118
|
+
if (op.kind !== "move") continue;
|
|
119
|
+
if (changed.has(op.fromPath) && changed.has(op.toPath)) {
|
|
120
|
+
changed.delete(op.fromPath);
|
|
121
|
+
changed.set(op.toPath, {
|
|
122
|
+
kind: "moved",
|
|
123
|
+
path: op.toPath,
|
|
124
|
+
fromPath: op.fromPath,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...changed.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function publicCheckpoint(checkpoint) {
|
|
132
|
+
if (!checkpoint) return null;
|
|
133
|
+
return { ...checkpoint, changes: deriveChanges(checkpoint) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Durable, local-only per-turn pre-image checkpoints. File contents are never
|
|
138
|
+
* sent to the backend; the mode-0600 store exists solely so `/undo` survives a
|
|
139
|
+
* CLI restart. Every pre-image is persisted before the corresponding mutation.
|
|
140
|
+
*/
|
|
141
|
+
function createCheckpointManager({ rootDir, file = checkpointPath() }) {
|
|
142
|
+
const root = path.resolve(rootDir);
|
|
143
|
+
let activeId = null;
|
|
144
|
+
|
|
145
|
+
async function withWorkspace(mutator) {
|
|
146
|
+
const document = await readDocument(file);
|
|
147
|
+
const workspace =
|
|
148
|
+
document.workspaces[root] ||
|
|
149
|
+
(document.workspaces[root] = { checkpoints: [] });
|
|
150
|
+
const result = await mutator(workspace);
|
|
151
|
+
await writeDocument(file, document);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function beginTurn({ turnId, prompt }) {
|
|
156
|
+
const id = randomUUID();
|
|
157
|
+
activeId = id;
|
|
158
|
+
await withWorkspace((workspace) => {
|
|
159
|
+
workspace.checkpoints.push({
|
|
160
|
+
id,
|
|
161
|
+
turnId,
|
|
162
|
+
prompt: String(prompt || "").slice(0, 500),
|
|
163
|
+
createdAt: new Date().toISOString(),
|
|
164
|
+
status: "active",
|
|
165
|
+
files: {},
|
|
166
|
+
operations: [],
|
|
167
|
+
});
|
|
168
|
+
workspace.checkpoints = workspace.checkpoints.slice(-MAX_CHECKPOINTS);
|
|
169
|
+
});
|
|
170
|
+
return id;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function capture(paths) {
|
|
174
|
+
if (!activeId) throw new Error("No active turn checkpoint.");
|
|
175
|
+
const snapshots = [];
|
|
176
|
+
for (const relPath of paths)
|
|
177
|
+
snapshots.push(await snapshotFile(root, relPath));
|
|
178
|
+
await withWorkspace((workspace) => {
|
|
179
|
+
const checkpoint = workspace.checkpoints.find(
|
|
180
|
+
(item) => item.id === activeId
|
|
181
|
+
);
|
|
182
|
+
if (!checkpoint) throw new Error("The active checkpoint is unavailable.");
|
|
183
|
+
let bytes = Object.values(checkpoint.files).reduce(
|
|
184
|
+
(sum, entry) => sum + (entry.before?.content?.length || 0),
|
|
185
|
+
0
|
|
186
|
+
);
|
|
187
|
+
for (const snapshot of snapshots) {
|
|
188
|
+
if (checkpoint.files[snapshot.path]) continue;
|
|
189
|
+
bytes += snapshot.content?.length || 0;
|
|
190
|
+
if (bytes > MAX_CHECKPOINT_BYTES) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"This turn would exceed the local checkpoint safety limit; the change was not applied."
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
checkpoint.files[snapshot.path] = { before: snapshot };
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function recordMove(fromPath, toPath) {
|
|
201
|
+
if (!activeId) return;
|
|
202
|
+
const from = normalizedPath(root, fromPath);
|
|
203
|
+
const to = normalizedPath(root, toPath);
|
|
204
|
+
await withWorkspace((workspace) => {
|
|
205
|
+
const checkpoint = workspace.checkpoints.find(
|
|
206
|
+
(item) => item.id === activeId
|
|
207
|
+
);
|
|
208
|
+
if (checkpoint)
|
|
209
|
+
checkpoint.operations.push({
|
|
210
|
+
kind: "move",
|
|
211
|
+
fromPath: from,
|
|
212
|
+
toPath: to,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function finishTurn(status = "completed") {
|
|
218
|
+
if (!activeId) return null;
|
|
219
|
+
const id = activeId;
|
|
220
|
+
activeId = null;
|
|
221
|
+
const document = await readDocument(file);
|
|
222
|
+
const workspace = document.workspaces[root];
|
|
223
|
+
const checkpoint = workspace?.checkpoints.find((item) => item.id === id);
|
|
224
|
+
if (!checkpoint) return null;
|
|
225
|
+
|
|
226
|
+
for (const [filePath, entry] of Object.entries(checkpoint.files)) {
|
|
227
|
+
entry.after = await snapshotFile(root, filePath);
|
|
228
|
+
}
|
|
229
|
+
checkpoint.status = status;
|
|
230
|
+
checkpoint.completedAt = new Date().toISOString();
|
|
231
|
+
const changes = deriveChanges(checkpoint);
|
|
232
|
+
if (!changes.length) {
|
|
233
|
+
workspace.checkpoints = workspace.checkpoints.filter(
|
|
234
|
+
(item) => item.id !== id
|
|
235
|
+
);
|
|
236
|
+
await writeDocument(file, document);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
await writeDocument(file, document);
|
|
240
|
+
return publicCheckpoint(checkpoint);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function latest({ includeUndone = true } = {}) {
|
|
244
|
+
const document = await readDocument(file);
|
|
245
|
+
const checkpoints = document.workspaces[root]?.checkpoints || [];
|
|
246
|
+
const checkpoint = [...checkpoints]
|
|
247
|
+
.reverse()
|
|
248
|
+
.find((item) => includeUndone || !item.undoneAt);
|
|
249
|
+
return publicCheckpoint(checkpoint);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function undoLatest() {
|
|
253
|
+
const document = await readDocument(file);
|
|
254
|
+
const workspace = document.workspaces[root];
|
|
255
|
+
const checkpoint = [...(workspace?.checkpoints || [])]
|
|
256
|
+
.reverse()
|
|
257
|
+
.find((item) => !item.undoneAt && Object.keys(item.files || {}).length);
|
|
258
|
+
if (!checkpoint) return { ok: false, reason: "empty", conflicts: [] };
|
|
259
|
+
|
|
260
|
+
const conflicts = [];
|
|
261
|
+
for (const [filePath, entry] of Object.entries(checkpoint.files)) {
|
|
262
|
+
const current = await snapshotFile(root, filePath);
|
|
263
|
+
if (entry.after && !sameSnapshot(current, entry.after))
|
|
264
|
+
conflicts.push(filePath);
|
|
265
|
+
}
|
|
266
|
+
if (conflicts.length) return { ok: false, reason: "conflict", conflicts };
|
|
267
|
+
|
|
268
|
+
for (const entry of Object.values(checkpoint.files).reverse()) {
|
|
269
|
+
await restoreSnapshot(root, entry.before);
|
|
270
|
+
}
|
|
271
|
+
checkpoint.undoneAt = new Date().toISOString();
|
|
272
|
+
checkpoint.status = "undone";
|
|
273
|
+
await writeDocument(file, document);
|
|
274
|
+
return { ok: true, checkpoint: publicCheckpoint(checkpoint) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
beginTurn,
|
|
279
|
+
capture,
|
|
280
|
+
finishTurn,
|
|
281
|
+
latest,
|
|
282
|
+
recordMove,
|
|
283
|
+
undoLatest,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
checkpointPath,
|
|
289
|
+
createCheckpointManager,
|
|
290
|
+
deriveChanges,
|
|
291
|
+
snapshotFile,
|
|
292
|
+
};
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
const { randomUUID } = require("crypto");
|
|
5
|
+
const { color } = require("./ui");
|
|
6
|
+
const { isInteractive, confirmCommand } = require("./prompt");
|
|
7
|
+
|
|
8
|
+
/** Kill a foreground command that runs longer than this (ms). Background jobs
|
|
9
|
+
* are exempt — not terminating is the entire point of starting one. */
|
|
10
|
+
const COMMAND_TIMEOUT_MS = 120000;
|
|
11
|
+
/** Cap captured output so a chatty command can't blow up memory / the socket. */
|
|
12
|
+
const MAX_CAPTURE = 200000;
|
|
13
|
+
/** Per-job rolling buffer between checks; a dev server logs forever. */
|
|
14
|
+
const MAX_JOB_BUFFER = 60000;
|
|
15
|
+
/** Grace period between SIGTERM and SIGKILL. */
|
|
16
|
+
const KILL_GRACE_MS = 2000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs shell commands the server agent asks for, on THIS machine, under the same
|
|
20
|
+
* consent model as file overwrites (M1): a pre-approved run (`--allow-commands`),
|
|
21
|
+
* an interactive "yes"/"all", or a refusal. The safe default when we can't ask
|
|
22
|
+
* (non-interactive, no flag) is to refuse — the agent is never allowed to run
|
|
23
|
+
* arbitrary commands unattended.
|
|
24
|
+
*
|
|
25
|
+
* `handle(command)` resolves to the payload the server expects back:
|
|
26
|
+
* { refused: true } — user declined
|
|
27
|
+
* { exitCode, stdout, stderr, timedOut } — command ran
|
|
28
|
+
* Output is streamed to this terminal live AND captured (capped) for the agent.
|
|
29
|
+
*/
|
|
30
|
+
function createCommandRunner({ rootDir, allowCommands, onNotice }) {
|
|
31
|
+
const interactive = isInteractive();
|
|
32
|
+
let allowAll = allowCommands === true;
|
|
33
|
+
const allowedExact = new Set();
|
|
34
|
+
const active = new Map();
|
|
35
|
+
// Background jobs are session-scoped, not turn-scoped: the whole point is to
|
|
36
|
+
// start a server in one turn and probe it in the next.
|
|
37
|
+
const jobs = new Map();
|
|
38
|
+
let turnReport = [];
|
|
39
|
+
|
|
40
|
+
const notice = (text) => {
|
|
41
|
+
if (onNotice) onNotice(text);
|
|
42
|
+
else console.log(text);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function mayRun(command) {
|
|
46
|
+
if (allowAll) return true;
|
|
47
|
+
if (allowedExact.has(command)) return true;
|
|
48
|
+
if (!interactive) return false; // safe default: never run unattended
|
|
49
|
+
const choice = await confirmCommand(command);
|
|
50
|
+
if (choice === "all") allowAll = true;
|
|
51
|
+
if (choice === "exact") allowedExact.add(command);
|
|
52
|
+
return choice !== "no";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function spawnCommand(command, requestId) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
notice(color.dim(` $ ${command}`));
|
|
58
|
+
const child = spawn(command, {
|
|
59
|
+
cwd: rootDir,
|
|
60
|
+
shell: true,
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
// Own process group: killing `sh` alone would leave whatever it
|
|
63
|
+
// started running. See terminate().
|
|
64
|
+
detached: process.platform !== "win32",
|
|
65
|
+
});
|
|
66
|
+
if (requestId !== undefined) active.set(requestId, child);
|
|
67
|
+
|
|
68
|
+
let stdout = "";
|
|
69
|
+
let stderr = "";
|
|
70
|
+
let timedOut = false;
|
|
71
|
+
// Echo by whole lines. Chunks arrive at arbitrary boundaries, and the
|
|
72
|
+
// interactive screen owns the terminal by the row — handing it a partial
|
|
73
|
+
// line would desync its accounting against what the terminal displays.
|
|
74
|
+
let pendingLine = "";
|
|
75
|
+
const echoLines = (text) => {
|
|
76
|
+
pendingLine += text;
|
|
77
|
+
let index = pendingLine.indexOf("\n");
|
|
78
|
+
while (index !== -1) {
|
|
79
|
+
notice(color.dim(` │ ${pendingLine.slice(0, index)}`));
|
|
80
|
+
pendingLine = pendingLine.slice(index + 1);
|
|
81
|
+
index = pendingLine.indexOf("\n");
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const capture = (buf, which) => {
|
|
85
|
+
const text = buf.toString();
|
|
86
|
+
echoLines(text);
|
|
87
|
+
if (which === "out") {
|
|
88
|
+
if (stdout.length < MAX_CAPTURE) stdout += text;
|
|
89
|
+
} else if (stderr.length < MAX_CAPTURE) {
|
|
90
|
+
stderr += text;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
child.stdout.on("data", (b) => capture(b, "out"));
|
|
94
|
+
child.stderr.on("data", (b) => capture(b, "err"));
|
|
95
|
+
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
timedOut = true;
|
|
98
|
+
child.kill("SIGTERM");
|
|
99
|
+
// Escalate if it ignores SIGTERM.
|
|
100
|
+
setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS).unref();
|
|
101
|
+
}, COMMAND_TIMEOUT_MS);
|
|
102
|
+
|
|
103
|
+
const finish = (exitCode) => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
if (pendingLine) {
|
|
106
|
+
notice(color.dim(` │ ${pendingLine}`));
|
|
107
|
+
pendingLine = "";
|
|
108
|
+
}
|
|
109
|
+
if (requestId !== undefined) active.delete(requestId);
|
|
110
|
+
resolve({
|
|
111
|
+
exitCode: typeof exitCode === "number" ? exitCode : null,
|
|
112
|
+
stdout: stdout.slice(0, MAX_CAPTURE),
|
|
113
|
+
stderr: stderr.slice(0, MAX_CAPTURE),
|
|
114
|
+
timedOut,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
child.on("close", (code) => finish(code));
|
|
118
|
+
child.on("error", (err) => {
|
|
119
|
+
stderr += `\n${err.message}`;
|
|
120
|
+
finish(null);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handle(command, requestId) {
|
|
126
|
+
if (!(await mayRun(command))) {
|
|
127
|
+
notice(color.yellow(` ✗ skipped command: ${command}`));
|
|
128
|
+
const refused = {
|
|
129
|
+
command,
|
|
130
|
+
exitCode: null,
|
|
131
|
+
timedOut: false,
|
|
132
|
+
refused: true,
|
|
133
|
+
};
|
|
134
|
+
turnReport.push(refused);
|
|
135
|
+
return { refused: true };
|
|
136
|
+
}
|
|
137
|
+
const result = await spawnCommand(command, requestId);
|
|
138
|
+
turnReport.push({ command, ...result });
|
|
139
|
+
const tag = result.timedOut
|
|
140
|
+
? color.yellow("timed out — use a background job for long-running processes")
|
|
141
|
+
: result.exitCode === 0
|
|
142
|
+
? color.green("exit 0")
|
|
143
|
+
: color.red(`exit ${result.exitCode}`);
|
|
144
|
+
notice(color.dim(` └ ${tag}`));
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── background jobs ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Signal the child's whole process group. Commands run under `sh -c`, so
|
|
152
|
+
* signalling the child alone hits the shell and orphans everything it
|
|
153
|
+
* started — the `npm run dev` that keeps holding port 3000 after you quit.
|
|
154
|
+
*/
|
|
155
|
+
function signalTree(child, signal) {
|
|
156
|
+
if (!child?.pid) return false;
|
|
157
|
+
try {
|
|
158
|
+
if (process.platform === "win32") {
|
|
159
|
+
child.kill(signal);
|
|
160
|
+
} else {
|
|
161
|
+
process.kill(-child.pid, signal);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
// ESRCH: already gone. Fall back to the direct signal in case the group
|
|
166
|
+
// was never created (spawn failed before setsid).
|
|
167
|
+
try {
|
|
168
|
+
child.kill(signal);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function terminate(child) {
|
|
177
|
+
signalTree(child, "SIGTERM");
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
if (!child.killed) signalTree(child, "SIGKILL");
|
|
180
|
+
}, KILL_GRACE_MS).unref();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Start a long-running process and return immediately. Output is buffered
|
|
185
|
+
* rather than echoed: a dev server would otherwise flood the transcript the
|
|
186
|
+
* user is trying to read. `check` drains the buffer, so each call returns
|
|
187
|
+
* only what is new.
|
|
188
|
+
*/
|
|
189
|
+
async function handleStart(command) {
|
|
190
|
+
if (!(await mayRun(command))) {
|
|
191
|
+
notice(color.yellow(` ✗ skipped background command: ${command}`));
|
|
192
|
+
turnReport.push({ command, exitCode: null, timedOut: false, refused: true });
|
|
193
|
+
return { refused: true, jobId: null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const jobId = randomUUID().slice(0, 8);
|
|
197
|
+
const job = {
|
|
198
|
+
jobId,
|
|
199
|
+
command,
|
|
200
|
+
running: true,
|
|
201
|
+
exitCode: null,
|
|
202
|
+
stdout: "",
|
|
203
|
+
stderr: "",
|
|
204
|
+
truncated: false,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const child = spawn(command, {
|
|
208
|
+
cwd: rootDir,
|
|
209
|
+
shell: true,
|
|
210
|
+
// No inherited stdin: a background process must never contend with the
|
|
211
|
+
// composer for the terminal.
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
// Own process group, so `npm run dev` does not survive the shell that
|
|
214
|
+
// started it. See terminate().
|
|
215
|
+
detached: process.platform !== "win32",
|
|
216
|
+
});
|
|
217
|
+
job.child = child;
|
|
218
|
+
|
|
219
|
+
const capture = (buf, which) => {
|
|
220
|
+
const text = buf.toString();
|
|
221
|
+
const next = job[which] + text;
|
|
222
|
+
if (next.length > MAX_JOB_BUFFER) {
|
|
223
|
+
job.truncated = true;
|
|
224
|
+
job[which] = next.slice(next.length - MAX_JOB_BUFFER);
|
|
225
|
+
} else {
|
|
226
|
+
job[which] = next;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
child.stdout.on("data", (b) => capture(b, "stdout"));
|
|
230
|
+
child.stderr.on("data", (b) => capture(b, "stderr"));
|
|
231
|
+
|
|
232
|
+
child.on("close", (code) => {
|
|
233
|
+
job.running = false;
|
|
234
|
+
job.stopping = false;
|
|
235
|
+
job.exitCode = typeof code === "number" ? code : null;
|
|
236
|
+
job.child = null;
|
|
237
|
+
notice(
|
|
238
|
+
color.dim(` └ background ${jobId} exited (${job.exitCode ?? "killed"})`)
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
child.on("error", (err) => {
|
|
242
|
+
job.running = false;
|
|
243
|
+
job.stderr += `\n${err.message}`;
|
|
244
|
+
job.child = null;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
jobs.set(jobId, job);
|
|
248
|
+
turnReport.push({ command, background: true, jobId });
|
|
249
|
+
notice(color.dim(` $ ${command} ${color.cyan(`[background ${jobId}]`)}`));
|
|
250
|
+
return { jobId };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Status plus output since the previous check; the buffer is drained. */
|
|
254
|
+
function handleCheck(jobId) {
|
|
255
|
+
const job = jobs.get(jobId);
|
|
256
|
+
if (!job) return { found: false, running: false, exitCode: null, stdout: "", stderr: "" };
|
|
257
|
+
const payload = {
|
|
258
|
+
found: true,
|
|
259
|
+
running: job.running,
|
|
260
|
+
exitCode: job.exitCode,
|
|
261
|
+
stdout: job.stdout,
|
|
262
|
+
stderr: job.stderr,
|
|
263
|
+
truncated: job.truncated,
|
|
264
|
+
};
|
|
265
|
+
job.stdout = "";
|
|
266
|
+
job.stderr = "";
|
|
267
|
+
job.truncated = false;
|
|
268
|
+
return payload;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function handleStop(jobId) {
|
|
272
|
+
const job = jobs.get(jobId);
|
|
273
|
+
if (!job) return { found: false, stopped: false };
|
|
274
|
+
if (!job.running || !job.child) return { found: true, stopped: false };
|
|
275
|
+
terminate(job.child);
|
|
276
|
+
// Stays `running` until the process actually closes; the exit teardown
|
|
277
|
+
// relies on that to escalate to SIGKILL if it ignores SIGTERM.
|
|
278
|
+
job.stopping = true;
|
|
279
|
+
notice(color.dim(` └ stopping background ${jobId}`));
|
|
280
|
+
return { found: true, stopped: true };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function handleList() {
|
|
284
|
+
return {
|
|
285
|
+
jobs: [...jobs.values()].map((job) => ({
|
|
286
|
+
jobId: job.jobId,
|
|
287
|
+
command: job.command,
|
|
288
|
+
running: job.running,
|
|
289
|
+
stopping: Boolean(job.stopping),
|
|
290
|
+
exitCode: job.exitCode,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** For the shell's `/jobs` view — same data, no socket round-trip. */
|
|
296
|
+
function listJobs() {
|
|
297
|
+
return handleList().jobs;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function stopJob(jobId) {
|
|
301
|
+
return handleStop(jobId);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Kill every background job. Called when the session ends, so it cannot rely
|
|
306
|
+
* on terminate()'s unref'd SIGKILL timer — the process exits first and the
|
|
307
|
+
* kill never lands. This one waits out the grace period itself and returns
|
|
308
|
+
* only once nothing is left running.
|
|
309
|
+
*/
|
|
310
|
+
async function killAllJobs() {
|
|
311
|
+
const live = [...jobs.values()].filter((job) => job.running && job.child);
|
|
312
|
+
if (!live.length) return [];
|
|
313
|
+
for (const job of live) signalTree(job.child, "SIGTERM");
|
|
314
|
+
|
|
315
|
+
const deadline = Date.now() + KILL_GRACE_MS;
|
|
316
|
+
while (Date.now() < deadline && live.some((job) => job.running)) {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const stubborn = live.filter((job) => job.running);
|
|
321
|
+
for (const job of stubborn) signalTree(job.child, "SIGKILL");
|
|
322
|
+
for (const job of live) job.running = false;
|
|
323
|
+
return live.map((job) => job.jobId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function cancel(requestId) {
|
|
327
|
+
const child = active.get(requestId);
|
|
328
|
+
if (!child) return false;
|
|
329
|
+
terminate(child);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Cancels foreground commands only; background jobs outlive a turn. */
|
|
334
|
+
function cancelAll() {
|
|
335
|
+
for (const requestId of active.keys()) cancel(requestId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function setAllowCommands(value) {
|
|
339
|
+
allowAll = value === true;
|
|
340
|
+
if (!allowAll) allowedExact.clear();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function beginTurn() {
|
|
344
|
+
turnReport = [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function getTurnReport() {
|
|
348
|
+
return turnReport.map((entry) => ({ ...entry }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
handle,
|
|
353
|
+
handleStart,
|
|
354
|
+
handleCheck,
|
|
355
|
+
handleStop,
|
|
356
|
+
handleList,
|
|
357
|
+
listJobs,
|
|
358
|
+
stopJob,
|
|
359
|
+
killAllJobs,
|
|
360
|
+
cancel,
|
|
361
|
+
cancelAll,
|
|
362
|
+
setAllowCommands,
|
|
363
|
+
beginTurn,
|
|
364
|
+
getTurnReport,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = { createCommandRunner };
|