@brainervirus/workit-core 2.5.0 → 2.7.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/package.json +1 -1
- package/scripts/analyze-release-scope.ts +99 -8
- package/scripts/bundled-deps.json +133 -0
- package/scripts/publish-changed-packages.ts +28 -16
- package/scripts/rewrite-workspace-deps.ts +11 -5
- package/scripts/sync-release-manifests.ts +5 -1
- package/skills/workit-behavioral-tdd/SKILL.md +15 -9
- package/skills/workit-debug/SKILL.md +2 -1
- package/src/check-config.ts +294 -0
- package/src/checks.ts +480 -0
- package/src/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/host-install.ts +144 -1
- package/src/core/methods.ts +5 -2
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/task-context.ts +6 -2
- package/src/core/task-contract.ts +70 -12
- package/src/core/task-engine.ts +120 -10
- package/src/core/task-evaluation.ts +176 -1
- package/src/core/uninstall.ts +107 -4
- package/src/git/rev.ts +50 -0
- package/src/hooks/context.ts +44 -5
- package/src/hooks/handle.ts +15 -4
package/src/checks.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// Check runs for `workit check` (design §2.1 S9, §2.2; D5, D14): no shell
|
|
2
|
+
// (unless the caller passes one string with --shell), the child's output
|
|
3
|
+
// streams through while a bounded, redacted copy is kept for the log blob
|
|
4
|
+
// (`<store>/blobs/logs/<sha256>.log`) and the tail. Named-check config lives
|
|
5
|
+
// in check-config.ts.
|
|
6
|
+
//
|
|
7
|
+
// Plain TS over node built-ins: no zod, no task store.
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { redactText } from "./forge/redact";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// bounded, redacted capture
|
|
18
|
+
|
|
19
|
+
/** Bytes of combined output kept for the log blob (the newest bytes win). */
|
|
20
|
+
export const MAX_LOG_BYTES = 2 * 1024 * 1024;
|
|
21
|
+
export const TAIL_LINES = 80;
|
|
22
|
+
const MAX_LINE_CHARS = 400;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mask secrets and strip terminal escapes from captured output with the forge
|
|
26
|
+
* redaction (tokens, key=value and quoted secrets, URL credentials, private
|
|
27
|
+
* keys, signed URLs, credential-looking base64). The logger's redaction is not
|
|
28
|
+
* used: it rewrites Windows backslashes and home paths, which would alter the
|
|
29
|
+
* observed output.
|
|
30
|
+
*/
|
|
31
|
+
export const redactLog = (text: string): string => redactText(text);
|
|
32
|
+
|
|
33
|
+
/** The last `lines` non-empty lines, each cut to a bounded width. */
|
|
34
|
+
export function tailLines(text: string, lines: number = TAIL_LINES): string[] {
|
|
35
|
+
const all = text.replace(/\r\n?/gu, "\n").split("\n");
|
|
36
|
+
while (all.length && !all.at(-1)?.trim()) all.pop();
|
|
37
|
+
return all
|
|
38
|
+
.slice(-lines)
|
|
39
|
+
.map((line) => (line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keeps the newest `max` bytes of already-redacted text. */
|
|
43
|
+
export class TailBuffer {
|
|
44
|
+
private chunks: string[] = [];
|
|
45
|
+
private size = 0;
|
|
46
|
+
total = 0;
|
|
47
|
+
constructor(private readonly max: number) {}
|
|
48
|
+
push(text: string): void {
|
|
49
|
+
const bytes = Buffer.byteLength(text);
|
|
50
|
+
this.total += bytes;
|
|
51
|
+
this.chunks.push(text);
|
|
52
|
+
this.size += bytes;
|
|
53
|
+
while (this.chunks.length > 1 && this.size - Buffer.byteLength(this.chunks[0]) >= this.max)
|
|
54
|
+
this.size -= Buffer.byteLength(this.chunks.shift()!);
|
|
55
|
+
}
|
|
56
|
+
text(): string {
|
|
57
|
+
const joined = Buffer.from(this.chunks.join(""));
|
|
58
|
+
if (joined.length <= this.max) return joined.toString("utf8");
|
|
59
|
+
const value = joined.subarray(joined.length - this.max).toString("utf8");
|
|
60
|
+
// The head was cut: drop the partial first line.
|
|
61
|
+
const newline = value.indexOf("\n");
|
|
62
|
+
return newline >= 0 ? value.slice(newline + 1) : value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PEM_BEGIN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
|
|
67
|
+
const PEM_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/u;
|
|
68
|
+
const BATCH_BYTES = 64 * 1024;
|
|
69
|
+
const MAX_PENDING = 64 * 1024;
|
|
70
|
+
|
|
71
|
+
/** Lines that can belong to a PEM body: base64 (or blank), or an RFC 1421 encapsulated header. */
|
|
72
|
+
const PEM_BODY = /^(?:[A-Za-z0-9+/=]*|(?:Proc-Type|DEK-Info|Comment): .*)\s*$/u;
|
|
73
|
+
/** Suppression bounds for a key whose END never appears (truncated output). */
|
|
74
|
+
const MAX_KEY_LINES = 4096;
|
|
75
|
+
const MAX_KEY_BYTES = 256 * 1024;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Redacts output as it streams, before anything is bounded: complete lines
|
|
79
|
+
* are redacted in batches, and a private key block is replaced as a whole
|
|
80
|
+
* while its BEGIN line is still in view, so trimming the log to its newest
|
|
81
|
+
* bytes can never cut a key loose from its header. Key state spans the merged
|
|
82
|
+
* stdout+stderr log: while a key is open, base64-looking lines from either
|
|
83
|
+
* stream are dropped. The key ends at its END line, at the first non-base64
|
|
84
|
+
* line from the stream that began it, or after MAX_KEY_LINES/MAX_KEY_BYTES, so
|
|
85
|
+
* a key without an END never swallows the rest of the log. Non-key lines from
|
|
86
|
+
* the other stream pass through. A line longer than 64 KB is treated as complete.
|
|
87
|
+
*/
|
|
88
|
+
export class StreamRedactor {
|
|
89
|
+
private pending = new Map<string, string>();
|
|
90
|
+
private key: { stream: string; lines: number; bytes: number } | null = null;
|
|
91
|
+
private batch: string[] = [];
|
|
92
|
+
private batchBytes = 0;
|
|
93
|
+
constructor(private readonly sink: (text: string) => void) {}
|
|
94
|
+
write(stream: string, text: string): void {
|
|
95
|
+
let rest = (this.pending.get(stream) ?? "") + text;
|
|
96
|
+
for (let newline = rest.indexOf("\n"); newline >= 0; newline = rest.indexOf("\n")) {
|
|
97
|
+
this.line(stream, rest.slice(0, newline + 1));
|
|
98
|
+
rest = rest.slice(newline + 1);
|
|
99
|
+
}
|
|
100
|
+
if (rest.length > MAX_PENDING) {
|
|
101
|
+
this.line(stream, rest);
|
|
102
|
+
rest = "";
|
|
103
|
+
}
|
|
104
|
+
this.pending.set(stream, rest);
|
|
105
|
+
}
|
|
106
|
+
end(): void {
|
|
107
|
+
for (const [stream, rest] of this.pending) if (rest) this.line(stream, `${rest}\n`);
|
|
108
|
+
this.pending.clear();
|
|
109
|
+
this.flush();
|
|
110
|
+
}
|
|
111
|
+
private line(stream: string, line: string): void {
|
|
112
|
+
if (this.key) {
|
|
113
|
+
if (PEM_END.test(line)) {
|
|
114
|
+
this.key = null;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const body = PEM_BODY.test(line);
|
|
118
|
+
if (body) {
|
|
119
|
+
this.key.lines += 1;
|
|
120
|
+
this.key.bytes += line.length;
|
|
121
|
+
if (this.key.lines > MAX_KEY_LINES || this.key.bytes > MAX_KEY_BYTES) this.key = null;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (stream === this.key.stream) this.key = null;
|
|
125
|
+
}
|
|
126
|
+
const begin = line.search(PEM_BEGIN);
|
|
127
|
+
if (begin >= 0) {
|
|
128
|
+
if (!PEM_END.test(line.slice(begin))) this.key = { stream, lines: 0, bytes: 0 };
|
|
129
|
+
this.push(`${line.slice(0, begin)}[REDACTED PRIVATE KEY]\n`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.push(line);
|
|
133
|
+
}
|
|
134
|
+
private push(line: string): void {
|
|
135
|
+
this.batch.push(line);
|
|
136
|
+
this.batchBytes += line.length;
|
|
137
|
+
if (this.batchBytes >= BATCH_BYTES) this.flush();
|
|
138
|
+
}
|
|
139
|
+
private flush(): void {
|
|
140
|
+
if (!this.batch.length) return;
|
|
141
|
+
this.sink(redactLog(this.batch.join("")));
|
|
142
|
+
this.batch = [];
|
|
143
|
+
this.batchBytes = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type CheckRun = {
|
|
148
|
+
/** The child's exit code; 128+n when killed by signal n; 127 when it could not start. */
|
|
149
|
+
exitCode: number;
|
|
150
|
+
signal: string | null;
|
|
151
|
+
timedOut: boolean;
|
|
152
|
+
durationMs: number;
|
|
153
|
+
/** Redacted, bounded combined output. */
|
|
154
|
+
log: string;
|
|
155
|
+
/** Total bytes of redacted output (before bounding). */
|
|
156
|
+
outputBytes: number;
|
|
157
|
+
truncated: boolean;
|
|
158
|
+
/** Set when the command could not be started. */
|
|
159
|
+
spawnError: string | null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type RunOptions = {
|
|
163
|
+
cwd: string;
|
|
164
|
+
shell?: boolean;
|
|
165
|
+
timeoutMs?: number;
|
|
166
|
+
env?: NodeJS.ProcessEnv;
|
|
167
|
+
/** Receives the child's stdout as it arrives. */
|
|
168
|
+
onStdout?: (text: string) => void;
|
|
169
|
+
onStderr?: (text: string) => void;
|
|
170
|
+
/** For tests: the platform whose spawn rules apply (default: process.platform). */
|
|
171
|
+
platform?: NodeJS.Platform;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// cmd.exe metacharacters (the cross-spawn set).
|
|
175
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/gu;
|
|
176
|
+
|
|
177
|
+
/** One argument for a `cmd /d /s /c "…"` line; `double` for node_modules/.bin shims, which re-parse. */
|
|
178
|
+
export function cmdArgument(arg: string, double = false): string {
|
|
179
|
+
let out = arg.replace(/(\\*)"/gu, '$1$1\\"').replace(/(\\*)$/u, "$1$1");
|
|
180
|
+
out = `"${out}"`.replace(CMD_META, "^$1");
|
|
181
|
+
return double ? out.replace(CMD_META, "^$1") : out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type SpawnPlan = {
|
|
185
|
+
file: string;
|
|
186
|
+
args: string[];
|
|
187
|
+
/** Pass the args to CreateProcess verbatim (cmd.exe lines carry their own quoting). */
|
|
188
|
+
verbatim: boolean;
|
|
189
|
+
shell: boolean;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How to start argv without a shell. On Windows, argv[0] is resolved through
|
|
194
|
+
* PATH and PATHEXT (CreateProcess finds only .exe/.com); a .cmd/.bat shim
|
|
195
|
+
* (npm, pnpm, yarn, node_modules/.bin) runs as `cmd.exe /d /s /c "<line>"`
|
|
196
|
+
* with every argument escaped, so the observed argv (and `configured`) stay
|
|
197
|
+
* the configured ones. Null `isFile` uses the real filesystem.
|
|
198
|
+
*/
|
|
199
|
+
export function planSpawn(
|
|
200
|
+
argv: readonly string[],
|
|
201
|
+
options: {
|
|
202
|
+
platform: NodeJS.Platform;
|
|
203
|
+
env: NodeJS.ProcessEnv;
|
|
204
|
+
cwd: string;
|
|
205
|
+
shell?: boolean;
|
|
206
|
+
isFile?: (file: string) => boolean;
|
|
207
|
+
},
|
|
208
|
+
): SpawnPlan {
|
|
209
|
+
if (options.shell) return { file: argv[0], args: [], verbatim: false, shell: true };
|
|
210
|
+
if (options.platform !== "win32")
|
|
211
|
+
return { file: argv[0], args: argv.slice(1), verbatim: false, shell: false };
|
|
212
|
+
const win = path.win32;
|
|
213
|
+
const isFile =
|
|
214
|
+
options.isFile ??
|
|
215
|
+
((file: string) => {
|
|
216
|
+
try {
|
|
217
|
+
return fs.statSync(file).isFile();
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// Windows names are case-insensitive; an exact upper-case key wins over a duplicate.
|
|
223
|
+
const env = (name: string): string | undefined =>
|
|
224
|
+
options.env[name] ??
|
|
225
|
+
Object.entries(options.env).find(([key]) => key.toUpperCase() === name)?.[1];
|
|
226
|
+
const exts = (env("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
227
|
+
const command = argv[0];
|
|
228
|
+
const hasDir = /[\\/]/u.test(command);
|
|
229
|
+
const dirs = hasDir ? [""] : (env("PATH") ?? "").split(";").filter(Boolean);
|
|
230
|
+
const candidates = (base: string) =>
|
|
231
|
+
win.extname(base) ? [base, ...exts.map((ext) => base + ext)] : exts.map((ext) => base + ext);
|
|
232
|
+
let resolved: string | null = null;
|
|
233
|
+
for (const dir of dirs) {
|
|
234
|
+
const base = hasDir ? win.resolve(options.cwd, command) : win.join(dir, command);
|
|
235
|
+
resolved = candidates(base).find(isFile) ?? null;
|
|
236
|
+
if (resolved) break;
|
|
237
|
+
}
|
|
238
|
+
if (!resolved) return { file: command, args: argv.slice(1), verbatim: false, shell: false };
|
|
239
|
+
if (!/\.(?:cmd|bat)$/iu.test(resolved))
|
|
240
|
+
return { file: resolved, args: argv.slice(1), verbatim: false, shell: false };
|
|
241
|
+
const double = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/iu.test(resolved);
|
|
242
|
+
const line = [
|
|
243
|
+
win.normalize(resolved).replace(CMD_META, "^$1"),
|
|
244
|
+
...argv.slice(1).map((arg) => cmdArgument(arg, double)),
|
|
245
|
+
].join(" ");
|
|
246
|
+
return {
|
|
247
|
+
file: env("COMSPEC") ?? "cmd.exe",
|
|
248
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
249
|
+
verbatim: true,
|
|
250
|
+
shell: false,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** After the child exits, how long its pipes may stay open (held by leftovers) before they are cut. */
|
|
255
|
+
const DRAIN_MS = 250;
|
|
256
|
+
const FORWARDED_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Run one command, streaming its output to the callbacks while a redacted,
|
|
260
|
+
* bounded copy is captured. With `shell`, argv must be a single command
|
|
261
|
+
* string. On POSIX the child leads its own process group (stdin is not
|
|
262
|
+
* inherited), so a timeout, or the exit of the command, kills every process
|
|
263
|
+
* it started; Ctrl-C and SIGTERM sent to workit are forwarded to the group.
|
|
264
|
+
* On Windows the tree is killed with `taskkill /T /F`. The run settles on
|
|
265
|
+
* the child's exit plus a short drain, so a leftover holding the pipes open
|
|
266
|
+
* cannot hang it.
|
|
267
|
+
*/
|
|
268
|
+
export function runCheckCommand(argv: readonly string[], options: RunOptions): Promise<CheckRun> {
|
|
269
|
+
const started = Date.now();
|
|
270
|
+
const platform = options.platform ?? process.platform;
|
|
271
|
+
const windows = process.platform === "win32";
|
|
272
|
+
const buffer = new TailBuffer(MAX_LOG_BYTES);
|
|
273
|
+
const redactor = new StreamRedactor((text) => buffer.push(text));
|
|
274
|
+
const env = options.env ?? process.env;
|
|
275
|
+
const plan = planSpawn(argv, { platform, env, cwd: options.cwd, shell: options.shell });
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
let settled = false;
|
|
278
|
+
let timedOut = false;
|
|
279
|
+
let drain: NodeJS.Timeout | null = null;
|
|
280
|
+
let child: ReturnType<typeof spawn>;
|
|
281
|
+
let timer: NodeJS.Timeout | null = null;
|
|
282
|
+
const killTree = () => {
|
|
283
|
+
if (!child?.pid) return;
|
|
284
|
+
if (windows)
|
|
285
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
286
|
+
stdio: "ignore",
|
|
287
|
+
windowsHide: true,
|
|
288
|
+
});
|
|
289
|
+
else
|
|
290
|
+
try {
|
|
291
|
+
process.kill(-child.pid, "SIGKILL");
|
|
292
|
+
} catch {
|
|
293
|
+
// The group is already gone.
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const forwarders = new Map<NodeJS.Signals, () => void>();
|
|
297
|
+
const finish = (code: number | null, signal: NodeJS.Signals | null, spawnError?: string) => {
|
|
298
|
+
if (settled) return;
|
|
299
|
+
settled = true;
|
|
300
|
+
if (timer) clearTimeout(timer);
|
|
301
|
+
if (drain) clearTimeout(drain);
|
|
302
|
+
for (const [name, handler] of forwarders) process.off(name, handler);
|
|
303
|
+
child?.stdout?.destroy();
|
|
304
|
+
child?.stderr?.destroy();
|
|
305
|
+
if (spawnError) redactor.write("workit", `workit: cannot start ${argv[0]}: ${spawnError}\n`);
|
|
306
|
+
redactor.end();
|
|
307
|
+
resolve({
|
|
308
|
+
exitCode: spawnError
|
|
309
|
+
? 127
|
|
310
|
+
: code !== null
|
|
311
|
+
? code
|
|
312
|
+
: 128 + (signal ? (os.constants.signals[signal] ?? 1) : 1),
|
|
313
|
+
signal: signal ?? null,
|
|
314
|
+
timedOut,
|
|
315
|
+
durationMs: Date.now() - started,
|
|
316
|
+
log: buffer.text(),
|
|
317
|
+
outputBytes: buffer.total,
|
|
318
|
+
truncated: buffer.total > MAX_LOG_BYTES,
|
|
319
|
+
spawnError: spawnError ?? null,
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
const spawnOptions = {
|
|
323
|
+
cwd: options.cwd,
|
|
324
|
+
env,
|
|
325
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
326
|
+
windowsHide: true,
|
|
327
|
+
detached: !windows,
|
|
328
|
+
windowsVerbatimArguments: plan.verbatim,
|
|
329
|
+
shell: plan.shell,
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
child = spawn(plan.file, plan.args, spawnOptions);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
finish(null, null, (error as NodeJS.ErrnoException).code ?? (error as Error).message);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (!windows)
|
|
338
|
+
for (const name of FORWARDED_SIGNALS) {
|
|
339
|
+
const handler = () => {
|
|
340
|
+
if (child.pid)
|
|
341
|
+
try {
|
|
342
|
+
process.kill(-child.pid, name);
|
|
343
|
+
} catch {
|
|
344
|
+
// Already gone.
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
forwarders.set(name, handler);
|
|
348
|
+
process.on(name, handler);
|
|
349
|
+
}
|
|
350
|
+
if (options.timeoutMs && options.timeoutMs > 0)
|
|
351
|
+
timer = setTimeout(() => {
|
|
352
|
+
timedOut = true;
|
|
353
|
+
killTree();
|
|
354
|
+
}, options.timeoutMs);
|
|
355
|
+
const forward = (
|
|
356
|
+
name: string,
|
|
357
|
+
stream: NodeJS.ReadableStream | null,
|
|
358
|
+
sink?: (text: string) => void,
|
|
359
|
+
) => {
|
|
360
|
+
if (!stream) return;
|
|
361
|
+
const decoder = new StringDecoder("utf8");
|
|
362
|
+
stream.on("data", (chunk: Buffer) => {
|
|
363
|
+
const text = decoder.write(chunk);
|
|
364
|
+
redactor.write(name, text);
|
|
365
|
+
if (sink) sink(text);
|
|
366
|
+
});
|
|
367
|
+
stream.on("end", () => {
|
|
368
|
+
const rest = decoder.end();
|
|
369
|
+
if (rest) {
|
|
370
|
+
redactor.write(name, rest);
|
|
371
|
+
if (sink) sink(rest);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
stream.on("error", () => {
|
|
375
|
+
// A destroyed pipe after the drain: the captured output stands.
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
forward("stdout", child.stdout, options.onStdout);
|
|
379
|
+
forward("stderr", child.stderr, options.onStderr);
|
|
380
|
+
child.on("error", (error: NodeJS.ErrnoException) =>
|
|
381
|
+
finish(null, null, error.code ?? error.message),
|
|
382
|
+
);
|
|
383
|
+
child.on("exit", (code, signal) => {
|
|
384
|
+
// Leftovers of the command die with it; then give the pipes a moment.
|
|
385
|
+
if (!windows) killTree();
|
|
386
|
+
drain = setTimeout(() => finish(code, signal), DRAIN_MS);
|
|
387
|
+
});
|
|
388
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Content address of a stored log: `sha256:<hex>`. */
|
|
393
|
+
export const logDigest = (log: string): string =>
|
|
394
|
+
`sha256:${createHash("sha256").update(log).digest("hex")}`;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Store a redacted log under `<storeRoot>/blobs/logs/<hex>.log` (content
|
|
398
|
+
* addressed, so concurrent writers of the same log agree). Returns the path
|
|
399
|
+
* relative to the store root, or null when the write fails.
|
|
400
|
+
*/
|
|
401
|
+
export function storeLog(storeRoot: string, log: string): { digest: string; ref: string } | null {
|
|
402
|
+
const digest = logDigest(log);
|
|
403
|
+
const ref = `blobs/logs/${digest.slice("sha256:".length)}.log`;
|
|
404
|
+
const file = path.join(storeRoot, ref);
|
|
405
|
+
try {
|
|
406
|
+
if (!fs.existsSync(file)) {
|
|
407
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
408
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
409
|
+
fs.writeFileSync(temp, log, { mode: 0o600 });
|
|
410
|
+
fs.renameSync(temp, file);
|
|
411
|
+
}
|
|
412
|
+
return { digest, ref };
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export type LogPruneReport = { removed: number; removedBytes: number; kept: number };
|
|
419
|
+
|
|
420
|
+
/** Check-log retention for `workit gc`: newest 200, at most 30 days old, at most 256 MB. */
|
|
421
|
+
export const LOG_RETENTION = {
|
|
422
|
+
maxCount: 200,
|
|
423
|
+
maxAgeMs: 30 * 86_400_000,
|
|
424
|
+
maxBytes: 256 * 1024 * 1024,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prune `<storeRoot>/blobs/logs`: keep the newest logs within the count, age
|
|
429
|
+
* and size caps, and drop leftover temp files. Evidence keeps its logDigest
|
|
430
|
+
* when its blob is pruned. `dryRun` only reports.
|
|
431
|
+
*/
|
|
432
|
+
export function pruneCheckLogs(
|
|
433
|
+
storeRoot: string,
|
|
434
|
+
options: { dryRun?: boolean; now?: number; retention?: Partial<typeof LOG_RETENTION> } = {},
|
|
435
|
+
): LogPruneReport {
|
|
436
|
+
const limits = { ...LOG_RETENTION, ...options.retention };
|
|
437
|
+
const now = options.now ?? Date.now();
|
|
438
|
+
const dir = path.join(storeRoot, "blobs", "logs");
|
|
439
|
+
const report: LogPruneReport = { removed: 0, removedBytes: 0, kept: 0 };
|
|
440
|
+
let names: string[];
|
|
441
|
+
try {
|
|
442
|
+
names = fs.readdirSync(dir);
|
|
443
|
+
} catch {
|
|
444
|
+
return report;
|
|
445
|
+
}
|
|
446
|
+
const files = names
|
|
447
|
+
.flatMap((name) => {
|
|
448
|
+
try {
|
|
449
|
+
const stat = fs.statSync(path.join(dir, name));
|
|
450
|
+
return stat.isFile() ? [{ name, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
|
|
451
|
+
} catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
.toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
456
|
+
let keptBytes = 0;
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const temp = file.name.endsWith(".tmp");
|
|
459
|
+
const keep =
|
|
460
|
+
!temp &&
|
|
461
|
+
report.kept < limits.maxCount &&
|
|
462
|
+
now - file.mtimeMs <= limits.maxAgeMs &&
|
|
463
|
+
keptBytes + file.size <= limits.maxBytes;
|
|
464
|
+
if (keep) {
|
|
465
|
+
report.kept += 1;
|
|
466
|
+
keptBytes += file.size;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (temp && now - file.mtimeMs < 3_600_000) continue;
|
|
470
|
+
if (!options.dryRun)
|
|
471
|
+
try {
|
|
472
|
+
fs.rmSync(path.join(dir, file.name), { force: true });
|
|
473
|
+
} catch {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
report.removed += 1;
|
|
477
|
+
report.removedBytes += file.size;
|
|
478
|
+
}
|
|
479
|
+
return report;
|
|
480
|
+
}
|
package/src/core/detect-hosts.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { planUninstall, type UninstallPaths } from "./uninstall";
|
|
|
9
9
|
import {
|
|
10
10
|
findHostExecutable,
|
|
11
11
|
installedHostApp,
|
|
12
|
+
isClaudeWorkitInstalled,
|
|
12
13
|
isCodexWorkitInstalled,
|
|
13
14
|
isPiWorkitInstalled,
|
|
14
15
|
type HostId,
|
|
@@ -24,7 +25,7 @@ export type HostDetection = {
|
|
|
24
25
|
|
|
25
26
|
export type DetectHostsOptions = UninstallPaths;
|
|
26
27
|
|
|
27
|
-
const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
|
|
28
|
+
const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi", "claude-code"];
|
|
28
29
|
|
|
29
30
|
/** Hosts the setup wizard configures through their native install paths. */
|
|
30
31
|
export const WIZARD_HOSTS: HostId[] = [...HOSTS];
|
|
@@ -40,6 +41,7 @@ export function emptyDetection(): Record<HostId, HostDetection> {
|
|
|
40
41
|
cursor: { detected: false, configured: false },
|
|
41
42
|
codex: { detected: false, configured: false },
|
|
42
43
|
pi: { detected: false, configured: false },
|
|
44
|
+
"claude-code": { detected: false, configured: false },
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -52,8 +54,10 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
|
|
|
52
54
|
const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
|
|
53
55
|
const found = emptyDetection();
|
|
54
56
|
for (const host of HOSTS) {
|
|
57
|
+
// Claude Code ships as the `claude` CLI (the desktop app embeds it).
|
|
58
|
+
const executable = host === "claude-code" ? "claude" : host;
|
|
55
59
|
const detected =
|
|
56
|
-
findHostExecutable(
|
|
60
|
+
findHostExecutable(executable, { home, env }) !== null || installedHostApp(host, home, env);
|
|
57
61
|
found[host] = {
|
|
58
62
|
detected,
|
|
59
63
|
configured:
|
|
@@ -61,7 +65,9 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
|
|
|
61
65
|
? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
62
66
|
: host === "pi"
|
|
63
67
|
? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
|
|
64
|
-
:
|
|
68
|
+
: host === "claude-code"
|
|
69
|
+
? isClaudeWorkitInstalled(home, env)
|
|
70
|
+
: (configuredByHost.get(host) ?? false),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
return found;
|
package/src/core/doctor.ts
CHANGED
|
@@ -33,6 +33,11 @@ import {
|
|
|
33
33
|
isWorkitPlugin,
|
|
34
34
|
} from "./registration";
|
|
35
35
|
import { readWorkspacesResult, resolveWorkspaceFrom } from "./workspaces";
|
|
36
|
+
import {
|
|
37
|
+
CLAUDE_MARKETPLACE_NAME,
|
|
38
|
+
claudeWorkitInstalls,
|
|
39
|
+
type ClaudeWorkitInstall,
|
|
40
|
+
} from "./host-install";
|
|
36
41
|
import { validateCursorSkills, WORKIT_METHOD_SKILLS } from "./skill-manifests";
|
|
37
42
|
import {
|
|
38
43
|
classifyHostGeneration,
|
|
@@ -53,6 +58,7 @@ export type DoctorCheckId =
|
|
|
53
58
|
| "runtime"
|
|
54
59
|
| "versions"
|
|
55
60
|
| "codex_pin"
|
|
61
|
+
| "claude_plugin"
|
|
56
62
|
| "assets"
|
|
57
63
|
| "launcher"
|
|
58
64
|
| "utility"
|
|
@@ -1737,10 +1743,65 @@ const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
|
|
|
1737
1743
|
};
|
|
1738
1744
|
};
|
|
1739
1745
|
|
|
1746
|
+
const CLAUDE_PLUGIN_PACKAGE = "@brainervirus/workit-claude-code";
|
|
1747
|
+
|
|
1748
|
+
const versionBehind = (version: string, latest: string): boolean =>
|
|
1749
|
+
version !== latest && semverAtLeast(latest, version);
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Claude Code installs the marketplace plugin as a snapshot of the published
|
|
1753
|
+
* package (auto-update is off by default), so an install can lag the release.
|
|
1754
|
+
* It warns only when a newer plugin version is actually published: a plugin
|
|
1755
|
+
* older than this CLI is normal when no plugin payload changed since (the
|
|
1756
|
+
* plugin is republished only when its own or its bundled sources change).
|
|
1757
|
+
* The plugin keeps working; the fix is one native update. A `--plugin-dir`
|
|
1758
|
+
* local pin is per-session, never recorded, and never checked here.
|
|
1759
|
+
*/
|
|
1760
|
+
const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
|
|
1761
|
+
// Only installs Claude loads here: user scope, plus this project's.
|
|
1762
|
+
const installs = claudeWorkitInstalls(res.home, res.env, res.cwd);
|
|
1763
|
+
if (installs.length === 0)
|
|
1764
|
+
return {
|
|
1765
|
+
id: "claude_plugin",
|
|
1766
|
+
status: "pass",
|
|
1767
|
+
detail: "no Workit Claude Code plugin install recorded — skipping",
|
|
1768
|
+
};
|
|
1769
|
+
const fix = (install: ClaudeWorkitInstall) =>
|
|
1770
|
+
`claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && ${
|
|
1771
|
+
install.projectPath ? `cd ${JSON.stringify(install.projectPath)} && ` : ""
|
|
1772
|
+
}claude plugin update ${install.id}${install.scope === "user" ? "" : ` --scope ${install.scope}`}`;
|
|
1773
|
+
const latest = registryLatestVersion(res, CLAUDE_PLUGIN_PACKAGE);
|
|
1774
|
+
const problems: string[] = [];
|
|
1775
|
+
let repair: string | undefined;
|
|
1776
|
+
for (const install of installs) {
|
|
1777
|
+
const label = `${install.id} ${install.version ?? "(unknown version)"}${install.scope === "user" ? "" : ` (${install.scope} scope)`}`;
|
|
1778
|
+
if (latest && install.version && versionBehind(install.version, latest)) {
|
|
1779
|
+
problems.push(`stale_install: ${label} is behind published ${latest}`);
|
|
1780
|
+
repair ??= fix(install);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
|
|
1784
|
+
if (problems.length === 0)
|
|
1785
|
+
return {
|
|
1786
|
+
id: "claude_plugin",
|
|
1787
|
+
status: "pass",
|
|
1788
|
+
detail: `Claude Code plugin ${installs.map((i) => `${i.id} ${i.version ?? "?"}`).join(", ")} is current`,
|
|
1789
|
+
registryProbed,
|
|
1790
|
+
};
|
|
1791
|
+
return {
|
|
1792
|
+
id: "claude_plugin",
|
|
1793
|
+
status: "warn",
|
|
1794
|
+
detail: problems.join("; "),
|
|
1795
|
+
fix: repair,
|
|
1796
|
+
registryProbed,
|
|
1797
|
+
};
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1740
1800
|
const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
|
|
1741
1801
|
checkRuntime,
|
|
1742
1802
|
checkVersions,
|
|
1743
1803
|
checkCodexPin,
|
|
1804
|
+
checkClaudePlugin,
|
|
1744
1805
|
checkAssets,
|
|
1745
1806
|
checkLauncher,
|
|
1746
1807
|
checkUtility,
|