@clossys/writer 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +647 -6
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/approval-bypass.d.ts +120 -0
  7. package/dist/approval-bypass.d.ts.map +1 -0
  8. package/dist/approval-bypass.js +2318 -0
  9. package/dist/approval-bypass.js.map +1 -0
  10. package/dist/approval.d.ts +62 -0
  11. package/dist/approval.d.ts.map +1 -0
  12. package/dist/approval.js +421 -0
  13. package/dist/approval.js.map +1 -0
  14. package/dist/approve-cli.d.ts +82 -0
  15. package/dist/approve-cli.d.ts.map +1 -0
  16. package/dist/approve-cli.js +430 -0
  17. package/dist/approve-cli.js.map +1 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +264 -22
  21. package/dist/cli.js.map +1 -1
  22. package/dist/field-options.d.ts +59 -0
  23. package/dist/field-options.d.ts.map +1 -0
  24. package/dist/field-options.js +109 -0
  25. package/dist/field-options.js.map +1 -0
  26. package/dist/fold-wallpaper-gate.d.ts +7 -0
  27. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  28. package/dist/fold-wallpaper-gate.js +29 -0
  29. package/dist/fold-wallpaper-gate.js.map +1 -0
  30. package/dist/front-door.check.d.ts +2 -0
  31. package/dist/front-door.check.d.ts.map +1 -0
  32. package/dist/front-door.check.js +8 -0
  33. package/dist/front-door.check.js.map +1 -0
  34. package/dist/front-door.d.ts +82 -0
  35. package/dist/front-door.d.ts.map +1 -0
  36. package/dist/front-door.js +271 -0
  37. package/dist/front-door.js.map +1 -0
  38. package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
  39. package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
  40. package/dist/generated/advisor-plan-contract.generated.js +430 -0
  41. package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
  42. package/dist/generated/contract-schema.generated.d.ts +97 -0
  43. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  44. package/dist/generated/contract-schema.generated.js +496 -0
  45. package/dist/generated/contract-schema.generated.js.map +1 -0
  46. package/dist/index.d.ts +18 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/live-copy-gate.d.ts +16 -0
  51. package/dist/live-copy-gate.d.ts.map +1 -0
  52. package/dist/live-copy-gate.js +135 -0
  53. package/dist/live-copy-gate.js.map +1 -0
  54. package/dist/messaging-kit.d.ts +113 -0
  55. package/dist/messaging-kit.d.ts.map +1 -0
  56. package/dist/messaging-kit.js +121 -0
  57. package/dist/messaging-kit.js.map +1 -0
  58. package/dist/passage.d.ts +2 -1
  59. package/dist/passage.d.ts.map +1 -1
  60. package/dist/passage.js +2 -1
  61. package/dist/passage.js.map +1 -1
  62. package/dist/plan-authority.d.ts +73 -0
  63. package/dist/plan-authority.d.ts.map +1 -0
  64. package/dist/plan-authority.js +126 -0
  65. package/dist/plan-authority.js.map +1 -0
  66. package/dist/plan-contract.d.ts +16 -0
  67. package/dist/plan-contract.d.ts.map +1 -0
  68. package/dist/plan-contract.js +33 -0
  69. package/dist/plan-contract.js.map +1 -0
  70. package/dist/plan-digest.d.ts +23 -0
  71. package/dist/plan-digest.d.ts.map +1 -0
  72. package/dist/plan-digest.js +82 -0
  73. package/dist/plan-digest.js.map +1 -0
  74. package/dist/plan-rules.d.ts +54 -0
  75. package/dist/plan-rules.d.ts.map +1 -0
  76. package/dist/plan-rules.js +145 -0
  77. package/dist/plan-rules.js.map +1 -0
  78. package/dist/render-registry-parity.d.ts +20 -0
  79. package/dist/render-registry-parity.d.ts.map +1 -0
  80. package/dist/render-registry-parity.js +45 -0
  81. package/dist/render-registry-parity.js.map +1 -0
  82. package/dist/resolve.d.ts +40 -6
  83. package/dist/resolve.d.ts.map +1 -1
  84. package/dist/resolve.js +144 -18
  85. package/dist/resolve.js.map +1 -1
  86. package/dist/scan.d.ts +32 -0
  87. package/dist/scan.d.ts.map +1 -1
  88. package/dist/scan.js +67 -6
  89. package/dist/scan.js.map +1 -1
  90. package/dist/schema.d.ts +1 -0
  91. package/dist/schema.d.ts.map +1 -1
  92. package/dist/schema.js +231 -5
  93. package/dist/schema.js.map +1 -1
  94. package/dist/site-identity.d.ts +88 -0
  95. package/dist/site-identity.d.ts.map +1 -0
  96. package/dist/site-identity.js +82 -0
  97. package/dist/site-identity.js.map +1 -0
  98. package/dist/treatment-word-budget.d.ts +31 -0
  99. package/dist/treatment-word-budget.d.ts.map +1 -0
  100. package/dist/treatment-word-budget.js +80 -0
  101. package/dist/treatment-word-budget.js.map +1 -0
  102. package/dist/types.d.ts +76 -7
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js +9 -7
  105. package/dist/types.js.map +1 -1
  106. package/fit-signals.json +11 -0
  107. package/package.json +201 -11
  108. package/skill/SKILL.md +49 -0
  109. package/src/addressability.ts +186 -4
  110. package/src/approval-bypass.ts +2263 -0
  111. package/src/approval.ts +491 -0
  112. package/src/approve-cli.ts +474 -0
  113. package/src/cli.ts +274 -22
  114. package/src/field-options.ts +134 -0
  115. package/src/fold-wallpaper-gate.ts +29 -0
  116. package/src/front-door.check.ts +10 -0
  117. package/src/front-door.ts +312 -0
  118. package/src/generated/advisor-plan-contract.generated.ts +430 -0
  119. package/src/generated/contract-schema.generated.ts +520 -0
  120. package/src/index.ts +107 -1
  121. package/src/live-copy-gate.ts +150 -0
  122. package/src/messaging-kit.ts +195 -0
  123. package/src/passage.ts +2 -1
  124. package/src/plan-authority.ts +160 -0
  125. package/src/plan-contract.ts +45 -0
  126. package/src/plan-digest.ts +81 -0
  127. package/src/plan-rules.ts +179 -0
  128. package/src/render-registry-parity.ts +65 -0
  129. package/src/resolve.ts +185 -19
  130. package/src/scan.ts +97 -6
  131. package/src/schema.ts +247 -5
  132. package/src/site-identity.ts +125 -0
  133. package/src/treatment-word-budget.ts +100 -0
  134. package/src/types.ts +83 -7
  135. package/templates/front-door.en.json +426 -0
  136. package/CHANGELOG.md +0 -330
@@ -0,0 +1,474 @@
1
+ /**
2
+ * `writer-check approve` — writes a `CopyRegistryEntry.approval` record so
3
+ * nobody has to hand-edit one. It exists as a command, not a hand-edited
4
+ * JSON field, for the same reason `textFingerprint` (`fingerprint.ts`) is a
5
+ * content hash rather than a human-maintained counter: a hand-typed record
6
+ * is easy to get wrong — copying yesterday's `approvedAt`, pinning `textFingerprint` to
7
+ * some other entry's text, or leaving `pendingOwnerReview` set after an
8
+ * owner actually reviewed it. Every field this command writes is either
9
+ * computed (`approvedAt` from the clock, `textFingerprint` from the
10
+ * entry's own current `text` via `computeCopyFingerprint`) or copied
11
+ * verbatim from an argument this command itself validated (`--delegate`,
12
+ * `--scope`, `--expires`) — there is no field here a human types free-form
13
+ * into the JSON.
14
+ *
15
+ * This module owns argument parsing, registry-file I/O, and the mutate-
16
+ * then-atomically-rename write; `./approval.ts`'s `isDelegateScopeItem` and
17
+ * `isEntryInDelegateScope` decide whether a requested id is in a delegate's
18
+ * declared scope, and `./schema.ts`'s `validateCopyRegistryShape` decides
19
+ * whether the registry file is trustworthy enough to mutate at all — this
20
+ * file calls both rather than re-deriving either.
21
+ *
22
+ * `main`-equivalent shape is `mainApproveCommand`, exported (unlike a
23
+ * typical CLI `main`) so this package's tests (which do not ship) can exercise the whole
24
+ * argv-to-exit-code contract directly against real `mkdtemp` fixture
25
+ * files, without spawning a subprocess — the same discipline every other
26
+ * subcommand in this package's `cli.ts` already holds to. `argv` excludes
27
+ * the leading `"approve"` token: `cli.ts`'s top-level dispatch calls
28
+ * `mainApproveCommand(argv.slice(1))` for `writer-check approve ...`, the same way `"passages"` and
29
+ * `"addressability"` are already dispatched there.
30
+ *
31
+ * Exit codes — a three-state contract matching every other gate/command in
32
+ * this package:
33
+ *
34
+ * 0 — written: every named entry was approved or revoked, and the
35
+ * registry file on disk now reflects it.
36
+ * 1 — refused: at least one named entry is unknown, is `retired`, or —
37
+ * for a delegate approval — falls outside `--scope`. This is
38
+ * ALL-OR-NOTHING: when any requested id has a problem, EVERY problem
39
+ * is printed and NOTHING is written, even for the other ids that
40
+ * would otherwise have succeeded. A partial write here would leave a
41
+ * consumer's own audit trail unable to tell whether a whole
42
+ * requested batch was actually reviewed together.
43
+ * 2 — could not run: bad arguments (see below), or the registry file is
44
+ * missing/unreadable, not valid JSON, or fails
45
+ * `validateCopyRegistryShape`. Nothing is written in this state
46
+ * either — this command never mutates a registry it does not first
47
+ * fully trust.
48
+ *
49
+ * Bad-argument cases that produce exit 2, all before the registry file is
50
+ * ever opened: no registry-file argument, zero entry ids, neither `--by`
51
+ * nor `--revoke` (or both), `--by` other than `owner`/`delegate`, `--by
52
+ * owner` combined with `--delegate`/`--scope`/`--expires`, `--by delegate`
53
+ * missing `--delegate` or `--scope`, an empty `--delegate` value, a
54
+ * `--scope` item that fails `isDelegateScopeItem` or an empty scope list,
55
+ * `--revoke` combined with any of `--by`/`--delegate`/`--scope`/
56
+ * `--expires`, an `--expires` value that is not a well-formed ISO 8601 UTC
57
+ * timestamp or is not strictly after the current time, or an unknown flag.
58
+ * `--help`/`-h` always prints usage and returns 0, taking precedence over
59
+ * every other check reachable in the same parse. Any other unexpected
60
+ * exception is caught and mapped to exit 2 as well — this command never
61
+ * lets an uncaught error double as a "something went wrong" report.
62
+ */
63
+
64
+ import { randomBytes } from "node:crypto";
65
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
66
+ import { basename, dirname, join, resolve } from "node:path";
67
+ import { isDelegateScopeItem, isEntryInDelegateScope } from "./approval.js";
68
+ import { COPY_FINGERPRINT_ALGORITHM, computeCopyFingerprint } from "./fingerprint.js";
69
+ import { isIsoUtcTimestamp, validateCopyRegistryShape } from "./schema.js";
70
+
71
+ export const APPROVE_USAGE = `Usage: writer-check approve <registry-file> <entry-id>... --by owner
72
+ or: writer-check approve <registry-file> <entry-id>... --by delegate --delegate <id> --scope <ns>[,<ns>...] [--expires <ISO>]
73
+ or: writer-check approve <registry-file> <entry-id>... --revoke
74
+
75
+ registry-file Path to a CopyRegistry JSON file. Required.
76
+ entry-id One or more entry ids to approve or revoke (repeatable positional argument; duplicates are deduplicated). Required.
77
+
78
+ Options:
79
+ --help Print this message and exit 0.
80
+ --by <owner|delegate> Who is recording this approval. Exactly one of --by or --revoke is required.
81
+ --delegate <id> Opaque delegate identifier, never a personal name. Required (and only allowed) with --by delegate.
82
+ --scope <ns>[,<ns>...] One or more dot-separated entry-id namespaces the delegate may approve (repeatable; a comma-separated value and repeated flags union). Required (and only allowed) with --by delegate.
83
+ --expires <ISO> ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ, optionally with milliseconds) after which the delegate approval expires. Must be strictly after the current time. Only allowed with --by delegate.
84
+ --revoke Return every named entry to "draft" and delete its approval record. Cannot be combined with --by, --delegate, --scope, or --expires.
85
+
86
+ Approving an entry that already carries a delegate's approval record with
87
+ --by owner replaces that record with the owner's own — this is how a
88
+ pending delegate approval is durably confirmed.
89
+
90
+ Exit codes: 0 = written (every named entry approved or revoked; the
91
+ registry file was updated), 1 = refused (an unknown, retired, or — for a
92
+ delegate approval — out-of-scope entry id was named; every problem is
93
+ printed and nothing is written), 2 = could not run (bad arguments, or the
94
+ registry file is missing/unreadable/not valid JSON/fails schema
95
+ validation; nothing is written).
96
+ `;
97
+
98
+ /** Bad arguments only — never thrown once the registry file has been read. */
99
+ class ApproveInputError extends Error {}
100
+
101
+ type ApproveAction =
102
+ | { kind: "approve-owner" }
103
+ | { kind: "approve-delegate"; delegate: string; scope: string[]; expiresAt?: string }
104
+ | { kind: "revoke" };
105
+
106
+ interface RawArgs {
107
+ registryFile?: string;
108
+ entryIds: string[];
109
+ by?: string;
110
+ delegate?: string;
111
+ scope?: string[];
112
+ expires?: string;
113
+ revoke: boolean;
114
+ help: boolean;
115
+ }
116
+
117
+ interface ValidatedArgs {
118
+ registryFile: string;
119
+ entryIds: string[];
120
+ action: ApproveAction;
121
+ }
122
+
123
+ function parseRawArgs(argv: string[]): RawArgs {
124
+ let registryFile: string | undefined;
125
+ const entryIds: string[] = [];
126
+ let by: string | undefined;
127
+ let delegate: string | undefined;
128
+ let scope: string[] | undefined;
129
+ let expires: string | undefined;
130
+ let revoke = false;
131
+ let help = false;
132
+
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const arg = argv[i] as string;
135
+ if (arg === "--help" || arg === "-h") {
136
+ help = true;
137
+ continue;
138
+ }
139
+ if (arg === "--revoke") {
140
+ revoke = true;
141
+ continue;
142
+ }
143
+ if (arg === "--by") {
144
+ const value = argv[++i];
145
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--by requires a value");
146
+ by = value;
147
+ continue;
148
+ }
149
+ if (arg === "--delegate") {
150
+ const value = argv[++i];
151
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--delegate requires a value");
152
+ delegate = value;
153
+ continue;
154
+ }
155
+ if (arg === "--scope") {
156
+ const value = argv[++i];
157
+ if (value === undefined || value.startsWith("-")) throw new ApproveInputError("--scope requires a value");
158
+ const tokens = value.split(",").map((token) => token.trim());
159
+ scope = [...(scope ?? []), ...tokens];
160
+ continue;
161
+ }
162
+ if (arg === "--expires") {
163
+ const value = argv[++i];
164
+ if (value === undefined || value.startsWith("-")) {
165
+ throw new ApproveInputError("--expires requires an ISO 8601 UTC timestamp");
166
+ }
167
+ expires = value;
168
+ continue;
169
+ }
170
+ if (arg.startsWith("-")) {
171
+ throw new ApproveInputError(`unknown flag "${arg}"`);
172
+ }
173
+ if (registryFile === undefined) {
174
+ registryFile = arg;
175
+ } else {
176
+ entryIds.push(arg);
177
+ }
178
+ }
179
+
180
+ return { registryFile, entryIds, by, delegate, scope, expires, revoke, help };
181
+ }
182
+
183
+ /**
184
+ * `YYYY-MM-DDTHH:MM:SS(.sss)?Z` — deliberately the same shape
185
+ * `schema.ts`'s own (unexported) `ISO_UTC_TIMESTAMP_RE` accepts for
186
+ * `approvedAt`/`expiresAt`, so a value this command accepts on argv is
187
+ * never one `validateCopyRegistryShape` would then reject from the file
188
+ * this command just wrote. `Date.parse` must also actually accept it, so a
189
+ * string that merely matches the pattern but names an impossible date
190
+ * (e.g. a bad month) is still rejected.
191
+ */
192
+ function validateExpires(raw: string, now: Date): string {
193
+ if (!isIsoUtcTimestamp(raw)) {
194
+ throw new ApproveInputError(
195
+ `--expires must be an ISO 8601 UTC timestamp (e.g. 2026-01-01T00:00:00Z), got ${JSON.stringify(raw)}`,
196
+ );
197
+ }
198
+ if (Date.parse(raw) <= now.getTime()) {
199
+ throw new ApproveInputError(`--expires ${JSON.stringify(raw)} must be strictly after the current time`);
200
+ }
201
+ return raw;
202
+ }
203
+
204
+ /**
205
+ * Turns the raw, syntactically-parsed argv into a fully validated action —
206
+ * every semantic rule this command enforces before ever touching the
207
+ * registry file lives here. Throws `ApproveInputError` on the first
208
+ * violation, matching every other subcommand's `parseArgs` in this
209
+ * package.
210
+ */
211
+ function validateArgs(raw: RawArgs, now: Date): ValidatedArgs {
212
+ if (!raw.registryFile) {
213
+ throw new ApproveInputError("registry-file is required");
214
+ }
215
+ if (raw.entryIds.length === 0) {
216
+ throw new ApproveInputError("at least one entry-id is required");
217
+ }
218
+
219
+ if (!raw.revoke && raw.by === undefined) {
220
+ throw new ApproveInputError("exactly one of --by or --revoke is required");
221
+ }
222
+
223
+ let action: ApproveAction;
224
+ if (raw.revoke) {
225
+ if (raw.by !== undefined || raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
226
+ throw new ApproveInputError("--revoke cannot be combined with --by, --delegate, --scope, or --expires");
227
+ }
228
+ action = { kind: "revoke" };
229
+ } else if (raw.by === "owner") {
230
+ if (raw.delegate !== undefined || raw.scope !== undefined || raw.expires !== undefined) {
231
+ throw new ApproveInputError("--by owner cannot be combined with --delegate, --scope, or --expires");
232
+ }
233
+ action = { kind: "approve-owner" };
234
+ } else if (raw.by === "delegate") {
235
+ if (raw.delegate === undefined) {
236
+ throw new ApproveInputError("--by delegate requires --delegate <id>");
237
+ }
238
+ if (raw.delegate.trim().length === 0) {
239
+ throw new ApproveInputError("--delegate must not be empty");
240
+ }
241
+ const delegateId = raw.delegate;
242
+ if (raw.scope === undefined || raw.scope.length === 0) {
243
+ throw new ApproveInputError("--by delegate requires --scope <ns>[,<ns>...]");
244
+ }
245
+ for (const item of raw.scope) {
246
+ if (!isDelegateScopeItem(item)) {
247
+ throw new ApproveInputError(`invalid --scope item ${JSON.stringify(item)}`);
248
+ }
249
+ }
250
+ const expiresAt = raw.expires !== undefined ? validateExpires(raw.expires, now) : undefined;
251
+ action = { kind: "approve-delegate", delegate: delegateId, scope: raw.scope, expiresAt };
252
+ } else {
253
+ throw new ApproveInputError(`--by must be "owner" or "delegate", got ${JSON.stringify(raw.by)}`);
254
+ }
255
+
256
+ return { registryFile: raw.registryFile, entryIds: [...new Set(raw.entryIds)], action };
257
+ }
258
+
259
+ interface ReadOutcome {
260
+ ok: boolean;
261
+ parsed?: Record<string, unknown>;
262
+ messages: string[];
263
+ }
264
+
265
+ /**
266
+ * Reads, parses, and shape-validates the registry file — never throws.
267
+ * Deliberately does NOT go through `parseCopyRegistry`/`buildCopyRegistry`
268
+ * (`schema.ts`): those rebuild a fresh object from validated fields, which
269
+ * would lose the original file's key order and any field this package
270
+ * does not itself model. This command mutates the raw parsed JSON value in
271
+ * place instead (see `mainApproveCommand`), so it only needs
272
+ * `validateCopyRegistryShape`'s findings, never a rebuilt object.
273
+ */
274
+ function readRegistry(path: string): ReadOutcome {
275
+ let raw: string;
276
+ try {
277
+ raw = readFileSync(path, "utf8");
278
+ } catch (error) {
279
+ return {
280
+ ok: false,
281
+ messages: [`registry file "${path}" could not be read: ${error instanceof Error ? error.message : String(error)}`],
282
+ };
283
+ }
284
+
285
+ let parsed: unknown;
286
+ try {
287
+ parsed = JSON.parse(raw);
288
+ } catch (error) {
289
+ return {
290
+ ok: false,
291
+ messages: [`registry file "${path}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
292
+ };
293
+ }
294
+
295
+ const findings = validateCopyRegistryShape(parsed);
296
+ if (findings.length > 0) {
297
+ return {
298
+ ok: false,
299
+ messages: findings.map((f) => `[${f.rule}]${f.path ? ` ${f.path}:` : ""} ${f.message}`),
300
+ };
301
+ }
302
+
303
+ return { ok: true, parsed: parsed as Record<string, unknown>, messages: [] };
304
+ }
305
+
306
+ function indexEntries(parsed: Record<string, unknown>): Map<string, Record<string, unknown>> {
307
+ const entries = parsed.entries as unknown[];
308
+ const byId = new Map<string, Record<string, unknown>>();
309
+ for (const raw of entries) {
310
+ const entry = raw as Record<string, unknown>;
311
+ byId.set(entry.id as string, entry);
312
+ }
313
+ return byId;
314
+ }
315
+
316
+ /**
317
+ * All-or-nothing pre-flight: every requested id that is unknown, retired,
318
+ * or (for a delegate approval) outside `--scope` is collected here, so
319
+ * `mainApproveCommand` can print every problem and refuse the WHOLE batch
320
+ * — never write some of the requested entries and skip others.
321
+ */
322
+ function findProblems(
323
+ entriesById: Map<string, Record<string, unknown>>,
324
+ entryIds: string[],
325
+ action: ApproveAction,
326
+ ): string[] {
327
+ const problems: string[] = [];
328
+ for (const id of entryIds) {
329
+ const entry = entriesById.get(id);
330
+ if (!entry) {
331
+ problems.push(`unknown entry id "${id}"`);
332
+ continue;
333
+ }
334
+ if (entry.status === "retired") {
335
+ problems.push(`entry "${id}" is retired and cannot be approved or revoked`);
336
+ continue;
337
+ }
338
+ if (action.kind === "approve-delegate" && !isEntryInDelegateScope(id, action.scope)) {
339
+ problems.push(`entry "${id}" is outside delegate scope [${action.scope.join(", ")}]`);
340
+ }
341
+ }
342
+ return problems;
343
+ }
344
+
345
+ /**
346
+ * Builds a fresh `CopyApproval`-shaped record with keys in the exact order
347
+ * this package's contract fixes: `approvedBy`, `approvedAt`,
348
+ * `textFingerprint`, `fingerprintAlgorithm`, then — delegate records only —
349
+ * `delegate`, `pendingOwnerReview`, `expiresAt`. Assigning this whole
350
+ * object to `entry.approval` either appends a new `approval` key at the
351
+ * end of the entry (it did not exist before) or replaces an existing one
352
+ * in place (its position in the entry is unchanged, because reassigning an
353
+ * existing object key never moves it) — see this file's top doc comment
354
+ * and `mainApproveCommand` for why that is exactly the key-order contract
355
+ * this command must preserve.
356
+ */
357
+ function buildApprovalRecord(action: ApproveAction, entry: Record<string, unknown>, now: Date): Record<string, unknown> {
358
+ const record: Record<string, unknown> = {
359
+ approvedBy: action.kind === "approve-delegate" ? "delegate" : "owner",
360
+ approvedAt: now.toISOString(),
361
+ textFingerprint: computeCopyFingerprint(entry.text as string),
362
+ fingerprintAlgorithm: COPY_FINGERPRINT_ALGORITHM,
363
+ };
364
+ if (action.kind === "approve-delegate") {
365
+ record.delegate = { id: action.delegate, scope: [...action.scope] };
366
+ record.pendingOwnerReview = true;
367
+ if (action.expiresAt !== undefined) {
368
+ record.expiresAt = action.expiresAt;
369
+ }
370
+ }
371
+ return record;
372
+ }
373
+
374
+ /**
375
+ * Writes `data` to `path` by writing a temp file in the SAME directory,
376
+ * then `renameSync`-ing it into place, so the registry file is replaced by
377
+ * one rename rather than rewritten in place. The temp file is cleaned up
378
+ * on any failure; a failure after a successful `renameSync` cannot happen,
379
+ * since nothing runs after it.
380
+ */
381
+ function writeRegistryFile(path: string, data: unknown): void {
382
+ const dir = dirname(path);
383
+ const base = basename(path);
384
+ const tempPath = join(dir, `.${base}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
385
+ const content = `${JSON.stringify(data, null, 2)}\n`;
386
+ try {
387
+ writeFileSync(tempPath, content, "utf8");
388
+ renameSync(tempPath, path);
389
+ } catch (error) {
390
+ try {
391
+ if (existsSync(tempPath)) unlinkSync(tempPath);
392
+ } catch {
393
+ // Best-effort cleanup only — the original error below is what matters.
394
+ }
395
+ throw error;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Exported (unlike a typical CLI `main`) so this package's tests (which do
401
+ * not ship) can exercise the whole argv-to-exit-code contract directly against real
402
+ * `mkdtemp` fixture files, without spawning a subprocess — see this file's
403
+ * top doc comment for the full exit-code contract. `argv` excludes the
404
+ * leading `"approve"` subcommand token. `io.now` lets a test pin the clock
405
+ * that `approvedAt` and `--expires` validation are measured against;
406
+ * omitted, it defaults to `new Date()`.
407
+ *
408
+ * Never throws: every failure, expected or not, is caught here and mapped
409
+ * to an exit code — bad arguments and an untrustworthy registry file both
410
+ * become 2 with nothing written, a refused (but well-formed) request
411
+ * becomes 1 with nothing written, and a clean run becomes 0 with the
412
+ * registry file updated on disk.
413
+ */
414
+ export function mainApproveCommand(argv: string[], io: { now?: Date } = {}): number {
415
+ try {
416
+ const now = io.now ?? new Date();
417
+ const raw = parseRawArgs(argv);
418
+ if (raw.help) {
419
+ console.log(APPROVE_USAGE);
420
+ return 0;
421
+ }
422
+
423
+ const validated = validateArgs(raw, now);
424
+
425
+ const registryFile = resolve(validated.registryFile);
426
+ const read = readRegistry(registryFile);
427
+ if (!read.ok || !read.parsed) {
428
+ console.error(`[approve] registry could not be loaded:`);
429
+ for (const message of read.messages) console.error(` ${message}`);
430
+ console.error("[approve] Refusing to modify a registry that could not be fully validated.");
431
+ return 2;
432
+ }
433
+
434
+ const entriesById = indexEntries(read.parsed);
435
+ const problems = findProblems(entriesById, validated.entryIds, validated.action);
436
+ if (problems.length > 0) {
437
+ console.error(`[approve] refusing to write — ${problems.length} problem(s) with the requested entries:`);
438
+ for (const problem of problems) console.error(` ${problem}`);
439
+ return 1;
440
+ }
441
+
442
+ const lines: string[] = [];
443
+ for (const id of validated.entryIds) {
444
+ const entry = entriesById.get(id) as Record<string, unknown>;
445
+ if (validated.action.kind === "revoke") {
446
+ entry.status = "draft";
447
+ delete entry.approval;
448
+ lines.push(`[approve] ${id}: revoked (now draft)`);
449
+ } else {
450
+ entry.status = "approved";
451
+ entry.approval = buildApprovalRecord(validated.action, entry, now);
452
+ lines.push(
453
+ validated.action.kind === "approve-delegate"
454
+ ? `[approve] ${id}: approved by delegate ${validated.action.delegate} (pending owner review)`
455
+ : `[approve] ${id}: approved by owner`,
456
+ );
457
+ }
458
+ }
459
+
460
+ writeRegistryFile(registryFile, read.parsed);
461
+ for (const line of lines) console.log(line);
462
+ return 0;
463
+ } catch (error) {
464
+ if (error instanceof ApproveInputError) {
465
+ console.error(`writer-check approve: ${error.message}`);
466
+ console.error(`\n${APPROVE_USAGE}`);
467
+ return 2;
468
+ }
469
+ console.error(
470
+ `writer-check approve: unexpected error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
471
+ );
472
+ return 2;
473
+ }
474
+ }