@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.0-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.
- package/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +43 -0
- package/template/_claude/hooks/session-start.mjs +10 -0
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- 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 };
|