@brainervirus/workit-core 2.0.2 → 2.1.1
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 +2 -2
- package/package.json +2 -3
- package/scripts/sync-runtime.sh +9 -5
- package/src/core/detect-hosts.ts +24 -22
- package/src/core/doctor.ts +11 -4
- package/src/core/host-install.ts +458 -0
- package/src/core/init.ts +1 -256
- package/src/core/policy-resolver.ts +0 -45
- package/src/core/registration.ts +3 -4
- package/src/core/repo-context.ts +1 -1
- package/src/core/rules.ts +1 -61
- package/src/core/setup.ts +203 -11
- package/src/core/templates.ts +1 -26
- package/src/core/vcs-config.ts +2 -8
- package/src/core/workspaces.ts +18 -17
- package/src/core/config-guard.ts +0 -27
- package/src/core/doc-render.ts +0 -14
- package/src/core/docs-layout.ts +0 -279
- package/src/core/docs-migration.ts +0 -639
- package/src/core/docs-repo.ts +0 -233
- package/src/core/docs-validate.ts +0 -393
- package/src/core/parse-sections.ts +0 -24
- package/src/core/ports/init-apply.ts +0 -15
- package/src/core/ports/init-status.ts +0 -4
- package/src/core/ports/init-toolkit-status.ts +0 -4
- package/src/core/ports/pr-create.ts +0 -30
- package/src/core/ports/present-ascii.ts +0 -10
- package/src/core/ports/present-flow.ts +0 -10
- package/src/core/ports/vcs-config.ts +0 -14
- package/src/core/ports/vcs-merged-style.ts +0 -5
- package/src/core/ports/vcs-verify-token.ts +0 -4
- package/src/core/ports/youtrack-api.ts +0 -19
- package/src/core/ports/youtrack-config.ts +0 -21
- package/src/core/ports/youtrack-greeting.ts +0 -10
- package/src/core/ports/youtrack-parse-duration.ts +0 -14
- package/src/core/ports/youtrack-token-create-url.ts +0 -4
- package/src/core/ports/youtrack-verify-token.ts +0 -12
- package/src/core/ports/youtrack-work-date-ms.ts +0 -10
- package/src/core/present.ts +0 -109
- package/src/core/repo-tool.ts +0 -12
- package/src/core/repo-tools.ts +0 -22
- package/src/core/sync-runtime.ts +0 -375
- package/src/core/verify-parse.ts +0 -29
- package/src/core/verify-project.ts +0 -181
package/src/core/sync-runtime.ts
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
accessSync,
|
|
4
|
-
constants,
|
|
5
|
-
copyFileSync,
|
|
6
|
-
existsSync,
|
|
7
|
-
mkdirSync,
|
|
8
|
-
readFileSync,
|
|
9
|
-
readdirSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
statSync,
|
|
12
|
-
writeFileSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import { validateCursorSkills } from "./skill-manifests";
|
|
17
|
-
|
|
18
|
-
// Port of scripts/sync-runtime.sh — explicit runtime sync used by the installers.
|
|
19
|
-
// NOT part of runtime startup: the session-start hook performs no sync (RL-09).
|
|
20
|
-
// The bash installer scripts still invoke sync-runtime.sh directly; this typed
|
|
21
|
-
// operation mirrors the same RR-05 failure semantics (missing flock, held lock,
|
|
22
|
-
// and failed fetch all fail loudly).
|
|
23
|
-
|
|
24
|
-
export type SyncRuntimeResult = { ok: true } | { ok: false; error: string };
|
|
25
|
-
|
|
26
|
-
export type SyncRuntimeOptions = {
|
|
27
|
-
home?: string;
|
|
28
|
-
dev?: string;
|
|
29
|
-
repoSlug?: string;
|
|
30
|
-
lockDir?: string;
|
|
31
|
-
env?: NodeJS.ProcessEnv;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const run = (cmd: string, args: string[], opts: { cwd?: string; env: NodeJS.ProcessEnv }) => {
|
|
35
|
-
const r = spawnSync(cmd, args, {
|
|
36
|
-
cwd: opts.cwd,
|
|
37
|
-
env: opts.env,
|
|
38
|
-
encoding: "utf8",
|
|
39
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
40
|
-
});
|
|
41
|
-
return {
|
|
42
|
-
exitCode: r.status ?? 1,
|
|
43
|
-
stdout: (r.stdout ?? "").trim(),
|
|
44
|
-
stderr: (r.stderr ?? "").trim(),
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Acquire a non-blocking flock for the duration of the sync (mirrors
|
|
50
|
-
* `exec 9>"$LOCK"; flock -n 9`). The `sh` child opens fd 9, takes the flock,
|
|
51
|
-
* then blocks reading its stdin. When this parent exits — normally, or
|
|
52
|
-
* hard-killed (SIGKILL/SIGINT/crash) — the kernel closes the stdin pipe write
|
|
53
|
-
* end, the child's read hits EOF, and the child exits, releasing the flock.
|
|
54
|
-
* The lock thus dies with its owner (no orphan holder), matching
|
|
55
|
-
* sync-runtime.sh where the flock dies with the shell process itself. No
|
|
56
|
-
* probe-then-hold TOCTOU window and no duration cap: the child holds the lock
|
|
57
|
-
* for exactly the parent's lifetime. The child signals acquisition by writing
|
|
58
|
-
* "ok" to stdout; any other exit means the flock was already held.
|
|
59
|
-
*/
|
|
60
|
-
async function acquireLock(
|
|
61
|
-
lock: string,
|
|
62
|
-
env: NodeJS.ProcessEnv,
|
|
63
|
-
): Promise<{ proc: ChildProcess } | { error: string }> {
|
|
64
|
-
const proc = spawn(
|
|
65
|
-
"sh",
|
|
66
|
-
[
|
|
67
|
-
"-c",
|
|
68
|
-
`exec 9>"$1"; flock -n 9 || exit 7; printf 'ok\\n'; while IFS= read -r _; do :; done`,
|
|
69
|
-
"workit-lock",
|
|
70
|
-
lock,
|
|
71
|
-
],
|
|
72
|
-
{ stdio: ["pipe", "pipe", "ignore"], env },
|
|
73
|
-
);
|
|
74
|
-
const acquired = await new Promise<boolean>((resolve) => {
|
|
75
|
-
let settled = false;
|
|
76
|
-
const finish = (ok: boolean) => {
|
|
77
|
-
if (!settled) {
|
|
78
|
-
settled = true;
|
|
79
|
-
resolve(ok);
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
proc.stdout?.once("data", () => finish(true));
|
|
83
|
-
proc.once("exit", () => finish(false));
|
|
84
|
-
proc.once("error", () => finish(false));
|
|
85
|
-
});
|
|
86
|
-
if (!acquired) {
|
|
87
|
-
killLockProcess(proc);
|
|
88
|
-
return { error: `sync-runtime: another process holds ${lock} — state unverified, failing` };
|
|
89
|
-
}
|
|
90
|
-
return { proc };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Kill the lock-holder child so the flock is released immediately. */
|
|
94
|
-
function killLockProcess(proc: ChildProcess): void {
|
|
95
|
-
try {
|
|
96
|
-
proc.kill();
|
|
97
|
-
} catch {
|
|
98
|
-
/* already exited */
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export async function syncRuntime(options: SyncRuntimeOptions = {}): Promise<SyncRuntimeResult> {
|
|
103
|
-
const env = options.env ?? process.env;
|
|
104
|
-
const home = options.home ?? env.HOME ?? os.homedir();
|
|
105
|
-
const dev =
|
|
106
|
-
options.dev ??
|
|
107
|
-
env.WORKFLOW_TOOLKIT_DEV ??
|
|
108
|
-
path.join(home, "Documents/projects/personal/workflow-toolkit");
|
|
109
|
-
const repoSlug = options.repoSlug ?? env.WORKFLOW_TOOLKIT_REPO ?? "BrainerVirus/workit";
|
|
110
|
-
const share = path.join(home, ".local/share/workit");
|
|
111
|
-
const pluginDir = path.join(home, ".cursor/plugins/local/workit");
|
|
112
|
-
const opencodePlugins = path.join(home, ".config/opencode/plugins");
|
|
113
|
-
const lock = path.join(
|
|
114
|
-
options.lockDir ?? env.XDG_RUNTIME_DIR ?? "/tmp",
|
|
115
|
-
"workflow-toolkit-sync.lock",
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
// RR-05: a missing required tool must never look like a successful sync.
|
|
119
|
-
if (run("flock", ["--version"], { env }).exitCode !== 0) {
|
|
120
|
-
return {
|
|
121
|
-
ok: false,
|
|
122
|
-
error: "FATAL: sync-runtime requires flock (util-linux) — not found in PATH",
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const acquired = await acquireLock(lock, env);
|
|
127
|
-
if ("error" in acquired) return { ok: false, error: acquired.error };
|
|
128
|
-
const lockProc = acquired.proc;
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
let src = "";
|
|
132
|
-
if (
|
|
133
|
-
existsSync(path.join(dev, "packages/workit-opencode/src/plugin.ts")) &&
|
|
134
|
-
existsSync(path.join(dev, "packages/workit-cursor/.cursor-plugin"))
|
|
135
|
-
) {
|
|
136
|
-
src = dev;
|
|
137
|
-
} else if (existsSync(path.join(share, ".git"))) {
|
|
138
|
-
// RR-05: propagate fetch/pull failures to a FATAL nonzero exit.
|
|
139
|
-
if (run("git", ["-C", share, "fetch", "--quiet", "origin"], { env }).exitCode !== 0) {
|
|
140
|
-
return { ok: false, error: `FATAL: could not fetch updates for ${share}` };
|
|
141
|
-
}
|
|
142
|
-
const pull = run("git", ["-C", share, "pull", "--ff-only", "--quiet", "origin", "main"], {
|
|
143
|
-
env,
|
|
144
|
-
});
|
|
145
|
-
if (pull.exitCode !== 0) {
|
|
146
|
-
return { ok: false, error: `FATAL: could not update ${share} from origin/main` };
|
|
147
|
-
}
|
|
148
|
-
src = share;
|
|
149
|
-
} else if (!existsSync(path.join(share, "packages/workit-core/src"))) {
|
|
150
|
-
mkdirSync(path.dirname(share), { recursive: true });
|
|
151
|
-
const clone = run(
|
|
152
|
-
"git",
|
|
153
|
-
["clone", "--depth", "1", `https://github.com/${repoSlug}.git`, share],
|
|
154
|
-
{ env },
|
|
155
|
-
);
|
|
156
|
-
if (clone.exitCode !== 0) {
|
|
157
|
-
return {
|
|
158
|
-
ok: false,
|
|
159
|
-
error: `FATAL: could not clone https://github.com/${repoSlug}.git into ${share}`,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
src = share;
|
|
163
|
-
} else {
|
|
164
|
-
src = share;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const cursorSrc = path.join(src, "packages/workit-cursor");
|
|
168
|
-
const cursorEntries = ["mcp-server.js", "cursor-session-start.js", "workit-hook.js"];
|
|
169
|
-
const bun = resolveBun(env, home);
|
|
170
|
-
if (!bun.ok) return bun;
|
|
171
|
-
const dependencies = [
|
|
172
|
-
"@brainervirus/workit-core",
|
|
173
|
-
"@brainervirus/workit-mcp",
|
|
174
|
-
"@modelcontextprotocol/sdk",
|
|
175
|
-
"zod",
|
|
176
|
-
];
|
|
177
|
-
if (
|
|
178
|
-
dependencies.some((dependency) => !existsSync(path.join(src, "node_modules", dependency)))
|
|
179
|
-
) {
|
|
180
|
-
if (!existsSync(path.join(src, "bun.lock"))) {
|
|
181
|
-
return { ok: false, error: `FATAL: dependency install requires ${src}/bun.lock` };
|
|
182
|
-
}
|
|
183
|
-
const install = run(bun.path, ["install", "--frozen-lockfile"], { cwd: src, env });
|
|
184
|
-
if (install.exitCode !== 0) {
|
|
185
|
-
return {
|
|
186
|
-
ok: false,
|
|
187
|
-
error: `FATAL: root dependency install failed in ${src}: ${install.stderr}`,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const mcpBuild = run(bun.path, [path.join(src, "packages/workit-mcp/scripts/build.ts")], {
|
|
192
|
-
cwd: src,
|
|
193
|
-
env: { ...env, PATH: `${path.dirname(bun.path)}${path.delimiter}${env.PATH ?? ""}` },
|
|
194
|
-
});
|
|
195
|
-
if (mcpBuild.exitCode !== 0) {
|
|
196
|
-
return { ok: false, error: `FATAL: shared MCP build failed in ${src}: ${mcpBuild.stderr}` };
|
|
197
|
-
}
|
|
198
|
-
const build = run(bun.path, [path.join(cursorSrc, "scripts/build.ts")], {
|
|
199
|
-
cwd: src,
|
|
200
|
-
env: { ...env, PATH: `${path.dirname(bun.path)}${path.delimiter}${env.PATH ?? ""}` },
|
|
201
|
-
});
|
|
202
|
-
if (build.exitCode !== 0) {
|
|
203
|
-
return {
|
|
204
|
-
ok: false,
|
|
205
|
-
error: `FATAL: Cursor adapter build failed in ${cursorSrc}: ${build.stderr}`,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
for (const entry of cursorEntries) {
|
|
209
|
-
const distEntry = path.join(cursorSrc, "dist", entry);
|
|
210
|
-
try {
|
|
211
|
-
const stat = statSync(distEntry);
|
|
212
|
-
if (
|
|
213
|
-
!stat.isFile() ||
|
|
214
|
-
stat.size === 0 ||
|
|
215
|
-
!readFileSync(distEntry, "utf8").startsWith("#!/usr/bin/env node\n")
|
|
216
|
-
) {
|
|
217
|
-
throw new Error("invalid");
|
|
218
|
-
}
|
|
219
|
-
} catch {
|
|
220
|
-
return {
|
|
221
|
-
ok: false,
|
|
222
|
-
error: `FATAL: Cursor adapter invalid dist entry: ${distEntry}`,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (src !== share) {
|
|
228
|
-
mkdirSync(share, { recursive: true });
|
|
229
|
-
// Keep .git if share is a clone; never wipe it when syncing from the monorepo.
|
|
230
|
-
const r = run(
|
|
231
|
-
"rsync",
|
|
232
|
-
[
|
|
233
|
-
"-a",
|
|
234
|
-
"--delete",
|
|
235
|
-
"--exclude",
|
|
236
|
-
".git",
|
|
237
|
-
"--exclude",
|
|
238
|
-
"node_modules",
|
|
239
|
-
"--exclude",
|
|
240
|
-
"cursor/mcp/node_modules",
|
|
241
|
-
"--exclude",
|
|
242
|
-
".cache",
|
|
243
|
-
`${src}/`,
|
|
244
|
-
`${share}/`,
|
|
245
|
-
],
|
|
246
|
-
{ env },
|
|
247
|
-
);
|
|
248
|
-
if (r.exitCode !== 0) return { ok: false, error: `FATAL: rsync share failed: ${r.stderr}` };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
mkdirSync(path.join(home, ".cursor/plugins/local"), { recursive: true });
|
|
252
|
-
mkdirSync(pluginDir, { recursive: true });
|
|
253
|
-
const cursorCopy = run(
|
|
254
|
-
"rsync",
|
|
255
|
-
[
|
|
256
|
-
"-a",
|
|
257
|
-
"--delete",
|
|
258
|
-
"--exclude",
|
|
259
|
-
"mcp/node_modules",
|
|
260
|
-
`${share}/packages/workit-cursor/`,
|
|
261
|
-
`${pluginDir}/`,
|
|
262
|
-
],
|
|
263
|
-
{ env },
|
|
264
|
-
);
|
|
265
|
-
if (cursorCopy.exitCode !== 0) {
|
|
266
|
-
return { ok: false, error: `FATAL: rsync cursor plugin failed: ${cursorCopy.stderr}` };
|
|
267
|
-
}
|
|
268
|
-
const vendorError = validateCursorSkills(pluginDir);
|
|
269
|
-
if (vendorError) return { ok: false, error: `FATAL: ${vendorError}` };
|
|
270
|
-
|
|
271
|
-
// Canonical user rules -> Cursor .mdc (compiled by the shared core).
|
|
272
|
-
const configRules = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? "", "rules");
|
|
273
|
-
if (env.WORKFLOW_TOOLKIT_CONFIG && existsSync(configRules)) {
|
|
274
|
-
const rulesBun = resolveBun(env, home);
|
|
275
|
-
if (rulesBun.ok) {
|
|
276
|
-
run(
|
|
277
|
-
rulesBun.path,
|
|
278
|
-
[
|
|
279
|
-
"-e",
|
|
280
|
-
`import('${share}/packages/workit-core/src/core/rules.ts').then(({ writeCompiledCursorRules }) => writeCompiledCursorRules('${pluginDir}/rules'));`,
|
|
281
|
-
],
|
|
282
|
-
{ env },
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
writeFileSync(path.join(pluginDir, ".workit-root"), `${share}/packages/workit-core\n`);
|
|
287
|
-
|
|
288
|
-
// CA-08/CA-09: migrate the legacy local plugin identity to `workit` only
|
|
289
|
-
// after the canonical sync succeeded; carry the legacy user rules forward
|
|
290
|
-
// first so no user data is lost.
|
|
291
|
-
const legacyDir = path.join(home, ".cursor", "plugins", "local", "workflow-toolkit");
|
|
292
|
-
if (legacyDir !== pluginDir && existsSync(legacyDir)) {
|
|
293
|
-
const legacyRules = path.join(legacyDir, "rules");
|
|
294
|
-
if (existsSync(legacyRules)) {
|
|
295
|
-
mkdirSync(path.join(pluginDir, "rules"), { recursive: true });
|
|
296
|
-
for (const entry of readdirSync(legacyRules, { withFileTypes: true })) {
|
|
297
|
-
if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
|
|
298
|
-
const target = path.join(pluginDir, "rules", entry.name);
|
|
299
|
-
if (!existsSync(target)) copyFileSync(path.join(legacyRules, entry.name), target);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
rmSync(legacyDir, { recursive: true, force: true });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Remove broken TLA live-loader if present (OpenCode ignored it; /wk-* vanished).
|
|
306
|
-
rmSync(path.join(opencodePlugins, "workflow-toolkit.ts"), { force: true });
|
|
307
|
-
|
|
308
|
-
// Ensure OpenCode has plugin peer dep.
|
|
309
|
-
const pkg = path.join(home, ".config/opencode/package.json");
|
|
310
|
-
if (existsSync(pkg)) {
|
|
311
|
-
try {
|
|
312
|
-
const data = JSON.parse(readFileSync(pkg, "utf8")) as {
|
|
313
|
-
dependencies?: Record<string, string>;
|
|
314
|
-
};
|
|
315
|
-
data.dependencies = data.dependencies ?? {};
|
|
316
|
-
data.dependencies["@opencode-ai/plugin"] ??= "1.18.30";
|
|
317
|
-
writeFileSync(pkg, JSON.stringify(data, null, 2) + "\n");
|
|
318
|
-
} catch {
|
|
319
|
-
/* best-effort */
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Drop bun package cache so old github/file installs cannot shadow file:// plugin.ts.
|
|
324
|
-
const cacheDir = path.join(home, ".cache/opencode/packages");
|
|
325
|
-
if (existsSync(cacheDir)) {
|
|
326
|
-
try {
|
|
327
|
-
for (const entry of readdirSync(cacheDir)) {
|
|
328
|
-
if (entry.startsWith("workflow-toolkit-opencode@")) {
|
|
329
|
-
rmSync(path.join(cacheDir, entry), { recursive: true, force: true });
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
} catch {
|
|
333
|
-
/* best-effort */
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return { ok: true };
|
|
338
|
-
} finally {
|
|
339
|
-
killLockProcess(lockProc);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const resolveBun = (
|
|
344
|
-
env: NodeJS.ProcessEnv,
|
|
345
|
-
home: string,
|
|
346
|
-
): { ok: true; path: string } | { ok: false; error: string } => {
|
|
347
|
-
if (env.BUN) {
|
|
348
|
-
return executable(env.BUN)
|
|
349
|
-
? { ok: true, path: env.BUN }
|
|
350
|
-
: { ok: false, error: `FATAL: BUN is set but unusable: ${env.BUN}` };
|
|
351
|
-
}
|
|
352
|
-
const homeBun = path.join(home, ".bun/bin/bun");
|
|
353
|
-
if (executable(homeBun)) return { ok: true, path: homeBun };
|
|
354
|
-
const pathBun = executableOnPath("bun", env);
|
|
355
|
-
return pathBun
|
|
356
|
-
? { ok: true, path: pathBun }
|
|
357
|
-
: { ok: false, error: "FATAL: Bun is required to build the Cursor adapter" };
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
const executable = (file: string): boolean => {
|
|
361
|
-
try {
|
|
362
|
-
accessSync(file, constants.X_OK);
|
|
363
|
-
return true;
|
|
364
|
-
} catch {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
const executableOnPath = (name: string, env: NodeJS.ProcessEnv): string | null => {
|
|
370
|
-
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
371
|
-
const candidate = path.join(dir, name);
|
|
372
|
-
if (executable(candidate)) return candidate;
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
375
|
-
};
|
package/src/core/verify-parse.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/** Parse verify-project.sh stdout (# Summary + ## check sections). */
|
|
2
|
-
export function parseVerifyOutput(stdout: string): Record<string, any> {
|
|
3
|
-
const commands = [];
|
|
4
|
-
const parts = stdout.split(/\n## /);
|
|
5
|
-
for (const part of parts.slice(1)) {
|
|
6
|
-
const nl = part.indexOf("\n");
|
|
7
|
-
const label = part.slice(0, nl).trim();
|
|
8
|
-
const body = part.slice(nl + 1);
|
|
9
|
-
const cmdMatch = body.match(/^command: (.+)$/m);
|
|
10
|
-
const statusMatch = body.match(/^status: (pass|fail|skipped)(?: \((.+)\))?/m);
|
|
11
|
-
if (cmdMatch && statusMatch) {
|
|
12
|
-
commands.push({
|
|
13
|
-
label,
|
|
14
|
-
command: cmdMatch[1].trim(),
|
|
15
|
-
status: statusMatch[1],
|
|
16
|
-
reason: statusMatch[2]?.trim(),
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const summaryMatch = stdout.match(
|
|
22
|
-
/# Summary[\s\S]*?passed: (\d+)[\s\S]*?failed: (\d+)[\s\S]*?skipped: (\d+)/,
|
|
23
|
-
);
|
|
24
|
-
const passed = summaryMatch ? Number(summaryMatch[1]) : 0;
|
|
25
|
-
const failed = summaryMatch ? Number(summaryMatch[2]) : 0;
|
|
26
|
-
const skipped = summaryMatch ? Number(summaryMatch[3]) : 0;
|
|
27
|
-
|
|
28
|
-
return { passed, failed, skipped, commands };
|
|
29
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { repoRoot } from "./repo-context";
|
|
5
|
-
|
|
6
|
-
// Port of scripts/verify-project.sh — discover + run project verification checks
|
|
7
|
-
// (npm/pnpm/yarn, cargo, pytest/ruff, CHANGELOG format). Output is parsed by
|
|
8
|
-
// verify-parse.ts and keeps the same ## section text shape.
|
|
9
|
-
|
|
10
|
-
export type VerifyResult = { stdout: string; stderr: string; exitCode: number; cwd: string };
|
|
11
|
-
|
|
12
|
-
const commandOnPath = (name: string): boolean => {
|
|
13
|
-
const dirs = (process.env.PATH ?? "").split(path.delimiter);
|
|
14
|
-
// win32 executables carry an .exe suffix (pytest.exe), so probe both names.
|
|
15
|
-
const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
|
|
16
|
-
for (const dir of dirs) {
|
|
17
|
-
if (!dir) continue;
|
|
18
|
-
for (const candidateName of names) {
|
|
19
|
-
const candidate = path.join(dir, candidateName);
|
|
20
|
-
try {
|
|
21
|
-
statSync(candidate);
|
|
22
|
-
if (process.platform !== "win32") {
|
|
23
|
-
const mode = statSync(candidate).mode & 0o111;
|
|
24
|
-
if (mode === 0) continue;
|
|
25
|
-
}
|
|
26
|
-
return true;
|
|
27
|
-
} catch {
|
|
28
|
-
/* keep scanning */
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return false;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const hasScript = (cwd: string, name: string): boolean => {
|
|
36
|
-
const pkgPath = path.join(cwd, "package.json");
|
|
37
|
-
if (!existsSync(pkgPath)) return false;
|
|
38
|
-
try {
|
|
39
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> };
|
|
40
|
-
return Boolean(pkg.scripts && typeof pkg.scripts[name] === "string");
|
|
41
|
-
} catch {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const packageRunner = (cwd: string): string => {
|
|
47
|
-
if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
48
|
-
const pkgPath = path.join(cwd, "package.json");
|
|
49
|
-
if (existsSync(pkgPath)) {
|
|
50
|
-
try {
|
|
51
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { packageManager?: string };
|
|
52
|
-
if (typeof pkg.packageManager === "string" && pkg.packageManager.startsWith("pnpm")) {
|
|
53
|
-
return "pnpm";
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
/* fall through */
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
60
|
-
return "npm";
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export function runVerifyProject(root: string, dryRun = false): VerifyResult {
|
|
64
|
-
// Shell parity: verify-project.sh did `root=$(git rev-parse --show-toplevel || pwd); cd "$root"`,
|
|
65
|
-
// so a run from a repo subdirectory still checks the repo root.
|
|
66
|
-
const cwd = repoRoot(root);
|
|
67
|
-
let passed = 0;
|
|
68
|
-
let failed = 0;
|
|
69
|
-
let skipped = 0;
|
|
70
|
-
const lines: string[] = [];
|
|
71
|
-
const stderrParts: string[] = [];
|
|
72
|
-
|
|
73
|
-
const runCheck = (label: string, cmd: string, args: string[]) => {
|
|
74
|
-
lines.push(`\n## ${label}\n\n`);
|
|
75
|
-
lines.push(`command: ${[cmd, ...args].join(" ")}\n\n`);
|
|
76
|
-
if (dryRun) {
|
|
77
|
-
lines.push("status: skipped (dry run)\n");
|
|
78
|
-
skipped += 1;
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const r = spawnSync(cmd, args, { cwd, encoding: "utf8" });
|
|
82
|
-
const so = (r.stdout ?? "").trimEnd();
|
|
83
|
-
if (so) lines.push(so + "\n");
|
|
84
|
-
const se = (r.stderr ?? "").trimEnd();
|
|
85
|
-
if (se) stderrParts.push(se);
|
|
86
|
-
if (r.status === 0) {
|
|
87
|
-
lines.push("\nstatus: pass\n");
|
|
88
|
-
passed += 1;
|
|
89
|
-
} else {
|
|
90
|
-
lines.push(`\nstatus: fail (exit ${r.status})\n`);
|
|
91
|
-
failed += 1;
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const skipCheck = (label: string, reason: string) => {
|
|
96
|
-
lines.push(`\n## ${label}\n\nstatus: skipped (${reason})\n`);
|
|
97
|
-
skipped += 1;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
lines.push("# Verification Context\n");
|
|
101
|
-
lines.push(`\nroot: ${cwd}\n`);
|
|
102
|
-
lines.push(`dry_run: ${String(dryRun)}\n`);
|
|
103
|
-
|
|
104
|
-
const pkgPath = path.join(cwd, "package.json");
|
|
105
|
-
if (existsSync(pkgPath)) {
|
|
106
|
-
const runner = packageRunner(cwd);
|
|
107
|
-
for (const script of ["lint", "format:check", "test", "build"]) {
|
|
108
|
-
if (hasScript(cwd, script)) {
|
|
109
|
-
if (runner === "npm") runCheck(script, "npm", ["run", script]);
|
|
110
|
-
else runCheck(script, runner, [script]);
|
|
111
|
-
} else {
|
|
112
|
-
skipCheck(script, `package.json has no ${script} script`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
} else {
|
|
116
|
-
skipCheck("javascript", "package.json not found");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const cargoManifest = existsSync(path.join(cwd, "Cargo.toml"))
|
|
120
|
-
? "Cargo.toml"
|
|
121
|
-
: existsSync(path.join(cwd, "src-tauri/Cargo.toml"))
|
|
122
|
-
? "src-tauri/Cargo.toml"
|
|
123
|
-
: "";
|
|
124
|
-
if (cargoManifest) {
|
|
125
|
-
runCheck("cargo fmt", "cargo", ["fmt", "--manifest-path", cargoManifest, "--", "--check"]);
|
|
126
|
-
runCheck("cargo clippy", "cargo", [
|
|
127
|
-
"clippy",
|
|
128
|
-
"--manifest-path",
|
|
129
|
-
cargoManifest,
|
|
130
|
-
"--all-targets",
|
|
131
|
-
"--",
|
|
132
|
-
"-D",
|
|
133
|
-
"warnings",
|
|
134
|
-
]);
|
|
135
|
-
runCheck("cargo test", "cargo", ["test", "--manifest-path", cargoManifest, "--all-targets"]);
|
|
136
|
-
} else {
|
|
137
|
-
skipCheck("rust", "Cargo.toml not found");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
existsSync(path.join(cwd, "pyproject.toml")) ||
|
|
142
|
-
existsSync(path.join(cwd, "pytest.ini")) ||
|
|
143
|
-
existsSync(path.join(cwd, "tests"))
|
|
144
|
-
) {
|
|
145
|
-
if (commandOnPath("pytest")) runCheck("pytest", "pytest", []);
|
|
146
|
-
else skipCheck("pytest", "pytest not available");
|
|
147
|
-
if (commandOnPath("ruff")) runCheck("ruff check", "ruff", ["check", "."]);
|
|
148
|
-
else skipCheck("ruff check", "ruff not available");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
lines.push("\n## CHANGELOG.md format\n\n");
|
|
152
|
-
lines.push('command: grep -qi "## [Unreleased]" CHANGELOG.md\n\n');
|
|
153
|
-
const changelogPath = path.join(cwd, "CHANGELOG.md");
|
|
154
|
-
if (dryRun) {
|
|
155
|
-
lines.push("status: skipped (dry run)\n");
|
|
156
|
-
skipped += 1;
|
|
157
|
-
} else if (existsSync(changelogPath)) {
|
|
158
|
-
if (/## \[Unreleased\]/i.test(readFileSync(changelogPath, "utf8"))) {
|
|
159
|
-
lines.push("status: pass\n");
|
|
160
|
-
passed += 1;
|
|
161
|
-
} else {
|
|
162
|
-
lines.push("status: fail (missing ## [Unreleased])\n");
|
|
163
|
-
failed += 1;
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
lines.push("status: fail (missing CHANGELOG.md)\n");
|
|
167
|
-
failed += 1;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
lines.push("\n# Summary\n\n");
|
|
171
|
-
lines.push(`passed: ${passed}\n`);
|
|
172
|
-
lines.push(`failed: ${failed}\n`);
|
|
173
|
-
lines.push(`skipped: ${skipped}\n`);
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
stdout: lines.join(""),
|
|
177
|
-
stderr: stderrParts.join("\n"),
|
|
178
|
-
exitCode: failed > 0 ? 1 : 0,
|
|
179
|
-
cwd,
|
|
180
|
-
};
|
|
181
|
-
}
|