@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.1-beta.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.
Files changed (35) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +70 -0
  4. package/template/_claude/hooks/session-start.mjs +21 -1
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  15. package/template/_claude/scripts/task-pr.mjs +316 -72
  16. package/template/_claude/scripts/task-worktree.mjs +9 -1
  17. package/template/_claude/scripts/template-merge.mjs +21 -4
  18. package/template/_claude/scripts/template-modifications.mjs +152 -0
  19. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  20. package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
  21. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  22. package/template/_claude/skills/braindump/SKILL.md +1 -1
  23. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  24. package/template/_claude/skills/handoff/SKILL.md +1 -1
  25. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  26. package/template/_claude/skills/release/SKILL.md +20 -6
  27. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  28. package/template/_claude/skills/start-work/SKILL.md +23 -5
  29. package/template/_claude/skills/workspace-init/SKILL.md +51 -9
  30. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  31. package/template/workspace.json.tmpl +2 -1
  32. package/lib/init.test.mjs +0 -75
  33. package/lib/payload.test.mjs +0 -210
  34. package/lib/scaffold.test.mjs +0 -79
  35. package/lib/upgrade.test.mjs +0 -547
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ // Release leak audit (gh:203): scan exactly what a release would publish
3
+ // against the leak patterns the workspace configured, before the tag is
4
+ // pushed and the publish becomes permanent.
5
+ //
6
+ // /release keeps the process honest — the bump rides a PR, the tag marks
7
+ // the merge — but no step of it looks at content. A credential pasted into
8
+ // a shipped file or a commit message is public from the moment the tag is
9
+ // pushed, and deleting a tag does not unpublish a tarball or a clone
10
+ // somebody already fetched. This script is the optional content gate.
11
+ //
12
+ // Patterns (workspace.json, arrays of strings):
13
+ // repos.{repo}.release.leakPatterns this repo alone
14
+ // workspace.release.leakPatterns every repo — and, as with
15
+ // repos.{repo}.merge, the workspace
16
+ // repo "." itself reads this block
17
+ // A pattern written "/body/flags" (flags from d g i m s u v y) is that
18
+ // regex; any other string is a case-insensitive literal. Mind the seam
19
+ // between the forms: a path-like literal whose tail parses as a flags run
20
+ // ("/keys/i") reads as a regex — write such literals without the leading
21
+ // slash ("keys/i"), or escape the separators as a regex. Global and
22
+ // sticky patterns are safe to configure: the scanner resets lastIndex
23
+ // before every line, so no match state leaks across lines.
24
+ //
25
+ // The surface is what this release publishes, unioned rather than either:
26
+ // - the `npm pack --dry-run --ignore-scripts --json` file list, when the
27
+ // repo's package.json is not "private": true — run with
28
+ // --ignore-scripts because an audit must not execute a prepack or
29
+ // prepare hook as a side effect. The list covers the root package
30
+ // only (a monorepo's workspace packages are not enumerated) and is
31
+ // built from the worktree, so build artifacts a publish workflow
32
+ // generates only in CI are not on it — which is why the union below
33
+ // matters;
34
+ // - the files changed in the tag range (`git diff --name-only -z
35
+ // --no-renames`, so paths with non-ASCII bytes, quotes, or leading
36
+ // spaces arrive raw rather than quoted-and-trimmed); with no previous
37
+ // tag, every file at the end ref (`git ls-tree -r -z --name-only`)
38
+ // is the same surface.
39
+ // File contents are read from the worktree — except with an explicit
40
+ // --tag-range whose end is not HEAD, where they are read from that commit
41
+ // (git show), so the audit scans the state the range actually names.
42
+ //
43
+ // Every commit message in the range is scanned too (--format=%B): squash
44
+ // merges fold PR bodies into public history, so a body line is as
45
+ // published as a file line. A match cites the commit's short sha as its
46
+ // file ("commit <sha>") with the line inside the message.
47
+ //
48
+ // Usage:
49
+ // node release-leak-audit.mjs --root <launcher> --repo <repo>
50
+ // [--tag-range <from>..<to>] [--allow-dirty]
51
+ //
52
+ // The range defaults to <last tag>..HEAD; a repo with no tag yet takes
53
+ // every file at the end ref and logs its whole history, so the root
54
+ // commit is part of a first release's surface too. --tag-range overrides
55
+ // both ends and must name commits ("a...b" symmetric ranges are
56
+ // rejected). The audit is read-only throughout — it never writes so much
57
+ // as a git object into the repository it is auditing.
58
+ //
59
+ // The worktree must be clean: contents are read from disk, so auditing a
60
+ // dirty tree would scan changes that then ship differently. Uncommitted
61
+ // changes stop the audit (exit 2) unless --allow-dirty is passed.
62
+ //
63
+ // Output on stdout: { scanned, matches, skipped, skippedBinary }.
64
+ // matches: [{ file, line, pattern, excerpt }] — the excerpt carries
65
+ // ~40 chars of context with the matched span masked as
66
+ // "[match:N chars]", so the report never echoes the secret it found.
67
+ // skipped: [{ file, reason }] — every file that could not be read,
68
+ // except ones deleted in the range (a deletion publishes nothing) and
69
+ // binary ones (a NUL byte anywhere — git's own heuristic), which are
70
+ // counted in skippedBinary instead. scanned counts the text files and
71
+ // commit messages actually searched. Exit codes: 0 — clean, or nothing
72
+ // configured; 1 — matches found; 2 — configuration or environment
73
+ // error, including a dirty worktree.
74
+ //
75
+ // With no patterns configured it exits 0 having scanned nothing — the
76
+ // step runs and reports that there was nothing to look for, so a
77
+ // workspace that never opts in needs no special-casing. On a match it
78
+ // never edits anything; whether a match is a true leak, an over-broad
79
+ // pattern, or a release to redo is the operator's decision, made before
80
+ // the tag exists.
81
+
82
+ import '../lib/require-node.mjs';
83
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
84
+ import { join, resolve } from 'node:path';
85
+ import { spawnSync } from 'node:child_process';
86
+ import { fileURLToPath } from 'node:url';
87
+ import { readWorkspace, repoDirFor, WORKSPACE_REPO } from './merge-mode.mjs';
88
+
89
+ function isMainModule(metaUrl) {
90
+ if (!process.argv[1]) return false;
91
+ try {
92
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
93
+ } catch { return false; }
94
+ }
95
+
96
+ const NPM_TIMEOUT_MS = 120_000;
97
+ const CONTEXT_CHARS = 40;
98
+
99
+ // npm on Windows is npm.cmd, which spawnSync cannot execute without a
100
+ // shell. The args are fixed flags with no interpolated value, so the
101
+ // joined command line stays honest — the same shape lib/payload.mjs uses.
102
+ function defaultNpmFn(cmd, args, opts = {}) {
103
+ if (process.platform !== 'win32') return spawnSync(cmd, args, { encoding: 'utf8', ...opts });
104
+ return spawnSync('npm.cmd', args.map((a) => `"${a}"`), { shell: true, encoding: 'utf8', ...opts });
105
+ }
106
+
107
+ function gitRun(gitFn, repoDir, args, opts = {}) {
108
+ const res = gitFn('git', ['-C', repoDir, ...args], { encoding: 'utf8', ...opts });
109
+ if (res.error) throw new Error(`git: ${res.error.message}`);
110
+ return res;
111
+ }
112
+
113
+ // Compile one configured pattern. The regex form is only the full
114
+ // /body/flags shape with known flags; everything else is a literal, so a
115
+ // slashed string whose tail is not a flags run ("/data/keys") still works
116
+ // as the path literal its author meant. Literals compile to an escaped
117
+ // case-insensitive regex so the match index refers to the original line
118
+ // (lowercasing the whole line would shift indices for some Unicode).
119
+ const REGEX_FORM = /^\/(.+)\/([dgimsuvy]*)$/;
120
+ const REGEX_ESCAPE = /[.*+?^${}()|[\]\\]/g;
121
+
122
+ function compilePattern(raw) {
123
+ if (typeof raw !== 'string' || raw === '') {
124
+ throw new Error(`leak patterns must be non-empty strings, got: ${JSON.stringify(raw)}`);
125
+ }
126
+ const m = raw.match(REGEX_FORM);
127
+ let re;
128
+ if (!m) {
129
+ try {
130
+ re = new RegExp(raw.replace(REGEX_ESCAPE, '\\$&'), 'i');
131
+ } catch (err) {
132
+ throw new Error(`leak pattern ${JSON.stringify(raw)} is not a valid literal: ${err.message}`);
133
+ }
134
+ } else {
135
+ // Keep every flag the author wrote (g and y included) — find() resets
136
+ // lastIndex around each use, so stateful flags cannot leak across lines.
137
+ const flags = [...new Set(m[2])].join('');
138
+ try {
139
+ re = new RegExp(m[1], flags);
140
+ } catch (err) {
141
+ throw new Error(`leak pattern ${JSON.stringify(raw)} is not a valid regex: ${err.message}`);
142
+ }
143
+ }
144
+ return {
145
+ raw,
146
+ find: (line) => {
147
+ re.lastIndex = 0;
148
+ const hit = re.exec(line);
149
+ re.lastIndex = 0;
150
+ return hit === null ? null : { index: hit.index, length: hit[0].length };
151
+ },
152
+ };
153
+ }
154
+
155
+ // The combined pattern list for one repo: its own patterns first, then
156
+ // the workspace-wide ones, de-duplicated. The workspace repo "." has no
157
+ // repos.{repo} entry of its own — its per-repo settings live in the
158
+ // workspace block (the same split mergeModeFor makes for `merge`), which
159
+ // for leak patterns means one block serves both roles.
160
+ function leakPatternsFor(ws, repo) {
161
+ const read = (value, setting) => {
162
+ if (value === undefined) return [];
163
+ if (!Array.isArray(value) || value.some((p) => typeof p !== 'string' || p === '')) {
164
+ throw new Error(`${setting} must be an array of non-empty strings`);
165
+ }
166
+ return value;
167
+ };
168
+ const repoLevel = repo === WORKSPACE_REPO
169
+ ? read(ws?.workspace?.release?.leakPatterns, 'workspace.release.leakPatterns')
170
+ : read(ws?.repos?.[repo]?.release?.leakPatterns, `repos.${repo}.release.leakPatterns`);
171
+ const allRepos = read(ws?.workspace?.release?.leakPatterns, 'workspace.release.leakPatterns');
172
+ return [...new Set([...repoLevel, ...allRepos])];
173
+ }
174
+
175
+ // A package.json without "private": true describes a package npm would
176
+ // publish — its pack list is part of the release surface. Anything else
177
+ // (no package.json, private, or a file that does not parse) publishes
178
+ // through git alone, so the tag range carries the surface instead.
179
+ function isPublishedPackage(repoDir) {
180
+ const pkgPath = join(repoDir, 'package.json');
181
+ if (!existsSync(pkgPath)) return false;
182
+ try {
183
+ return JSON.parse(readFileSync(pkgPath, 'utf8'))?.private !== true;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ // npm prints notices before the JSON; take the trailing JSON array and
190
+ // find the '[' that actually starts it (a notice may contain brackets).
191
+ function parsePackJson(stdout) {
192
+ const out = String(stdout || '');
193
+ const end = out.lastIndexOf(']');
194
+ if (end === -1) throw new Error('no JSON array found in npm pack output');
195
+ let idx = out.indexOf('[');
196
+ while (idx !== -1 && idx < end) {
197
+ try {
198
+ const parsed = JSON.parse(out.slice(idx, end + 1));
199
+ if (parsed !== null && typeof parsed === 'object') return parsed;
200
+ } catch { /* this '[' was noise — try the next one */ }
201
+ idx = out.indexOf('[', idx + 1);
202
+ }
203
+ throw new Error('npm pack printed unparseable JSON output');
204
+ }
205
+
206
+ function packFileList(npmFn, repoDir) {
207
+ const res = npmFn('npm', ['pack', '--dry-run', '--ignore-scripts', '--json'], {
208
+ cwd: repoDir, encoding: 'utf8', timeout: NPM_TIMEOUT_MS,
209
+ });
210
+ if (res.error || res.status !== 0) {
211
+ throw new Error(`npm pack --dry-run failed in ${repoDir}: ${String(res.stderr || res.error?.message || '').trim()}`);
212
+ }
213
+ const parsed = parsePackJson(res.stdout);
214
+ const files = Array.isArray(parsed) ? parsed[0]?.files : parsed?.files;
215
+ if (!Array.isArray(files)) {
216
+ throw new Error(`npm pack --dry-run --json in ${repoDir} returned no file list`);
217
+ }
218
+ return files.map((f) => f?.path).filter((p) => typeof p === 'string' && p !== '');
219
+ }
220
+
221
+ // The release range: explicit --tag-range, else <last tag>..HEAD, else (a
222
+ // first release, no tag yet) a from-less range with rootMode set, so the
223
+ // file list comes from ls-tree of the end ref and the commit log runs
224
+ // unbounded — the root commit is included. Null only for a repository
225
+ // with no commits at all. Nothing here writes to the repository: the
226
+ // audit is read-only, so the no-tag listing is a plain ls-tree rather
227
+ // than an empty-tree diff (which would have to materialize the empty-tree
228
+ // object first).
229
+ function resolveRange(gitFn, repoDir, tagRange) {
230
+ const head = gitRun(gitFn, repoDir, ['rev-parse', '--verify', 'HEAD^{commit}']);
231
+ if (head.status !== 0) return null;
232
+ if (tagRange !== null) {
233
+ const parts = tagRange.split('..');
234
+ if (tagRange.includes('...') || parts.length !== 2 || parts[0] === '' || parts[1] === '') {
235
+ throw new Error(`--tag-range must look like <from>..<to>, got: ${tagRange}`);
236
+ }
237
+ const [from, to] = parts;
238
+ const end = gitRun(gitFn, repoDir, ['rev-parse', '--verify', `${to}^{commit}`]);
239
+ if (end.status !== 0) {
240
+ throw new Error(`--tag-range end "${to}" is not a commit: ${String(end.stderr || '').trim()}`);
241
+ }
242
+ // When the range ends away from HEAD, file contents are read from that
243
+ // end commit — the audit must scan the state the range names, not the
244
+ // worktree's.
245
+ const readRef = String(end.stdout || '').trim() !== String(head.stdout || '').trim() ? to : null;
246
+ return { from, to, rootMode: false, readRef };
247
+ }
248
+ const describe = gitRun(gitFn, repoDir, ['describe', '--tags', '--abbrev=0']);
249
+ if (describe.status === 0) {
250
+ const last = String(describe.stdout || '').trim();
251
+ if (last) return { from: last, to: 'HEAD', rootMode: false, readRef: null };
252
+ }
253
+ return { from: null, to: 'HEAD', rootMode: true, readRef: null };
254
+ }
255
+
256
+ // Raw NUL-delimited paths, no quoting and no trimming: a file named
257
+ // " notes.md" or "quoté \"file\".js" must arrive exactly as git stores it.
258
+ // A from-less range (no previous tag) lists the end ref itself — the same
259
+ // set an empty-tree diff would produce, without writing anything.
260
+ function changedFiles(gitFn, repoDir, range) {
261
+ const res = range.rootMode
262
+ ? gitRun(gitFn, repoDir, ['ls-tree', '-r', '-z', '--name-only', range.to])
263
+ : gitRun(gitFn, repoDir, ['diff', '--name-only', '-z', '--no-renames', range.from, range.to]);
264
+ if (res.status !== 0) {
265
+ const what = range.rootMode ? `git ls-tree ${range.to}` : `git diff --name-only ${range.from}..${range.to}`;
266
+ throw new Error(`${what} failed in ${repoDir}: ${String(res.stderr || '').trim()}`);
267
+ }
268
+ return String(res.stdout || '').split('\0').filter((p) => p !== '');
269
+ }
270
+
271
+ // Full bodies (%B), oldest first: squash merges fold PR bodies into public
272
+ // history, so a body line is as much the release surface as the subject.
273
+ function commitMessages(gitFn, repoDir, range) {
274
+ const revs = range.rootMode ? [range.to] : [`${range.from}..${range.to}`];
275
+ const res = gitRun(gitFn, repoDir, ['log', '--reverse', '--format=%h%x1f%B%x1e', ...revs]);
276
+ if (res.status !== 0) {
277
+ throw new Error(`git log ${revs[0]} failed in ${repoDir}: ${String(res.stderr || '').trim()}`);
278
+ }
279
+ return String(res.stdout || '').split('\x1e')
280
+ .map((record) => {
281
+ // git separates log entries with a newline even after %x1e.
282
+ const rec = record.replace(/^[\r\n]+/, '');
283
+ const sep = rec.indexOf('\x1f');
284
+ if (sep < 0) return null;
285
+ const sha = rec.slice(0, sep);
286
+ const body = rec.slice(sep + 1).replace(/\s+$/, '');
287
+ return sha && body ? { sha, body } : null;
288
+ })
289
+ .filter(Boolean);
290
+ }
291
+
292
+ // Auditing reads the worktree, so an uncommitted change would be scanned
293
+ // and then ship differently — refuse instead, naming the files.
294
+ function ensureCleanTree(gitFn, repoDir, repo, allowDirty) {
295
+ const res = gitRun(gitFn, repoDir, ['status', '--porcelain']);
296
+ if (res.status !== 0) {
297
+ throw new Error(`git status --porcelain failed in ${repoDir}: ${String(res.stderr || '').trim()}`);
298
+ }
299
+ const lines = String(res.stdout || '').split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
300
+ if (lines.length > 0 && !allowDirty) {
301
+ throw new Error(`repo "${repo}" has uncommitted changes — pass --allow-dirty to audit anyway:\n${lines.join('\n')}`);
302
+ }
303
+ }
304
+
305
+ // ~40 chars of context with the matched span masked, so a report shown to
306
+ // a human never reproduces the secret it found.
307
+ function maskedExcerpt(line, index, length) {
308
+ const start = Math.max(0, index - CONTEXT_CHARS);
309
+ const end = Math.min(line.length, index + length + CONTEXT_CHARS);
310
+ const window = `${line.slice(start, index)}[match:${length} chars]${line.slice(index + length, end)}`;
311
+ return `${start > 0 ? '...' : ''}${window}${end < line.length ? '...' : ''}`.trim();
312
+ }
313
+
314
+ function scanText(label, text, patterns, matches) {
315
+ const lines = text.split(/\r?\n/);
316
+ for (let i = 0; i < lines.length; i += 1) {
317
+ for (const p of patterns) {
318
+ const hit = p.find(lines[i]);
319
+ if (hit !== null) {
320
+ matches.push({
321
+ file: label, line: i + 1, pattern: p.raw,
322
+ excerpt: maskedExcerpt(lines[i], hit.index, hit.length),
323
+ });
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ // Reading one candidate file from the worktree. deleted = gone from disk
330
+ // (a deletion in the range publishes nothing); anything else unreadable
331
+ // is reported, never silently dropped.
332
+ function classifyDiskRead(fileAbs) {
333
+ let buf;
334
+ try {
335
+ buf = readFileSync(fileAbs);
336
+ } catch (err) {
337
+ if (err.code === 'ENOENT') return { kind: 'deleted' };
338
+ return { kind: 'unreadable', reason: err.code || err.message };
339
+ }
340
+ if (buf.includes(0)) return { kind: 'binary' };
341
+ return { kind: 'text', text: buf.toString('utf8') };
342
+ }
343
+
344
+ // Reading one candidate file out of a commit (--tag-range ending away
345
+ // from HEAD). A file that came from npm's pack list but is not committed
346
+ // at that ref (a generated artifact, say) is reported rather than silent;
347
+ // one that came from the range diff simply does not exist there.
348
+ function classifyShowRead(gitFn, repoDir, ref, path, source) {
349
+ const res = gitRun(gitFn, repoDir, ['show', `${ref}:${path}`]);
350
+ if (res.status !== 0) {
351
+ const err = String(res.stderr || '').trim();
352
+ if (/does not exist|invalid path|exists on disk/i.test(err)) {
353
+ return source === 'pack'
354
+ ? { kind: 'unreadable', reason: `not present at ${ref}` }
355
+ : { kind: 'deleted' };
356
+ }
357
+ return { kind: 'unreadable', reason: err || `git show ${ref}:${path} failed` };
358
+ }
359
+ const out = String(res.stdout || '');
360
+ if (out.includes('\0')) return { kind: 'binary' };
361
+ return { kind: 'text', text: out };
362
+ }
363
+
364
+ const VALUE_FLAGS = new Set(['--root', '--repo', '--tag-range']);
365
+ const BOOL_FLAGS = new Set(['--allow-dirty']);
366
+
367
+ function parseArgs(argv) {
368
+ const args = { root: '.', repo: null, tagRange: null, allowDirty: false };
369
+ const rest = argv.slice(2);
370
+ const value = (i, flag) => {
371
+ const v = rest[i + 1];
372
+ if (v === undefined || v.startsWith('--')) throw new Error(`${flag} requires a value`);
373
+ return v;
374
+ };
375
+ for (let i = 0; i < rest.length; i += 1) {
376
+ const a = rest[i];
377
+ if (VALUE_FLAGS.has(a)) {
378
+ args[a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value(i, a);
379
+ i += 1;
380
+ continue;
381
+ }
382
+ if (BOOL_FLAGS.has(a)) {
383
+ args[a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
384
+ continue;
385
+ }
386
+ throw new Error(`unknown argument: ${a}`);
387
+ }
388
+ if (!args.repo) throw new Error('--repo is required (the workspace repo itself is ".")');
389
+ return args;
390
+ }
391
+
392
+ /**
393
+ * Run one audit. `argv` is a process.argv-shaped array; `deps` is
394
+ * injectable so tests can fake git and npm:
395
+ * { gitFn(cmd, args, opts), npmFn(cmd, args, opts) }
396
+ * Resolves with { scanned, matches, skipped, skippedBinary, note? } —
397
+ * note rides along only when nothing was configured. Throws on any
398
+ * configuration or environment failure; the CLI maps a non-empty matches
399
+ * to exit 1.
400
+ */
401
+ function run(argv, deps = {}) {
402
+ const d = { gitFn: spawnSync, npmFn: defaultNpmFn, ...deps };
403
+ const args = parseArgs(argv);
404
+ const rootDir = resolve(args.root);
405
+ const ws = readWorkspace(rootDir);
406
+
407
+ // A repo name is "." or a directory name under repos/ — never a path
408
+ // (same shape task-pr.mjs accepts for its entries).
409
+ const REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
410
+ if (args.repo !== WORKSPACE_REPO && (args.repo === '..' || !REPO_NAME_RE.test(args.repo))) {
411
+ throw new Error(`invalid repo name: ${args.repo} — expected "." or a plain repo name`);
412
+ }
413
+ const repoDir = repoDirFor(rootDir, args.repo);
414
+ if (!existsSync(repoDir)) {
415
+ throw new Error(`no repo directory at ${repoDir} — is "${args.repo}" cloned?`);
416
+ }
417
+
418
+ const patterns = leakPatternsFor(ws, args.repo).map(compilePattern);
419
+ if (patterns.length === 0) {
420
+ return { scanned: 0, matches: [], skipped: [], skippedBinary: 0, note: 'no leak patterns configured' };
421
+ }
422
+
423
+ // Messages need a range even on the npm path, so git is probed first; a
424
+ // directory that is not a git repo at all has no messages to leak.
425
+ const isGit = gitRun(d.gitFn, repoDir, ['rev-parse', '--git-dir']).status === 0;
426
+ if (isGit) ensureCleanTree(d.gitFn, repoDir, args.repo, args.allowDirty);
427
+ const range = isGit ? resolveRange(d.gitFn, repoDir, args.tagRange) : null;
428
+
429
+ // The surface is the union, de-duplicated, pack list first: npm knows
430
+ // what ships but not what CI generates; the range knows what changed
431
+ // but not what the tarball would exclude.
432
+ const surface = [];
433
+ if (isPublishedPackage(repoDir)) {
434
+ for (const path of packFileList(d.npmFn, repoDir)) surface.push({ path, source: 'pack' });
435
+ }
436
+ if (range) {
437
+ for (const path of changedFiles(d.gitFn, repoDir, range)) surface.push({ path, source: 'diff' });
438
+ }
439
+ const seen = new Set();
440
+ const files = surface.filter((f) => (seen.has(f.path) ? false : seen.add(f.path)));
441
+
442
+ const matches = [];
443
+ const skipped = [];
444
+ let scanned = 0;
445
+ let skippedBinary = 0;
446
+ for (const { path, source } of files) {
447
+ const cls = range?.readRef
448
+ ? classifyShowRead(d.gitFn, repoDir, range.readRef, path, source)
449
+ : classifyDiskRead(join(repoDir, path));
450
+ if (cls.kind === 'deleted') continue;
451
+ if (cls.kind === 'binary') { skippedBinary += 1; continue; }
452
+ if (cls.kind === 'unreadable') { skipped.push({ file: path, reason: cls.reason }); continue; }
453
+ scanned += 1;
454
+ scanText(path, cls.text, patterns, matches);
455
+ }
456
+ if (range) {
457
+ for (const { sha, body } of commitMessages(d.gitFn, repoDir, range)) {
458
+ scanned += 1;
459
+ scanText(`commit ${sha}`, body, patterns, matches);
460
+ }
461
+ }
462
+ return { scanned, matches, skipped, skippedBinary };
463
+ }
464
+
465
+ function main() {
466
+ const out = run(process.argv);
467
+ process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
468
+ process.exitCode = out.matches.length > 0 ? 1 : 0;
469
+ }
470
+
471
+ if (isMainModule(import.meta.url)) {
472
+ try {
473
+ main();
474
+ } catch (err) {
475
+ process.stderr.write(`release-leak-audit: ${err.message}\n`);
476
+ process.exit(2);
477
+ }
478
+ }
479
+
480
+ export { run, parseArgs };