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