pm-ops 2026.7.6

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/dist/index.js ADDED
@@ -0,0 +1,1007 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import * as fs from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { resolve, basename, dirname, join, relative } from "node:path";
6
+ import { createRequire } from "node:module";
7
+ const defineExtension = ((extension) => extension);
8
+ // ---------------------------------------------------------------------------
9
+ // Error contract — mirror pm-cli SDK EXIT_CODE so the host treats thrown
10
+ // CommandError as a clean non-zero exit instead of re-invoking the handler.
11
+ // ---------------------------------------------------------------------------
12
+ const EXIT_CODE = {
13
+ GENERIC_FAILURE: 1,
14
+ USAGE: 2,
15
+ NOT_FOUND: 3,
16
+ };
17
+ class CommandError extends Error {
18
+ exitCode;
19
+ constructor(message, exitCode = EXIT_CODE.GENERIC_FAILURE) {
20
+ super(message);
21
+ this.name = "CommandError";
22
+ this.exitCode = exitCode;
23
+ }
24
+ }
25
+ function renderedCommandResult(output) {
26
+ return { pmOpsRendered: true, output: output.endsWith("\n") ? output : `${output}\n` };
27
+ }
28
+ function renderCommandResult(context) {
29
+ const result = context.result;
30
+ return result?.pmOpsRendered === true && typeof result.output === "string" ? result.output : null;
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Option helpers
34
+ // ---------------------------------------------------------------------------
35
+ function readBool(options, ...keys) {
36
+ return keys.some((key) => options[key] === true || options[key] === "true" || options[key] === "1");
37
+ }
38
+ function readString(options, ...keys) {
39
+ for (const key of keys) {
40
+ const value = options[key];
41
+ if (typeof value === "string" && value.trim())
42
+ return value.trim();
43
+ }
44
+ return undefined;
45
+ }
46
+ function asArray(value) {
47
+ if (Array.isArray(value))
48
+ return value.flatMap(asArray);
49
+ if (typeof value !== "string")
50
+ return [];
51
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
52
+ }
53
+ // Expand a leading `~` to the user's home directory so agent- or user-provided
54
+ // paths like `~/container/pm-csv` resolve correctly instead of failing ENOENT
55
+ // relative to the current working directory.
56
+ function resolvePath(p) {
57
+ if (p === "~")
58
+ return homedir();
59
+ if (p.startsWith("~/"))
60
+ return resolve(join(homedir(), p.slice(2)));
61
+ return resolve(p);
62
+ }
63
+ // Expand glob patterns (e.g. `~/container/pm-*`) so they work when the CLI is
64
+ // invoked without a shell (agents, npx). Uses Node's fs.globSync when available
65
+ // (Node 22+); falls back to the literal path on older Node or no matches.
66
+ function expandGlob(pattern) {
67
+ const resolved = resolvePath(pattern);
68
+ if (!resolved.includes("*") && !resolved.includes("?"))
69
+ return [resolved];
70
+ const globSyncFn = fs.globSync;
71
+ if (typeof globSyncFn === "function") {
72
+ try {
73
+ const matches = globSyncFn(resolved);
74
+ if (matches.length > 0)
75
+ return matches.map((m) => resolve(m));
76
+ }
77
+ catch {
78
+ /* fall through to literal */
79
+ }
80
+ }
81
+ return [resolved];
82
+ }
83
+ function resolveRepos(options) {
84
+ const repos = asArray(options["repos"]);
85
+ if (repos.length > 0)
86
+ return repos.flatMap((r) => expandGlob(r));
87
+ return [process.cwd()];
88
+ }
89
+ function resolveFormat(options) {
90
+ if (readBool(options, "json"))
91
+ return "json";
92
+ const raw = readString(options, "format")?.toLowerCase();
93
+ if (raw === "json" || raw === "markdown" || raw === "toon")
94
+ return raw;
95
+ return "toon";
96
+ }
97
+ function runSync(cmd, args, opts = {}) {
98
+ // On Windows, npm/pm (and other globally-installed CLIs) are .cmd shims; spawning
99
+ // them with shell:false fails with ENOENT. Append .cmd for those well-known
100
+ // commands so the extension works cross-platform without forcing shell:true.
101
+ const isWin = process.platform === "win32";
102
+ const spawnCmd = isWin && (cmd === "npm" || cmd === "pm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
103
+ const r = spawnSync(spawnCmd, args, {
104
+ encoding: "utf-8",
105
+ maxBuffer: 64 * 1024 * 1024,
106
+ cwd: opts.cwd,
107
+ timeout: opts.timeoutMs,
108
+ });
109
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
110
+ }
111
+ function parseJsonSafe(text) {
112
+ try {
113
+ return JSON.parse(text);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ function readJsonFile(path) {
120
+ if (!existsSync(path))
121
+ return undefined;
122
+ try {
123
+ return JSON.parse(readFileSync(path, "utf-8"));
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ // Strip JSONC (// line comments and /* */ block comments) and trailing commas
130
+ // so tsconfig.json files — which TypeScript permits to contain comments — parse
131
+ // cleanly. Naive but sufficient for tsconfig: it does not strip comments inside
132
+ // string literals, but tsconfig values do not contain "//" or "/*" literals.
133
+ function stripJsonc(text) {
134
+ return text
135
+ .replace(/\/\*[\s\S]*?\*\//g, "")
136
+ .replace(/\/\/[^\n\r]*/g, "")
137
+ .replace(/,\s*([}\]])/g, "$1");
138
+ }
139
+ function readJsoncFile(path) {
140
+ if (!existsSync(path))
141
+ return undefined;
142
+ try {
143
+ return JSON.parse(stripJsonc(readFileSync(path, "utf-8")));
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ function readPackageJson(repoPath) {
150
+ return readJsonFile(join(repoPath, "package.json"));
151
+ }
152
+ // pm-changelog is most often a devDependency, but some setups declare build
153
+ // tooling under `dependencies`. Check both so we don't false-fail policy/scan.
154
+ function hasPmChangelogDep(pkg) {
155
+ return Boolean((pkg?.devDependencies && "pm-changelog" in pkg.devDependencies) ||
156
+ (pkg?.dependencies && "pm-changelog" in pkg.dependencies));
157
+ }
158
+ // Resolve a tsconfig `extends` value to an absolute file path. The value may
159
+ // be a relative path (./base.json, ../shared/tsconfig.json) or an npm package
160
+ // specifier (@tsconfig/node20, @org/tsconfig/base.json). Relative values are
161
+ // resolved against the current file's directory; package specifiers are
162
+ // resolved via Node module resolution from the current file's directory.
163
+ function resolveTsConfigExtends(fromPath, extendsValue) {
164
+ // Relative/absolute path: resolve directly.
165
+ if (extendsValue.startsWith("./") || extendsValue.startsWith("../") || extendsValue.startsWith("/")) {
166
+ return resolve(dirname(fromPath), extendsValue);
167
+ }
168
+ // Package specifier (e.g. "@tsconfig/node20" or "@tsconfig/node20/tsconfig.json"):
169
+ // use Node module resolution relative to the current config file. Some packages
170
+ // expose the config at the package root (exportless), some via "exports"; try
171
+ // the bare specifier first, then with /tsconfig.json appended.
172
+ try {
173
+ const req = createRequire(fromPath);
174
+ try {
175
+ return req.resolve(extendsValue);
176
+ }
177
+ catch {
178
+ if (!extendsValue.endsWith(".json")) {
179
+ try {
180
+ return req.resolve(`${extendsValue}/tsconfig.json`);
181
+ }
182
+ catch {
183
+ /* fall through */
184
+ }
185
+ }
186
+ }
187
+ }
188
+ catch {
189
+ /* createRequire needs a file URL; ignore on failure */
190
+ }
191
+ return undefined;
192
+ }
193
+ // Follow `extends` chains so a repo that inherits `strict: true` from a shared
194
+ // base tsconfig (common in monorepos, or via npm packages like @tsconfig/node20)
195
+ // is correctly detected as strict.
196
+ function readTsConfigStrict(repoPath) {
197
+ let currentPath = join(repoPath, "tsconfig.json");
198
+ const visited = new Set();
199
+ for (;;) {
200
+ if (visited.has(currentPath))
201
+ break;
202
+ visited.add(currentPath);
203
+ const cfg = readJsoncFile(currentPath);
204
+ if (!cfg)
205
+ break;
206
+ if (cfg.compilerOptions?.strict === true)
207
+ return true;
208
+ if (cfg.compilerOptions?.strict === false)
209
+ return false;
210
+ if (typeof cfg.extends !== "string")
211
+ break;
212
+ const next = resolveTsConfigExtends(currentPath, cfg.extends);
213
+ if (!next)
214
+ break;
215
+ currentPath = next;
216
+ }
217
+ return false;
218
+ }
219
+ // Resolve the pm CLI invocation. We prefer spawning the `pm` executable by name
220
+ // (the common case), but fall back to the current Node + script path when `pm`
221
+ // is not on PATH (npx, local monorepo bin, relative invocation). A quick
222
+ // `spawnSync` probe avoids falling back unnecessarily and keeps the happy path
223
+ // zero-config. Memoised: the probe result cannot change during a single process,
224
+ // and runPm is called per-repo (2N times in report), so caching avoids 2N probes.
225
+ let pmTargetCache;
226
+ function pmSpawnTarget() {
227
+ if (pmTargetCache)
228
+ return pmTargetCache;
229
+ const probe = spawnSync("pm", ["--version"], { encoding: "utf-8", shell: false, timeout: 10_000 });
230
+ let target;
231
+ if (!probe.error && probe.status === 0) {
232
+ target = { cmd: "pm", leadArgs: [] };
233
+ }
234
+ else if (process.argv[0] && process.argv[1]) {
235
+ // process.argv[0] = node binary, process.argv[1] = pm CLI script path.
236
+ target = { cmd: process.argv[0], leadArgs: [process.argv[1]] };
237
+ }
238
+ else {
239
+ target = { cmd: "pm", leadArgs: [] };
240
+ }
241
+ pmTargetCache = target;
242
+ return target;
243
+ }
244
+ function runPm(args, opts = {}) {
245
+ const target = pmSpawnTarget();
246
+ return runSync(target.cmd, [...target.leadArgs, ...args], opts);
247
+ }
248
+ // Per-process memo of readPmItems by repo path. scanRepos and runPolicy both
249
+ // call this for the same repo, and ops report runs both, so without memoisation
250
+ // a fleet of N repos spawns pm list 2N times. Cached for the lifetime of the
251
+ // process (a single CLI invocation).
252
+ const pmItemsCache = new Map();
253
+ function readPmItems(repoPath) {
254
+ if (pmItemsCache.has(repoPath))
255
+ return pmItemsCache.get(repoPath);
256
+ const result = readPmItemsUncached(repoPath);
257
+ pmItemsCache.set(repoPath, result);
258
+ return result;
259
+ }
260
+ function readPmItemsUncached(repoPath) {
261
+ const pmRoot = join(repoPath, ".agents", "pm");
262
+ if (!existsSync(pmRoot))
263
+ return null;
264
+ // Timeout so a single hung pm process can't block the whole fleet run.
265
+ const r = runPm(["list", "--json", "--pm-path", pmRoot], { timeoutMs: 30_000 });
266
+ if (r.status !== 0)
267
+ return null;
268
+ const parsed = parseJsonSafe(r.stdout);
269
+ if (!parsed)
270
+ return null;
271
+ const items = Array.isArray(parsed) ? parsed : parsed.items ?? parsed.results ?? [];
272
+ if (!Array.isArray(items))
273
+ return null;
274
+ return items.filter((it) => Boolean(it) && typeof it === "object" && typeof it.id === "string");
275
+ }
276
+ function isOffline() {
277
+ return process.env.PM_OPS_OFFLINE === "1" || process.env.PM_OPS_OFFLINE === "true";
278
+ }
279
+ function countOutdated(repoPath) {
280
+ if (isOffline())
281
+ return null;
282
+ const r = runSync("npm", ["outdated", "--json"], { cwd: repoPath, timeoutMs: 60_000 });
283
+ if (r.error)
284
+ return null;
285
+ // `npm outdated --json` exits 0 with empty stdout when nothing is outdated.
286
+ // That is the "0 outdated" case, not an error — return 0 instead of null.
287
+ if (r.status === 0 && r.stdout.trim() === "")
288
+ return 0;
289
+ const parsed = parseJsonSafe(r.stdout);
290
+ if (!parsed || typeof parsed !== "object")
291
+ return null;
292
+ return Object.keys(parsed).length;
293
+ }
294
+ function readAudit(repoPath) {
295
+ if (isOffline())
296
+ return { critical: null, high: null };
297
+ const r = runSync("npm", ["audit", "--omit=dev", "--json"], { cwd: repoPath, timeoutMs: 60_000 });
298
+ if (r.error)
299
+ return { critical: null, high: null };
300
+ const parsed = parseJsonSafe(r.stdout);
301
+ const v = parsed?.metadata?.vulnerabilities;
302
+ if (!v)
303
+ return { critical: null, high: null };
304
+ return { critical: v.critical ?? 0, high: v.high ?? 0 };
305
+ }
306
+ function ghRepoIsPrivate(repoPath) {
307
+ if (isOffline())
308
+ return null;
309
+ const r = runSync("gh", ["repo", "view", "--json", "isPrivate", "--jq", ".isPrivate"], { cwd: repoPath, timeoutMs: 30_000 });
310
+ if (r.status !== 0)
311
+ return null;
312
+ const raw = r.stdout.trim();
313
+ if (raw !== "true" && raw !== "false")
314
+ return null;
315
+ return raw === "true";
316
+ }
317
+ function ghOpenCount(repoPath, kind) {
318
+ if (isOffline())
319
+ return null;
320
+ const args = kind === "pr"
321
+ ? ["pr", "list", "--state", "open", "--json", "number"]
322
+ : ["issue", "list", "--state", "open", "--json", "number"];
323
+ const r = runSync("gh", args, { cwd: repoPath, timeoutMs: 30_000 });
324
+ if (r.status !== 0)
325
+ return null;
326
+ const parsed = parseJsonSafe(r.stdout);
327
+ return Array.isArray(parsed) ? parsed.length : null;
328
+ }
329
+ function scanRepo(repoPath) {
330
+ const errors = [];
331
+ // Guard against a non-existent directory so we don't spawn a flurry of
332
+ // failing subprocesses (npm/gh) against it; report a clean not-ready result.
333
+ if (!existsSync(repoPath)) {
334
+ errors.push("repository directory does not exist");
335
+ return {
336
+ path: repoPath,
337
+ name: null,
338
+ version: null,
339
+ strict_ts: false,
340
+ has_changelog: false,
341
+ has_release_workflow: false,
342
+ has_ci: false,
343
+ pm_workspace: false,
344
+ pm_open_items: null,
345
+ pm_inprogress_items: null,
346
+ has_pm_changelog: false,
347
+ outdated_count: null,
348
+ audit_critical: null,
349
+ audit_high: null,
350
+ open_prs: null,
351
+ open_issues: null,
352
+ ready: false,
353
+ errors,
354
+ };
355
+ }
356
+ const pkg = readPackageJson(repoPath);
357
+ const name = pkg?.name ?? null;
358
+ const version = pkg?.version ?? null;
359
+ const strict_ts = readTsConfigStrict(repoPath);
360
+ const has_changelog = existsSync(join(repoPath, "CHANGELOG.md"));
361
+ const has_release_workflow = existsSync(join(repoPath, ".github", "workflows", "release.yml"));
362
+ const has_ci = existsSync(join(repoPath, ".github", "workflows", "ci.yml"));
363
+ const has_pm_changelog = hasPmChangelogDep(pkg);
364
+ const items = readPmItems(repoPath);
365
+ const pm_workspace = items !== null;
366
+ const pm_open_items = items ? items.filter((i) => (i.status ?? "").toLowerCase() === "open").length : null;
367
+ const pm_inprogress_items = items ? items.filter((i) => (i.status ?? "").toLowerCase() === "in_progress").length : null;
368
+ let outdated_count = null;
369
+ try {
370
+ outdated_count = countOutdated(repoPath);
371
+ }
372
+ catch (err) {
373
+ errors.push(`outdated: ${err instanceof Error ? err.message : String(err)}`);
374
+ }
375
+ let audit_critical = null;
376
+ let audit_high = null;
377
+ try {
378
+ const a = readAudit(repoPath);
379
+ audit_critical = a.critical;
380
+ audit_high = a.high;
381
+ }
382
+ catch (err) {
383
+ errors.push(`audit: ${err instanceof Error ? err.message : String(err)}`);
384
+ }
385
+ const open_prs = ghOpenCount(repoPath, "pr");
386
+ const open_issues = ghOpenCount(repoPath, "issue");
387
+ const has_pkg = Boolean(pkg);
388
+ const criticalGate = audit_critical === null ? true : audit_critical === 0;
389
+ const ready = has_pkg && strict_ts && has_changelog && has_release_workflow && has_ci && has_pm_changelog && criticalGate;
390
+ return {
391
+ path: repoPath,
392
+ name,
393
+ version,
394
+ strict_ts,
395
+ has_changelog,
396
+ has_release_workflow,
397
+ has_ci,
398
+ pm_workspace,
399
+ pm_open_items,
400
+ pm_inprogress_items,
401
+ has_pm_changelog,
402
+ outdated_count,
403
+ audit_critical,
404
+ audit_high,
405
+ open_prs,
406
+ open_issues,
407
+ ready,
408
+ errors,
409
+ };
410
+ }
411
+ function scanRepos(repos, progress) {
412
+ const results = repos.map((repo) => {
413
+ progress(`scanning ${repo}`);
414
+ return scanRepo(repo);
415
+ });
416
+ const ready = results.filter((r) => r.ready).length;
417
+ return { repos: results, summary: { total: results.length, ready, not_ready: results.length - ready } };
418
+ }
419
+ // Named defaults referenced when a policy check omits `params`. Declared before
420
+ // DEFAULT_POLICY so the bundle references the same single source of truth (no
421
+ // duplicated literals that could silently diverge), and used directly by
422
+ // runPolicyCheck so reordering/inserting checks can never grab the wrong entry.
423
+ const DEFAULT_REQUIRED_SCRIPTS = ["typecheck", "test", "build", "release:check", "changelog", "changelog:check"];
424
+ const DEFAULT_REQUIRED_WORKFLOWS = ["ci.yml", "release.yml"];
425
+ const DEFAULT_POLICY = {
426
+ checks: [
427
+ { id: "naming", severity: "error" },
428
+ { id: "required-scripts", severity: "error", params: { scripts: DEFAULT_REQUIRED_SCRIPTS } },
429
+ { id: "required-workflows", severity: "error", params: { workflows: DEFAULT_REQUIRED_WORKFLOWS } },
430
+ { id: "private-no-runners", severity: "error" },
431
+ { id: "pm-duplicate-titles", severity: "warning" },
432
+ { id: "pm-changelog-wired", severity: "error" },
433
+ ],
434
+ };
435
+ const NAME_PATTERN = /^pm-[a-z][a-z0-9-]*$/;
436
+ const FORBIDDEN_PREFIXES = ["pm-ext-", "pm-preset-"];
437
+ const RUNNER_PATTERN = /(github-hosted|macos-|windows-|ubuntu-)/;
438
+ function checkNaming(name) {
439
+ if (!name)
440
+ return { id: "naming", severity: "error", pass: false, message: "package.json has no name" };
441
+ if (FORBIDDEN_PREFIXES.some((p) => name.startsWith(p))) {
442
+ return { id: "naming", severity: "error", pass: false, message: `name "${name}" uses a forbidden prefix (pm-ext- / pm-preset-)` };
443
+ }
444
+ const pass = NAME_PATTERN.test(name);
445
+ return { id: "naming", severity: "error", pass, message: pass ? `name "${name}" matches ^pm-[a-z][a-z0-9-]*$` : `name "${name}" does not match ^pm-[a-z][a-z0-9-]*$` };
446
+ }
447
+ function checkRequiredScripts(pkg, required) {
448
+ const scripts = pkg?.scripts ?? {};
449
+ const missing = required.filter((s) => typeof scripts[s] !== "string");
450
+ return {
451
+ id: "required-scripts",
452
+ severity: "error",
453
+ pass: missing.length === 0,
454
+ message: missing.length === 0 ? "all required scripts present" : `missing scripts: ${missing.join(", ")}`,
455
+ details: missing.length > 0 ? missing : undefined,
456
+ };
457
+ }
458
+ function checkRequiredWorkflows(repoPath, required) {
459
+ const missing = required.filter((w) => !existsSync(join(repoPath, ".github", "workflows", w)));
460
+ return {
461
+ id: "required-workflows",
462
+ severity: "error",
463
+ pass: missing.length === 0,
464
+ message: missing.length === 0 ? "all required workflows present" : `missing workflows: ${missing.join(", ")}`,
465
+ details: missing.length > 0 ? missing : undefined,
466
+ };
467
+ }
468
+ function checkPrivateNoRunners(repoPath) {
469
+ const isPrivate = ghRepoIsPrivate(repoPath);
470
+ if (isPrivate === null || isPrivate === false) {
471
+ return { id: "private-no-runners", severity: "error", pass: true, message: "repo is public or unknown — check skipped" };
472
+ }
473
+ const wfDir = join(repoPath, ".github", "workflows");
474
+ const violations = [];
475
+ if (existsSync(wfDir)) {
476
+ for (const file of readdirSync(wfDir)) {
477
+ if (!file.endsWith(".yml") && !file.endsWith(".yaml"))
478
+ continue;
479
+ // Guard the read so a broken symlink, permission error, or a file removed
480
+ // between readdirSync/readFileSync records a per-file violation instead
481
+ // of crashing the entire policy run.
482
+ let content;
483
+ try {
484
+ content = readFileSync(join(wfDir, file), "utf-8");
485
+ }
486
+ catch (err) {
487
+ violations.push(`${file}: could not read file (${err instanceof Error ? err.message : String(err)})`);
488
+ continue;
489
+ }
490
+ // Detect GitHub-hosted runners across the YAML forms GitHub Actions supports:
491
+ // runs-on: ubuntu-latest (single line)
492
+ // runs-on: (multi-line list)
493
+ // - ubuntu-latest
494
+ // runs-on: (multi-line object: group/labels)
495
+ // group: ...
496
+ // labels:
497
+ // - ubuntu-latest
498
+ // Skip blank lines and #-comments when scanning the following block, and
499
+ // stop at the first line indented no deeper than the runs-on key.
500
+ const lines = content.split("\n");
501
+ for (let i = 0; i < lines.length; i++) {
502
+ const line = lines[i];
503
+ const m = line.match(/^(\s*)runs-on:\s*(.*)$/);
504
+ if (!m)
505
+ continue;
506
+ const indentLen = m[1].length;
507
+ let value = m[2].trim();
508
+ if (!value) {
509
+ const items = [];
510
+ for (let j = i + 1; j < lines.length; j++) {
511
+ const nextLine = lines[j];
512
+ if (nextLine.trim() === "" || nextLine.trim().startsWith("#"))
513
+ continue;
514
+ const indentMatch = nextLine.match(/^(\s*)(.*)$/);
515
+ if (!indentMatch || indentMatch[1].length <= indentLen)
516
+ break;
517
+ const body = indentMatch[2];
518
+ const itemMatch = body.match(/^-\s+(.*)$/);
519
+ if (itemMatch) {
520
+ items.push(itemMatch[1].trim());
521
+ }
522
+ else {
523
+ // Object key form. Only `labels:` values correspond to actual
524
+ // runner assignments; `group:` names are arbitrary identifiers for
525
+ // runner groups and must NOT be tested against RUNNER_PATTERN
526
+ // (a group named "ubuntu-pool" would otherwise false-positive).
527
+ const kvMatch = body.match(/^labels:\s*(.*)$/);
528
+ if (kvMatch && kvMatch[2])
529
+ items.push(kvMatch[2].trim());
530
+ }
531
+ }
532
+ value = items.join(" ");
533
+ }
534
+ if (RUNNER_PATTERN.test(value))
535
+ violations.push(`${file}: runs-on ${value}`);
536
+ }
537
+ }
538
+ }
539
+ return {
540
+ id: "private-no-runners",
541
+ severity: "error",
542
+ pass: violations.length === 0,
543
+ message: violations.length === 0 ? "private repo uses no GitHub-hosted runners" : `private repo uses GitHub-hosted runners in ${violations.length} workflow(s)`,
544
+ details: violations.length > 0 ? violations : undefined,
545
+ };
546
+ }
547
+ function checkPmDuplicateTitles(items) {
548
+ if (items === null)
549
+ return { id: "pm-duplicate-titles", severity: "warning", pass: true, message: "no pm workspace — check skipped" };
550
+ const open = items.filter((i) => (i.status ?? "").toLowerCase() === "open");
551
+ const seen = new Map();
552
+ for (const it of open) {
553
+ const title = (it.title ?? "").trim();
554
+ if (!title)
555
+ continue;
556
+ seen.set(title, (seen.get(title) ?? 0) + 1);
557
+ }
558
+ const dups = [...seen.entries()].filter(([, n]) => n > 1);
559
+ return {
560
+ id: "pm-duplicate-titles",
561
+ severity: "warning",
562
+ pass: dups.length === 0,
563
+ message: dups.length === 0 ? "no duplicate open titles" : `${dups.length} duplicate open title(s)`,
564
+ details: dups.length > 0 ? dups.map(([t, n]) => `${t} (${n})`) : undefined,
565
+ };
566
+ }
567
+ function checkPmChangelogWired(pkg) {
568
+ const hasDep = hasPmChangelogDep(pkg);
569
+ const hasScript = Boolean(pkg?.scripts && typeof pkg.scripts["changelog"] === "string");
570
+ const pass = hasDep && hasScript;
571
+ return {
572
+ id: "pm-changelog-wired",
573
+ severity: "error",
574
+ pass,
575
+ message: pass ? "pm-changelog wired (dep + script)" : `pm-changelog not wired (dep: ${hasDep}, script: ${hasScript})`,
576
+ };
577
+ }
578
+ function runPolicyCheck(def, ctx) {
579
+ let res;
580
+ switch (def.id) {
581
+ case "naming":
582
+ res = checkNaming(ctx.pkg?.name ?? null);
583
+ break;
584
+ case "required-scripts":
585
+ res = checkRequiredScripts(ctx.pkg, def.params?.scripts ?? DEFAULT_REQUIRED_SCRIPTS);
586
+ break;
587
+ case "required-workflows":
588
+ res = checkRequiredWorkflows(ctx.repoPath, def.params?.workflows ?? DEFAULT_REQUIRED_WORKFLOWS);
589
+ break;
590
+ case "private-no-runners":
591
+ res = checkPrivateNoRunners(ctx.repoPath);
592
+ break;
593
+ case "pm-duplicate-titles":
594
+ res = checkPmDuplicateTitles(ctx.items);
595
+ break;
596
+ case "pm-changelog-wired":
597
+ res = checkPmChangelogWired(ctx.pkg);
598
+ break;
599
+ default:
600
+ res = { id: def.id, severity: def.severity, pass: false, message: `unknown check id "${def.id}"` };
601
+ }
602
+ // Preserve any custom severity override declared in the policy bundle so
603
+ // per-check output is consistent with the policy definition (a check the
604
+ // user downgraded to "warning" should not be reported as "error").
605
+ if (def.severity && res.severity !== def.severity) {
606
+ return { ...res, severity: def.severity };
607
+ }
608
+ return res;
609
+ }
610
+ function matchesFilter(repoPath, name, filter) {
611
+ if (!filter)
612
+ return true;
613
+ if (filter === "*")
614
+ return true;
615
+ if (name && name === filter)
616
+ return true;
617
+ return basename(repoPath) === filter;
618
+ }
619
+ function runPolicy(repos, bundle, progress) {
620
+ const by_severity = { error: 0, warning: 0, info: 0 };
621
+ let totalPassed = 0;
622
+ let totalFailed = 0;
623
+ const repoResults = repos.map((repoPath) => {
624
+ progress(`policy ${repoPath}`);
625
+ const pkg = readPackageJson(repoPath);
626
+ const items = readPmItems(repoPath);
627
+ const checks = bundle.checks
628
+ .filter((def) => matchesFilter(repoPath, pkg?.name ?? null, def.repo_filter))
629
+ .map((def) => {
630
+ const res = runPolicyCheck(def, { repoPath, pkg, items });
631
+ if (!res.pass)
632
+ by_severity[def.severity] += 1;
633
+ return res;
634
+ });
635
+ const passed = checks.filter((c) => c.pass).length;
636
+ const failed = checks.filter((c) => !c.pass).length;
637
+ totalPassed += passed;
638
+ totalFailed += failed;
639
+ return { path: repoPath, name: pkg?.name ?? null, checks, passed, failed };
640
+ });
641
+ return { repos: repoResults, summary: { total: repos.length, passed: totalPassed, failed: totalFailed, by_severity } };
642
+ }
643
+ const FALLBACK_STEPS = ["typecheck", "build", "test", "audit:prod", "pack:dry-run", "changelog:check"];
644
+ function runReleaseCheck(repoPath, name, args, progress) {
645
+ progress(`verify ${relative(process.cwd(), repoPath) || repoPath}: ${name}`);
646
+ const start = Date.now();
647
+ const r = runSync("npm", args, { cwd: repoPath, timeoutMs: 5 * 60_000 });
648
+ const duration_ms = Date.now() - start;
649
+ // Treat a spawn/timeout error (r.error, e.g. ETIMEDOUT/ENOENT) as a failure
650
+ // and surface its message so agents get a clear reason, not just an exit code.
651
+ const pass = r.status === 0 && !r.error;
652
+ const error = pass
653
+ ? undefined
654
+ : (r.error?.message || r.stderr.trim() || r.stdout.trim() || `npm ${args.join(" ")} exited ${r.status}`).slice(-2000);
655
+ return { name, pass, duration_ms, error };
656
+ }
657
+ function verifyReleaseRepo(repoPath, progress) {
658
+ const pkg = readPackageJson(repoPath);
659
+ const scripts = pkg?.scripts ?? {};
660
+ let checks;
661
+ if (typeof scripts["release:check"] === "string") {
662
+ checks = [runReleaseCheck(repoPath, "release:check", ["run", "release:check"], progress)];
663
+ }
664
+ else {
665
+ checks = FALLBACK_STEPS
666
+ .filter((s) => typeof scripts[s] === "string")
667
+ .map((s) => runReleaseCheck(repoPath, s, ["run", s], progress));
668
+ }
669
+ // A missing repo directory should be reported as a path error, not mislabeled
670
+ // as "no release gate found". Check existence first for accurate diagnostics.
671
+ if (!existsSync(repoPath)) {
672
+ checks = [
673
+ {
674
+ name: "release:check",
675
+ pass: false,
676
+ duration_ms: 0,
677
+ error: `repository directory does not exist: ${repoPath}`,
678
+ },
679
+ ];
680
+ }
681
+ else if (checks.length === 0) {
682
+ // A repo with no runnable release scripts would otherwise be a false green.
683
+ // Surface it as a single failed check so verify-release never reports a
684
+ // release-ready state for a repo that has no gate defined.
685
+ checks = [
686
+ {
687
+ name: "release:check",
688
+ pass: false,
689
+ duration_ms: 0,
690
+ error: "no release gate found: define a `release:check` script (or at least one of typecheck/build/test/audit:prod/pack:dry-run/changelog:check)",
691
+ },
692
+ ];
693
+ }
694
+ const passed = checks.filter((c) => c.pass).length;
695
+ const failed = checks.filter((c) => !c.pass).length;
696
+ return { path: repoPath, name: pkg?.name ?? null, checks, passed, failed };
697
+ }
698
+ function verifyRelease(repos, progress) {
699
+ const results = repos.map((r) => verifyReleaseRepo(r, progress));
700
+ return {
701
+ repos: results,
702
+ summary: { total: results.length, passed: results.filter((r) => r.failed === 0).length, failed: results.filter((r) => r.failed > 0).length },
703
+ };
704
+ }
705
+ function renderVerifyReleaseMarkdown(result) {
706
+ const lines = [
707
+ "# pm-ops verify-release",
708
+ "",
709
+ `Verified **${result.summary.total}** repo(s): **${result.summary.passed}** passed, **${result.summary.failed}** failed.`,
710
+ "",
711
+ renderMarkdownRow(["repo", "check", "pass", "duration_ms", "error"]),
712
+ renderMarkdownRow(["---", "---", "---", "---", "---"]),
713
+ ];
714
+ for (const repo of result.repos) {
715
+ for (const c of repo.checks) {
716
+ lines.push(renderMarkdownRow([
717
+ repo.name ?? basename(repo.path),
718
+ c.name,
719
+ c.pass ? "yes" : "no",
720
+ String(c.duration_ms),
721
+ // Escape pipes AND collapse newlines so a multi-line build/test error
722
+ // can't split this cell across rows and corrupt the markdown table.
723
+ (c.error ?? "").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ⏎ ").slice(0, 200),
724
+ ]));
725
+ }
726
+ }
727
+ lines.push("");
728
+ return lines.join("\n");
729
+ }
730
+ function buildReport(repos, progress) {
731
+ return { generated_at: new Date().toISOString(), scan: scanRepos(repos, progress), policy: runPolicy(repos, DEFAULT_POLICY, progress) };
732
+ }
733
+ // ---------------------------------------------------------------------------
734
+ // Formatters
735
+ // ---------------------------------------------------------------------------
736
+ function formatBool(v) {
737
+ return v ? "yes" : "no";
738
+ }
739
+ function formatCount(v) {
740
+ return v === null ? "?" : String(v);
741
+ }
742
+ function renderMarkdownRow(cells) {
743
+ return `| ${cells.join(" | ")} |`;
744
+ }
745
+ function renderScanMarkdown(result) {
746
+ const lines = [];
747
+ lines.push("# pm-ops scan");
748
+ lines.push("");
749
+ lines.push(`Scanned **${result.summary.total}** repo(s): **${result.summary.ready}** ready, **${result.summary.not_ready}** not ready.`);
750
+ lines.push("");
751
+ lines.push(renderMarkdownRow(["repo", "version", "strict", "changelog", "release", "ci", "pm-changelog", "open items", "outdated", "critical", "high", "prs", "issues", "ready"]));
752
+ lines.push(renderMarkdownRow(["---", "---", "---", "---", "---", "---", "---", "---", "---", "---", "---", "---", "---", "---"]));
753
+ for (const r of result.repos) {
754
+ const openItems = r.pm_open_items === null ? "?" : `${r.pm_open_items}/${r.pm_inprogress_items ?? 0}`;
755
+ lines.push(renderMarkdownRow([
756
+ r.name ?? basename(r.path),
757
+ r.version ?? "-",
758
+ formatBool(r.strict_ts),
759
+ formatBool(r.has_changelog),
760
+ formatBool(r.has_release_workflow),
761
+ formatBool(r.has_ci),
762
+ formatBool(r.has_pm_changelog),
763
+ openItems,
764
+ formatCount(r.outdated_count),
765
+ formatCount(r.audit_critical),
766
+ formatCount(r.audit_high),
767
+ formatCount(r.open_prs),
768
+ formatCount(r.open_issues),
769
+ r.ready ? "yes" : "no",
770
+ ]));
771
+ }
772
+ lines.push("");
773
+ return lines.join("\n");
774
+ }
775
+ function renderPolicyMarkdown(result) {
776
+ const lines = [];
777
+ lines.push("# pm-ops policy");
778
+ lines.push("");
779
+ lines.push(`Checked **${result.summary.total}** repo(s): **${result.summary.passed}** checks passed, **${result.summary.failed}** failed.`);
780
+ lines.push("");
781
+ lines.push(renderMarkdownRow(["repo", "check", "severity", "pass", "message"]));
782
+ lines.push(renderMarkdownRow(["---", "---", "---", "---", "---"]));
783
+ for (const repo of result.repos) {
784
+ for (const c of repo.checks) {
785
+ lines.push(renderMarkdownRow([
786
+ repo.name ?? basename(repo.path),
787
+ c.id,
788
+ c.severity,
789
+ c.pass ? "yes" : "no",
790
+ c.message.replace(/\|/g, "\\|"),
791
+ ]));
792
+ }
793
+ }
794
+ lines.push("");
795
+ return lines.join("\n");
796
+ }
797
+ function renderReportMarkdown(result) {
798
+ return [renderScanMarkdown(result.scan), "", renderPolicyMarkdown(result.policy)].join("\n");
799
+ }
800
+ // Serialize the structured result as JSON. Branching JSON separately from the
801
+ // markdown formatter ensures `--format json` / `--json` and `--output <file>`
802
+ // write real serialized JSON, not markdown.
803
+ function jsonOf(structured) {
804
+ return `${JSON.stringify(structured, null, 2)}\n`;
805
+ }
806
+ function emitResult(structured, format, outputPath, formatter) {
807
+ if (outputPath) {
808
+ // Create parent directories so --output ./reports/fleet.md works on a fresh
809
+ // checkout instead of crashing with a raw ENOENT.
810
+ const parent = dirname(resolvePath(outputPath));
811
+ try {
812
+ mkdirSync(parent, { recursive: true });
813
+ }
814
+ catch {
815
+ /* ignore — writeFileSync will surface a clean error if the dir is unusable */
816
+ }
817
+ const body = format === "markdown" ? formatter() : jsonOf(structured);
818
+ writeFileSync(outputPath, body, "utf-8");
819
+ console.error(`pm-ops: wrote ${format} output to ${outputPath}`);
820
+ return { written_to: outputPath, format };
821
+ }
822
+ if (format === "toon")
823
+ return structured;
824
+ if (format === "json")
825
+ return renderedCommandResult(jsonOf(structured));
826
+ return renderedCommandResult(formatter());
827
+ }
828
+ // ---------------------------------------------------------------------------
829
+ // Extension
830
+ // ---------------------------------------------------------------------------
831
+ export default defineExtension({
832
+ name: "pm-ops",
833
+ version: "2026.7.6",
834
+ activate(api) {
835
+ if (typeof api.registerRenderer === "function") {
836
+ api.registerRenderer("toon", renderCommandResult);
837
+ api.registerRenderer("json", renderCommandResult);
838
+ }
839
+ api.registerCommand({
840
+ name: "ops scan",
841
+ description: "Scan a set of pm repositories and produce a per-repo release-readiness snapshot " +
842
+ "(strict TS, changelog, CI/release workflows, pm items, pm-changelog wiring, npm outdated, " +
843
+ "npm audit critical/high, open PRs/issues). Use --repos to pass multiple paths " +
844
+ "(comma-separated or repeatable). --json emits clean JSON; --format markdown emits a table.",
845
+ intent: "audit release readiness across many pm repositories",
846
+ examples: [
847
+ "pm ops scan",
848
+ "pm ops scan --repos ./pm-csv ./pm-github",
849
+ "pm ops scan --repos ./pm-csv,./pm-github --json",
850
+ "pm ops scan --format markdown",
851
+ "pm ops scan --repos ~/container/pm-* --format markdown --output FLEET.md",
852
+ ],
853
+ flags: [
854
+ { long: "--repos", value_name: "paths", description: "Repo paths to scan (comma-separated or repeatable; default: current dir)", list: true },
855
+ { long: "--json", description: "Emit clean JSON to stdout (progress on stderr)" },
856
+ { long: "--format", value_name: "toon|json|markdown", description: "Output format (default: toon)" },
857
+ { long: "--output", value_name: "file", description: "Write the rendered output to a file instead of stdout" },
858
+ ],
859
+ async run(ctx) {
860
+ const options = ctx.options;
861
+ const repos = resolveRepos(options);
862
+ const format = resolveFormat(options);
863
+ const outputPath = readString(options, "output");
864
+ console.error(`pm-ops scan: ${repos.length} repo(s)`);
865
+ const result = scanRepos(repos, (m) => console.error(` ${m}`));
866
+ console.error(`scan: ${result.summary.ready}/${result.summary.total} ready`);
867
+ return emitResult(result, format, outputPath, () => renderScanMarkdown(result));
868
+ },
869
+ });
870
+ api.registerCommand({
871
+ name: "ops policy",
872
+ description: "Validate a policy bundle against repos. Default policy checks: naming " +
873
+ "(^pm-[a-z][a-z0-9-]*$, no pm-ext-/pm-preset- prefixes), required-scripts, required-workflows, " +
874
+ "private-no-runners (private repos must not use GitHub-hosted runners), pm-duplicate-titles " +
875
+ "(no two open items share a title), pm-changelog-wired. --policy <file> loads a JSON bundle " +
876
+ "({ checks: [{ id, severity, repo_filter?, params? }] }). --strict exits non-zero on any failure.",
877
+ intent: "enforce naming/workflow/pm policies across many pm repositories",
878
+ examples: [
879
+ "pm ops policy",
880
+ "pm ops policy --repos ./pm-csv ./pm-github",
881
+ "pm ops policy --policy ./fleet-policy.json --strict",
882
+ "pm ops policy --format markdown",
883
+ ],
884
+ flags: [
885
+ { long: "--repos", value_name: "paths", description: "Repo paths to check (comma-separated or repeatable; default: current dir)", list: true },
886
+ { long: "--policy", value_name: "file", description: "JSON policy bundle overriding the default checks" },
887
+ { long: "--json", description: "Emit clean JSON to stdout (progress on stderr)" },
888
+ { long: "--format", value_name: "toon|json|markdown", description: "Output format (default: toon)" },
889
+ { long: "--strict", description: "Exit non-zero on any failed check (any severity)" },
890
+ { long: "--output", value_name: "file", description: "Write the rendered output to a file instead of stdout" },
891
+ ],
892
+ async run(ctx) {
893
+ const options = ctx.options;
894
+ const repos = resolveRepos(options);
895
+ const format = resolveFormat(options);
896
+ const strict = readBool(options, "strict");
897
+ const outputPath = readString(options, "output");
898
+ let bundle = DEFAULT_POLICY;
899
+ const policyFile = readString(options, "policy");
900
+ if (policyFile) {
901
+ const loaded = readJsonFile(resolvePath(policyFile));
902
+ if (!loaded || !Array.isArray(loaded.checks)) {
903
+ throw new CommandError(`--policy file "${policyFile}" is not a valid policy bundle (expected { checks: [...] })`, EXIT_CODE.USAGE);
904
+ }
905
+ bundle = loaded;
906
+ }
907
+ console.error(`pm-ops policy: ${repos.length} repo(s), ${bundle.checks.length} check(s)`);
908
+ const result = runPolicy(repos, bundle, (m) => console.error(` ${m}`));
909
+ console.error(`policy: ${result.summary.passed} passed, ${result.summary.failed} failed`);
910
+ const failed = result.summary.failed > 0;
911
+ // In strict mode with failures, ensure the formatted result is produced
912
+ // (to the --output file if set, else to stdout) BEFORE throwing so
913
+ // users/agents still see which checks failed. The throw itself must
914
+ // happen regardless of --output so CI exit-code gating is never bypassed.
915
+ if (strict && failed) {
916
+ if (outputPath) {
917
+ // emitResult writes the file and returns a summary; discard return.
918
+ emitResult(result, format, outputPath, () => renderPolicyMarkdown(result));
919
+ }
920
+ else if (format === "markdown") {
921
+ process.stdout.write(`${renderPolicyMarkdown(result)}\n`);
922
+ }
923
+ else {
924
+ process.stdout.write(jsonOf(result));
925
+ }
926
+ throw new CommandError(`policy: ${result.summary.failed} check(s) failed (strict mode)`, EXIT_CODE.GENERIC_FAILURE);
927
+ }
928
+ return emitResult(result, format, outputPath, () => renderPolicyMarkdown(result));
929
+ },
930
+ });
931
+ api.registerCommand({
932
+ name: "ops verify-release",
933
+ description: "Run the release gate matrix per repo: executes `npm run release:check` (or the individual " +
934
+ "typecheck/build/test/audit:prod/pack:dry-run/changelog:check steps when release:check is missing) " +
935
+ "and reports pass/fail with per-step timing. Does NOT publish. Exits non-zero if any repo fails.",
936
+ intent: "run a release gate matrix across many pm repositories",
937
+ examples: [
938
+ "pm ops verify-release",
939
+ "pm ops verify-release --repos ./pm-csv ./pm-github",
940
+ "pm ops verify-release --json",
941
+ ],
942
+ flags: [
943
+ { long: "--repos", value_name: "paths", description: "Repo paths to verify (comma-separated or repeatable; default: current dir)", list: true },
944
+ { long: "--json", description: "Emit clean JSON to stdout (progress on stderr)" },
945
+ { long: "--format", value_name: "toon|json|markdown", description: "Output format (default: toon)" },
946
+ ],
947
+ async run(ctx) {
948
+ const options = ctx.options;
949
+ const repos = resolveRepos(options);
950
+ const format = resolveFormat(options);
951
+ console.error(`pm-ops verify-release: ${repos.length} repo(s)`);
952
+ const result = verifyRelease(repos, (m) => console.error(` ${m}`));
953
+ console.error(`verify-release: ${result.summary.passed}/${result.summary.total} repos passed`);
954
+ const failed = result.summary.failed > 0;
955
+ if (format === "json") {
956
+ if (failed) {
957
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
958
+ throw new CommandError(`verify-release: ${result.summary.failed} repo(s) failed`, EXIT_CODE.GENERIC_FAILURE);
959
+ }
960
+ return renderedCommandResult(`${JSON.stringify(result, null, 2)}\n`);
961
+ }
962
+ if (format === "markdown") {
963
+ const md = renderVerifyReleaseMarkdown(result);
964
+ if (failed) {
965
+ process.stdout.write(md.endsWith("\n") ? md : `${md}\n`);
966
+ throw new CommandError(`verify-release: ${result.summary.failed} repo(s) failed`, EXIT_CODE.GENERIC_FAILURE);
967
+ }
968
+ return renderedCommandResult(md);
969
+ }
970
+ if (failed) {
971
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
972
+ throw new CommandError(`verify-release: ${result.summary.failed} repo(s) failed`, EXIT_CODE.GENERIC_FAILURE);
973
+ }
974
+ return result;
975
+ },
976
+ });
977
+ api.registerCommand({
978
+ name: "ops report",
979
+ description: "Emit a concise fleet report combining scan + policy results. --format markdown produces " +
980
+ "a PR/issue-ready summary table. --output writes the report to a file. Default stdout TOON.",
981
+ intent: "produce a concise fleet report across many pm repositories",
982
+ examples: [
983
+ "pm ops report",
984
+ "pm ops report --repos ./pm-csv ./pm-github --format markdown",
985
+ "pm ops report --format markdown --output FLEET.md",
986
+ "pm ops report --json",
987
+ ],
988
+ flags: [
989
+ { long: "--repos", value_name: "paths", description: "Repo paths to report on (comma-separated or repeatable; default: current dir)", list: true },
990
+ { long: "--json", description: "Emit clean JSON to stdout (progress on stderr)" },
991
+ { long: "--format", value_name: "toon|json|markdown", description: "Output format (default: toon)" },
992
+ { long: "--output", value_name: "file", description: "Write the rendered report to a file instead of stdout" },
993
+ ],
994
+ async run(ctx) {
995
+ const options = ctx.options;
996
+ const repos = resolveRepos(options);
997
+ const format = resolveFormat(options);
998
+ const outputPath = readString(options, "output");
999
+ console.error(`pm-ops report: ${repos.length} repo(s)`);
1000
+ const result = buildReport(repos, (m) => console.error(` ${m}`));
1001
+ console.error(`report: scan ${result.scan.summary.ready}/${result.scan.summary.total} ready; policy ${result.policy.summary.failed} failed`);
1002
+ return emitResult(result, format, outputPath, () => renderReportMarkdown(result));
1003
+ },
1004
+ });
1005
+ },
1006
+ });
1007
+ //# sourceMappingURL=index.js.map