@fro.bot/systematic 3.16.4 → 3.17.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/dist/cli.js +1 -1
- package/dist/{index-65g87tgr.js → index-y33enbkc.js} +5 -5
- package/dist/index.js +81 -42
- package/dist/lib/bundled-names.d.ts +1 -1
- package/dist/lib/config-schema.d.ts +426 -3
- package/dist/pi.js +7 -6
- package/dist/schemas/systematic-config.schema.json +1 -0
- package/package.json +5 -5
- package/skills/ce-review/SKILL.md +16 -2
- package/skills/ce-review/references/synthesis-artifact-contract.md +6 -0
- package/skills/ce-review/scripts/ensure-ignore.mjs +599 -0
- package/skills/ce-review-cleanup/SKILL.md +69 -0
- package/skills/ce-review-cleanup/scripts/cleanup.mjs +1391 -0
|
@@ -0,0 +1,1391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Preview and snapshot-bound deletion scanner for historical `ce:review`
|
|
4
|
+
// run directories.
|
|
5
|
+
//
|
|
6
|
+
// `preview` never mutates the filesystem: it resolves a caller-supplied
|
|
7
|
+
// project root, canonicalizes it, and walks direct child directories of
|
|
8
|
+
// `.context/systematic/ce-review` to report which are older than a
|
|
9
|
+
// caller-supplied age cutoff, returning a bounded token over that scan.
|
|
10
|
+
//
|
|
11
|
+
// `execute` deletes only the exact candidates the token was built from. It
|
|
12
|
+
// rescans using the token's own fixed time boundary (never "now"), and
|
|
13
|
+
// deletes nothing at all if that rescan's digest no longer matches the
|
|
14
|
+
// token -- a changed root, changed membership, or changed candidate
|
|
15
|
+
// invalidates the whole approval. A token is not proof of human consent;
|
|
16
|
+
// the caller (a skill) must still ask separately before invoking execute.
|
|
17
|
+
//
|
|
18
|
+
// Usage:
|
|
19
|
+
// node cleanup.mjs preview --root <path> --age <Nd|Nw|N> --ack-offline
|
|
20
|
+
// node cleanup.mjs execute --root <path> --ack-offline --token <token>
|
|
21
|
+
//
|
|
22
|
+
// Output: a single JSON object on stdout. Each reported name is
|
|
23
|
+
// length-bounded via boundedName(), and each candidate's subtree is bounded
|
|
24
|
+
// via MAX_ENTRIES/MAX_DEPTH -- but the root's direct-child and
|
|
25
|
+
// selected/excluded/skipped candidate *counts* are not separately capped,
|
|
26
|
+
// so overall output size scales with the number of ce-review run
|
|
27
|
+
// directories under the root. No absolute paths, nested relative paths,
|
|
28
|
+
// subprocess errors, or artifact contents are ever emitted -- only fixed
|
|
29
|
+
// diagnostic categories, bounded/JSON-escaped run names, and hash-derived
|
|
30
|
+
// display ids.
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import {
|
|
34
|
+
closeSync,
|
|
35
|
+
constants as fsConstants,
|
|
36
|
+
fstatSync,
|
|
37
|
+
lstatSync,
|
|
38
|
+
openSync,
|
|
39
|
+
readdirSync,
|
|
40
|
+
readSync,
|
|
41
|
+
realpathSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
} from 'node:fs'
|
|
44
|
+
import { basename, join } from 'node:path'
|
|
45
|
+
import { pathToFileURL } from 'node:url'
|
|
46
|
+
|
|
47
|
+
// ── Constants ─────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const SCHEMA_VERSION = 1
|
|
50
|
+
const TOKEN_VERSION = 1
|
|
51
|
+
const MAX_DEPTH = 32
|
|
52
|
+
const MAX_ENTRIES = 10_000
|
|
53
|
+
const MAX_SUMMARY_BYTES = 1 * 1024 * 1024 // 1 MiB
|
|
54
|
+
const MAX_NAME_LENGTH = 200
|
|
55
|
+
// ECMA-262 Date's representable range is exactly +/-100,000,000 days (ms)
|
|
56
|
+
// from the epoch. An age cutoff whose duration would push a valid "now"
|
|
57
|
+
// reference time's cutoff outside that range must be rejected up front,
|
|
58
|
+
// before any Date is ever constructed from it.
|
|
59
|
+
const MAX_REPRESENTABLE_DATE_MS = 8_640_000_000_000_000
|
|
60
|
+
const REVIEW_ROOT_SEGMENTS = ['.context', 'systematic', 'ce-review']
|
|
61
|
+
const SUMMARY_FILE_NAME = 'review-summary.json'
|
|
62
|
+
const KNOWN_RUN_STATUSES = new Set([
|
|
63
|
+
'in_progress',
|
|
64
|
+
'completed',
|
|
65
|
+
'degraded',
|
|
66
|
+
'abnormal',
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
// Fixed diagnostic categories. Never combine with dynamic/raw text.
|
|
70
|
+
const CATEGORY = Object.freeze({
|
|
71
|
+
INVALID_AGE: 'invalid-age',
|
|
72
|
+
INVALID_ARGUMENTS: 'invalid-arguments',
|
|
73
|
+
INVALID_ROOT: 'invalid-root',
|
|
74
|
+
INVALID_TOKEN: 'invalid-token',
|
|
75
|
+
MISSING_ACKNOWLEDGMENT: 'missing-acknowledgment',
|
|
76
|
+
MISSING_AGE: 'missing-age',
|
|
77
|
+
MISSING_ROOT: 'missing-root-argument',
|
|
78
|
+
MISSING_TOKEN: 'missing-token',
|
|
79
|
+
INTERNAL_ERROR: 'internal-error',
|
|
80
|
+
ROOT_ENUMERATION_FAILED: 'root-enumeration-failed',
|
|
81
|
+
UNKNOWN_OPERATION: 'unknown-operation',
|
|
82
|
+
UNSAFE_REVIEW_ROOT: 'unsafe-review-root',
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ── Pure helpers (exported for deterministic Node subprocess tests) ────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parses a positive-integer age cutoff, optionally suffixed with `d` (days,
|
|
89
|
+
* default) or `w` (weeks). Rejects zero, decimals, negative values,
|
|
90
|
+
* malformed text, and arithmetic that would overflow a safe integer. Does
|
|
91
|
+
* not reject a large duration by itself -- whether the *derived cutoff*
|
|
92
|
+
* (which also depends on the reference time) is a representable `Date` is
|
|
93
|
+
* checked by the caller once both are known; see MAX_REPRESENTABLE_DATE_MS.
|
|
94
|
+
* @param {unknown} input
|
|
95
|
+
* @returns {{ days: number, ms: number } | undefined}
|
|
96
|
+
*/
|
|
97
|
+
export function parseAgeCutoff(input) {
|
|
98
|
+
if (typeof input !== 'string') return undefined
|
|
99
|
+
const match = /^([1-9]\d*)([dw]?)$/.exec(input.trim())
|
|
100
|
+
if (!match) return undefined
|
|
101
|
+
const amount = Number.parseInt(match[1], 10)
|
|
102
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) return undefined
|
|
103
|
+
const unit = match[2] || 'd'
|
|
104
|
+
const days = unit === 'w' ? amount * 7 : amount
|
|
105
|
+
if (!Number.isSafeInteger(days) || days <= 0) return undefined
|
|
106
|
+
const ms = days * 86_400_000
|
|
107
|
+
if (!Number.isSafeInteger(ms) || ms <= 0) return undefined
|
|
108
|
+
return { days, ms }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Computes the absolute cutoff time (ms since epoch): candidates whose
|
|
113
|
+
* maximum modification time is strictly earlier than this are eligible.
|
|
114
|
+
* @param {number} referenceTimeMs
|
|
115
|
+
* @param {number} ageDurationMs
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
export function computeCutoffTimeMs(referenceTimeMs, ageDurationMs) {
|
|
119
|
+
return referenceTimeMs - ageDurationMs
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Truncates a name to a fixed maximum length so a single pathological entry
|
|
124
|
+
* cannot produce unbounded output. JSON.stringify still escapes any control
|
|
125
|
+
* characters the truncated name contains.
|
|
126
|
+
* @param {string} name
|
|
127
|
+
* @param {number} [maxLength]
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function boundedName(name, maxLength = MAX_NAME_LENGTH) {
|
|
131
|
+
if (name.length <= maxLength) return name
|
|
132
|
+
return `${name.slice(0, maxLength)}…`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Widens a fixed starting prefix length across a set of already-computed
|
|
137
|
+
* hex digests until every prefix is unique (or the full digest length is
|
|
138
|
+
* reached). Pure and digest-agnostic so tests can exercise the widening
|
|
139
|
+
* logic directly with synthetic digests, without needing to engineer a
|
|
140
|
+
* real SHA-256 collision.
|
|
141
|
+
* @param {readonly string[]} hexDigests
|
|
142
|
+
* @param {{ startLength?: number, step?: number, maxLength?: number }} [options]
|
|
143
|
+
* @returns {string[]} prefixes in the same order as the input digests
|
|
144
|
+
*/
|
|
145
|
+
export function widenUniquePrefixes(hexDigests, options = {}) {
|
|
146
|
+
const startLength = options.startLength ?? 8
|
|
147
|
+
const step = options.step ?? 4
|
|
148
|
+
const maxLength = options.maxLength ?? 64
|
|
149
|
+
let prefixLen = startLength
|
|
150
|
+
for (; prefixLen < maxLength; prefixLen += step) {
|
|
151
|
+
const prefixes = hexDigests.map((h) => h.slice(0, prefixLen))
|
|
152
|
+
if (new Set(prefixes).size === prefixes.length) return prefixes
|
|
153
|
+
}
|
|
154
|
+
return hexDigests.map((h) => h.slice(0, maxLength))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Derives a bounded, collision-resistant display id for each name without
|
|
159
|
+
* the id itself revealing the underlying name (the name is still reported
|
|
160
|
+
* separately, bounded and JSON-escaped).
|
|
161
|
+
* @param {readonly string[]} names
|
|
162
|
+
* @returns {Map<string, string>} name -> displayId
|
|
163
|
+
*/
|
|
164
|
+
export function deriveDisplayIds(names) {
|
|
165
|
+
const fullHashes = names.map((name) =>
|
|
166
|
+
createHash('sha256').update(name, 'utf8').digest('hex'),
|
|
167
|
+
)
|
|
168
|
+
const prefixes = widenUniquePrefixes(fullHashes)
|
|
169
|
+
const result = new Map()
|
|
170
|
+
names.forEach((name, i) => {
|
|
171
|
+
result.set(name, prefixes[i])
|
|
172
|
+
})
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Builds a versioned, bounded preview token. The token carries the fixed
|
|
178
|
+
* reference time, normalized age duration, absolute cutoff, and a digest of
|
|
179
|
+
* the scanned snapshot -- all recoverable without external state. It is not
|
|
180
|
+
* proof of human approval; the skill must still ask separately.
|
|
181
|
+
* @param {{ referenceTimeMs: number, ageDurationMs: number, cutoffTimeMs: number, digest: string }} fields
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
export function buildPreviewToken(fields) {
|
|
185
|
+
const payload = {
|
|
186
|
+
ageDurationMs: fields.ageDurationMs,
|
|
187
|
+
cutoffTimeMs: fields.cutoffTimeMs,
|
|
188
|
+
digest: fields.digest,
|
|
189
|
+
referenceTimeMs: fields.referenceTimeMs,
|
|
190
|
+
v: TOKEN_VERSION,
|
|
191
|
+
}
|
|
192
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Filesystem-safe path resolution ─────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Walks a fixed sequence of path segments below a trusted canonical root,
|
|
199
|
+
* refusing any symlink or non-directory component. Returns 'missing' the
|
|
200
|
+
* moment a segment does not exist (never created), 'symlink' or
|
|
201
|
+
* 'not-directory' if an existing segment is unsafe, or 'ok' with the final
|
|
202
|
+
* resolved absolute path.
|
|
203
|
+
* @param {string} canonicalRoot
|
|
204
|
+
* @param {readonly string[]} segments
|
|
205
|
+
*/
|
|
206
|
+
function resolveSegmentChain(canonicalRoot, segments) {
|
|
207
|
+
let current = canonicalRoot
|
|
208
|
+
for (const segment of segments) {
|
|
209
|
+
current = join(current, segment)
|
|
210
|
+
let stat
|
|
211
|
+
try {
|
|
212
|
+
stat = lstatSync(current)
|
|
213
|
+
} catch {
|
|
214
|
+
return { status: 'missing' }
|
|
215
|
+
}
|
|
216
|
+
if (stat.isSymbolicLink()) return { status: 'symlink' }
|
|
217
|
+
if (!stat.isDirectory()) return { status: 'not-directory' }
|
|
218
|
+
}
|
|
219
|
+
return { path: current, status: 'ok' }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Per-entry stat (reusable by Unit 3's per-candidate rescan) ──────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Lstats a single path and classifies it. Never follows symlinks. This is
|
|
226
|
+
* the single point of "real filesystem truth" the walker and any future
|
|
227
|
+
* rescan share -- calling it against a missing or replaced path produces a
|
|
228
|
+
* genuine, deterministic lstat failure, not a simulated one.
|
|
229
|
+
* @param {string} absPath
|
|
230
|
+
* @returns {{ ok: true, type: 'file' | 'directory', dev: string, ino: string, mode: string, size: string, mtimeNs: string, ctimeNs: string } | { ok: false, reason: 'unreadable' | 'symlink' | 'special-file' }}
|
|
231
|
+
*/
|
|
232
|
+
export function statSnapshotEntry(absPath) {
|
|
233
|
+
let stat
|
|
234
|
+
try {
|
|
235
|
+
stat = lstatSync(absPath, { bigint: true })
|
|
236
|
+
} catch {
|
|
237
|
+
return { ok: false, reason: 'unreadable' }
|
|
238
|
+
}
|
|
239
|
+
if (stat.isSymbolicLink()) return { ok: false, reason: 'symlink' }
|
|
240
|
+
let type
|
|
241
|
+
if (stat.isDirectory()) type = 'directory'
|
|
242
|
+
else if (stat.isFile()) type = 'file'
|
|
243
|
+
else return { ok: false, reason: 'special-file' }
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
ctimeNs: stat.ctimeNs.toString(),
|
|
247
|
+
dev: stat.dev.toString(),
|
|
248
|
+
ino: stat.ino.toString(),
|
|
249
|
+
mode: stat.mode.toString(),
|
|
250
|
+
mtimeNs: stat.mtimeNs.toString(),
|
|
251
|
+
ok: true,
|
|
252
|
+
size: stat.size.toString(),
|
|
253
|
+
type,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Candidate subtree walk ──────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @typedef {{
|
|
261
|
+
* relativePath: string,
|
|
262
|
+
* type: 'file' | 'directory',
|
|
263
|
+
* dev: string,
|
|
264
|
+
* ino: string,
|
|
265
|
+
* mode: string,
|
|
266
|
+
* size: string,
|
|
267
|
+
* mtimeNs: string,
|
|
268
|
+
* ctimeNs: string,
|
|
269
|
+
* }} SnapshotEntry
|
|
270
|
+
*/
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Recursively lstats a candidate directory and every descendant via
|
|
274
|
+
* {@link statSnapshotEntry}, rejecting the whole candidate on any symlink,
|
|
275
|
+
* special file, unreadable entry, or traversal bound violation.
|
|
276
|
+
*
|
|
277
|
+
* The subtree's maximum modification time is tracked as a BigInt count of
|
|
278
|
+
* nanoseconds, not a float. Epoch nanosecond values exceed float64's exact
|
|
279
|
+
* integer range (2^53), so converting through `Number` before comparing
|
|
280
|
+
* would introduce rounding noise large enough to flip a strict
|
|
281
|
+
* older-than-cutoff comparison at exact-boundary inputs.
|
|
282
|
+
* @param {string} candidateAbsPath
|
|
283
|
+
* @returns {{ ok: true, entries: SnapshotEntry[], maxMtimeNs: bigint | null } | { ok: false, reason: string }}
|
|
284
|
+
*/
|
|
285
|
+
export function walkCandidateSubtree(candidateAbsPath) {
|
|
286
|
+
/** @type {SnapshotEntry[]} */
|
|
287
|
+
const entries = []
|
|
288
|
+
/** @type {bigint | null} */
|
|
289
|
+
let maxMtimeNs = null
|
|
290
|
+
let count = 0
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} absPath
|
|
294
|
+
* @param {string} relPath
|
|
295
|
+
* @param {number} depth
|
|
296
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
297
|
+
*/
|
|
298
|
+
function visit(absPath, relPath, depth) {
|
|
299
|
+
if (depth > MAX_DEPTH) return { ok: false, reason: 'depth-limit' }
|
|
300
|
+
|
|
301
|
+
const stat = statSnapshotEntry(absPath)
|
|
302
|
+
if (!stat.ok) return { ok: false, reason: stat.reason }
|
|
303
|
+
|
|
304
|
+
count += 1
|
|
305
|
+
if (count > MAX_ENTRIES) return { ok: false, reason: 'entry-limit' }
|
|
306
|
+
|
|
307
|
+
const mtimeNs = BigInt(stat.mtimeNs)
|
|
308
|
+
if (maxMtimeNs === null || mtimeNs > maxMtimeNs) {
|
|
309
|
+
maxMtimeNs = mtimeNs
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { ok: _statOk, ...meta } = stat
|
|
313
|
+
entries.push({ ...meta, relativePath: relPath })
|
|
314
|
+
|
|
315
|
+
if (stat.type === 'directory') {
|
|
316
|
+
let children
|
|
317
|
+
try {
|
|
318
|
+
children = readdirSync(absPath, { withFileTypes: true })
|
|
319
|
+
} catch {
|
|
320
|
+
return { ok: false, reason: 'unreadable' }
|
|
321
|
+
}
|
|
322
|
+
for (const child of children) {
|
|
323
|
+
const childRel =
|
|
324
|
+
relPath === '' ? child.name : `${relPath}/${child.name}`
|
|
325
|
+
const result = visit(join(absPath, child.name), childRel, depth + 1)
|
|
326
|
+
if (!result.ok) return result
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { ok: true }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const outcome = visit(candidateAbsPath, '', 0)
|
|
334
|
+
if (!outcome.ok) return { ok: false, reason: outcome.reason }
|
|
335
|
+
|
|
336
|
+
entries.sort((a, b) =>
|
|
337
|
+
a.relativePath < b.relativePath
|
|
338
|
+
? -1
|
|
339
|
+
: a.relativePath > b.relativePath
|
|
340
|
+
? 1
|
|
341
|
+
: 0,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
entries,
|
|
346
|
+
maxMtimeNs,
|
|
347
|
+
ok: true,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Status label projection ─────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Derives a bounded status label from the CURRENT summary file, not the
|
|
355
|
+
* walked snapshot's cached size/type -- only identity-corroborated content
|
|
356
|
+
* up to MAX_SUMMARY_BYTES is ever parsed.
|
|
357
|
+
*
|
|
358
|
+
* Checks, in order: path-level lstat rejects non-regular/symlink before
|
|
359
|
+
* open; O_NOFOLLOW (where the platform defines it) is a second, defense-in-
|
|
360
|
+
* depth barrier against a symlink, not the sole protection; the opened fd's
|
|
361
|
+
* identity (dev/ino) must match both the pre-open lstat and the walked
|
|
362
|
+
* snapshot's recorded identity, or the file was replaced since the walk and
|
|
363
|
+
* the label is `unknown`; after the bounded read, the path is re-lstat'd
|
|
364
|
+
* and must still match the fd's identity.
|
|
365
|
+
*
|
|
366
|
+
* This narrows ordinary stale-state drift (a file resized, replaced, or
|
|
367
|
+
* symlinked between the walk and this call). It is not an atomic guarantee
|
|
368
|
+
* against an adversarial writer racing every check.
|
|
369
|
+
*
|
|
370
|
+
* The open also sets O_NONBLOCK (where defined): a regular file's reads are
|
|
371
|
+
* unaffected by this flag, but it closes one specific hang window -- an
|
|
372
|
+
* attacker swapping the path for a FIFO between the lstat above and this
|
|
373
|
+
* open would otherwise block this open indefinitely waiting for a writer.
|
|
374
|
+
* With O_NONBLOCK the open returns immediately regardless, and the fd's
|
|
375
|
+
* post-open identity/type check below then rejects it as non-regular.
|
|
376
|
+
* @param {string} candidateAbsPath
|
|
377
|
+
* @param {readonly SnapshotEntry[]} entries
|
|
378
|
+
* @returns {'in_progress' | 'completed' | 'degraded' | 'abnormal' | 'legacy' | 'artifactless' | 'unknown'}
|
|
379
|
+
*/
|
|
380
|
+
export function deriveStatusLabel(candidateAbsPath, entries) {
|
|
381
|
+
const summaryEntry = entries.find((e) => e.relativePath === SUMMARY_FILE_NAME)
|
|
382
|
+
if (!summaryEntry) return 'artifactless'
|
|
383
|
+
|
|
384
|
+
const summaryPath = join(candidateAbsPath, SUMMARY_FILE_NAME)
|
|
385
|
+
|
|
386
|
+
let pathStat
|
|
387
|
+
try {
|
|
388
|
+
pathStat = lstatSync(summaryPath, { bigint: true })
|
|
389
|
+
} catch {
|
|
390
|
+
return 'unknown'
|
|
391
|
+
}
|
|
392
|
+
if (!pathStat.isFile()) return 'unknown'
|
|
393
|
+
|
|
394
|
+
const openFlags =
|
|
395
|
+
fsConstants.O_RDONLY |
|
|
396
|
+
(typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0) |
|
|
397
|
+
(typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0)
|
|
398
|
+
|
|
399
|
+
let fd
|
|
400
|
+
try {
|
|
401
|
+
fd = openSync(summaryPath, openFlags)
|
|
402
|
+
} catch {
|
|
403
|
+
return 'unknown'
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
let fdStat
|
|
408
|
+
try {
|
|
409
|
+
fdStat = fstatSync(fd, { bigint: true })
|
|
410
|
+
} catch {
|
|
411
|
+
return 'unknown'
|
|
412
|
+
}
|
|
413
|
+
if (!fdStat.isFile()) return 'unknown'
|
|
414
|
+
|
|
415
|
+
const identityMatches =
|
|
416
|
+
fdStat.dev === pathStat.dev &&
|
|
417
|
+
fdStat.ino === pathStat.ino &&
|
|
418
|
+
fdStat.dev.toString() === summaryEntry.dev &&
|
|
419
|
+
fdStat.ino.toString() === summaryEntry.ino
|
|
420
|
+
if (!identityMatches) return 'unknown'
|
|
421
|
+
|
|
422
|
+
if (fdStat.size > BigInt(MAX_SUMMARY_BYTES)) return 'unknown'
|
|
423
|
+
|
|
424
|
+
const size = Number(fdStat.size)
|
|
425
|
+
const buffer = Buffer.alloc(size)
|
|
426
|
+
let readTotal = 0
|
|
427
|
+
try {
|
|
428
|
+
while (readTotal < buffer.length) {
|
|
429
|
+
const bytesRead = readSync(
|
|
430
|
+
fd,
|
|
431
|
+
buffer,
|
|
432
|
+
readTotal,
|
|
433
|
+
buffer.length - readTotal,
|
|
434
|
+
readTotal,
|
|
435
|
+
)
|
|
436
|
+
if (bytesRead <= 0) break
|
|
437
|
+
readTotal += bytesRead
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
// A read-time failure (e.g. EIO/EBADF from the underlying storage) is
|
|
441
|
+
// not authoritative about run status -- report it the same as any
|
|
442
|
+
// other unreadable/ambiguous summary file instead of throwing out of
|
|
443
|
+
// this non-authoritative label projection.
|
|
444
|
+
return 'unknown'
|
|
445
|
+
}
|
|
446
|
+
const raw = buffer.subarray(0, readTotal).toString('utf8')
|
|
447
|
+
|
|
448
|
+
let postStat
|
|
449
|
+
try {
|
|
450
|
+
postStat = lstatSync(summaryPath, { bigint: true })
|
|
451
|
+
} catch {
|
|
452
|
+
return 'unknown'
|
|
453
|
+
}
|
|
454
|
+
if (postStat.dev !== fdStat.dev || postStat.ino !== fdStat.ino) {
|
|
455
|
+
return 'unknown'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let parsed
|
|
459
|
+
try {
|
|
460
|
+
parsed = JSON.parse(raw)
|
|
461
|
+
} catch {
|
|
462
|
+
return 'unknown'
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (
|
|
466
|
+
parsed === null ||
|
|
467
|
+
typeof parsed !== 'object' ||
|
|
468
|
+
Array.isArray(parsed)
|
|
469
|
+
) {
|
|
470
|
+
return 'unknown'
|
|
471
|
+
}
|
|
472
|
+
if (!Object.hasOwn(parsed, 'schema_version')) return 'legacy'
|
|
473
|
+
|
|
474
|
+
const status = parsed.run_status
|
|
475
|
+
if (typeof status === 'string' && KNOWN_RUN_STATUSES.has(status)) {
|
|
476
|
+
return status
|
|
477
|
+
}
|
|
478
|
+
return 'unknown'
|
|
479
|
+
} finally {
|
|
480
|
+
closeSync(fd)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Digest ───────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Hashes a canonical serialization of the root identity, direct-child
|
|
488
|
+
* inventory, and every selected candidate's full sorted metadata. Aggregate
|
|
489
|
+
* max-mtime/count alone would miss same-count replacements and changes
|
|
490
|
+
* beneath an unchanged maximum timestamp, so the full snapshot is bound.
|
|
491
|
+
* @param {{ dev: string, ino: string }} rootIdentity
|
|
492
|
+
* @param {readonly { name: string, type: string }[]} directChildren
|
|
493
|
+
* @param {readonly { name: string, entries: readonly SnapshotEntry[] }[]} selectedCandidates
|
|
494
|
+
* @returns {string}
|
|
495
|
+
*/
|
|
496
|
+
export function computeSnapshotDigest(
|
|
497
|
+
rootIdentity,
|
|
498
|
+
directChildren,
|
|
499
|
+
selectedCandidates,
|
|
500
|
+
) {
|
|
501
|
+
const structure = {
|
|
502
|
+
directChildren: [...directChildren]
|
|
503
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
504
|
+
.map((c) => ({ name: c.name, type: c.type })),
|
|
505
|
+
root: rootIdentity,
|
|
506
|
+
selectedCandidates: [...selectedCandidates]
|
|
507
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
508
|
+
.map((c) => ({ entries: c.entries, name: c.name })),
|
|
509
|
+
v: TOKEN_VERSION,
|
|
510
|
+
}
|
|
511
|
+
return createHash('sha256')
|
|
512
|
+
.update(JSON.stringify(structure), 'utf8')
|
|
513
|
+
.digest('hex')
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Shared scan core (used by both preview and execute) ────────────────────
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @typedef {{
|
|
520
|
+
* ok: true,
|
|
521
|
+
* rootIdentity: { dev: string, ino: string },
|
|
522
|
+
* directChildren: { name: string, type: string }[],
|
|
523
|
+
* selected: { name: string, label: string, lastModifiedMs: number, entries: SnapshotEntry[] }[],
|
|
524
|
+
* excludedRecent: { name: string, label: string, lastModifiedMs: number }[],
|
|
525
|
+
* skippedUnknownUnsafe: { name: string, reason: string }[],
|
|
526
|
+
* } | { ok: false, category: string }} ScanResult
|
|
527
|
+
*/
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Enumerates direct children of an already-resolved, already-safety-checked
|
|
531
|
+
* review root and classifies every candidate directory by age and status.
|
|
532
|
+
* Both `runPreview` and `runExecute` call this so their selection logic can
|
|
533
|
+
* never drift apart; `runExecute` passes the token's fixed `cutoffTimeMs`
|
|
534
|
+
* and `referenceTimeMs` instead of the current wall clock, so elapsed real
|
|
535
|
+
* time cannot silently expand or shrink the approved set -- any actual
|
|
536
|
+
* difference in the underlying tree shows up as a digest mismatch instead.
|
|
537
|
+
* @param {string} reviewRoot
|
|
538
|
+
* @param {number} cutoffTimeMs
|
|
539
|
+
* @param {number} referenceTimeMs
|
|
540
|
+
* @returns {ScanResult}
|
|
541
|
+
*/
|
|
542
|
+
function scanReviewRoot(reviewRoot, cutoffTimeMs, referenceTimeMs) {
|
|
543
|
+
let rootStat
|
|
544
|
+
let directEntries
|
|
545
|
+
try {
|
|
546
|
+
rootStat = lstatSync(reviewRoot, { bigint: true })
|
|
547
|
+
directEntries = readdirSync(reviewRoot, { withFileTypes: true })
|
|
548
|
+
} catch {
|
|
549
|
+
return { category: CATEGORY.ROOT_ENUMERATION_FAILED, ok: false }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Comparisons happen in nanosecond BigInt space (see walkCandidateSubtree)
|
|
553
|
+
// to avoid float64 precision loss at exact-boundary inputs.
|
|
554
|
+
const referenceTimeNs = BigInt(referenceTimeMs) * 1_000_000n
|
|
555
|
+
const cutoffTimeNs = BigInt(cutoffTimeMs) * 1_000_000n
|
|
556
|
+
|
|
557
|
+
/** @type {{ name: string, type: string }[]} */
|
|
558
|
+
const directChildren = []
|
|
559
|
+
/** @type {string[]} */
|
|
560
|
+
const candidateDirNames = []
|
|
561
|
+
/** @type {{ name: string; reason: string }[]} */
|
|
562
|
+
const skippedUnknownUnsafe = []
|
|
563
|
+
|
|
564
|
+
for (const dirent of directEntries) {
|
|
565
|
+
if (dirent.isSymbolicLink()) {
|
|
566
|
+
directChildren.push({ name: dirent.name, type: 'symlink' })
|
|
567
|
+
skippedUnknownUnsafe.push({ name: dirent.name, reason: 'symlink' })
|
|
568
|
+
continue
|
|
569
|
+
}
|
|
570
|
+
if (dirent.isDirectory()) {
|
|
571
|
+
directChildren.push({ name: dirent.name, type: 'directory' })
|
|
572
|
+
candidateDirNames.push(dirent.name)
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
// Non-directory administrative entries (e.g. .gitignore) are neither
|
|
576
|
+
// candidates nor reported, but still bound into the digest below.
|
|
577
|
+
directChildren.push({ name: dirent.name, type: 'other' })
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @type {{ name: string; label: string; lastModifiedMs: number; entries: SnapshotEntry[] }[]} */
|
|
581
|
+
const selected = []
|
|
582
|
+
/** @type {{ name: string; label: string; lastModifiedMs: number }[]} */
|
|
583
|
+
const excludedRecent = []
|
|
584
|
+
|
|
585
|
+
for (const name of candidateDirNames) {
|
|
586
|
+
const candidateAbsPath = join(reviewRoot, name)
|
|
587
|
+
const walked = walkCandidateSubtree(candidateAbsPath)
|
|
588
|
+
if (!walked.ok) {
|
|
589
|
+
skippedUnknownUnsafe.push({ name, reason: walked.reason })
|
|
590
|
+
continue
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const { maxMtimeNs } = walked
|
|
594
|
+
if (maxMtimeNs === null) {
|
|
595
|
+
skippedUnknownUnsafe.push({ name, reason: 'invalid-timestamp' })
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
if (maxMtimeNs > referenceTimeNs) {
|
|
599
|
+
skippedUnknownUnsafe.push({ name, reason: 'future-timestamp' })
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const label = deriveStatusLabel(candidateAbsPath, walked.entries)
|
|
604
|
+
// Safe: epoch milliseconds (~1.7e12) are far below float64's exact
|
|
605
|
+
// integer range, unlike the raw nanosecond value above.
|
|
606
|
+
const lastModifiedMs = Number(maxMtimeNs / 1_000_000n)
|
|
607
|
+
|
|
608
|
+
if (maxMtimeNs < cutoffTimeNs) {
|
|
609
|
+
selected.push({ entries: walked.entries, label, lastModifiedMs, name })
|
|
610
|
+
} else {
|
|
611
|
+
excludedRecent.push({ label, lastModifiedMs, name })
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
directChildren,
|
|
617
|
+
excludedRecent,
|
|
618
|
+
ok: true,
|
|
619
|
+
rootIdentity: {
|
|
620
|
+
dev: rootStat.dev.toString(),
|
|
621
|
+
ino: rootStat.ino.toString(),
|
|
622
|
+
},
|
|
623
|
+
selected,
|
|
624
|
+
skippedUnknownUnsafe,
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── Preview operation ────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* @typedef {{
|
|
632
|
+
* root: string | undefined,
|
|
633
|
+
* age: string | undefined,
|
|
634
|
+
* ackOffline: boolean,
|
|
635
|
+
* referenceTimeMs: number,
|
|
636
|
+
* }} PreviewOptions
|
|
637
|
+
*/
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* @param {string} operation
|
|
641
|
+
* @param {string} category
|
|
642
|
+
* @returns {{ exitCode: number, response: Record<string, unknown> }}
|
|
643
|
+
*/
|
|
644
|
+
function errorResultFor(operation, category) {
|
|
645
|
+
return {
|
|
646
|
+
exitCode: 2,
|
|
647
|
+
response: {
|
|
648
|
+
category,
|
|
649
|
+
operation,
|
|
650
|
+
result: 'error',
|
|
651
|
+
schema_version: SCHEMA_VERSION,
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Resolves a caller-supplied project root to the canonical, symlink-free
|
|
658
|
+
* review root, shared by preview and execute.
|
|
659
|
+
* @param {string | undefined} rootArg
|
|
660
|
+
* @returns {{ status: 'ok', path: string, canonicalProjectRoot: string } | { status: 'missing' } | { status: 'invalid-root' } | { status: 'unsafe' }}
|
|
661
|
+
*/
|
|
662
|
+
function resolveReviewRoot(rootArg) {
|
|
663
|
+
if (!rootArg) return { status: 'invalid-root' }
|
|
664
|
+
let canonicalRoot
|
|
665
|
+
try {
|
|
666
|
+
canonicalRoot = realpathSync(rootArg)
|
|
667
|
+
if (!lstatSync(canonicalRoot).isDirectory()) {
|
|
668
|
+
return { status: 'invalid-root' }
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
return { status: 'invalid-root' }
|
|
672
|
+
}
|
|
673
|
+
const chain = resolveSegmentChain(canonicalRoot, REVIEW_ROOT_SEGMENTS)
|
|
674
|
+
if (chain.status === 'missing') return { status: 'missing' }
|
|
675
|
+
if (chain.status !== 'ok') return { status: 'unsafe' }
|
|
676
|
+
return { canonicalProjectRoot: canonicalRoot, path: chain.path, status: 'ok' }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Executes the read-only preview scan. Pure with respect to time (the
|
|
681
|
+
* reference time is an explicit input), so tests can exercise exact-cutoff
|
|
682
|
+
* and future-timestamp scenarios deterministically without sleeping.
|
|
683
|
+
* @param {PreviewOptions} options
|
|
684
|
+
* @returns {{ exitCode: number, response: Record<string, unknown> }}
|
|
685
|
+
*/
|
|
686
|
+
export function runPreview(options) {
|
|
687
|
+
if (!options.ackOffline) {
|
|
688
|
+
return errorResultFor('preview', CATEGORY.MISSING_ACKNOWLEDGMENT)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (options.age === undefined) {
|
|
692
|
+
return errorResultFor('preview', CATEGORY.MISSING_AGE)
|
|
693
|
+
}
|
|
694
|
+
const ageCutoff = parseAgeCutoff(options.age)
|
|
695
|
+
if (!ageCutoff) {
|
|
696
|
+
return errorResultFor('preview', CATEGORY.INVALID_AGE)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!options.root) {
|
|
700
|
+
return errorResultFor('preview', CATEGORY.MISSING_ROOT)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const resolved = resolveReviewRoot(options.root)
|
|
704
|
+
if (resolved.status === 'invalid-root') {
|
|
705
|
+
return errorResultFor('preview', CATEGORY.INVALID_ROOT)
|
|
706
|
+
}
|
|
707
|
+
if (resolved.status === 'missing') {
|
|
708
|
+
return {
|
|
709
|
+
exitCode: 0,
|
|
710
|
+
response: {
|
|
711
|
+
operation: 'preview',
|
|
712
|
+
result: 'root-missing',
|
|
713
|
+
schema_version: SCHEMA_VERSION,
|
|
714
|
+
},
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (resolved.status === 'unsafe') {
|
|
718
|
+
return errorResultFor('preview', CATEGORY.UNSAFE_REVIEW_ROOT)
|
|
719
|
+
}
|
|
720
|
+
const reviewRoot = resolved.path
|
|
721
|
+
|
|
722
|
+
const referenceTimeMs = options.referenceTimeMs
|
|
723
|
+
const cutoffTimeMs = computeCutoffTimeMs(referenceTimeMs, ageCutoff.ms)
|
|
724
|
+
// Duration alone doesn't determine representability -- it combines with
|
|
725
|
+
// referenceTimeMs. Checked here, on the actual derived value, before any
|
|
726
|
+
// Date is constructed from it below.
|
|
727
|
+
if (Math.abs(cutoffTimeMs) > MAX_REPRESENTABLE_DATE_MS) {
|
|
728
|
+
return errorResultFor('preview', CATEGORY.INVALID_AGE)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const scan = scanReviewRoot(reviewRoot, cutoffTimeMs, referenceTimeMs)
|
|
732
|
+
if (!scan.ok) {
|
|
733
|
+
return errorResultFor('preview', scan.category)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const allNames = [
|
|
737
|
+
...scan.selected.map((c) => c.name),
|
|
738
|
+
...scan.excludedRecent.map((c) => c.name),
|
|
739
|
+
...scan.skippedUnknownUnsafe.map((c) => c.name),
|
|
740
|
+
]
|
|
741
|
+
const displayIds = deriveDisplayIds(allNames)
|
|
742
|
+
|
|
743
|
+
const digest = computeSnapshotDigest(
|
|
744
|
+
scan.rootIdentity,
|
|
745
|
+
scan.directChildren,
|
|
746
|
+
scan.selected.map((c) => ({ entries: c.entries, name: c.name })),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
const token =
|
|
750
|
+
scan.selected.length > 0
|
|
751
|
+
? buildPreviewToken({
|
|
752
|
+
ageDurationMs: ageCutoff.ms,
|
|
753
|
+
cutoffTimeMs,
|
|
754
|
+
digest,
|
|
755
|
+
referenceTimeMs,
|
|
756
|
+
})
|
|
757
|
+
: null
|
|
758
|
+
|
|
759
|
+
const result = scan.selected.length === 0 ? 'nothing-eligible' : 'preview'
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
exitCode: 0,
|
|
763
|
+
response: {
|
|
764
|
+
candidates: {
|
|
765
|
+
excludedRecent: scan.excludedRecent.map((c) => ({
|
|
766
|
+
displayId: displayIds.get(c.name),
|
|
767
|
+
label: c.label,
|
|
768
|
+
lastModified: new Date(c.lastModifiedMs).toISOString(),
|
|
769
|
+
name: boundedName(c.name),
|
|
770
|
+
})),
|
|
771
|
+
selected: scan.selected.map((c) => ({
|
|
772
|
+
displayId: displayIds.get(c.name),
|
|
773
|
+
label: c.label,
|
|
774
|
+
lastModified: new Date(c.lastModifiedMs).toISOString(),
|
|
775
|
+
name: boundedName(c.name),
|
|
776
|
+
})),
|
|
777
|
+
skippedUnknownUnsafe: scan.skippedUnknownUnsafe.map((c) => ({
|
|
778
|
+
displayId: displayIds.get(c.name),
|
|
779
|
+
name: boundedName(c.name),
|
|
780
|
+
reason: c.reason,
|
|
781
|
+
})),
|
|
782
|
+
},
|
|
783
|
+
counts: {
|
|
784
|
+
excludedRecent: scan.excludedRecent.length,
|
|
785
|
+
selected: scan.selected.length,
|
|
786
|
+
skippedUnknownUnsafe: scan.skippedUnknownUnsafe.length,
|
|
787
|
+
},
|
|
788
|
+
cutoff: new Date(cutoffTimeMs).toISOString(),
|
|
789
|
+
operation: 'preview',
|
|
790
|
+
referenceTime: new Date(referenceTimeMs).toISOString(),
|
|
791
|
+
result,
|
|
792
|
+
schema_version: SCHEMA_VERSION,
|
|
793
|
+
token,
|
|
794
|
+
},
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── Execute (deletion) token ─────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
const MAX_TOKEN_LENGTH = 4096
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Decodes and structurally validates a preview token: correct version,
|
|
804
|
+
* every field present with the expected type, all integers finite/safe,
|
|
805
|
+
* the cutoff/duration/reference-time relationship internally consistent,
|
|
806
|
+
* and the digest shaped like a SHA-256 hex string. Does not check the
|
|
807
|
+
* digest against any scan -- that happens once the review root is known.
|
|
808
|
+
* @param {unknown} token
|
|
809
|
+
* @returns {{ v: number, referenceTimeMs: number, ageDurationMs: number, cutoffTimeMs: number, digest: string } | undefined}
|
|
810
|
+
*/
|
|
811
|
+
const TOKEN_FIELDS = Object.freeze([
|
|
812
|
+
'v',
|
|
813
|
+
'referenceTimeMs',
|
|
814
|
+
'ageDurationMs',
|
|
815
|
+
'cutoffTimeMs',
|
|
816
|
+
'digest',
|
|
817
|
+
])
|
|
818
|
+
|
|
819
|
+
export function decodeExecutionToken(token) {
|
|
820
|
+
if (typeof token !== 'string' || token.length === 0) return undefined
|
|
821
|
+
if (token.length > MAX_TOKEN_LENGTH) return undefined
|
|
822
|
+
|
|
823
|
+
let decodedBytes
|
|
824
|
+
try {
|
|
825
|
+
decodedBytes = Buffer.from(token, 'base64url')
|
|
826
|
+
} catch {
|
|
827
|
+
return undefined
|
|
828
|
+
}
|
|
829
|
+
// Buffer.from(..., 'base64url') is permissive: it silently drops
|
|
830
|
+
// characters outside the base64url alphabet and tolerates missing
|
|
831
|
+
// padding. Re-encoding the decoded bytes and requiring an exact match
|
|
832
|
+
// rejects any input that is not itself the canonical base64url form.
|
|
833
|
+
if (decodedBytes.toString('base64url') !== token) return undefined
|
|
834
|
+
|
|
835
|
+
let parsed
|
|
836
|
+
try {
|
|
837
|
+
parsed = JSON.parse(decodedBytes.toString('utf8'))
|
|
838
|
+
} catch {
|
|
839
|
+
return undefined
|
|
840
|
+
}
|
|
841
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
842
|
+
return undefined
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Structural guard, not authentication: reject any payload that does not
|
|
846
|
+
// carry exactly the fixed field set (no extra/unknown keys, none absent).
|
|
847
|
+
const keys = Object.keys(parsed)
|
|
848
|
+
if (
|
|
849
|
+
keys.length !== TOKEN_FIELDS.length ||
|
|
850
|
+
!TOKEN_FIELDS.every((field) => keys.includes(field))
|
|
851
|
+
) {
|
|
852
|
+
return undefined
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const { v, referenceTimeMs, ageDurationMs, cutoffTimeMs, digest } = parsed
|
|
856
|
+
if (v !== TOKEN_VERSION) return undefined
|
|
857
|
+
if (!Number.isSafeInteger(referenceTimeMs) || referenceTimeMs < 0) {
|
|
858
|
+
return undefined
|
|
859
|
+
}
|
|
860
|
+
if (!Number.isSafeInteger(ageDurationMs) || ageDurationMs <= 0) {
|
|
861
|
+
return undefined
|
|
862
|
+
}
|
|
863
|
+
if (!Number.isSafeInteger(cutoffTimeMs)) return undefined
|
|
864
|
+
if (cutoffTimeMs !== referenceTimeMs - ageDurationMs) return undefined
|
|
865
|
+
if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
866
|
+
return undefined
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return { ageDurationMs, cutoffTimeMs, digest, referenceTimeMs, v }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── Execute (deletion) per-candidate primitive ──────────────────────────
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Immediately before removing one previously-approved candidate, re-walks
|
|
876
|
+
* the fixed `.context/systematic/ce-review` segment chain from the
|
|
877
|
+
* canonical project root -- never trusting a previously-resolved review
|
|
878
|
+
* root path string, which could since traverse an ancestor symlink even
|
|
879
|
+
* when the review directory's own device/inode identity is unchanged
|
|
880
|
+
* (e.g. the whole `.context` tree relocated and a symlink left in its
|
|
881
|
+
* place still resolves to the same underlying directory). Also rechecks
|
|
882
|
+
* this candidate's entire subtree against its approved snapshot, then
|
|
883
|
+
* removes only that exact direct-child path -- never a path derived from
|
|
884
|
+
* a display name or supplied by the token.
|
|
885
|
+
*
|
|
886
|
+
* Deliberately does not re-diff the whole root's membership: a caller's
|
|
887
|
+
* own prior deletions earlier in the same batch legitimately change the
|
|
888
|
+
* root's mtime and child list, and re-checking that here would misclassify
|
|
889
|
+
* expected self-induced change as external drift. The ancestor chain,
|
|
890
|
+
* root identity (device/inode, not-a-symlink), and this one candidate's
|
|
891
|
+
* full metadata are the only things rechecked.
|
|
892
|
+
* @param {{ canonicalProjectRoot: string, rootIdentity: { dev: string, ino: string }, candidate: { name: string, entries: SnapshotEntry[] } }} input
|
|
893
|
+
* @returns {{ status: 'deleted' } | { status: 'skipped', reason: string } | { status: 'failed', reason: string }}
|
|
894
|
+
*/
|
|
895
|
+
export function executeApprovedCandidate({
|
|
896
|
+
canonicalProjectRoot,
|
|
897
|
+
rootIdentity,
|
|
898
|
+
candidate,
|
|
899
|
+
}) {
|
|
900
|
+
if (
|
|
901
|
+
!candidate.name ||
|
|
902
|
+
candidate.name === '.' ||
|
|
903
|
+
candidate.name === '..' ||
|
|
904
|
+
candidate.name.includes('/') ||
|
|
905
|
+
// Defense-in-depth: a direct-child name must equal its own basename
|
|
906
|
+
// under the platform's own path semantics (the same module `join`
|
|
907
|
+
// above uses) -- catches any other single-segment-violating name
|
|
908
|
+
// without a hardcoded platform ban and without rejecting legitimate
|
|
909
|
+
// names containing a literal backslash character on this platform.
|
|
910
|
+
basename(candidate.name) !== candidate.name
|
|
911
|
+
) {
|
|
912
|
+
return { reason: 'refused-unsafe-name', status: 'failed' }
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const chain = resolveSegmentChain(canonicalProjectRoot, REVIEW_ROOT_SEGMENTS)
|
|
916
|
+
if (chain.status !== 'ok') {
|
|
917
|
+
return { reason: 'root-identity-changed', status: 'failed' }
|
|
918
|
+
}
|
|
919
|
+
const reviewRoot = chain.path
|
|
920
|
+
|
|
921
|
+
let freshRootStat
|
|
922
|
+
try {
|
|
923
|
+
freshRootStat = lstatSync(reviewRoot, { bigint: true })
|
|
924
|
+
} catch {
|
|
925
|
+
return { reason: 'root-unavailable', status: 'failed' }
|
|
926
|
+
}
|
|
927
|
+
if (
|
|
928
|
+
freshRootStat.isSymbolicLink() ||
|
|
929
|
+
freshRootStat.dev.toString() !== rootIdentity.dev ||
|
|
930
|
+
freshRootStat.ino.toString() !== rootIdentity.ino
|
|
931
|
+
) {
|
|
932
|
+
return { reason: 'root-identity-changed', status: 'failed' }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const candidateAbsPath = join(reviewRoot, candidate.name)
|
|
936
|
+
const rewalked = walkCandidateSubtree(candidateAbsPath)
|
|
937
|
+
if (!rewalked.ok) {
|
|
938
|
+
return { reason: 'drift-detected', status: 'skipped' }
|
|
939
|
+
}
|
|
940
|
+
if (JSON.stringify(rewalked.entries) !== JSON.stringify(candidate.entries)) {
|
|
941
|
+
return { reason: 'drift-detected', status: 'skipped' }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
rmSync(candidateAbsPath, { recursive: true })
|
|
946
|
+
} catch {
|
|
947
|
+
return { reason: 'deletion-failed', status: 'failed' }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return { status: 'deleted' }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ── Execute (deletion) operation ─────────────────────────────────────────
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* @typedef {{
|
|
957
|
+
* root: string | undefined,
|
|
958
|
+
* ackOffline: boolean,
|
|
959
|
+
* token: string | undefined,
|
|
960
|
+
* nowMs: number,
|
|
961
|
+
* }} ExecuteOptions
|
|
962
|
+
*/
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* @returns {{ exitCode: number, response: Record<string, unknown> }}
|
|
966
|
+
*/
|
|
967
|
+
function previewStaleResult() {
|
|
968
|
+
return {
|
|
969
|
+
exitCode: 3,
|
|
970
|
+
response: {
|
|
971
|
+
operation: 'execute',
|
|
972
|
+
result: 'preview-stale',
|
|
973
|
+
schema_version: SCHEMA_VERSION,
|
|
974
|
+
},
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Validates the token and offline acknowledgment, rescans the review root
|
|
980
|
+
* using the token's fixed time boundary (never the current wall clock),
|
|
981
|
+
* and -- only if the fresh digest still matches the token -- deletes each
|
|
982
|
+
* originally-selected candidate via {@link executeApprovedCandidate}.
|
|
983
|
+
*
|
|
984
|
+
* `nowMs` is used only to reject a token whose reference time is in the
|
|
985
|
+
* future; it never substitutes for the token's own fixed reference time in
|
|
986
|
+
* the rescan itself, so elapsed real time cannot silently expand the
|
|
987
|
+
* approved set.
|
|
988
|
+
* @param {ExecuteOptions} options
|
|
989
|
+
* @returns {{ exitCode: number, response: Record<string, unknown> }}
|
|
990
|
+
*/
|
|
991
|
+
export function runExecute(options) {
|
|
992
|
+
if (!options.ackOffline) {
|
|
993
|
+
return errorResultFor('execute', CATEGORY.MISSING_ACKNOWLEDGMENT)
|
|
994
|
+
}
|
|
995
|
+
if (options.token === undefined) {
|
|
996
|
+
return errorResultFor('execute', CATEGORY.MISSING_TOKEN)
|
|
997
|
+
}
|
|
998
|
+
const decoded = decodeExecutionToken(options.token)
|
|
999
|
+
if (!decoded) {
|
|
1000
|
+
return errorResultFor('execute', CATEGORY.INVALID_TOKEN)
|
|
1001
|
+
}
|
|
1002
|
+
if (decoded.referenceTimeMs > options.nowMs) {
|
|
1003
|
+
return errorResultFor('execute', CATEGORY.INVALID_TOKEN)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (!options.root) {
|
|
1007
|
+
return errorResultFor('execute', CATEGORY.MISSING_ROOT)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const resolved = resolveReviewRoot(options.root)
|
|
1011
|
+
if (resolved.status === 'invalid-root') {
|
|
1012
|
+
return errorResultFor('execute', CATEGORY.INVALID_ROOT)
|
|
1013
|
+
}
|
|
1014
|
+
if (resolved.status === 'missing') {
|
|
1015
|
+
// The review root existed at preview time (a token was issued) but is
|
|
1016
|
+
// gone now: membership changed, not a fresh no-op.
|
|
1017
|
+
return previewStaleResult()
|
|
1018
|
+
}
|
|
1019
|
+
if (resolved.status === 'unsafe') {
|
|
1020
|
+
return errorResultFor('execute', CATEGORY.UNSAFE_REVIEW_ROOT)
|
|
1021
|
+
}
|
|
1022
|
+
const reviewRoot = resolved.path
|
|
1023
|
+
const canonicalProjectRoot = resolved.canonicalProjectRoot
|
|
1024
|
+
|
|
1025
|
+
const scan = scanReviewRoot(
|
|
1026
|
+
reviewRoot,
|
|
1027
|
+
decoded.cutoffTimeMs,
|
|
1028
|
+
decoded.referenceTimeMs,
|
|
1029
|
+
)
|
|
1030
|
+
if (!scan.ok) {
|
|
1031
|
+
return errorResultFor('execute', scan.category)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const digest = computeSnapshotDigest(
|
|
1035
|
+
scan.rootIdentity,
|
|
1036
|
+
scan.directChildren,
|
|
1037
|
+
scan.selected.map((c) => ({ entries: c.entries, name: c.name })),
|
|
1038
|
+
)
|
|
1039
|
+
if (digest !== decoded.digest) {
|
|
1040
|
+
return previewStaleResult()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** @type {{ name: string }[]} */
|
|
1044
|
+
const deleted = []
|
|
1045
|
+
/** @type {{ name: string; reason: string }[]} */
|
|
1046
|
+
const skipped = []
|
|
1047
|
+
/** @type {{ name: string; reason: string }[]} */
|
|
1048
|
+
const failed = []
|
|
1049
|
+
|
|
1050
|
+
for (const candidate of scan.selected) {
|
|
1051
|
+
const outcome = executeApprovedCandidate({
|
|
1052
|
+
candidate,
|
|
1053
|
+
canonicalProjectRoot,
|
|
1054
|
+
rootIdentity: scan.rootIdentity,
|
|
1055
|
+
})
|
|
1056
|
+
if (outcome.status === 'deleted') {
|
|
1057
|
+
deleted.push({ name: candidate.name })
|
|
1058
|
+
} else if (outcome.status === 'skipped') {
|
|
1059
|
+
skipped.push({ name: candidate.name, reason: outcome.reason })
|
|
1060
|
+
} else {
|
|
1061
|
+
failed.push({ name: candidate.name, reason: outcome.reason })
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const allNames = [
|
|
1066
|
+
...scan.selected.map((c) => c.name),
|
|
1067
|
+
...scan.excludedRecent.map((c) => c.name),
|
|
1068
|
+
...scan.skippedUnknownUnsafe.map((c) => c.name),
|
|
1069
|
+
]
|
|
1070
|
+
const displayIds = deriveDisplayIds(allNames)
|
|
1071
|
+
|
|
1072
|
+
const partial = skipped.length > 0 || failed.length > 0
|
|
1073
|
+
|
|
1074
|
+
return {
|
|
1075
|
+
exitCode: partial ? 1 : 0,
|
|
1076
|
+
response: {
|
|
1077
|
+
candidates: {
|
|
1078
|
+
deleted: deleted.map((c) => ({
|
|
1079
|
+
displayId: displayIds.get(c.name),
|
|
1080
|
+
name: boundedName(c.name),
|
|
1081
|
+
})),
|
|
1082
|
+
excludedRecent: scan.excludedRecent.map((c) => ({
|
|
1083
|
+
displayId: displayIds.get(c.name),
|
|
1084
|
+
label: c.label,
|
|
1085
|
+
lastModified: new Date(c.lastModifiedMs).toISOString(),
|
|
1086
|
+
name: boundedName(c.name),
|
|
1087
|
+
})),
|
|
1088
|
+
failed: failed.map((c) => ({
|
|
1089
|
+
displayId: displayIds.get(c.name),
|
|
1090
|
+
name: boundedName(c.name),
|
|
1091
|
+
reason: c.reason,
|
|
1092
|
+
})),
|
|
1093
|
+
skipped: skipped.map((c) => ({
|
|
1094
|
+
displayId: displayIds.get(c.name),
|
|
1095
|
+
name: boundedName(c.name),
|
|
1096
|
+
reason: c.reason,
|
|
1097
|
+
})),
|
|
1098
|
+
skippedUnknownUnsafe: scan.skippedUnknownUnsafe.map((c) => ({
|
|
1099
|
+
displayId: displayIds.get(c.name),
|
|
1100
|
+
name: boundedName(c.name),
|
|
1101
|
+
reason: c.reason,
|
|
1102
|
+
})),
|
|
1103
|
+
},
|
|
1104
|
+
counts: {
|
|
1105
|
+
deleted: deleted.length,
|
|
1106
|
+
excludedRecent: scan.excludedRecent.length,
|
|
1107
|
+
failed: failed.length,
|
|
1108
|
+
selected: scan.selected.length,
|
|
1109
|
+
skipped: skipped.length,
|
|
1110
|
+
skippedUnknownUnsafe: scan.skippedUnknownUnsafe.length,
|
|
1111
|
+
},
|
|
1112
|
+
operation: 'execute',
|
|
1113
|
+
result: partial ? 'partial' : 'deleted',
|
|
1114
|
+
schema_version: SCHEMA_VERSION,
|
|
1115
|
+
},
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── CLI entry point ──────────────────────────────────────────────────────
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Strictly parses `preview`'s fixed argument set: only `--root <value>`,
|
|
1123
|
+
* `--age <value>`, and the boolean `--ack-offline` are recognized. Any
|
|
1124
|
+
* unknown flag, unexpected positional token, duplicate flag, a flag
|
|
1125
|
+
* missing its value, or a value that is itself another recognized flag
|
|
1126
|
+
* (e.g. `--root --age`) is rejected outright, before any filesystem scan
|
|
1127
|
+
* begins. Absence of a flag is not an error here -- `runPreview` reports
|
|
1128
|
+
* the specific `missing-root-argument`/`missing-age`/
|
|
1129
|
+
* `missing-acknowledgment` diagnostics for that.
|
|
1130
|
+
* @param {readonly string[]} args
|
|
1131
|
+
* @returns {{ ok: true, root: string | undefined, age: string | undefined, ackOffline: boolean } | { ok: false }}
|
|
1132
|
+
*/
|
|
1133
|
+
function parsePreviewArgs(args) {
|
|
1134
|
+
let root
|
|
1135
|
+
let age
|
|
1136
|
+
let ackOffline = false
|
|
1137
|
+
let rootSeen = false
|
|
1138
|
+
let ageSeen = false
|
|
1139
|
+
let ackSeen = false
|
|
1140
|
+
|
|
1141
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1142
|
+
const arg = args[i]
|
|
1143
|
+
if (arg === '--root' || arg === '--age') {
|
|
1144
|
+
const seen = arg === '--root' ? rootSeen : ageSeen
|
|
1145
|
+
if (seen) return { ok: false }
|
|
1146
|
+
const value = args[i + 1]
|
|
1147
|
+
if (value === undefined || value.startsWith('--')) return { ok: false }
|
|
1148
|
+
if (arg === '--root') {
|
|
1149
|
+
root = value
|
|
1150
|
+
rootSeen = true
|
|
1151
|
+
} else {
|
|
1152
|
+
age = value
|
|
1153
|
+
ageSeen = true
|
|
1154
|
+
}
|
|
1155
|
+
i += 1
|
|
1156
|
+
continue
|
|
1157
|
+
}
|
|
1158
|
+
if (arg === '--ack-offline') {
|
|
1159
|
+
if (ackSeen) return { ok: false }
|
|
1160
|
+
ackSeen = true
|
|
1161
|
+
ackOffline = true
|
|
1162
|
+
continue
|
|
1163
|
+
}
|
|
1164
|
+
return { ok: false }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return { ackOffline, age, ok: true, root }
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Strictly parses `execute`'s fixed argument set: only `--root <value>`,
|
|
1172
|
+
* `--token <value>`, and the boolean `--ack-offline` are recognized. Any
|
|
1173
|
+
* unknown flag, a duplicate flag, or a flag missing its value is rejected
|
|
1174
|
+
* outright -- there is no `--force` or other extra deletion path.
|
|
1175
|
+
* @param {readonly string[]} args
|
|
1176
|
+
* @returns {{ ok: true, root: string | undefined, token: string | undefined, ackOffline: boolean } | { ok: false }}
|
|
1177
|
+
*/
|
|
1178
|
+
function parseExecuteArgs(args) {
|
|
1179
|
+
let root
|
|
1180
|
+
let token
|
|
1181
|
+
let ackOffline = false
|
|
1182
|
+
let rootSeen = false
|
|
1183
|
+
let tokenSeen = false
|
|
1184
|
+
let ackSeen = false
|
|
1185
|
+
|
|
1186
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1187
|
+
const arg = args[i]
|
|
1188
|
+
if (arg === '--root' || arg === '--token') {
|
|
1189
|
+
const seen = arg === '--root' ? rootSeen : tokenSeen
|
|
1190
|
+
if (seen) return { ok: false }
|
|
1191
|
+
const value = args[i + 1]
|
|
1192
|
+
if (value === undefined || value.startsWith('--')) return { ok: false }
|
|
1193
|
+
if (arg === '--root') {
|
|
1194
|
+
root = value
|
|
1195
|
+
rootSeen = true
|
|
1196
|
+
} else {
|
|
1197
|
+
token = value
|
|
1198
|
+
tokenSeen = true
|
|
1199
|
+
}
|
|
1200
|
+
i += 1
|
|
1201
|
+
continue
|
|
1202
|
+
}
|
|
1203
|
+
if (arg === '--ack-offline') {
|
|
1204
|
+
if (ackSeen) return { ok: false }
|
|
1205
|
+
ackSeen = true
|
|
1206
|
+
ackOffline = true
|
|
1207
|
+
continue
|
|
1208
|
+
}
|
|
1209
|
+
return { ok: false }
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return { ackOffline, ok: true, root, token }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ── Static help (no scan, no state, no deletion) ────────────────────────
|
|
1216
|
+
|
|
1217
|
+
const HELP_COMMANDS = Object.freeze({
|
|
1218
|
+
execute: Object.freeze({
|
|
1219
|
+
description:
|
|
1220
|
+
'Deletes only the exact candidates approved by an unexpired preview token. Rescans the whole selection and each individual candidate immediately before deleting it, and refuses on any drift.',
|
|
1221
|
+
flags: Object.freeze([
|
|
1222
|
+
'--root <path> (required) The same project root the preview token was issued against.',
|
|
1223
|
+
'--ack-offline (required) Operator assertion that this run is offline; not independently verified by this tool.',
|
|
1224
|
+
'--token <token> (required) The bounded token returned by a prior preview.',
|
|
1225
|
+
]),
|
|
1226
|
+
usage: 'cleanup.mjs execute --root <path> --ack-offline --token <token>',
|
|
1227
|
+
}),
|
|
1228
|
+
preview: Object.freeze({
|
|
1229
|
+
description:
|
|
1230
|
+
'Read-only preview of historical ce:review run directories older than an age cutoff. Never mutates the filesystem.',
|
|
1231
|
+
flags: Object.freeze([
|
|
1232
|
+
'--root <path> (required) Project root containing .context/systematic/ce-review.',
|
|
1233
|
+
'--age <N|Nd|Nw> (required) Positive integer age cutoff in days (default) or weeks (w suffix).',
|
|
1234
|
+
'--ack-offline (required) Operator assertion that this run is offline; not independently verified by this tool.',
|
|
1235
|
+
]),
|
|
1236
|
+
usage: 'cleanup.mjs preview --root <path> --age <N|Nd|Nw> --ack-offline',
|
|
1237
|
+
}),
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
const HELP_EXIT_CODES = Object.freeze({
|
|
1241
|
+
0: 'Preview complete, all approved deletions completed, or an absent review root (no-op).',
|
|
1242
|
+
1: 'Partial: at least one approved candidate was skipped (drift detected) or failed (deletion error) during execute; other candidates in the same run still completed.',
|
|
1243
|
+
2: 'Invalid, ambiguous, or unrecognized arguments; missing offline acknowledgment; or an unsafe/invalid root -- no scan or deletion begins.',
|
|
1244
|
+
3: 'preview-stale: the execute-time rescan no longer matches the preview token (changed root, membership, or candidate). Zero deletions; a renewed preview and approval are required.',
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
const HELP_NOTES = Object.freeze([
|
|
1248
|
+
'--ack-offline is an operator assertion that this invocation is running offline; it is not independently verified by this tool.',
|
|
1249
|
+
'A valid --token for execute proves the rescanned tree still matches the earlier preview snapshot. It is not authenticated human approval -- the caller must still ask the operator to confirm deletion separately.',
|
|
1250
|
+
])
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Builds the static, read-only help payload. Never touches the filesystem,
|
|
1254
|
+
* never reads `--root`/`--age`/`--ack-offline`/`--token`, and never reveals
|
|
1255
|
+
* the invoking process's cwd or environment.
|
|
1256
|
+
* @param {'help' | 'preview' | 'execute'} scope
|
|
1257
|
+
* @returns {Record<string, unknown>}
|
|
1258
|
+
*/
|
|
1259
|
+
function buildHelpResponse(scope) {
|
|
1260
|
+
if (scope === 'help') {
|
|
1261
|
+
return {
|
|
1262
|
+
commands: HELP_COMMANDS,
|
|
1263
|
+
exitCodes: HELP_EXIT_CODES,
|
|
1264
|
+
notes: HELP_NOTES,
|
|
1265
|
+
operation: 'help',
|
|
1266
|
+
result: 'help',
|
|
1267
|
+
schema_version: SCHEMA_VERSION,
|
|
1268
|
+
usage: {
|
|
1269
|
+
execute: HELP_COMMANDS.execute.usage,
|
|
1270
|
+
preview: HELP_COMMANDS.preview.usage,
|
|
1271
|
+
},
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
...HELP_COMMANDS[scope],
|
|
1276
|
+
exitCodes: HELP_EXIT_CODES,
|
|
1277
|
+
notes: HELP_NOTES,
|
|
1278
|
+
operation: scope,
|
|
1279
|
+
result: 'help',
|
|
1280
|
+
schema_version: SCHEMA_VERSION,
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function emitHelpAndExit(scope) {
|
|
1285
|
+
process.stdout.write(`${JSON.stringify(buildHelpResponse(scope))}\n`)
|
|
1286
|
+
process.exit(0)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function main() {
|
|
1290
|
+
const argv = process.argv.slice(2)
|
|
1291
|
+
const operation = argv[0]
|
|
1292
|
+
const rest = argv.slice(1)
|
|
1293
|
+
|
|
1294
|
+
if ((operation === 'help' || operation === '--help') && rest.length === 0) {
|
|
1295
|
+
emitHelpAndExit('help')
|
|
1296
|
+
return
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (operation === 'execute') {
|
|
1300
|
+
if (rest.length === 1 && rest[0] === '--help') {
|
|
1301
|
+
emitHelpAndExit('execute')
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
const parsedArgs = parseExecuteArgs(rest)
|
|
1305
|
+
if (!parsedArgs.ok) {
|
|
1306
|
+
emitAndExit(
|
|
1307
|
+
errorResultFor('execute', CATEGORY.INVALID_ARGUMENTS),
|
|
1308
|
+
'execute',
|
|
1309
|
+
)
|
|
1310
|
+
return
|
|
1311
|
+
}
|
|
1312
|
+
const outcome = runExecute({
|
|
1313
|
+
ackOffline: parsedArgs.ackOffline,
|
|
1314
|
+
nowMs: Date.now(),
|
|
1315
|
+
root: parsedArgs.root,
|
|
1316
|
+
token: parsedArgs.token,
|
|
1317
|
+
})
|
|
1318
|
+
process.stdout.write(`${JSON.stringify(outcome.response)}\n`)
|
|
1319
|
+
process.exit(outcome.exitCode)
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (operation !== 'preview') {
|
|
1324
|
+
// Never echo the caller-supplied operation text back into the response:
|
|
1325
|
+
// it is unbounded, attacker-controlled input and this is an error path
|
|
1326
|
+
// that must stay within the fixed-vocabulary output contract.
|
|
1327
|
+
emitAndExit(
|
|
1328
|
+
errorResultFor('preview', CATEGORY.UNKNOWN_OPERATION),
|
|
1329
|
+
'unknown',
|
|
1330
|
+
)
|
|
1331
|
+
return
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (rest.length === 1 && rest[0] === '--help') {
|
|
1335
|
+
emitHelpAndExit('preview')
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const parsedArgs = parsePreviewArgs(rest)
|
|
1340
|
+
if (!parsedArgs.ok) {
|
|
1341
|
+
emitAndExit(
|
|
1342
|
+
errorResultFor('preview', CATEGORY.INVALID_ARGUMENTS),
|
|
1343
|
+
'preview',
|
|
1344
|
+
)
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const outcome = runPreview({
|
|
1349
|
+
ackOffline: parsedArgs.ackOffline,
|
|
1350
|
+
age: parsedArgs.age,
|
|
1351
|
+
referenceTimeMs: Date.now(),
|
|
1352
|
+
root: parsedArgs.root,
|
|
1353
|
+
})
|
|
1354
|
+
process.stdout.write(`${JSON.stringify(outcome.response)}\n`)
|
|
1355
|
+
process.exit(outcome.exitCode)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Top-level error boundary around {@link main}. Any exception that escapes
|
|
1360
|
+
* the operation handlers (e.g. an unexpected filesystem error surfacing
|
|
1361
|
+
* through a non-authoritative read path) is caught here so the process
|
|
1362
|
+
* still emits the one fixed, bounded JSON object on stdout with a defined
|
|
1363
|
+
* exit code -- never a raw Node stack trace (which would include absolute
|
|
1364
|
+
* paths) on stderr, and never a silent success/deletion. The underlying
|
|
1365
|
+
* error's message and stack are deliberately never read or emitted.
|
|
1366
|
+
*/
|
|
1367
|
+
function runMain() {
|
|
1368
|
+
try {
|
|
1369
|
+
main()
|
|
1370
|
+
} catch {
|
|
1371
|
+
emitAndExit(errorResultFor('unknown', CATEGORY.INTERNAL_ERROR), 'unknown')
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* @param {{ exitCode: number, response: Record<string, unknown> }} outcome
|
|
1377
|
+
* @param {string | undefined} operation
|
|
1378
|
+
*/
|
|
1379
|
+
function emitAndExit(outcome, operation) {
|
|
1380
|
+
const response = { ...outcome.response, operation: operation ?? null }
|
|
1381
|
+
process.stdout.write(`${JSON.stringify(response)}\n`)
|
|
1382
|
+
process.exit(outcome.exitCode)
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const isDirectInvocation =
|
|
1386
|
+
process.argv[1] !== undefined &&
|
|
1387
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
1388
|
+
|
|
1389
|
+
if (isDirectInvocation) {
|
|
1390
|
+
runMain()
|
|
1391
|
+
}
|