artshelf 0.10.1 → 0.10.2

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 (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/SPEC.md +1 -1
  3. package/dist/src/adapters/process.js +7 -0
  4. package/dist/src/adapters/update.js +143 -0
  5. package/dist/src/cli.js +44 -1847
  6. package/dist/src/commands/cleanup.js +52 -0
  7. package/dist/src/commands/doctor.js +79 -0
  8. package/dist/src/commands/due.js +32 -0
  9. package/dist/src/commands/find.js +44 -0
  10. package/dist/src/commands/get.js +31 -0
  11. package/dist/src/commands/index.js +69 -0
  12. package/dist/src/commands/ledgers.js +111 -0
  13. package/dist/src/commands/list.js +36 -0
  14. package/dist/src/commands/put.js +36 -0
  15. package/dist/src/commands/resolve.js +17 -0
  16. package/dist/src/commands/review.js +38 -0
  17. package/dist/src/commands/shared.js +160 -0
  18. package/dist/src/commands/status.js +101 -0
  19. package/dist/src/commands/trash.js +78 -0
  20. package/dist/src/commands/update.js +75 -0
  21. package/dist/src/commands/validate.js +35 -0
  22. package/dist/src/config/env.js +24 -0
  23. package/dist/src/config/package.js +17 -0
  24. package/dist/src/config/paths.js +5 -0
  25. package/dist/src/renderers/attention.js +3 -0
  26. package/dist/src/renderers/doctor.js +64 -0
  27. package/dist/src/renderers/json.js +10 -0
  28. package/dist/src/renderers/review.js +159 -0
  29. package/dist/src/renderers/status.js +112 -0
  30. package/dist/src/shared/cli-types.js +1 -0
  31. package/dist/src/shared/errors.js +4 -0
  32. package/dist/src/shared/flags.js +41 -0
  33. package/dist/src/shared/help-text.js +355 -0
  34. package/docs/agent-usage.html +1 -1
  35. package/docs/agent-usage.md +2 -2
  36. package/docs/reference.html +3 -3
  37. package/package.json +1 -1
package/dist/src/cli.js CHANGED
@@ -1,41 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import { appendPreparedRecord, createCleanupPlan, createTrashPurgePlan, dueEntries, executeCleanupPlan, executeTrashPurgePlan, filterRecordsByStatus, findRecords, getRecord, listTrashedRecords, normalizeLedgerPath, prepareRecord, previewCleanupPlan, readLedger, resolveRecord, validateLedger } from "./ledger.js";
7
- import { listRegisteredLedgers, normalizeRegistryPath, registerLedger } from "./registry.js";
8
- const VERSION = readPackageVersion();
9
- const PACKAGE_NAME = "artshelf";
10
- const NPM_REGISTRY_URL = process.env.ARTSHELF_NPM_REGISTRY_URL ?? `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
11
- const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
12
- const NO_UPDATE_CHECK_TTL_MS = 60 * 60 * 1000;
13
- const BOOLEAN_FLAGS = new Set(["all", "json", "agent", "manual-review", "dry-run", "execute", "help", "version", "plain"]);
14
- const VALUE_FLAGS = new Set([
15
- "cleanup",
16
- "kind",
17
- "label",
18
- "ledger",
19
- "name",
20
- "owner",
21
- "path",
22
- "plan-id",
23
- "older-than",
24
- "registry",
25
- "reason",
26
- "retain-until",
27
- "scope",
28
- "status",
29
- "ttl"
30
- ]);
31
- function readPackageVersion() {
32
- const packageJsonPath = decodeURIComponent(new URL("../../package.json", import.meta.url).pathname);
33
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
34
- if (typeof packageJson.version !== "string") {
35
- throw new Error("package.json version must be a string");
36
- }
37
- return packageJson.version;
38
- }
2
+ import { maybeNotifyAvailableUpdate, runCommand } from "./commands/index.js";
3
+ import { VERSION } from "./config/package.js";
4
+ import { formatCliError } from "./shared/errors.js";
5
+ import { BOOLEAN_FLAGS, boolFlag, VALUE_FLAGS } from "./shared/flags.js";
6
+ import { renderHelp, resolveHelpKey } from "./shared/help-text.js";
39
7
  async function main(argv) {
40
8
  try {
41
9
  const parsed = parseArgs(argv);
@@ -46,1848 +14,77 @@ async function main(argv) {
46
14
  return maybeNotifyUpdateAndReturn(0, parsed);
47
15
  }
48
16
  if (parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h" || boolFlag(parsed, "help")) {
49
- printHelp(resolveHelpKey(parsed));
17
+ process.stdout.write(renderHelp(resolveHelpKey(parsed), VERSION));
50
18
  return maybeNotifyUpdateAndReturn(0, parsed);
51
19
  }
52
- switch (parsed.command) {
53
- case "put":
54
- status = handlePut(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
55
- break;
56
- case "ledgers":
57
- status = handleLedgers(parsed, boolFlag(parsed, "json"));
58
- break;
59
- case "list":
60
- status = handleList(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
61
- break;
62
- case "find":
63
- status = handleFind(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
64
- break;
65
- case "get":
66
- status = handleGet(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
67
- break;
68
- case "due":
69
- status = handleDue(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
70
- break;
71
- case "validate":
72
- status = handleValidate(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
73
- break;
74
- case "cleanup":
75
- status = handleCleanup(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
76
- break;
77
- case "trash":
78
- status = handleTrash(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
79
- break;
80
- case "review":
81
- status = handleReview(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
82
- break;
83
- case "doctor":
84
- status = handleDoctor(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
85
- break;
86
- case "status":
87
- status = handleStatus(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
88
- break;
89
- case "resolve":
90
- status = handleResolve(parsed, normalizeLedgerPath(stringFlag(parsed, "ledger")), boolFlag(parsed, "json"));
91
- break;
92
- case "update":
93
- shouldCheckForUpdate = false;
94
- status = await handleUpdate(parsed, boolFlag(parsed, "json"));
95
- break;
96
- case undefined:
97
- printHelp();
98
- status = 0;
99
- break;
100
- default:
101
- throw new Error(`Unknown command: ${parsed.command}`);
20
+ if (parsed.command === undefined) {
21
+ process.stdout.write(renderHelp("", VERSION));
22
+ status = 0;
23
+ }
24
+ else {
25
+ const result = await runCommand(parsed);
26
+ status = result.status;
27
+ shouldCheckForUpdate = result.shouldCheckForUpdate;
102
28
  }
103
29
  if (!shouldCheckForUpdate)
104
30
  return status;
105
31
  return maybeNotifyUpdateAndReturn(status, parsed);
106
32
  }
107
33
  catch (error) {
108
- process.stderr.write(`artshelf: ${error.message}\nRun \`artshelf help\` for usage.\n`);
34
+ process.stderr.write(formatCliError(error));
109
35
  return 1;
110
36
  }
111
37
  }
112
- async function maybeNotifyUpdateAndReturn(status, parsed) {
113
- await maybeNotifyAvailableUpdate(parsed);
114
- return status;
115
- }
116
- function handlePut(parsed, ledgerPath, json) {
117
- const path = parsed.positionals[0];
118
- if (!path)
119
- throw new Error("put requires <path>");
120
- const record = prepareRecord({
121
- path,
122
- reason: requiredStringFlag(parsed, "reason"),
123
- ttl: stringFlag(parsed, "ttl"),
124
- retainUntil: stringFlag(parsed, "retain-until"),
125
- manualReview: boolFlag(parsed, "manual-review"),
126
- kind: stringFlag(parsed, "kind"),
127
- cleanup: stringFlag(parsed, "cleanup"),
128
- owner: stringFlag(parsed, "owner"),
129
- labels: arrayFlag(parsed, "label")
130
- });
131
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
132
- appendPreparedRecord(ledgerPath, record);
133
- let ledger;
134
- let registryError;
135
- try {
136
- ledger = registerLedger({ ledgerPath, registryPath });
137
- }
138
- catch (error) {
139
- registryError = error.message;
140
- }
141
- if (json)
142
- return printJson({ ok: true, record, ledgerPath, registryPath, ...(ledger ? { ledger } : {}), ...(registryError ? { registryError } : {}) });
143
- process.stdout.write(`recorded ${record.id}\npath: ${record.path}\nretains until: ${record.retainUntil ?? "manual review"}\nledger: ${ledgerPath}\n`);
144
- if (registryError)
145
- process.stdout.write(`registry warning: ${registryError}\n`);
146
- return 0;
147
- }
148
- function handleLedgers(parsed, json) {
149
- const action = parsed.positionals[0] ?? "list";
150
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
151
- if (action === "help") {
152
- printHelp("ledgers");
153
- return 0;
154
- }
155
- if (action === "add") {
156
- const ledgerPath = normalizeLedgerPath(requiredStringFlag(parsed, "ledger"));
157
- if (!existsSync(ledgerPath))
158
- throw new Error(`Ledger does not exist: ${ledgerPath}`);
159
- const entry = registerLedger({
160
- ledgerPath,
161
- name: stringFlag(parsed, "name"),
162
- scope: stringFlag(parsed, "scope"),
163
- registryPath
164
- });
165
- if (json)
166
- return printJson({ ok: true, registryPath, ledger: entry });
167
- process.stdout.write(`registered ${entry.name}\nledger: ${entry.path}\nregistry: ${registryPath}\n`);
168
- return 0;
169
- }
170
- if (action === "list") {
171
- if (boolFlag(parsed, "plain")) {
172
- const ledgers = listRegisteredLedgers(registryPath);
173
- if (json)
174
- return printJson({ ok: true, registryPath, ledgers });
175
- if (ledgers.length === 0) {
176
- process.stdout.write(`no registered Artshelf ledgers\nregistry: ${registryPath}\n`);
177
- return 0;
178
- }
179
- for (const ledger of ledgers)
180
- process.stdout.write(`${ledger.name} ${ledger.scope} ${ledger.path}\n`);
181
- process.stdout.write(`registry: ${registryPath}\n`);
182
- return 0;
183
- }
184
- const report = buildLedgersReport(registryPath);
185
- if (json) {
186
- printJson(report);
187
- return report.ok ? 0 : 1;
188
- }
189
- printLedgersList(report);
190
- return report.ok ? 0 : 1;
191
- }
192
- throw new Error(`Unknown ledgers action: ${action}`);
193
- }
194
- function buildLedgersReport(registryPath) {
195
- let registryOk = true;
196
- let registryError = null;
197
- let entries = [];
198
- try {
199
- entries = listRegisteredLedgers(registryPath);
200
- }
201
- catch (error) {
202
- registryOk = false;
203
- registryError = error.message;
204
- }
205
- const ledgers = entries.map((entry) => {
206
- const result = validateRegisteredLedger(entry);
207
- const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
208
- return {
209
- ...entry,
210
- status,
211
- ok: result.ok,
212
- entries: result.entries,
213
- errors: result.errors,
214
- warnings: result.warnings
215
- };
216
- });
217
- const summary = {
218
- ledgers: ledgers.length,
219
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
220
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
221
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
222
- warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
223
- };
224
- return {
225
- ok: registryOk && summary.stale === 0 && summary.invalid === 0,
226
- registryPath,
227
- registryExists: existsSync(registryPath),
228
- registryOk,
229
- registryError,
230
- ledgers,
231
- summary
232
- };
233
- }
234
- function printLedgersList(report) {
235
- process.stdout.write(`artshelf ledgers: ${report.ok ? "ok" : "needs attention"}\n`);
236
- process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"} — ${report.summary.ledgers} ledgers (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
237
- if (report.registryError)
238
- process.stdout.write(`registry error: ${report.registryError}\n`);
239
- if (report.ledgers.length === 0) {
240
- process.stdout.write("no registered Artshelf ledgers\n");
241
- return;
242
- }
243
- for (const ledger of report.ledgers) {
244
- if (ledger.status === "ok") {
245
- process.stdout.write(`[${ledger.name}] ok ${ledger.scope}: ${ledger.entries} entries, ${ledger.warnings.length} warnings — ${ledger.path}\n`);
246
- }
247
- else {
248
- process.stdout.write(`[${ledger.name}] ${ledger.status} ${ledger.scope}: ${ledger.errors.join("; ")} — ${ledger.path}\n`);
249
- }
250
- }
251
- }
252
- function handleList(parsed, ledgerPath, json) {
253
- if (boolFlag(parsed, "all")) {
254
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
255
- const status = stringFlag(parsed, "status");
256
- const validation = validateRegisteredLedgersOrThrow(registryPath);
257
- if (!validation.ok)
258
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
259
- const results = validation.results.map(({ ledger }) => ({
260
- ledger,
261
- entries: filterRecordsByStatus(readLedger(ledger.path), status)
262
- }));
263
- if (json)
264
- return printJson({ ok: true, registryPath, ...(status ? { status } : {}), ledgers: results });
265
- printLedgerEntries(results, status);
266
- process.stdout.write(`registry: ${registryPath}\n`);
267
- return 0;
268
- }
269
- const status = stringFlag(parsed, "status");
270
- const records = filterRecordsByStatus(readLedger(ledgerPath), status);
271
- if (json)
272
- return printJson({ ok: true, ledgerPath, ...(status ? { status } : {}), entries: records });
273
- if (records.length === 0) {
274
- process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\nledger: ${ledgerPath}\n`);
275
- return 0;
276
- }
277
- for (const record of records) {
278
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
279
- }
280
- process.stdout.write(`ledger: ${ledgerPath}\n`);
281
- return 0;
282
- }
283
- function handleFind(parsed, ledgerPath, json) {
284
- if (boolFlag(parsed, "all")) {
285
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
286
- const validation = validateRegisteredLedgersOrThrow(registryPath);
287
- if (!validation.ok)
288
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
289
- const results = validation.results.map(({ ledger }) => ({
290
- ledger,
291
- entries: findRecords(readLedger(ledger.path), {
292
- path: stringFlag(parsed, "path"),
293
- owner: stringFlag(parsed, "owner"),
294
- labels: arrayFlag(parsed, "label"),
295
- status: stringFlag(parsed, "status")
296
- })
297
- }));
298
- if (json)
299
- return printJson({ ok: true, registryPath, ledgers: results });
300
- printLedgerEntries(results);
301
- process.stdout.write(`registry: ${registryPath}\n`);
302
- return 0;
303
- }
304
- const records = findRecords(readLedger(ledgerPath), {
305
- path: stringFlag(parsed, "path"),
306
- owner: stringFlag(parsed, "owner"),
307
- labels: arrayFlag(parsed, "label"),
308
- status: stringFlag(parsed, "status")
309
- });
310
- if (json)
311
- return printJson({ ok: true, ledgerPath, entries: records });
312
- if (records.length === 0) {
313
- process.stdout.write(`no matching artshelf entries\nledger: ${ledgerPath}\n`);
314
- return 0;
315
- }
316
- for (const record of records) {
317
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
318
- }
319
- process.stdout.write(`ledger: ${ledgerPath}\n`);
320
- return 0;
321
- }
322
- function handleGet(parsed, ledgerPath, json) {
323
- const id = parsed.positionals[0];
324
- if (!id)
325
- throw new Error("get requires <id>");
326
- if (boolFlag(parsed, "all")) {
327
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
328
- const validation = validateRegisteredLedgersOrThrow(registryPath);
329
- if (!validation.ok)
330
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
331
- for (const { ledger } of validation.results) {
332
- const record = readLedger(ledger.path).find((entry) => entry.id === id);
333
- if (record) {
334
- if (json)
335
- return printJson({ ok: true, registryPath, ledger, record });
336
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledger.path}\nregistry: ${registryPath}\n`);
337
- return 0;
338
- }
339
- }
340
- throw new Error(`Artshelf record not found: ${id}`);
341
- }
342
- const record = getRecord(readLedger(ledgerPath), id);
343
- if (json)
344
- return printJson({ ok: true, ledgerPath, record });
345
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path}\nreason: ${record.reason}\nledger: ${ledgerPath}\n`);
346
- return 0;
347
- }
348
- function handleResolve(parsed, ledgerPath, json) {
349
- const id = parsed.positionals[0];
350
- if (!id)
351
- throw new Error("resolve requires <id>");
352
- const record = resolveRecord(ledgerPath, {
353
- id,
354
- status: requiredStringFlag(parsed, "status"),
355
- reason: requiredStringFlag(parsed, "reason")
356
- });
357
- if (json)
358
- return printJson({ ok: true, record, ledgerPath });
359
- process.stdout.write(`resolved ${record.id}\nstatus: ${record.status}\nledger: ${ledgerPath}\n`);
360
- return 0;
361
- }
362
- function handleDue(parsed, ledgerPath, json) {
363
- if (boolFlag(parsed, "all")) {
364
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
365
- const validation = validateRegisteredLedgersOrThrow(registryPath);
366
- if (!validation.ok)
367
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
368
- const results = validation.results.map(({ ledger }) => ({ ledger, entries: dueEntries(readLedger(ledger.path)) }));
369
- if (json)
370
- return printJson({ ok: true, registryPath, ledgers: results });
371
- printDueEntries(results);
372
- process.stdout.write(`registry: ${registryPath}\n`);
373
- return 0;
374
- }
375
- const entries = dueEntries(readLedger(ledgerPath));
376
- const visible = entries.filter((entry) => entry.dueStatus !== "kept");
377
- if (json)
378
- return printJson({ ok: true, ledgerPath, entries });
379
- if (visible.length === 0) {
380
- process.stdout.write(`nothing due\nledger: ${ledgerPath}\n`);
381
- return 0;
382
- }
383
- for (const entry of visible) {
384
- process.stdout.write(`${entry.dueStatus} ${entry.id} ${entry.cleanup} ${entry.path} :: ${entry.reason}\n`);
385
- }
386
- process.stdout.write(`ledger: ${ledgerPath}\n`);
387
- return 0;
388
- }
389
- function handleValidate(parsed, ledgerPath, json) {
390
- if (boolFlag(parsed, "all")) {
391
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
392
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
393
- const ok = results.every((entry) => entry.result.ok);
394
- if (json) {
395
- printJson({ ok, registryPath, ledgers: results });
396
- return ok ? 0 : 1;
397
- }
398
- for (const entry of results) {
399
- process.stdout.write(`${entry.result.ok ? "ok" : "invalid"} ${entry.ledger.name}: ${entry.result.entries} entries, ${entry.result.errors.length} errors, ${entry.result.warnings.length} warnings\nledger: ${entry.ledger.path}\n`);
400
- for (const error of entry.result.errors)
401
- process.stdout.write(`error: ${error}\n`);
402
- for (const warning of entry.result.warnings)
403
- process.stdout.write(`warning: ${warning}\n`);
404
- }
405
- process.stdout.write(`registry: ${registryPath}\n`);
406
- return ok ? 0 : 1;
407
- }
408
- const result = validateLedger(ledgerPath);
409
- if (json)
410
- return printJson({ ledgerPath, ...result });
411
- process.stdout.write(`${result.ok ? "ok" : "invalid"}: ${result.entries} entries, ${result.errors.length} errors, ${result.warnings.length} warnings\n`);
412
- for (const error of result.errors)
413
- process.stdout.write(`error: ${error}\n`);
414
- for (const warning of result.warnings)
415
- process.stdout.write(`warning: ${warning}\n`);
416
- process.stdout.write(`ledger: ${ledgerPath}\n`);
417
- return result.ok ? 0 : 1;
418
- }
419
- function handleCleanup(parsed, ledgerPath, json) {
420
- const dryRun = boolFlag(parsed, "dry-run");
421
- const execute = boolFlag(parsed, "execute");
422
- if (dryRun && execute)
423
- throw new Error("cleanup accepts either --dry-run or --execute, not both");
424
- if (boolFlag(parsed, "all") && execute)
425
- throw new Error("cleanup --all is dry-run only; execute requires an explicit --ledger and reviewed --plan-id");
426
- if (dryRun) {
427
- if (boolFlag(parsed, "all")) {
428
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
429
- const ledgers = registeredLedgersOrThrow(registryPath);
430
- const validations = ledgers.map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
431
- const ok = validations.every((entry) => entry.result.ok);
432
- if (!ok) {
433
- if (json) {
434
- printJson({ ok, registryPath, ledgers: validations });
435
- return 1;
436
- }
437
- for (const entry of validations.filter((item) => !item.result.ok)) {
438
- process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
439
- }
440
- process.stdout.write(`registry: ${registryPath}\n`);
441
- return 1;
442
- }
443
- const plans = ledgers.map((ledger) => ({ ledger, plan: createCleanupPlan(ledger.path) }));
444
- if (json)
445
- return printJson({ ok: true, registryPath, plans });
446
- printPlans(plans);
447
- process.stdout.write(`registry: ${registryPath}\n`);
448
- return 0;
449
- }
450
- const plan = createCleanupPlan(ledgerPath);
451
- if (json)
452
- return printJson({ ok: true, plan });
453
- printPlan(plan, ledgerPath);
454
- return 0;
455
- }
456
- if (execute) {
457
- const planId = requiredStringFlag(parsed, "plan-id");
458
- const receipt = executeCleanupPlan(ledgerPath, planId);
459
- if (json)
460
- return printJson({ ok: true, receipt });
461
- process.stdout.write(`receipt ${receipt.planId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
462
- return 0;
463
- }
464
- throw new Error("cleanup requires --dry-run or --execute");
465
- }
466
- function handleTrash(parsed, ledgerPath, json) {
467
- const action = parsed.positionals[0];
468
- if (!action)
469
- throw new Error("trash requires a subcommand: list or purge");
470
- if (action === "list") {
471
- return handleTrashList(parsed, ledgerPath, json);
472
- }
473
- if (action === "purge") {
474
- return handleTrashPurge(parsed, ledgerPath, json);
475
- }
476
- if (action === "help") {
477
- printHelp("trash");
478
- return 0;
479
- }
480
- throw new Error(`Unknown trash subcommand: ${action}`);
481
- }
482
- function handleTrashList(parsed, ledgerPath, json) {
483
- if (boolFlag(parsed, "all")) {
484
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
485
- const validation = validateRegisteredLedgersOrThrow(registryPath);
486
- if (!validation.ok)
487
- return printRegisteredLedgerValidation(registryPath, validation.results, json);
488
- const results = validation.results.map(({ ledger }) => ({ ledger, entries: listTrashedRecords(ledger.path) }));
489
- if (json)
490
- return printJson({ ok: true, registryPath, ledgers: results });
491
- printTrashListEntries(results);
492
- process.stdout.write(`registry: ${registryPath}\n`);
493
- return 0;
494
- }
495
- const entries = listTrashedRecords(ledgerPath);
496
- if (json)
497
- return printJson({ ok: true, ledgerPath, entries });
498
- if (entries.length === 0) {
499
- process.stdout.write(`no trashed records\nledger: ${ledgerPath}\n`);
500
- return 0;
501
- }
502
- for (const entry of entries) {
503
- process.stdout.write(`${entry.id} age ${entry.age} target ${entry.targetPath} cleaned ${entry.cleanedAt} receipt ${entry.receiptPath} plan ${entry.cleanupPlanId}\n`);
504
- }
505
- process.stdout.write(`ledger: ${ledgerPath}\n`);
506
- return 0;
507
- }
508
- function handleTrashPurge(parsed, ledgerPath, json) {
509
- const execute = boolFlag(parsed, "execute");
510
- const dryRun = boolFlag(parsed, "dry-run");
511
- if (dryRun && execute)
512
- throw new Error("trash purge accepts either --dry-run or --execute, not both");
513
- if (boolFlag(parsed, "all")) {
514
- throw new Error("trash purge --all is not supported; scope the purge to one --ledger and review the plan id before execute");
515
- }
516
- if (!dryRun && !execute)
517
- throw new Error("trash purge requires either --dry-run or --execute");
518
- if (execute) {
519
- const planId = requiredStringFlag(parsed, "plan-id");
520
- const receipt = executeTrashPurgePlan(ledgerPath, planId);
521
- if (json)
522
- return printJson({ ok: true, receipt });
523
- process.stdout.write(`trash receipt ${receipt.purgePlanId}: ${receipt.results.length} results\nreceipt: ${receipt.receiptPath}\nledger: ${ledgerPath}\n`);
524
- return 0;
525
- }
526
- const olderThan = requiredStringFlag(parsed, "older-than");
527
- const plan = createTrashPurgePlan(ledgerPath, olderThan);
528
- if (json)
529
- return printJson({ ok: true, plan });
530
- if (plan.entries.length === 0) {
531
- process.stdout.write(`trash purge plan ${plan.purgePlanId}: no matching trashed records\nledger: ${ledgerPath}\n`);
532
- return 0;
533
- }
534
- process.stdout.write(`trash purge plan ${plan.purgePlanId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
535
- process.stdout.write(`plan: ${plan.planPath ?? "not-created"}\nledger: ${ledgerPath}\n`);
536
- return 0;
537
- }
538
- function handleReview(parsed, ledgerPath, json) {
539
- const agent = boolFlag(parsed, "agent");
540
- if (boolFlag(parsed, "all")) {
541
- const registryPath = normalizeRegistryPath(stringFlag(parsed, "registry"));
542
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => reviewLedger(ledger));
543
- const ok = results.every((entry) => entry.validate.ok);
544
- const summary = summarizeReview(results);
545
- if (agent) {
546
- printCompactJson(buildReviewAgentPacketAll(results, summary, registryPath));
547
- return ok ? 0 : 1;
548
- }
549
- const nextAction = reviewNextAction(summary, "all");
550
- if (json) {
551
- printJson({ ok, registryPath, summary, nextAction, ledgers: results });
552
- return ok ? 0 : 1;
553
- }
554
- printReviewAll(results, summary, nextAction, registryPath);
555
- return ok ? 0 : 1;
556
- }
557
- const result = reviewLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
558
- if (agent) {
559
- printCompactJson(buildReviewAgentPacketSingle(result, ledgerPath));
560
- return result.validate.ok ? 0 : 1;
561
- }
562
- if (json) {
563
- printJson({ ok: result.validate.ok, ledger: result });
564
- return result.validate.ok ? 0 : 1;
565
- }
566
- printReview([result]);
567
- return result.validate.ok ? 0 : 1;
568
- }
569
- function handleDoctor(parsed, ledgerPath, json) {
570
- const report = buildDoctorReport(ledgerPath, normalizeRegistryPath(stringFlag(parsed, "registry")));
571
- if (boolFlag(parsed, "agent")) {
572
- printCompactJson(buildDoctorAgentPacket(report));
573
- return report.ok ? 0 : 1;
574
- }
575
- if (json) {
576
- printJson(report);
577
- return report.ok ? 0 : 1;
578
- }
579
- printDoctor(report);
580
- return report.ok ? 0 : 1;
581
- }
582
- function buildDoctorReport(ledgerPath, registryPath) {
583
- const errors = [];
584
- let registryOk = true;
585
- let registryError = null;
586
- let entries = [];
587
- try {
588
- entries = listRegisteredLedgers(registryPath);
589
- }
590
- catch (error) {
591
- registryOk = false;
592
- registryError = error.message;
593
- errors.push(`registry could not be read: ${registryPath} (${registryError})`);
594
- }
595
- const ledgers = entries.map((entry) => {
596
- const result = validateRegisteredLedger(entry);
597
- const status = result.ok ? "ok" : existsSync(entry.path) ? "invalid" : "missing";
598
- if (!result.ok) {
599
- for (const message of result.errors)
600
- errors.push(`${entry.name}: ${message}`);
601
- }
602
- return {
603
- name: entry.name,
604
- path: entry.path,
605
- scope: entry.scope,
606
- status,
607
- ok: result.ok,
608
- entries: result.entries,
609
- errors: result.errors,
610
- warnings: result.warnings
611
- };
612
- });
613
- const summary = {
614
- ledgers: ledgers.length,
615
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
616
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
617
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
618
- warnings: ledgers.reduce((count, ledger) => count + ledger.warnings.length, 0)
619
- };
620
- return {
621
- ok: registryOk && summary.stale === 0 && summary.invalid === 0,
622
- version: VERSION,
623
- node: process.version,
624
- ledgerPath,
625
- ledgerExists: existsSync(ledgerPath),
626
- registryPath,
627
- registryExists: existsSync(registryPath),
628
- registryOk,
629
- registryError,
630
- ledgers,
631
- summary,
632
- cleanupSafety: {
633
- executeRequiresLedgerAndPlanId: true,
634
- globalExecuteRefused: true,
635
- deleteRefusedInV1: true,
636
- dryRunBeforeMutation: true
637
- },
638
- errors
639
- };
640
- }
641
- // Actionable categories only — ok ledgers are healthy states, never attention.
642
- // Order is fixed so the packet is byte-for-byte deterministic. Warnings surface
643
- // even when health is ok (they never fail the machine), mirroring status attention.
644
- const DOCTOR_ATTENTION_CATEGORIES = ["stale", "invalid", "warnings"];
645
- function doctorAttention(summary) {
646
- return DOCTOR_ATTENTION_CATEGORIES.filter((key) => summary[key] > 0);
647
- }
648
- function doctorNextAction(blockers, summary) {
649
- if (blockers.length > 0) {
650
- return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
651
- }
652
- if (summary.warnings > 0) {
653
- return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf validate --all\` to inspect; nothing is auto-executed`;
654
- }
655
- return "artshelf is healthy on this machine — cleanup safety enforced; no action needed";
656
- }
657
- function buildDoctorAgentPacket(report) {
658
- const blockers = [];
659
- if (report.registryError)
660
- blockers.push(`registry unreadable: ${report.registryError}`);
661
- for (const ledger of report.ledgers) {
662
- if (ledger.status !== "ok") {
663
- blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
664
- }
665
- }
666
- return {
667
- schemaVersion: 1,
668
- command: "doctor",
669
- health: report.ok ? "ok" : "attention",
670
- version: report.version,
671
- node: report.node,
672
- ledgerPath: report.ledgerPath,
673
- registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
674
- ledgers: {
675
- total: report.summary.ledgers,
676
- ok: report.summary.ok,
677
- stale: report.summary.stale,
678
- invalid: report.summary.invalid,
679
- warnings: report.summary.warnings
680
- },
681
- attention: doctorAttention(report.summary),
682
- blockers,
683
- cleanupSafety: report.cleanupSafety,
684
- nextAction: doctorNextAction(blockers, report.summary),
685
- verification: `artshelf doctor --agent --registry ${report.registryPath}`
686
- };
687
- }
688
- // Human render (NGX-396): a scannable left-column glyph so attention state is
689
- // obvious at a glance — ✓ clear, ⚠ needs attention. Plain Unicode (no ANSI
690
- // color) keeps redirected/piped human output clean, and the `--agent`/`--json`
691
- // renders never carry glyphs (those stay machine contracts).
692
- const HUMAN_OK_GLYPH = "✓";
693
- const HUMAN_ATTENTION_GLYPH = "⚠";
694
- function attentionGlyph(needsAttention) {
695
- return needsAttention ? HUMAN_ATTENTION_GLYPH : HUMAN_OK_GLYPH;
696
- }
697
- function printDoctor(report) {
698
- process.stdout.write(`artshelf ${report.version} (node ${report.node})\n`);
699
- process.stdout.write(`${attentionGlyph(!report.ok)} health: ${report.ok ? "ok" : "needs attention"}\n`);
700
- process.stdout.write(`ledger: ${report.ledgerPath}${report.ledgerExists ? "" : " (absent)"}\n`);
701
- process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"}\n`);
702
- if (report.registryError)
703
- process.stdout.write(`registry error: ${report.registryError}\n`);
704
- process.stdout.write(`registered ledgers: ${report.summary.ledgers} (${report.summary.ok} ok, ${report.summary.stale} stale, ${report.summary.invalid} invalid)\n`);
705
- for (const ledger of report.ledgers) {
706
- process.stdout.write(` ${attentionGlyph(ledger.status !== "ok")} ${ledger.status} ${ledger.name} ${ledger.path}\n`);
707
- for (const message of ledger.errors)
708
- process.stdout.write(` error: ${message}\n`);
709
- }
710
- process.stdout.write("cleanup safety: execute requires a reviewed plan id against a single ledger; --all execute is refused; cleanup=delete is refused; physical trash purge requires a separate reviewed plan\n");
711
- if (!report.ok) {
712
- for (const message of report.errors)
713
- process.stdout.write(`error: ${message}\n`);
714
- }
715
- }
716
- function handleStatus(parsed, ledgerPath, json) {
717
- const agent = boolFlag(parsed, "agent");
718
- if (boolFlag(parsed, "all")) {
719
- const report = buildStatusReport(normalizeRegistryPath(stringFlag(parsed, "registry")));
720
- if (agent) {
721
- printCompactJson(buildStatusAgentPacketAll(report));
722
- return report.ok ? 0 : 1;
723
- }
724
- if (json) {
725
- printJson(report);
726
- return report.ok ? 0 : 1;
727
- }
728
- printStatusAll(report);
729
- return report.ok ? 0 : 1;
730
- }
731
- const ledger = statusLedger({ name: "current", path: ledgerPath, scope: "other", createdAt: "", updatedAt: "" }, false);
732
- if (agent) {
733
- printCompactJson(buildStatusAgentPacketSingle(ledger, ledgerPath));
734
- return ledger.ok ? 0 : 1;
735
- }
736
- if (json) {
737
- printJson({ ok: ledger.ok, ledger });
738
- return ledger.ok ? 0 : 1;
739
- }
740
- printStatusSingle(ledger);
741
- return ledger.ok ? 0 : 1;
742
- }
743
- function buildStatusReport(registryPath) {
744
- let registryOk = true;
745
- let registryError = null;
746
- let entries = [];
747
- try {
748
- entries = listRegisteredLedgers(registryPath);
749
- }
750
- catch (error) {
751
- registryOk = false;
752
- registryError = error.message;
753
- }
754
- const ledgers = entries.map((entry) => statusLedger(entry));
755
- const totals = {
756
- ledgers: ledgers.length,
757
- ok: ledgers.filter((ledger) => ledger.status === "ok").length,
758
- stale: ledgers.filter((ledger) => ledger.status === "missing").length,
759
- invalid: ledgers.filter((ledger) => ledger.status === "invalid").length,
760
- active: sumStatusCounts(ledgers, "active"),
761
- due: sumStatusCounts(ledgers, "due"),
762
- manualReview: sumStatusCounts(ledgers, "manualReview"),
763
- missingPath: sumStatusCounts(ledgers, "missingPath"),
764
- kept: sumStatusCounts(ledgers, "kept"),
765
- pendingCleanup: sumStatusCounts(ledgers, "pendingCleanup")
766
- };
767
- return {
768
- ok: registryOk && totals.stale === 0 && totals.invalid === 0,
769
- registryPath,
770
- registryExists: existsSync(registryPath),
771
- registryOk,
772
- registryError,
773
- ledgers,
774
- totals
775
- };
776
- }
777
- function statusLedger(ledger, registered = true) {
778
- const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
779
- if (!validate.ok) {
780
- return {
781
- name: ledger.name,
782
- path: ledger.path,
783
- scope: ledger.scope,
784
- status: existsSync(ledger.path) ? "invalid" : "missing",
785
- ok: false,
786
- counts: emptyStatusCounts(),
787
- errors: validate.errors
788
- };
789
- }
790
- const records = readLedger(ledger.path);
791
- const due = dueEntries(records);
792
- const counts = {
793
- active: records.filter((record) => record.status === "active").length,
794
- due: due.filter((entry) => entry.dueStatus === "due").length,
795
- manualReview: due.filter((entry) => entry.dueStatus === "manual-review").length,
796
- missingPath: due.filter((entry) => entry.dueStatus === "missing-path").length,
797
- kept: due.filter((entry) => entry.dueStatus === "kept").length,
798
- pendingCleanup: previewCleanupPlan(ledger.path).entries.length
799
- };
800
- return {
801
- name: ledger.name,
802
- path: ledger.path,
803
- scope: ledger.scope,
804
- status: "ok",
805
- ok: true,
806
- counts,
807
- errors: []
808
- };
809
- }
810
- function emptyStatusCounts() {
811
- return { active: 0, due: 0, manualReview: 0, missingPath: 0, kept: 0, pendingCleanup: 0 };
812
- }
813
- function sumStatusCounts(ledgers, key) {
814
- return ledgers.reduce((total, ledger) => total + ledger.counts[key], 0);
815
- }
816
- function formatStatusCounts(counts) {
817
- return `active ${counts.active} · due ${counts.due} · manual-review ${counts.manualReview} · missing ${counts.missingPath} · kept ${counts.kept} · pending ${counts.pendingCleanup}`;
818
- }
819
- // Actionable categories only — active and kept are healthy states, never
820
- // attention. Order is fixed so the packet is byte-for-byte deterministic.
821
- const STATUS_ATTENTION_CATEGORIES = ["due", "manualReview", "missingPath", "pendingCleanup"];
822
- function statusAttention(counts) {
823
- return STATUS_ATTENTION_CATEGORIES.filter((key) => counts[key] > 0);
824
- }
825
- function statusCommand(scope, command, ledgerPath) {
826
- if (scope === "all")
827
- return `artshelf ${command} --all`;
828
- return ledgerPath ? `artshelf ${command} --ledger ${ledgerPath}` : `artshelf ${command}`;
829
- }
830
- function statusNextAction(blockers, counts, scope, ledgerPath) {
831
- if (blockers.length > 0) {
832
- const verify = statusCommand(scope, "status", ledgerPath);
833
- return `repair ${blockers.length} broken ledger(s) above, then re-run \`${verify}\``;
834
- }
835
- const review = statusCommand(scope, "review", ledgerPath);
836
- if (counts.pendingCleanup > 0 || counts.due > 0) {
837
- return `run \`${review}\` to preview cleanup plans; nothing is auto-executed`;
838
- }
839
- if (counts.manualReview > 0) {
840
- return `run \`${review}\` to inspect manual-review records; nothing is auto-executed`;
841
- }
842
- if (counts.missingPath > 0) {
843
- return "inspect missing-path records and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
844
- }
845
- return "nothing due — no broken ledgers and no due, manual-review, missing-path, or pending cleanup entries";
846
- }
847
- function buildStatusAgentPacketAll(report) {
848
- const blockers = [];
849
- if (report.registryError)
850
- blockers.push(`registry unreadable: ${report.registryError}`);
851
- for (const ledger of report.ledgers) {
852
- if (ledger.status !== "ok") {
853
- blockers.push(`${ledger.name} ${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`);
854
- }
855
- }
856
- const counts = {
857
- active: report.totals.active,
858
- due: report.totals.due,
859
- manualReview: report.totals.manualReview,
860
- missingPath: report.totals.missingPath,
861
- kept: report.totals.kept,
862
- pendingCleanup: report.totals.pendingCleanup
863
- };
864
- return {
865
- schemaVersion: 1,
866
- command: "status",
867
- scope: "all",
868
- health: report.ok ? "ok" : "attention",
869
- registry: { path: report.registryPath, exists: report.registryExists, ok: report.registryOk, error: report.registryError },
870
- ledgers: { total: report.totals.ledgers, ok: report.totals.ok, stale: report.totals.stale, invalid: report.totals.invalid },
871
- counts,
872
- attention: statusAttention(counts),
873
- blockers,
874
- nextAction: statusNextAction(blockers, counts, "all"),
875
- verification: `artshelf status --all --agent --registry ${report.registryPath}`
876
- };
877
- }
878
- function buildStatusAgentPacketSingle(ledger, ledgerPath) {
879
- const blockers = ledger.ok
880
- ? []
881
- : [`${ledger.status}${ledger.errors.length ? `: ${ledger.errors[0]}` : ""}`];
882
- return {
883
- schemaVersion: 1,
884
- command: "status",
885
- scope: "single",
886
- health: ledger.ok ? "ok" : "attention",
887
- ledgerPath,
888
- counts: ledger.counts,
889
- attention: statusAttention(ledger.counts),
890
- blockers,
891
- nextAction: statusNextAction(blockers, ledger.counts, "single", ledgerPath),
892
- verification: `artshelf status --agent --ledger ${ledgerPath}`
893
- };
894
- }
895
- function printStatusAll(report) {
896
- const anyActionable = report.ledgers.some((ledger) => statusAttention(ledger.counts).length > 0);
897
- process.stdout.write(`${attentionGlyph(!report.ok || anyActionable)} artshelf status: ${report.ok ? "ok" : "needs attention"}\n`);
898
- process.stdout.write(`registry: ${report.registryPath}${report.registryExists ? "" : " (absent)"} — ${report.totals.ledgers} ledgers (${report.totals.ok} ok, ${report.totals.stale} stale, ${report.totals.invalid} invalid)\n`);
899
- if (report.registryError)
900
- process.stdout.write(`registry error: ${report.registryError}\n`);
901
- for (const ledger of report.ledgers) {
902
- if (ledger.status === "ok") {
903
- process.stdout.write(`${attentionGlyph(statusAttention(ledger.counts).length > 0)} [${ledger.name}] ${formatStatusCounts(ledger.counts)}\n`);
904
- }
905
- else {
906
- process.stdout.write(`${HUMAN_ATTENTION_GLYPH} [${ledger.name}] ${ledger.status}: ${ledger.errors.join("; ")}\n`);
907
- }
908
- }
909
- process.stdout.write(`total: ${formatStatusCounts(report.totals)}\n`);
910
- }
911
- function printStatusSingle(ledger) {
912
- const needsAttention = !ledger.ok || statusAttention(ledger.counts).length > 0;
913
- process.stdout.write(`${attentionGlyph(needsAttention)} artshelf status: ${ledger.ok ? "ok" : ledger.status}\n`);
914
- process.stdout.write(`ledger: ${ledger.path}\n`);
915
- if (ledger.ok) {
916
- process.stdout.write(`${formatStatusCounts(ledger.counts)}\n`);
917
- }
918
- else {
919
- for (const message of ledger.errors)
920
- process.stdout.write(`error: ${message}\n`);
921
- }
922
- }
923
- async function handleUpdate(parsed, json) {
924
- if (parsed.positionals.length > 0)
925
- throw new Error("update does not accept positional arguments");
926
- const info = await getUpdateInfo({ force: true });
927
- if (!info)
928
- throw new Error("Could not check npm for the latest Artshelf version");
929
- if (!info.updateAvailable) {
930
- if (json)
931
- return printJson({ ok: true, updated: false, current: info.current, latest: info.latest });
932
- process.stdout.write(`artshelf is already up to date: v${info.current}\n`);
933
- return 0;
934
- }
935
- if (process.env.ARTSHELF_UPDATE_DRY_RUN === "1") {
936
- if (json) {
937
- return printJson({
938
- ok: true,
939
- updated: false,
940
- dryRun: true,
941
- current: info.current,
942
- latest: info.latest,
943
- command: ["npm", "install", "-g", `${PACKAGE_NAME}@latest`]
944
- });
945
- }
946
- process.stdout.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
947
- process.stdout.write(`Dry run: would run "npm install -g ${PACKAGE_NAME}@latest"\n`);
948
- return 0;
949
- }
950
- if (!json) {
951
- process.stdout.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
952
- process.stdout.write(`Updating with "npm install -g ${PACKAGE_NAME}@latest"...\n`);
953
- }
954
- const result = json
955
- ? spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { encoding: "utf8" })
956
- : spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { stdio: "inherit" });
957
- const status = result.status ?? 1;
958
- const spawnError = result.error instanceof Error ? result.error.message : "";
959
- if (json) {
960
- const stderr = typeof result.stderr === "string" ? result.stderr : "";
961
- printJson({
962
- ok: status === 0,
963
- updated: status === 0,
964
- current: info.current,
965
- latest: info.latest,
966
- stdout: typeof result.stdout === "string" ? result.stdout : "",
967
- stderr: appendOutputMessage(stderr, spawnError)
968
- });
969
- return status;
970
- }
971
- if (spawnError)
972
- process.stderr.write(`Update failed: ${spawnError}\n`);
973
- if (status === 0)
974
- process.stdout.write(`artshelf updated to v${info.latest}\n`);
975
- return status;
976
- }
977
- function appendOutputMessage(output, message) {
978
- if (!message)
979
- return output;
980
- if (!output)
981
- return message;
982
- return `${output}${output.endsWith("\n") ? "" : "\n"}${message}`;
983
- }
984
- async function maybeNotifyAvailableUpdate(parsed) {
985
- if (process.env.ARTSHELF_NO_UPDATE_CHECK === "1")
986
- return;
987
- if (parsed.command === "update")
988
- return;
989
- const info = await getUpdateInfo({ force: false });
990
- if (!info?.updateAvailable)
991
- return;
992
- process.stderr.write(`A new version of artshelf is available: v${info.current} -> v${info.latest}\n`);
993
- process.stderr.write(`Run "artshelf update" to update npm installs\n`);
994
- }
995
- async function getUpdateInfo(options) {
996
- const latest = await getLatestVersion(options);
997
- if (!latest)
998
- return null;
999
- return {
1000
- current: VERSION,
1001
- latest,
1002
- updateAvailable: compareVersions(latest, VERSION) > 0
1003
- };
1004
- }
1005
- async function getLatestVersion(options) {
1006
- const override = process.env.ARTSHELF_LATEST_VERSION;
1007
- if (override)
1008
- return normalizeVersion(override);
1009
- if (!options.force) {
1010
- const cached = readUpdateCache();
1011
- if (cached)
1012
- return cached.latest;
1013
- }
1014
- const latest = await fetchLatestNpmVersion();
1015
- writeUpdateCache(latest);
1016
- return latest;
1017
- }
1018
- function readUpdateCache() {
1019
- const cachePath = updateCachePath();
1020
- if (!existsSync(cachePath))
1021
- return null;
1022
- try {
1023
- const cache = JSON.parse(readFileSync(cachePath, "utf8"));
1024
- if (!("latest" in cache))
1025
- cache.latest = null;
1026
- if (cache.latest !== null && typeof cache.latest !== "string")
1027
- return null;
1028
- if (typeof cache.checkedAt !== "number")
1029
- return null;
1030
- const latest = cache.latest === null ? null : normalizeVersion(cache.latest);
1031
- const ttl = updateCacheTtlFor(latest);
1032
- if (ttl < 0)
1033
- return null;
1034
- if (Date.now() - cache.checkedAt > ttl)
1035
- return null;
1036
- return { latest };
1037
- }
1038
- catch {
1039
- return null;
1040
- }
1041
- }
1042
- function updateCacheTtlFor(latest) {
1043
- if (latest && compareVersions(latest, VERSION) > 0) {
1044
- return resolveTtlMs(process.env.ARTSHELF_UPDATE_CHECK_TTL_MS, UPDATE_CHECK_TTL_MS);
1045
- }
1046
- return resolveTtlMs(process.env.ARTSHELF_NO_UPDATE_CHECK_TTL_MS ?? process.env.ARTSHELF_UPDATE_CHECK_TTL_MS, NO_UPDATE_CHECK_TTL_MS);
1047
- }
1048
- function resolveTtlMs(value, fallback) {
1049
- if (value === undefined)
1050
- return fallback;
1051
- const parsed = Number(value);
1052
- return Number.isFinite(parsed) ? parsed : fallback;
1053
- }
1054
- function writeUpdateCache(latest) {
1055
- try {
1056
- const cachePath = updateCachePath();
1057
- const dir = dirname(cachePath);
1058
- if (dir) {
1059
- mkdirSync(dir, { recursive: true });
1060
- writeFileSync(cachePath, `${JSON.stringify({ latest, checkedAt: Date.now() }, null, 2)}\n`);
1061
- }
1062
- }
1063
- catch {
1064
- // Update checks should never affect normal CLI behavior.
1065
- }
1066
- }
1067
- async function fetchLatestNpmVersion() {
1068
- const controller = new AbortController();
1069
- const timeout = setTimeout(() => controller.abort(), 750);
1070
- try {
1071
- const response = await fetch(NPM_REGISTRY_URL, {
1072
- signal: controller.signal,
1073
- headers: { accept: "application/json", "user-agent": `artshelf/${VERSION}` }
1074
- });
1075
- if (!response.ok)
1076
- return null;
1077
- const body = await response.json();
1078
- if (!body || typeof body !== "object" || typeof body.version !== "string")
1079
- return null;
1080
- return normalizeVersion(body.version);
1081
- }
1082
- catch {
1083
- return null;
1084
- }
1085
- finally {
1086
- clearTimeout(timeout);
1087
- }
1088
- }
1089
- function updateCachePath() {
1090
- return process.env.ARTSHELF_UPDATE_CACHE ?? join(homedir(), ".artshelf", "update-check.json");
1091
- }
1092
- function normalizeVersion(version) {
1093
- return version.trim().replace(/^v/i, "");
1094
- }
1095
- function compareVersions(left, right) {
1096
- const a = parseVersion(left);
1097
- const b = parseVersion(right);
1098
- for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) {
1099
- const diff = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0);
1100
- if (diff !== 0)
1101
- return diff;
1102
- }
1103
- if (a.prerelease === b.prerelease)
1104
- return 0;
1105
- if (!a.prerelease)
1106
- return 1;
1107
- if (!b.prerelease)
1108
- return -1;
1109
- return a.prerelease.localeCompare(b.prerelease);
1110
- }
1111
- function parseVersion(version) {
1112
- const [main = "", prerelease = ""] = normalizeVersion(version).split("-", 2);
1113
- return {
1114
- numbers: main.split(".").map((part) => {
1115
- const parsed = Number.parseInt(part, 10);
1116
- return Number.isFinite(parsed) ? parsed : 0;
1117
- }),
1118
- prerelease
1119
- };
1120
- }
1121
38
  function parseArgs(argv) {
1122
39
  const [command, ...rest] = argv;
1123
- const flags = new Map();
1124
40
  const positionals = [];
41
+ const flags = new Map();
1125
42
  for (let index = 0; index < rest.length; index += 1) {
1126
- const arg = rest[index];
1127
- if (!arg)
43
+ const token = rest[index];
44
+ if (!token)
1128
45
  continue;
1129
- if (arg === "-h") {
46
+ if (token === "-h") {
1130
47
  flags.set("help", true);
1131
48
  continue;
1132
49
  }
1133
- if (arg === "-v") {
50
+ if (token === "-v") {
1134
51
  flags.set("version", true);
1135
52
  continue;
1136
53
  }
1137
- if (!arg.startsWith("--")) {
1138
- positionals.push(arg);
1139
- continue;
1140
- }
1141
- const name = arg.slice(2);
1142
- if (BOOLEAN_FLAGS.has(name)) {
1143
- flags.set(name, true);
54
+ if (token.startsWith("--")) {
55
+ const name = token.slice(2);
56
+ if (BOOLEAN_FLAGS.has(name)) {
57
+ flags.set(name, true);
58
+ continue;
59
+ }
60
+ if (!VALUE_FLAGS.has(name))
61
+ throw new Error(`Unknown flag: --${name}`);
62
+ const value = rest[index + 1];
63
+ if (!value || value.startsWith("--"))
64
+ throw new Error(`Missing value for --${name}`);
65
+ index += 1;
66
+ if (name === "label") {
67
+ const previous = flags.get(name);
68
+ flags.set(name, [...(Array.isArray(previous) ? previous : []), value]);
69
+ }
70
+ else {
71
+ flags.set(name, value);
72
+ }
1144
73
  continue;
1145
74
  }
1146
- if (!VALUE_FLAGS.has(name))
1147
- throw new Error(`Unknown flag: --${name}`);
1148
- const value = rest[index + 1];
1149
- if (!value || value.startsWith("--"))
1150
- throw new Error(`Missing value for --${name}`);
1151
- index += 1;
1152
- if (name === "label") {
1153
- const current = flags.get(name);
1154
- flags.set(name, [...(Array.isArray(current) ? current : []), value]);
1155
- }
1156
- else {
1157
- flags.set(name, value);
1158
- }
75
+ positionals.push(token);
1159
76
  }
1160
77
  return { command, positionals, flags };
1161
78
  }
1162
- function requiredStringFlag(parsed, name) {
1163
- const value = stringFlag(parsed, name);
1164
- if (!value)
1165
- throw new Error(`Missing required --${name}`);
1166
- return value;
1167
- }
1168
- function stringFlag(parsed, name) {
1169
- const value = parsed.flags.get(name);
1170
- return typeof value === "string" ? value : undefined;
1171
- }
1172
- function boolFlag(parsed, name) {
1173
- return parsed.flags.get(name) === true;
1174
- }
1175
- function arrayFlag(parsed, name) {
1176
- const value = parsed.flags.get(name);
1177
- return Array.isArray(value) ? value : [];
1178
- }
1179
- function printJson(value) {
1180
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
1181
- return 0;
1182
- }
1183
- // Agent/compact surface: a single minified JSON line. The default `--json`
1184
- // stays pretty-printed for audit/debug; agent packets optimize for tokens.
1185
- function printCompactJson(value) {
1186
- process.stdout.write(`${JSON.stringify(value)}\n`);
1187
- return 0;
1188
- }
1189
- function registeredLedgersOrThrow(registryPath) {
1190
- const ledgers = listRegisteredLedgers(registryPath);
1191
- if (ledgers.length === 0)
1192
- throw new Error("No registered Artshelf ledgers. Run `artshelf ledgers add --ledger <path>` first.");
1193
- return ledgers;
1194
- }
1195
- function validateRegisteredLedgersOrThrow(registryPath) {
1196
- const results = registeredLedgersOrThrow(registryPath).map((ledger) => ({ ledger, result: validateRegisteredLedger(ledger) }));
1197
- return { ok: results.every((entry) => entry.result.ok), results };
1198
- }
1199
- function printRegisteredLedgerValidation(registryPath, results, json) {
1200
- if (json) {
1201
- printJson({ ok: false, registryPath, ledgers: results });
1202
- return 1;
1203
- }
1204
- for (const entry of results.filter((item) => !item.result.ok)) {
1205
- process.stdout.write(`invalid ${entry.ledger.name}: ${entry.result.errors.join("; ")}\nledger: ${entry.ledger.path}\n`);
1206
- }
1207
- process.stdout.write(`registry: ${registryPath}\n`);
1208
- return 1;
1209
- }
1210
- function validateRegisteredLedger(ledger) {
1211
- if (!existsSync(ledger.path)) {
1212
- return {
1213
- ok: false,
1214
- errors: [`registered ledger is missing: ${ledger.path}`],
1215
- warnings: [],
1216
- entries: 0
1217
- };
1218
- }
1219
- return validateLedger(ledger.path);
1220
- }
1221
- function reviewLedger(ledger, registered = true) {
1222
- const validate = registered ? validateRegisteredLedger(ledger) : validateLedger(ledger.path);
1223
- if (!validate.ok) {
1224
- return {
1225
- ledger,
1226
- validate,
1227
- due: [],
1228
- plan: emptyReviewPlan(ledger.path)
1229
- };
1230
- }
1231
- return {
1232
- ledger,
1233
- validate,
1234
- due: dueEntries(readLedger(ledger.path)),
1235
- plan: previewCleanupPlan(ledger.path)
1236
- };
1237
- }
1238
- function emptyReviewPlan(ledgerPath) {
1239
- return {
1240
- planId: "not-created",
1241
- generatedAt: "",
1242
- ledgerPath,
1243
- entries: [],
1244
- skipped: [],
1245
- planPath: null
1246
- };
1247
- }
1248
- function printLedgerEntries(results, status) {
1249
- const total = results.reduce((count, result) => count + result.entries.length, 0);
1250
- if (total === 0) {
1251
- process.stdout.write(`no artshelf entries${status ? ` with status ${status}` : ""}\n`);
1252
- return;
1253
- }
1254
- for (const result of results) {
1255
- if (result.entries.length === 0)
1256
- continue;
1257
- process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
1258
- for (const record of result.entries) {
1259
- process.stdout.write(`${record.id} ${record.kind} ${record.status} ${record.cleanup} ${record.path} :: ${record.reason}\n`);
1260
- }
1261
- }
1262
- }
1263
- function printDueEntries(results) {
1264
- const visible = results.flatMap((result) => result.entries.filter((entry) => entry.dueStatus !== "kept").map((entry) => ({ ledger: result.ledger, entry })));
1265
- if (visible.length === 0) {
1266
- process.stdout.write("nothing due\n");
1267
- return;
1268
- }
1269
- for (const item of visible) {
1270
- process.stdout.write(`${item.entry.dueStatus} ${item.entry.id} ${item.entry.cleanup} ${item.entry.path} :: ${item.entry.reason}\nledger: ${item.ledger.path}\n`);
1271
- }
1272
- }
1273
- function printPlans(results) {
1274
- for (const result of results) {
1275
- process.stdout.write(`plan ${result.plan.planId} [${result.ledger.name}]: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
1276
- process.stdout.write(`plan: ${result.plan.planPath ?? "not created"}\nledger: ${result.ledger.path}\n`);
1277
- }
1278
- }
1279
- function printPlan(plan, ledgerPath) {
1280
- process.stdout.write(`plan ${plan.planId}: ${plan.entries.length} entries, ${plan.skipped.length} skipped\n`);
1281
- process.stdout.write(`plan: ${plan.planPath ?? "not created"}\nledger: ${ledgerPath}\n`);
1282
- }
1283
- function printTrashListEntries(results) {
1284
- const total = results.reduce((count, result) => count + result.entries.length, 0);
1285
- if (total === 0) {
1286
- process.stdout.write("no trashed records\n");
1287
- return;
1288
- }
1289
- for (const result of results) {
1290
- if (result.entries.length === 0)
1291
- continue;
1292
- process.stdout.write(`\n[${result.ledger.name}] ${result.ledger.path}\n`);
1293
- for (const entry of result.entries) {
1294
- process.stdout.write(`trash ${entry.id} ${entry.age} ${entry.cleanedAt} ${entry.targetPath} -> ${entry.receiptPath} (${entry.cleanupPlanId})\n`);
1295
- }
1296
- }
1297
- }
1298
- function summarizeReview(results) {
1299
- const summary = {
1300
- ledgers: results.length,
1301
- ok: 0,
1302
- invalid: 0,
1303
- stale: 0,
1304
- affected: 0,
1305
- due: 0,
1306
- manualReview: 0,
1307
- missingPath: 0,
1308
- executable: 0,
1309
- skipped: 0,
1310
- previewPlanIds: []
1311
- };
1312
- for (const result of results) {
1313
- if (result.validate.ok) {
1314
- summary.ok += 1;
1315
- }
1316
- else if (existsSync(result.ledger.path)) {
1317
- summary.invalid += 1;
1318
- }
1319
- else {
1320
- summary.stale += 1;
1321
- }
1322
- const due = result.due.filter((entry) => entry.dueStatus === "due").length;
1323
- const manualReview = result.due.filter((entry) => entry.dueStatus === "manual-review").length;
1324
- const missingPath = result.due.filter((entry) => entry.dueStatus === "missing-path").length;
1325
- summary.due += due;
1326
- summary.manualReview += manualReview;
1327
- summary.missingPath += missingPath;
1328
- summary.executable += result.plan.entries.length;
1329
- summary.skipped += result.plan.skipped.length;
1330
- if (result.plan.planId !== "not-created")
1331
- summary.previewPlanIds.push(result.plan.planId);
1332
- if (!result.validate.ok || due + manualReview + missingPath > 0 || result.plan.entries.length > 0) {
1333
- summary.affected += 1;
1334
- }
1335
- }
1336
- return summary;
1337
- }
1338
- function reviewNextAction(summary, scope, ledgerPath) {
1339
- const broken = summary.invalid + summary.stale;
1340
- const review = statusCommand(scope, "review", ledgerPath);
1341
- if (broken > 0) {
1342
- const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
1343
- return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
1344
- }
1345
- if (summary.executable > 0) {
1346
- const dryRun = scope === "all" ? "artshelf cleanup --dry-run --all" : `artshelf cleanup --dry-run${ledgerPath ? ` --ledger ${ledgerPath}` : ""}`;
1347
- return `run \`${dryRun}\` to generate plans, then \`artshelf cleanup --execute --plan-id <id> --ledger <path>\` for each reviewed plan`;
1348
- }
1349
- if (summary.missingPath > 0) {
1350
- return "inspect missing-path entries and `artshelf resolve` the ones no longer needed; nothing is auto-executable";
1351
- }
1352
- return "nothing to do — no broken ledgers and no due, manual-review, missing-path, or executable cleanup entries";
1353
- }
1354
- function printReviewAll(results, summary, nextAction, registryPath) {
1355
- const needsAttention = summary.invalid + summary.stale + summary.executable + summary.due + summary.manualReview + summary.missingPath > 0;
1356
- process.stdout.write(`${attentionGlyph(needsAttention)} artshelf review --all: ${needsAttention ? "needs attention" : "all clear"}\n`);
1357
- process.stdout.write(`registry: ${registryPath} — ${summary.ledgers} ledgers (${summary.ok} ok, ${summary.invalid} invalid, ${summary.stale} stale)\n`);
1358
- printReview(results);
1359
- process.stdout.write(`triage: due ${summary.due} · manual-review ${summary.manualReview} · missing ${summary.missingPath} · executable ${summary.executable} · skipped ${summary.skipped}\n`);
1360
- process.stdout.write(`next: ${nextAction}\n`);
1361
- }
1362
- function printReview(results) {
1363
- for (const result of results) {
1364
- const visibleDue = result.due.filter((entry) => entry.dueStatus !== "kept");
1365
- const needsAttention = !result.validate.ok || visibleDue.length > 0 || result.plan.entries.length > 0;
1366
- process.stdout.write(`${attentionGlyph(needsAttention)} [${result.ledger.name}] ${result.validate.ok ? "ok" : "invalid"}: ${result.validate.entries} entries, ${result.validate.errors.length} errors, ${result.validate.warnings.length} warnings\n`);
1367
- process.stdout.write(`due/manual/missing: ${visibleDue.length}; plan ${result.plan.planId}: ${result.plan.entries.length} entries, ${result.plan.skipped.length} skipped\n`);
1368
- process.stdout.write(`ledger: ${result.ledger.path}\n`);
1369
- }
1370
- }
1371
- // review is read-only, so every safety guarantee holds unconditionally.
1372
- const REVIEW_SAFETY = {
1373
- dryRunOnly: true,
1374
- executeAllRefused: true,
1375
- noExecuteRan: true,
1376
- noResolveRan: true,
1377
- noDeleteRan: true
1378
- };
1379
- // Classify each registered ledger's records into decision groups. Order is
1380
- // fixed (registry order, then a stable per-ledger sub-order) so the packet is
1381
- // byte-for-byte deterministic.
1382
- function buildReviewDecisions(results, scope) {
1383
- const readyForApproval = [];
1384
- const needsReviewFirst = [];
1385
- const blocked = [];
1386
- const review = scope === "all" ? "artshelf review --all" : "artshelf review";
1387
- for (const result of results) {
1388
- const { ledger, validate, due } = result;
1389
- if (!validate.ok) {
1390
- const status = existsSync(ledger.path) ? "invalid" : "missing";
1391
- const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
1392
- blocked.push({
1393
- label: `Repair ${ledger.name} ledger (${status})`,
1394
- itemIds: [],
1395
- actionType: "fix-registry",
1396
- approvalTarget: null,
1397
- reason: validate.errors[0] ?? `${scope === "all" ? "registered ledger" : "ledger"} is ${status}`,
1398
- nextStep: `${repair}, then re-run \`${review}\``
1399
- });
1400
- continue;
1401
- }
1402
- const missingPath = due.filter((entry) => entry.dueStatus === "missing-path");
1403
- const trashSafe = due.filter((entry) => entry.dueStatus === "due" && entry.cleanup === "trash");
1404
- const inspectItems = due.filter((entry) => entry.dueStatus === "manual-review" ||
1405
- (entry.dueStatus === "due" && (entry.cleanup === "review" || entry.cleanup === "delete")));
1406
- // Ready for approval: missing-path records resolve ledger-only with an exact,
1407
- // plan-less approval target. Resolution updates the ledger and never touches
1408
- // files, so it is the one action review can hand an agent directly.
1409
- if (missingPath.length > 0) {
1410
- const ids = missingPath.map((entry) => entry.id).sort();
1411
- readyForApproval.push({
1412
- label: `Resolve ${ids.length} missing-path record(s) in ${ledger.name}`,
1413
- itemIds: ids,
1414
- actionType: "resolve-missing",
1415
- approvalTarget: `approve artshelf resolve missing ledger ${ledger.path} ids ${ids.join(" ")}`,
1416
- reason: "the recorded path is already missing",
1417
- nextStep: "confirm the artifact is no longer needed, then approve the ledger-only resolve"
1418
- });
1419
- }
1420
- // Trash-safe records are cleanup-eligible, but review never mints a plan, so
1421
- // they carry no approval target: the next step is the dry-run that produces
1422
- // the reviewed plan id to approve.
1423
- if (trashSafe.length > 0) {
1424
- const ids = trashSafe.map((entry) => entry.id).sort();
1425
- needsReviewFirst.push({
1426
- label: `Plan cleanup for ${ids.length} trash-eligible artifact(s) in ${ledger.name}`,
1427
- itemIds: ids,
1428
- actionType: "cleanup",
1429
- approvalTarget: null,
1430
- reason: "disposable artifacts are due but no reviewed cleanup plan exists yet",
1431
- nextStep: `run \`artshelf cleanup --dry-run --ledger ${ledger.path} --json\`, then approve \`approve artshelf cleanup ledger ${ledger.path} plan <plan-id>\``
1432
- });
1433
- }
1434
- // manual-review and cleanup=review records need a human decision before any
1435
- // cleanup; cleanup=delete is refused outright. None carry an approval target.
1436
- if (inspectItems.length > 0) {
1437
- const ids = inspectItems.map((entry) => entry.id).sort();
1438
- const hasDelete = inspectItems.some((entry) => entry.cleanup === "delete");
1439
- needsReviewFirst.push({
1440
- label: `Inspect ${ids.length} record(s) in ${ledger.name} before cleanup`,
1441
- itemIds: ids,
1442
- actionType: "inspect",
1443
- approvalTarget: null,
1444
- reason: hasDelete
1445
- ? "records need manual review; cleanup=delete is refused and never deletes files"
1446
- : "records are held for manual review before any cleanup",
1447
- nextStep: "inspect each path, then keep, change retention, resolve, or set cleanup=trash and plan a cleanup"
1448
- });
1449
- }
1450
- }
1451
- return { readyForApproval, needsReviewFirst, blocked };
1452
- }
1453
- function reviewCounts(summary) {
1454
- return {
1455
- due: summary.due,
1456
- manualReview: summary.manualReview,
1457
- missingPath: summary.missingPath,
1458
- executable: summary.executable,
1459
- skipped: summary.skipped
1460
- };
1461
- }
1462
- function buildReviewAgentPacketAll(results, summary, registryPath) {
1463
- const groups = buildReviewDecisions(results, "all");
1464
- return {
1465
- schemaVersion: 1,
1466
- command: "review",
1467
- scope: "all",
1468
- health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1469
- registry: { path: registryPath, exists: existsSync(registryPath) },
1470
- ledgers: { total: summary.ledgers, ok: summary.ok, stale: summary.stale, invalid: summary.invalid },
1471
- counts: reviewCounts(summary),
1472
- decisionSummary: {
1473
- readyForApproval: groups.readyForApproval.length,
1474
- needsReviewFirst: groups.needsReviewFirst.length,
1475
- blocked: groups.blocked.length
1476
- },
1477
- readyForApproval: groups.readyForApproval,
1478
- needsReviewFirst: groups.needsReviewFirst,
1479
- blocked: groups.blocked,
1480
- safety: REVIEW_SAFETY,
1481
- nextAction: reviewNextAction(summary, "all"),
1482
- verification: `artshelf review --all --agent --registry ${registryPath}`
1483
- };
1484
- }
1485
- function buildReviewAgentPacketSingle(result, ledgerPath) {
1486
- const summary = summarizeReview([result]);
1487
- const groups = buildReviewDecisions([result], "single");
1488
- return {
1489
- schemaVersion: 1,
1490
- command: "review",
1491
- scope: "single",
1492
- health: summary.invalid + summary.stale > 0 ? "attention" : "ok",
1493
- ledgerPath,
1494
- counts: reviewCounts(summary),
1495
- decisionSummary: {
1496
- readyForApproval: groups.readyForApproval.length,
1497
- needsReviewFirst: groups.needsReviewFirst.length,
1498
- blocked: groups.blocked.length
1499
- },
1500
- readyForApproval: groups.readyForApproval,
1501
- needsReviewFirst: groups.needsReviewFirst,
1502
- blocked: groups.blocked,
1503
- safety: REVIEW_SAFETY,
1504
- nextAction: reviewNextAction(summary, "single", ledgerPath),
1505
- verification: `artshelf review --agent --ledger ${ledgerPath}`
1506
- };
1507
- }
1508
- const COMMAND_GROUPS = [
1509
- {
1510
- group: "Create",
1511
- commands: [{ name: "put", summary: "Record an artifact with a reason and retention" }]
1512
- },
1513
- {
1514
- group: "Inspect",
1515
- commands: [
1516
- { name: "list", summary: "List ledger records" },
1517
- { name: "find", summary: "Find records by path, owner, label, or status" },
1518
- { name: "get", summary: "Show one record by id" },
1519
- { name: "due", summary: "Show due, manual-review, and missing-path records" },
1520
- { name: "status", summary: "Summarize ledger and registry counts" }
1521
- ]
1522
- },
1523
- {
1524
- group: "Review",
1525
- commands: [
1526
- { name: "validate", summary: "Check ledger shape and report warnings" },
1527
- { name: "review", summary: "Preview validate, due, and cleanup plans (read-only)" }
1528
- ]
1529
- },
1530
- {
1531
- group: "Clean",
1532
- commands: [
1533
- { name: "cleanup", summary: "Plan and execute approved cleanups" },
1534
- { name: "trash", summary: "Inspect and purge Artshelf trash" },
1535
- { name: "resolve", summary: "Mark a record manually resolved" }
1536
- ]
1537
- },
1538
- {
1539
- group: "System",
1540
- commands: [
1541
- { name: "ledgers", summary: "Manage the ledger registry" },
1542
- { name: "doctor", summary: "Report Artshelf health on this machine" },
1543
- { name: "update", summary: "Update the Artshelf CLI" }
1544
- ]
1545
- }
1546
- ];
1547
- // Commands with subcommands that carry their own focused help. Used to route
1548
- // `artshelf <command> <subcommand> --help` to a nested help key.
1549
- const NESTED_HELP = new Map([
1550
- ["trash", new Set(["list", "purge"])],
1551
- ["ledgers", new Set(["list", "add"])]
1552
- ]);
1553
- function resolveHelpKey(parsed) {
1554
- // `artshelf help [command [subcommand]]`
1555
- if (parsed.command === "help") {
1556
- return joinHelpKey(parsed.positionals[0], parsed.positionals[1]);
1557
- }
1558
- // `artshelf [--help|-h]` with no command resolves to the top-level help.
1559
- if (!parsed.command || parsed.command === "--help" || parsed.command === "-h") {
1560
- return "";
1561
- }
1562
- // `artshelf <command> [subcommand] --help`
1563
- return joinHelpKey(parsed.command, parsed.positionals[0]);
1564
- }
1565
- function joinHelpKey(command, subcommand) {
1566
- if (!command)
1567
- return "";
1568
- const subcommands = NESTED_HELP.get(command);
1569
- if (subcommands && subcommand && subcommands.has(subcommand)) {
1570
- return `${command} ${subcommand}`;
1571
- }
1572
- return command;
1573
- }
1574
- function renderTopLevelHelp() {
1575
- const names = COMMAND_GROUPS.flatMap((entry) => entry.commands.map((command) => command.name));
1576
- const width = Math.max(...names.map((name) => name.length)) + 2;
1577
- const lines = [
1578
- `Artshelf ${VERSION} — approval-first retention for the temporary files agents leave behind.`,
1579
- "",
1580
- "Usage:",
1581
- " artshelf <command> [options]",
1582
- "",
1583
- "Available Commands:"
1584
- ];
1585
- for (const { group, commands } of COMMAND_GROUPS) {
1586
- lines.push(` ${group}`);
1587
- for (const command of commands) {
1588
- lines.push(` ${command.name.padEnd(width)}${command.summary}`);
1589
- }
1590
- }
1591
- lines.push("", "Global Options:", " -h, --help Show help for artshelf or a specific command", " -v, --version Show the Artshelf version", "", "Output:", " --json Emit machine-readable JSON on commands that return data", "", "Scope (command-specific):", " --ledger <path> Target an explicit JSONL ledger", " --registry <path> Target an explicit ledger registry", " --all Read every registered ledger (on commands that support it)", "", `Use "artshelf <command> --help" for more information about a command.`, "");
1592
- return lines.join("\n");
1593
- }
1594
- function printHelp(command = "") {
1595
- if (command === "put") {
1596
- process.stdout.write(`Usage:
1597
- artshelf put <path> --reason <text> (--ttl <ttl>|--retain-until <date>|--manual-review) [options]
1598
-
1599
- Options:
1600
- --kind scratch|backup|run-artifact|evidence|cache|quarantine|other
1601
- --cleanup trash|review|delete (cleanup=delete is refused; trash purge needs a reviewed plan)
1602
- --owner <name>
1603
- --label <label> Repeatable
1604
- --ledger <path>
1605
- --registry <path>
1606
- --json
1607
- `);
1608
- return;
1609
- }
1610
- if (command === "cleanup") {
1611
- process.stdout.write(`Usage:
1612
- artshelf cleanup --dry-run [--ledger <path>] [--json]
1613
- artshelf cleanup --dry-run --all [--registry <path>] [--json]
1614
- artshelf cleanup --execute --plan-id <id> [--ledger <path>] [--json]
1615
-
1616
- Cleanup execution is approval-only. There is no daemon, no auto-execute, and no
1617
- global execute path: review a dry-run plan, then execute that one reviewed plan id.
1618
- Cleanup is ledger-first. Execute never computes a fresh live set; it only uses a reviewed plan id.
1619
- cleanup=delete records cleanup-refused instead of deleting files; physical trash purge needs a separate reviewed plan.
1620
- Dry-run writes and registers a plan only when executable cleanup entries exist; no-op dry-runs report not-created.
1621
- Matching dry-runs reuse the existing plan id and refresh its Artshelf-owned plan artifact.
1622
- Execute writes and registers an Artshelf-owned receipt artifact.
1623
- Global --all mode is dry-run only.
1624
- `);
1625
- return;
1626
- }
1627
- if (command === "trash") {
1628
- process.stdout.write(`Inspect and purge Artshelf trash.
1629
-
1630
- Usage:
1631
- artshelf trash [command]
1632
-
1633
- Available Commands:
1634
- list List records currently held in Artshelf trash
1635
- purge Plan or execute approved permanent trash deletion
1636
-
1637
- Flags:
1638
- -h, --help help for trash
1639
-
1640
- Use "artshelf trash <command> --help" for more information about a command.
1641
- `);
1642
- return;
1643
- }
1644
- if (command === "ledgers") {
1645
- process.stdout.write(`Manage the ledger registry.
1646
-
1647
- Usage:
1648
- artshelf ledgers [command]
1649
-
1650
- Available Commands:
1651
- list List and validate registered ledgers
1652
- add Register an existing ledger file
1653
-
1654
- Flags:
1655
- -h, --help help for ledgers
1656
-
1657
- Use "artshelf ledgers <command> --help" for more information about a command.
1658
- `);
1659
- return;
1660
- }
1661
- if (command === "list") {
1662
- process.stdout.write(`Usage:
1663
- artshelf list [--status <status>] [--ledger <path>] [--json]
1664
- artshelf list --all [--status <status>] [--registry <path>] [--json]
1665
-
1666
- Statuses:
1667
- active, review-required, trashed, cleanup-refused, resolved
1668
- `);
1669
- return;
1670
- }
1671
- if (command === "find") {
1672
- process.stdout.write(`Usage:
1673
- artshelf find (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1674
- artshelf find --all (--path <path>|--owner <name>|--label <label>|--status <status>) [options]
1675
-
1676
- Options:
1677
- --path <path> Match an exact artifact path after path normalization
1678
- --owner <name>
1679
- --label <label> Repeatable; all labels must match
1680
- --status <status>
1681
- --ledger <path>
1682
- --registry <path>
1683
- --json
1684
-
1685
- Find is read-only. Use it before put when an integration needs idempotent artifact registration.
1686
- `);
1687
- return;
1688
- }
1689
- if (command === "get") {
1690
- process.stdout.write(`Usage:
1691
- artshelf get <id> [--ledger <path>] [--json]
1692
- artshelf get <id> --all [--registry <path>] [--json]
1693
-
1694
- Get is read-only and returns one ledger record by Artshelf id.
1695
- `);
1696
- return;
1697
- }
1698
- if (command === "resolve") {
1699
- process.stdout.write(`Usage:
1700
- artshelf resolve <id> --status resolved --reason <text> [--ledger <path>] [--json]
1701
-
1702
- Resolve marks a handled, missing, or no-longer-needed record as manually resolved.
1703
- Resolved records stay in the audit trail but no longer participate in due or cleanup planning.
1704
- `);
1705
- return;
1706
- }
1707
- if (command === "review") {
1708
- process.stdout.write(`Usage:
1709
- artshelf review [--ledger <path>] [--json|--agent]
1710
- artshelf review --all [--registry <path>] [--json|--agent]
1711
-
1712
- Review runs validate, due, and cleanup plan preview without moving files or
1713
- writing a plan. With --all, review adds aggregate triage counts and the next
1714
- safe action.
1715
-
1716
- Render modes:
1717
- (default) Human summary of validation, triage counts, and the next safe action.
1718
- --json Full read-only audit report (backward-compatible).
1719
- --agent Compact single-line JSON decision packet for agents: health, triage
1720
- counts, and classified decision groups (ready for approval, needs
1721
- review first, blocked) with exact approval targets where they are
1722
- safe. Review is read-only, so cleanup approval targets are minted by
1723
- \`cleanup --dry-run\`, never leaked from a preview plan id.
1724
- Token-efficient; --agent takes precedence over --json.
1725
- `);
1726
- return;
1727
- }
1728
- if (command === "doctor") {
1729
- process.stdout.write(`Usage:
1730
- artshelf doctor [--registry <path>] [--ledger <path>] [--json|--agent]
1731
-
1732
- Doctor reports whether Artshelf is healthy on this machine: CLI version, selected
1733
- or default ledger path, selected or global registry path, registered ledger health
1734
- (stale/missing/invalid), and the cleanup safety posture. Execute is scoped to one
1735
- selected or default ledger and still requires a reviewed plan id; --all execute
1736
- and cleanup=delete are refused, while physical trash purge requires a separate
1737
- reviewed purge plan.
1738
-
1739
- Render modes:
1740
- (default) Human summary of machine health and cleanup safety.
1741
- --json Full audit report (backward-compatible; suitable for cron/reporting).
1742
- --agent Compact single-line JSON decision packet for agents: health, registry
1743
- and registered-ledger health, blockers, cleanup-safety posture, next
1744
- action, and a verify command. Token-efficient; --agent takes
1745
- precedence over --json.
1746
-
1747
- Run it after install, when --all commands behave unexpectedly, or on a schedule to
1748
- catch stale registry entries. Doctor is read-only. A healthy machine exits 0; a
1749
- broken registry or registered ledger exits non-zero with actionable errors.
1750
- `);
1751
- return;
1752
- }
1753
- if (command === "status") {
1754
- process.stdout.write(`Usage:
1755
- artshelf status [--ledger <path>] [--json|--agent]
1756
- artshelf status --all [--registry <path>] [--json|--agent]
1757
-
1758
- Status is the lightweight daily "what is going on?" view. Without --all, it
1759
- reports counts for the selected or default ledger only. With --all, it adds
1760
- registry health, total ledgers, and aggregated counts across registered ledgers.
1761
- Counts include active artifacts, kept, due, manual-review, missing-path, and
1762
- pending cleanup entries.
1763
-
1764
- Render modes:
1765
- (default) Human summary, short enough to paste into a chat.
1766
- --json Full audit report (backward-compatible; suitable for cron/reporting).
1767
- --agent Compact single-line JSON decision packet for agents: health, counts,
1768
- attention categories, blockers, next action, and a verify command.
1769
- Token-efficient; --agent takes precedence over --json.
1770
-
1771
- Status is read-only: it never creates plans or receipts and never mutates
1772
- records. A healthy selected ledger exits 0; with --all, a broken registry or any
1773
- stale or invalid registered ledger exits non-zero.
1774
- `);
1775
- return;
1776
- }
1777
- if (command === "update") {
1778
- process.stdout.write(`Usage:
1779
- artshelf update [--json]
1780
-
1781
- Update checks compare the current CLI version with the latest published npm
1782
- version. Normal commands may print a non-blocking update notice to stderr when a
1783
- newer version is available. Run update to upgrade npm global installs only:
1784
-
1785
- npm install -g artshelf@latest
1786
-
1787
- pnpm global installs should update with pnpm add -g artshelf@latest; source
1788
- installs should update by pulling, rebuilding, and linking the checkout.
1789
- `);
1790
- return;
1791
- }
1792
- if (command === "due") {
1793
- process.stdout.write(`Usage:
1794
- artshelf due [--ledger <path>] [--json]
1795
- artshelf due --all [--registry <path>] [--json]
1796
-
1797
- Due lists records whose retention has elapsed or that need attention: due,
1798
- manual-review, and missing-path entries. Kept entries are hidden in human output.
1799
- Due is read-only and never moves files or writes plans.
1800
- `);
1801
- return;
1802
- }
1803
- if (command === "validate") {
1804
- process.stdout.write(`Usage:
1805
- artshelf validate [--ledger <path>] [--json]
1806
- artshelf validate --all [--registry <path>] [--json]
1807
-
1808
- Validate checks ledger shape and reports errors and warnings, such as records
1809
- that point at missing artifact paths, without changing anything. A clean ledger
1810
- exits 0; shape errors exit non-zero. With --all it validates every registered
1811
- ledger.
1812
- `);
1813
- return;
1814
- }
1815
- if (command === "trash list") {
1816
- process.stdout.write(`Usage:
1817
- artshelf trash list [--ledger <path>] [--all] [--registry <path>] [--json]
1818
-
1819
- Options:
1820
- --ledger <path> Use a specific ledger file
1821
- --all Include records from all registered ledgers
1822
- --registry <path> Registry path used with --all
1823
- --json Emit machine-readable output
1824
-
1825
- Trash list shows records currently held in Artshelf trash without deleting anything.
1826
- With --all it reports trashed records across every registered ledger.
1827
- `);
1828
- return;
1829
- }
1830
- if (command === "trash purge") {
1831
- process.stdout.write(`Usage:
1832
- artshelf trash purge --older-than <ttl> --dry-run [--ledger <path>] [--json]
1833
- artshelf trash purge --execute --plan-id <id> [--ledger <path>] [--json]
1834
-
1835
- Options:
1836
- --older-than <ttl> Purge trashed records older than this duration
1837
- --dry-run Build a reviewed purge plan and output a plan id
1838
- --execute Execute a reviewed purge plan
1839
- --plan-id <id> Execute only this reviewed purge plan
1840
- --ledger <path> Target one specific ledger
1841
- --json Emit machine-readable output
1842
-
1843
- Trash purge permanently deletes aged trash from a reviewed plan. --dry-run turns
1844
- --older-than into a reviewed purge plan id; --execute deletes only that one reviewed
1845
- plan id. Purge is always scoped to one --ledger; --all is not supported for purge.
1846
- Completed receipts are refused on repeat execute; an interrupted purge may be resumed
1847
- and reconciled.
1848
- `);
1849
- return;
1850
- }
1851
- if (command === "ledgers list") {
1852
- process.stdout.write(`Usage:
1853
- artshelf ledgers list [--plain] [--registry <path>] [--json]
1854
-
1855
- Options:
1856
- --plain Skip ledger validation and list registrations directly
1857
- --registry <path> Registry path to use
1858
- --json Emit machine-readable output
1859
-
1860
- Ledgers list validates every registered ledger and reports ok/missing/invalid
1861
- status, entry counts, and warnings so agents can spot stale registry entries
1862
- without a separate validate pass. Use --plain for the fast path that lists
1863
- registered ledgers without reading them. It exits non-zero when the registry or
1864
- any registered ledger is broken.
1865
- `);
1866
- return;
1867
- }
1868
- if (command === "ledgers add") {
1869
- process.stdout.write(`Usage:
1870
- artshelf ledgers add --ledger <path> [--name <name>] [--scope repo|user|other] [--registry <path>] [--json]
1871
-
1872
- Options:
1873
- --ledger <path> Register this ledger file
1874
- --name <name> Override the ledger display name
1875
- --scope <scope> Registry scope: repo, user, or other
1876
- --registry <path> Registry path to update
1877
- --json Emit machine-readable output
1878
-
1879
- Ledgers add registers an existing ledger file in the global registry so --all
1880
- commands and the registry index can find it. The ledger file must already exist.
1881
- `);
1882
- return;
1883
- }
1884
- process.stdout.write(renderTopLevelHelp());
79
+ async function maybeNotifyUpdateAndReturn(status, parsed) {
80
+ await maybeNotifyAvailableUpdate(parsed);
81
+ return status;
1885
82
  }
1886
83
  main(process.argv.slice(2))
1887
84
  .then((status) => {
1888
85
  process.exitCode = status;
1889
86
  })
1890
87
  .catch((error) => {
1891
- process.stderr.write(`artshelf: ${error.message}\nRun \`artshelf help\` for usage.\n`);
88
+ process.stderr.write(formatCliError(error));
1892
89
  process.exitCode = 1;
1893
90
  });