@stll/folio-cli 0.0.1-placeholder.0 → 0.1.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 (45) hide show
  1. package/README.md +298 -0
  2. package/dist/bin.d.ts +1 -0
  3. package/dist/bin.js +30 -0
  4. package/dist/cli.d.ts +25 -0
  5. package/dist/cli.js +440 -0
  6. package/dist/document.d.ts +43 -0
  7. package/dist/document.js +127 -0
  8. package/dist/errors.d.ts +67 -0
  9. package/dist/errors.js +92 -0
  10. package/dist/execute-read.d.ts +64 -0
  11. package/dist/execute-read.js +220 -0
  12. package/dist/execute-write.d.ts +36 -0
  13. package/dist/execute-write.js +449 -0
  14. package/dist/flags.d.ts +38 -0
  15. package/dist/flags.js +102 -0
  16. package/dist/journal.d.ts +82 -0
  17. package/dist/journal.js +212 -0
  18. package/dist/lock.d.ts +55 -0
  19. package/dist/lock.js +157 -0
  20. package/dist/mcp.d.ts +28 -0
  21. package/dist/mcp.js +310 -0
  22. package/dist/output-file.d.ts +14 -0
  23. package/dist/output-file.js +60 -0
  24. package/dist/output.d.ts +33 -0
  25. package/dist/output.js +77 -0
  26. package/dist/package-parts.d.ts +24 -0
  27. package/dist/package-parts.js +172 -0
  28. package/dist/package.js +4 -0
  29. package/dist/preview-commands.d.ts +8 -0
  30. package/dist/preview-commands.js +226 -0
  31. package/dist/provenance.d.ts +17 -0
  32. package/dist/provenance.js +53 -0
  33. package/dist/registry.d.ts +69 -0
  34. package/dist/registry.js +255 -0
  35. package/dist/render.d.ts +35 -0
  36. package/dist/render.js +120 -0
  37. package/dist/roots.d.ts +21 -0
  38. package/dist/roots.js +57 -0
  39. package/dist/serve.d.ts +17 -0
  40. package/dist/serve.js +234 -0
  41. package/dist/sidecar.d.ts +52 -0
  42. package/dist/sidecar.js +156 -0
  43. package/dist/transaction.d.ts +55 -0
  44. package/dist/transaction.js +218 -0
  45. package/package.json +85 -3
@@ -0,0 +1,449 @@
1
+ import { checkExpectedVersion, errnoCode, openReviewer, readDocumentFile, sameFile } from "./document.js";
2
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
3
+ import { findCommit, journalPathFor, recoverStages } from "./journal.js";
4
+ import { acquireLease } from "./lock.js";
5
+ import { diffPackages, nextRevisionIdSeed } from "./package-parts.js";
6
+ import { SIDECAR_DIRECTORY, unsafePath } from "./sidecar.js";
7
+ import { commitTransaction } from "./transaction.js";
8
+ import { Result, panic } from "better-result";
9
+ import { createReviewerBridge } from "@stll/folio-agents/bridges/reviewer";
10
+ import { generateRedlineDocx } from "@stll/folio-agents/compare";
11
+ import { executeFolioToolCallUntyped } from "@stll/folio-agents/execute";
12
+ import { FOLIO_AGENT_TOOL_NAMES } from "@stll/folio-agents/types";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { realpath, stat } from "node:fs/promises";
15
+ import path from "node:path";
16
+ //#region src/execute-write.ts
17
+ /**
18
+ * Run one mutating tool call as a transaction: take the destination's lease,
19
+ * settle any crashed stage, replay a committed `txId`, apply the change to a
20
+ * fresh reviewer, save it (selective unless a full repack is allowed), and
21
+ * commit the bytes atomically with a journal line and, in place, a backup.
22
+ */
23
+ const TX_ID_PATTERN = /^[\w.-]{1,128}$/u;
24
+ const invalidInput = (message, hint) => cliError({
25
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
26
+ message,
27
+ hint
28
+ });
29
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
30
+ /** JSON with object keys sorted at every depth, for a stable request hash. */
31
+ const canonicalJson = (value) => {
32
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
33
+ if (isRecord(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
34
+ return JSON.stringify(value) ?? "null";
35
+ };
36
+ const STALE_ISSUE_CODES = /* @__PURE__ */ new Set([
37
+ "missingBlock",
38
+ "changedBlock",
39
+ "staleRange",
40
+ "preconditionFailed",
41
+ "documentVersionMismatch"
42
+ ]);
43
+ /**
44
+ * A batch lands whole or not at all: any skipped operation refuses the
45
+ * transaction. Stale targets are a conflict with current state; an ambiguous
46
+ * `find` or an operation the document cannot take is an input error.
47
+ */
48
+ const refusalFor = (summary) => {
49
+ if (!isRecord(summary) || !Array.isArray(summary["skipped"])) return null;
50
+ if (summary["skipped"].length === 0) return null;
51
+ const issues = Array.isArray(summary["issues"]) ? summary["issues"] : [];
52
+ const codes = issues.map((issue) => isRecord(issue) ? issue["code"] : void 0).filter((code) => typeof code === "string" && code !== "atomicBatchRejected");
53
+ const details = {
54
+ skipped: summary["skipped"],
55
+ issues
56
+ };
57
+ if (codes.some((code) => typeof code === "string" && STALE_ISSUE_CODES.has(code))) return cliError({
58
+ code: FOLIO_CLI_ERROR_CODES.staleTarget,
59
+ message: "An operation's target changed or no longer exists; nothing was written.",
60
+ hint: "Re-read the document (read, find) and retry with fresh ids, ranges, and fileVersion.",
61
+ details
62
+ });
63
+ if (codes.includes("ambiguousFind")) return cliError({
64
+ code: FOLIO_CLI_ERROR_CODES.ambiguousTarget,
65
+ message: "An operation's find text matches more than once in its block; nothing was written.",
66
+ hint: "Make the find text unique within the block, or target a range returned by find.",
67
+ details
68
+ });
69
+ return cliError({
70
+ code: FOLIO_CLI_ERROR_CODES.operationRejected,
71
+ message: "The document refused an operation; nothing was written.",
72
+ hint: "See error.details.skipped for each operation's reason.",
73
+ details
74
+ });
75
+ };
76
+ const saveReviewer = async (reviewer, repack) => {
77
+ const saved = await Result.tryPromise({
78
+ try: () => reviewer.save({ repack }),
79
+ catch: (error) => cliError({
80
+ code: FOLIO_CLI_ERROR_CODES.internal,
81
+ message: `The document could not be saved: ${error instanceof Error ? error.message : String(error)}`
82
+ })
83
+ });
84
+ if (saved.isErr()) return Result.err(saved.error);
85
+ switch (saved.value.type) {
86
+ case "selective": return Result.ok({
87
+ bytes: new Uint8Array(saved.value.buffer),
88
+ saveStrategy: "selective"
89
+ });
90
+ case "full-repack": return Result.ok({
91
+ bytes: new Uint8Array(saved.value.buffer),
92
+ saveStrategy: "full-repack",
93
+ repackReason: saved.value.reason
94
+ });
95
+ case "repackRefused": return Result.err(cliError({
96
+ code: FOLIO_CLI_ERROR_CODES.repackRequired,
97
+ message: `This change needs a full repack of the package (${saved.value.reason}); nothing was written.`,
98
+ hint: "Pass --allow-repack (MCP: allowRepack: true) to rewrite every part.",
99
+ details: { reason: saved.value.reason }
100
+ }));
101
+ default: {
102
+ const unreachable = saved.value;
103
+ return panic("Unhandled save result", { unreachable });
104
+ }
105
+ }
106
+ };
107
+ const commentExists = (reviewer, commentId) => reviewer.getComments().some((thread) => String(thread.id) === commentId || thread.replies.some((reply) => String(reply.id) === commentId));
108
+ const mutateWithAgentTool = async ({ tool, source, args, options }) => {
109
+ if (tool.type !== "agentWrite") return panic("mutateWithAgentTool needs an agentWrite tool");
110
+ const reviewer = await openReviewer(source, options.author);
111
+ if (reviewer.isErr()) return Result.err(reviewer.error);
112
+ const idSeed = await nextRevisionIdSeed(source.bytes);
113
+ if (idSeed.isErr()) return Result.err(idSeed.error);
114
+ if ((tool.agentTool === FOLIO_AGENT_TOOL_NAMES.replyComment || tool.agentTool === FOLIO_AGENT_TOOL_NAMES.resolveComment) && !commentExists(reviewer.value, args["commentId"])) return Result.err(cliError({
115
+ code: FOLIO_CLI_ERROR_CODES.notFound,
116
+ message: `No comment with id ${JSON.stringify(args["commentId"])}.`,
117
+ hint: "Take the id from folio comments."
118
+ }));
119
+ const bridge = createReviewerBridge(reviewer.value, {
120
+ mode: tool.editMode === "tracked-or-direct" ? options.mode : "tracked-changes",
121
+ revisionStamp: {
122
+ date: options.date,
123
+ idSeed: idSeed.value
124
+ }
125
+ });
126
+ const executed = executeFolioToolCallUntyped(tool.agentTool, args, bridge);
127
+ if (!executed.ok) return Result.err(invalidInput(executed.error));
128
+ const refusal = refusalFor(executed.result);
129
+ if (refusal !== null) return Result.err(refusal);
130
+ const saved = await saveReviewer(reviewer.value, options.repack);
131
+ if (saved.isErr()) return Result.err(saved.error);
132
+ const receipts = isRecord(executed.result) && Array.isArray(executed.result["receipts"]) ? executed.result["receipts"] : [];
133
+ return Result.ok({
134
+ ...saved.value,
135
+ result: executed.result,
136
+ receipts
137
+ });
138
+ };
139
+ const isResolveAction = (value) => value === "accept" || value === "reject";
140
+ const resolveChanges = async ({ source, args, options }) => {
141
+ const { action, ids, all } = args;
142
+ if (!isResolveAction(action)) return Result.err(invalidInput("resolve_changes needs action \"accept\" or \"reject\"."));
143
+ const idList = Array.isArray(ids) && ids.every((id) => typeof id === "string") ? ids : void 0;
144
+ if ((idList !== void 0 && idList.length > 0) === (all === true)) return Result.err(invalidInput("Pass change ids or all, exactly one of them.", "folio accept --id 12 or --all."));
145
+ const reviewer = await openReviewer(source, options.author);
146
+ if (reviewer.isErr()) return Result.err(reviewer.error);
147
+ let resolved;
148
+ if (all === true) {
149
+ const count = action === "accept" ? reviewer.value.acceptAll() : reviewer.value.rejectAll();
150
+ if (count === 0) return Result.err(cliError({
151
+ code: FOLIO_CLI_ERROR_CODES.operationRejected,
152
+ message: "The document has no pending tracked changes; nothing was written."
153
+ }));
154
+ resolved = count;
155
+ } else {
156
+ const selected = idList ?? [];
157
+ const known = new Set(reviewer.value.getChanges().map(({ id }) => String(id)));
158
+ const missing = selected.filter((id) => !known.has(id));
159
+ if (missing.length > 0) return Result.err(cliError({
160
+ code: FOLIO_CLI_ERROR_CODES.notFound,
161
+ message: `No pending change with id ${missing.join(", ")}.`,
162
+ hint: "Take ids from folio changes.",
163
+ details: { missing }
164
+ }));
165
+ for (const id of selected) if (!(action === "accept" ? reviewer.value.acceptChange(Number(id)) : reviewer.value.rejectChange(Number(id)))) return Result.err(cliError({
166
+ code: FOLIO_CLI_ERROR_CODES.operationRejected,
167
+ message: `Change ${id} could not be ${action}ed; nothing was written.`
168
+ }));
169
+ resolved = selected;
170
+ }
171
+ const saved = await saveReviewer(reviewer.value, options.repack);
172
+ if (saved.isErr()) return Result.err(saved.error);
173
+ return Result.ok({
174
+ ...saved.value,
175
+ result: {
176
+ action,
177
+ resolved,
178
+ remaining: reviewer.value.getChanges().length
179
+ },
180
+ receipts: []
181
+ });
182
+ };
183
+ const writeRedline = async ({ source, args, options }) => {
184
+ const revisedPath = args["revisedPath"];
185
+ if (typeof revisedPath !== "string" || revisedPath === "") return Result.err(invalidInput("compare_documents needs revisedPath."));
186
+ const revised = await readDocumentFile(revisedPath);
187
+ if (revised.isErr()) return Result.err(revised.error);
188
+ const revisedVersion = args["revisedFileVersion"];
189
+ const version = checkExpectedVersion(revised.value, typeof revisedVersion === "string" ? revisedVersion : void 0);
190
+ if (version.isErr()) return Result.err(version.error);
191
+ const redline = await Result.tryPromise({
192
+ try: () => generateRedlineDocx(source.bytes.slice().buffer, revised.value.bytes.slice().buffer, { author: options.author }),
193
+ catch: (error) => cliError({
194
+ code: FOLIO_CLI_ERROR_CODES.invalidDocument,
195
+ message: `The documents could not be compared: ${error instanceof Error ? error.message : String(error)}`
196
+ })
197
+ });
198
+ if (redline.isErr()) return Result.err(redline.error);
199
+ const { buffer, applied, skipped, unprocessedStories } = redline.value;
200
+ return Result.ok({
201
+ bytes: new Uint8Array(buffer),
202
+ saveStrategy: "redline",
203
+ result: {
204
+ applied: applied.length,
205
+ skipped,
206
+ unprocessedStories
207
+ },
208
+ receipts: [],
209
+ inputs: { revised: {
210
+ path: revised.value.path,
211
+ fileVersion: revised.value.fileVersion
212
+ } }
213
+ });
214
+ };
215
+ const mutate = (options) => {
216
+ switch (options.tool.type) {
217
+ case "agentWrite": return mutateWithAgentTool(options);
218
+ case "resolveChanges": return resolveChanges(options);
219
+ case "compare": return writeRedline(options);
220
+ case "agentRead": return panic("A read tool reached the write executor", { tool: options.tool.name });
221
+ default: {
222
+ const unreachable = options.tool;
223
+ return panic("Unhandled tool type", { unreachable });
224
+ }
225
+ }
226
+ };
227
+ const DOCX_NAME = /\.docx$/iu;
228
+ const invalidDestination = (message, hint) => cliError({
229
+ code: FOLIO_CLI_ERROR_CODES.invalidDestination,
230
+ message,
231
+ hint
232
+ });
233
+ /**
234
+ * A write lands only on a `.docx` whose name does not start with a dot and
235
+ * that is not inside a `.folio` directory, so no tool call can replace a
236
+ * dotfile, a sidecar, or another kind of file.
237
+ */
238
+ const checkDestinationName = (candidate) => {
239
+ const name = path.basename(candidate);
240
+ if (!DOCX_NAME.test(name) || name.startsWith(".")) return Result.err(invalidDestination(`${candidate} is not a writable destination: folio writes only .docx files whose name does not start with a dot.`));
241
+ if (candidate.split(path.sep).includes(".folio")) return Result.err(invalidDestination(`${candidate} is inside a ${SIDECAR_DIRECTORY} directory.`));
242
+ return Result.ok();
243
+ };
244
+ const notFound = (message) => cliError({
245
+ code: FOLIO_CLI_ERROR_CODES.notFound,
246
+ message
247
+ });
248
+ /**
249
+ * The real path a write goes to: an existing file resolved through its
250
+ * symlinks (it must be a regular file), or a new name in its directory's real
251
+ * path. The name rules apply to the resolved path, so a link cannot redirect
252
+ * a write onto a file the rules refuse.
253
+ */
254
+ const resolveWritePath = async (requested) => {
255
+ const absolute = path.resolve(requested);
256
+ const named = checkDestinationName(absolute);
257
+ if (named.isErr()) return Result.err(named.error);
258
+ const real = await Result.tryPromise(() => realpath(absolute));
259
+ let resolved;
260
+ if (real.isOk()) {
261
+ const info = await Result.tryPromise(() => stat(real.value));
262
+ if (info.isErr() || !info.value.isFile()) return Result.err(invalidDestination(`${absolute} is not a regular file.`));
263
+ resolved = {
264
+ path: real.value,
265
+ identity: {
266
+ dev: info.value.dev,
267
+ ino: info.value.ino
268
+ }
269
+ };
270
+ } else if (errnoCode(real.error.cause) === "ENOENT") {
271
+ const parent = await Result.tryPromise(() => realpath(path.dirname(absolute)));
272
+ if (parent.isErr()) return Result.err(notFound(`No directory ${path.dirname(absolute)}.`));
273
+ resolved = {
274
+ path: path.join(parent.value, path.basename(absolute)),
275
+ identity: null
276
+ };
277
+ } else return Result.err(invalidDestination(`Cannot resolve ${absolute}.`));
278
+ const resolvedName = checkDestinationName(resolved.path);
279
+ return resolvedName.isErr() ? Result.err(resolvedName.error) : Result.ok(resolved);
280
+ };
281
+ const identityOfInput = async (inputPath) => {
282
+ const real = await Result.tryPromise(() => realpath(path.resolve(inputPath)));
283
+ if (real.isErr()) return Result.err(notFound(`No file at ${path.resolve(inputPath)}.`));
284
+ const info = await Result.tryPromise(() => stat(real.value));
285
+ if (info.isErr() || !info.value.isFile()) return Result.err(invalidInput(`${real.value} is not a file.`));
286
+ return Result.ok({
287
+ path: real.value,
288
+ identity: {
289
+ dev: info.value.dev,
290
+ ino: info.value.ino
291
+ }
292
+ });
293
+ };
294
+ /**
295
+ * Resolve source and destination to real paths and refuse a destination
296
+ * that is the input under another name (a symlink, a hard link, a
297
+ * case-insensitive alias): files are compared by device and inode.
298
+ */
299
+ const resolveTarget = async (tool, call, destination) => {
300
+ const source = await identityOfInput(call.path);
301
+ if (source.isErr()) return Result.err(source.error);
302
+ if (destination.type === "inPlace") {
303
+ if (tool.type === "compare") return Result.err(cliError({
304
+ code: FOLIO_CLI_ERROR_CODES.usage,
305
+ message: "A redline is written to a new file, never in place.",
306
+ hint: "Pass -o <redline.docx>."
307
+ }));
308
+ const named = checkDestinationName(source.value.path);
309
+ if (named.isErr()) return Result.err(named.error);
310
+ return Result.ok({
311
+ sourcePath: source.value.path,
312
+ sourceIdentity: source.value.identity,
313
+ destinationPath: source.value.path
314
+ });
315
+ }
316
+ const target = await resolveWritePath(destination.path);
317
+ if (target.isErr()) return Result.err(target.error);
318
+ const inputs = [source.value];
319
+ const revisedPath = call.args["revisedPath"];
320
+ if (tool.type === "compare" && typeof revisedPath === "string") {
321
+ const revised = await identityOfInput(revisedPath);
322
+ if (revised.isErr()) return Result.err(revised.error);
323
+ inputs.push(revised.value);
324
+ }
325
+ const clash = inputs.find(({ path: inputPath, identity }) => inputPath === target.value.path || target.value.identity !== null && sameFile(identity, target.value.identity));
326
+ if (clash !== void 0) return Result.err(invalidDestination(`The destination is the input ${clash.path}.`, tool.type === "compare" ? "Write the redline to a new file." : "Use --in-place to change the file itself; it keeps a backup."));
327
+ return Result.ok({
328
+ sourcePath: source.value.path,
329
+ sourceIdentity: source.value.identity,
330
+ destinationPath: target.value.path
331
+ });
332
+ };
333
+ /** Run one write tool as a transaction and return its receipt. */
334
+ const executeWriteTool = async (tool, call, options) => {
335
+ if (options.txId !== void 0 && !TX_ID_PATTERN.test(options.txId)) return Result.err(invalidInput("txId must be 1 to 128 letters, digits, '.', '_' or '-'."));
336
+ if (options.sourcePrecondition === "required" && call.fileVersion === void 0) return Result.err(invalidInput("A change needs the fileVersion of the file it was read from.", "Pass --expect-version <fileVersion> from your read (MCP: fileVersion), or --no-expect-version to skip the check."));
337
+ const txId = options.txId ?? randomUUID();
338
+ const target = await resolveTarget(tool, call, options.destination);
339
+ if (target.isErr()) return Result.err(target.error);
340
+ const { sourcePath, sourceIdentity, destinationPath } = target.value;
341
+ const journalPath = journalPathFor(destinationPath, options.journalPath);
342
+ const lease = await acquireLease({
343
+ documentPath: destinationPath,
344
+ txId,
345
+ force: options.force
346
+ });
347
+ if (lease.isErr()) return Result.err(lease.error);
348
+ try {
349
+ const recovered = await recoverStages({
350
+ documentPath: destinationPath,
351
+ journalPath,
352
+ now: options.date
353
+ });
354
+ if (recovered.isErr()) return Result.err(recovered.error);
355
+ const requestHash = createHash("sha256").update(canonicalJson({
356
+ tool: tool.name,
357
+ args: call.args,
358
+ source: sourcePath,
359
+ destination: destinationPath,
360
+ mode: options.mode,
361
+ repack: options.repack
362
+ })).digest("hex");
363
+ if (options.txId !== void 0) {
364
+ const found = await findCommit(journalPath, txId);
365
+ if (found.isErr()) return Result.err(found.error);
366
+ const prior = found.value;
367
+ if (prior !== void 0) return prior.requestHash === requestHash ? Result.ok({
368
+ ...prior.receipt,
369
+ status: "replayed"
370
+ }) : Result.err(cliError({
371
+ code: FOLIO_CLI_ERROR_CODES.transactionConflict,
372
+ message: `Transaction ${txId} already committed a different request.`,
373
+ hint: "Use a new txId for a new change."
374
+ }));
375
+ }
376
+ const source = await readDocumentFile(sourcePath);
377
+ if (source.isErr()) return Result.err(source.error);
378
+ const expectedIdentity = destinationPath === sourcePath && recovered.value.some(({ action }) => action === "rolledForward") ? source.value.identity : sourceIdentity;
379
+ if (!sameFile(source.value.identity, expectedIdentity)) return Result.err(unsafePath(`${sourcePath} was replaced while the write started.`));
380
+ if (destinationPath === sourcePath && source.value.links > 1) return Result.err(unsafePath(`${sourcePath} has other hard links; an in-place write would detach them.`, "Write to a new file with -o instead."));
381
+ const version = checkExpectedVersion(source.value, call.fileVersion);
382
+ if (version.isErr()) return Result.err(recovered.value.length === 0 ? version.error : cliError({
383
+ code: version.error.code,
384
+ message: version.error.message,
385
+ hint: version.error.hint,
386
+ details: {
387
+ ...isRecord(version.error.details) ? version.error.details : {},
388
+ recovered: recovered.value
389
+ }
390
+ }));
391
+ const mutation = await mutate({
392
+ tool,
393
+ source: source.value,
394
+ args: call.args,
395
+ options
396
+ });
397
+ if (mutation.isErr()) return Result.err(mutation.error);
398
+ const { bytes, saveStrategy, repackReason, result, receipts, inputs } = mutation.value;
399
+ const changedParts = await diffPackages(source.value.bytes, bytes);
400
+ if (changedParts.isErr()) return Result.err(changedParts.error);
401
+ const receipt = {
402
+ txId,
403
+ status: "committed",
404
+ tool: tool.name,
405
+ path: destinationPath,
406
+ fromVersion: source.value.fileVersion,
407
+ ...destinationPath !== sourcePath && { source: {
408
+ path: sourcePath,
409
+ fileVersion: source.value.fileVersion
410
+ } },
411
+ ...inputs,
412
+ author: options.author,
413
+ time: options.date,
414
+ saveStrategy,
415
+ ...repackReason !== void 0 && { repackReason },
416
+ changedParts: changedParts.value,
417
+ ...recovered.value.length > 0 && { recovered: recovered.value },
418
+ result
419
+ };
420
+ const committed = await commitTransaction({
421
+ txId,
422
+ destinationPath,
423
+ destination: options.destination,
424
+ sourcePath,
425
+ sourceIdentity: expectedIdentity,
426
+ fromVersion: source.value.fileVersion,
427
+ bytes,
428
+ verifyLease: lease.value.verify,
429
+ changedParts: changedParts.value,
430
+ journalPath,
431
+ entry: {
432
+ txId,
433
+ requestHash,
434
+ tool: tool.name,
435
+ fromVersion: source.value.fileVersion,
436
+ author: options.author,
437
+ time: options.date,
438
+ ops: call.args,
439
+ receipts
440
+ },
441
+ receipt
442
+ });
443
+ return committed.isErr() ? Result.err(committed.error) : Result.ok(committed.value.receipt);
444
+ } finally {
445
+ await lease.value.release();
446
+ }
447
+ };
448
+ //#endregion
449
+ export { executeWriteTool };
@@ -0,0 +1,38 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { JsonObjectSchema } from "./registry.js";
3
+ import { Result } from "better-result";
4
+ //#region src/flags.d.ts
5
+ /** Largest `--input` document accepted. */
6
+ declare const MAX_INPUT_BYTES: number;
7
+ type FlagValueType = "boolean" | "integer" | "number" | "string" | "strings" | "json";
8
+ type GeneratedFlag = {
9
+ /** The schema property the flag sets. */
10
+ property: string;
11
+ /** `--flag` spelling, without the dashes. */
12
+ flag: string;
13
+ valueType: FlagValueType;
14
+ description: string;
15
+ };
16
+ declare const kebabCase: (name: string) => string;
17
+ /**
18
+ * One flag per top-level schema property, in declaration order. `flagNames`
19
+ * renames a property's flag (`{ ids: "id" }` for `--id`).
20
+ */
21
+ declare const flagsForSchema: (schema: JsonObjectSchema, flagNames?: Readonly<Record<string, string>>) => GeneratedFlag[];
22
+ type CoerceFlagOptions = {
23
+ flag: GeneratedFlag;
24
+ raw: string | boolean | readonly string[];
25
+ };
26
+ /** Convert one parsed flag value to the type its schema property declares. */
27
+ declare const coerceFlag: ({ flag, raw }: CoerceFlagOptions) => Result<unknown, FolioCliError>;
28
+ type ReadInputOptions = {
29
+ source: string;
30
+ readStdin: () => Promise<string>;
31
+ };
32
+ /**
33
+ * Read `--input`: `@path` reads a file, `-` reads stdin, anything else is
34
+ * inline JSON. The result must be a JSON object or array.
35
+ */
36
+ declare const readInputSource: ({ source, readStdin }: ReadInputOptions) => Promise<Result<Record<string, unknown> | unknown[], FolioCliError>>;
37
+ //#endregion
38
+ export { FlagValueType, GeneratedFlag, MAX_INPUT_BYTES, coerceFlag, flagsForSchema, kebabCase, readInputSource };
package/dist/flags.js ADDED
@@ -0,0 +1,102 @@
1
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { Result, panic } from "better-result";
3
+ import { readFile } from "node:fs/promises";
4
+ //#region src/flags.ts
5
+ /**
6
+ * Command flags generated from a tool's JSON Schema. Each top-level property
7
+ * becomes `--kebab-case-name`: booleans are switches, numbers and strings take
8
+ * a value, arrays of strings repeat (`--id 3 --id 4`, or `--id 3,4`), and
9
+ * other objects or arrays take a JSON value. `--input` supplies the
10
+ * whole argument object as JSON (inline, `@file`, or `-` for stdin), and
11
+ * flags given alongside it override its fields.
12
+ */
13
+ /** Largest `--input` document accepted. */
14
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
15
+ const kebabCase = (name) => name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
16
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ const valueTypeOf = (schema) => {
18
+ if (!isRecord(schema)) return "json";
19
+ switch (schema["type"]) {
20
+ case "boolean": return "boolean";
21
+ case "integer": return "integer";
22
+ case "number": return "number";
23
+ case "string": return "string";
24
+ case "array": {
25
+ const items = schema["items"];
26
+ return isRecord(items) && items["type"] === "string" ? "strings" : "json";
27
+ }
28
+ default: return "json";
29
+ }
30
+ };
31
+ const descriptionOf = (schema) => {
32
+ if (!isRecord(schema)) return "";
33
+ return `${typeof schema["description"] === "string" ? schema["description"] : ""}${Array.isArray(schema["enum"]) ? ` One of: ${schema["enum"].join(", ")}.` : ""}`.trim();
34
+ };
35
+ /**
36
+ * One flag per top-level schema property, in declaration order. `flagNames`
37
+ * renames a property's flag (`{ ids: "id" }` for `--id`).
38
+ */
39
+ const flagsForSchema = (schema, flagNames = {}) => Object.entries(schema.properties).map(([property, propertySchema]) => ({
40
+ property,
41
+ flag: flagNames[property] ?? kebabCase(property),
42
+ valueType: valueTypeOf(propertySchema),
43
+ description: descriptionOf(propertySchema)
44
+ }));
45
+ const usage = (message, hint) => cliError({
46
+ code: FOLIO_CLI_ERROR_CODES.usage,
47
+ message,
48
+ hint
49
+ });
50
+ /** Convert one parsed flag value to the type its schema property declares. */
51
+ const coerceFlag = ({ flag, raw }) => {
52
+ if (typeof raw === "boolean") return flag.valueType === "boolean" ? Result.ok(raw) : Result.err(usage(`--${flag.flag} takes a value.`));
53
+ if (typeof raw !== "string") return flag.valueType === "strings" ? Result.ok(raw.flatMap((value) => value.split(",")).filter((value) => value !== "")) : Result.err(usage(`--${flag.flag} is given more than once.`));
54
+ switch (flag.valueType) {
55
+ case "boolean": return Result.err(usage(`--${flag.flag} is a switch and takes no value.`));
56
+ case "string": return Result.ok(raw);
57
+ case "strings": return Result.ok(raw.split(",").filter((value) => value !== ""));
58
+ case "integer":
59
+ case "number": {
60
+ const value = Number(raw);
61
+ if (raw.trim() === "" || !Number.isFinite(value)) return Result.err(usage(`--${flag.flag} expects a number, got ${JSON.stringify(raw)}.`));
62
+ if (flag.valueType === "integer" && !Number.isInteger(value)) return Result.err(usage(`--${flag.flag} expects an integer, got ${JSON.stringify(raw)}.`));
63
+ return Result.ok(value);
64
+ }
65
+ case "json": {
66
+ const parsed = Result.try(() => JSON.parse(raw));
67
+ return parsed.isOk() ? Result.ok(parsed.value) : Result.err(usage(`--${flag.flag} expects a JSON value.`));
68
+ }
69
+ default: {
70
+ const unreachable = flag.valueType;
71
+ return panic("Unhandled flag value type", { unreachable });
72
+ }
73
+ }
74
+ };
75
+ /**
76
+ * Read `--input`: `@path` reads a file, `-` reads stdin, anything else is
77
+ * inline JSON. The result must be a JSON object or array.
78
+ */
79
+ const readInputSource = async ({ source, readStdin }) => {
80
+ let text = source;
81
+ if (source === "-") text = await readStdin();
82
+ else if (source.startsWith("@")) {
83
+ const inputPath = source.slice(1);
84
+ const read = await Result.tryPromise({
85
+ try: () => readFile(inputPath, "utf8"),
86
+ catch: () => usage(`Cannot read --input file ${inputPath}.`)
87
+ });
88
+ if (read.isErr()) return Result.err(read.error);
89
+ text = read.value;
90
+ }
91
+ if (Buffer.byteLength(text, "utf8") > 16777216) return Result.err(usage(`--input is over the ${MAX_INPUT_BYTES}-byte limit.`));
92
+ const parsed = Result.try(() => JSON.parse(text));
93
+ if (parsed.isErr()) return Result.err(usage("--input is not valid JSON.", "Pass inline JSON, @file.json, or - for stdin."));
94
+ const value = parsed.value;
95
+ if (Array.isArray(value)) {
96
+ const items = value;
97
+ return Result.ok(items);
98
+ }
99
+ return isRecord(value) ? Result.ok(value) : Result.err(usage("--input must be a JSON object or array."));
100
+ };
101
+ //#endregion
102
+ export { MAX_INPUT_BYTES, coerceFlag, flagsForSchema, kebabCase, readInputSource };
@@ -0,0 +1,82 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { Result } from "better-result";
3
+ //#region src/journal.d.ts
4
+ type JournalCommit = {
5
+ type: "commit";
6
+ txId: string;
7
+ /** Hash of the request, so a retry with the same txId but other arguments is refused. */
8
+ requestHash: string;
9
+ tool: string;
10
+ /** The destination file. */
11
+ path: string;
12
+ fromVersion: string;
13
+ toVersion: string;
14
+ /** The destination's version before the rename; `null` when it did not exist. */
15
+ destinationVersionBefore: string | null;
16
+ /** The staged file's name, beside the destination. */
17
+ stage: string;
18
+ author: string;
19
+ time: string;
20
+ /** The tool arguments the transaction applied. */
21
+ ops: unknown;
22
+ /** Per-operation receipts from the document-operation contract, when the tool has them. */
23
+ receipts: readonly unknown[];
24
+ /** What the command returned, replayed verbatim for a retry with the same txId. */
25
+ receipt: Readonly<Record<string, unknown>>;
26
+ };
27
+ declare const RECOVERY_DISCARD_REASONS: readonly ["notJournaled", "otherDocument", "notRegularFile", "stageChanged", "invalidPackage", "destinationMoved", "leaseLost", "renameRefused"];
28
+ type RecoveryDiscardReason = (typeof RECOVERY_DISCARD_REASONS)[number];
29
+ type JournalRecovery = {
30
+ type: "recovery";
31
+ txId: string;
32
+ path: string;
33
+ time: string;
34
+ } & ({
35
+ action: "rolledForward";
36
+ } | {
37
+ action: "discarded";
38
+ reason: RecoveryDiscardReason;
39
+ });
40
+ type JournalEntry = JournalCommit | JournalRecovery;
41
+ /** The fields of a commit line recovery and replay read back. */
42
+ type CommittedTransaction = {
43
+ txId: string;
44
+ path: string;
45
+ requestHash: string;
46
+ toVersion: string;
47
+ destinationVersionBefore: string | null;
48
+ receipt: Readonly<Record<string, unknown>>;
49
+ };
50
+ declare const journalPathFor: (documentPath: string, override: string | undefined) => string;
51
+ /** Append one line and sync it to disk before returning. */
52
+ declare const appendJournal: (journalPath: string, entry: JournalEntry) => Promise<Result<void, FolioCliError>>;
53
+ /**
54
+ * The committed transaction with `txId`, if the journal has one and recovery
55
+ * did not discard it. Torn or foreign lines (a crash mid-append) are skipped.
56
+ * A journal (or `.folio`) that is a symlink or hard link is refused.
57
+ */
58
+ declare const findCommit: (journalPath: string, txId: string) => Promise<Result<CommittedTransaction | undefined, FolioCliError>>;
59
+ declare const stagePrefixFor: (documentPath: string) => string;
60
+ declare const stagePathFor: (documentPath: string, txId: string) => string;
61
+ type RecoveryAction = {
62
+ txId: string;
63
+ action: "rolledForward";
64
+ } | {
65
+ txId: string;
66
+ action: "discarded";
67
+ reason: RecoveryDiscardReason;
68
+ };
69
+ type RecoverStagesOptions = {
70
+ documentPath: string;
71
+ journalPath: string;
72
+ now: string;
73
+ };
74
+ /**
75
+ * Settle every stage a crashed transaction left beside `documentPath`. The
76
+ * caller holds the document's lease, so no live transaction owns a stage.
77
+ * Removing a discarded stage unlinks the name only; a symlink's target is
78
+ * never touched.
79
+ */
80
+ declare const recoverStages: ({ documentPath, journalPath, now }: RecoverStagesOptions) => Promise<Result<RecoveryAction[], FolioCliError>>;
81
+ //#endregion
82
+ export { CommittedTransaction, JournalCommit, JournalEntry, JournalRecovery, RECOVERY_DISCARD_REASONS, RecoveryAction, RecoveryDiscardReason, appendJournal, findCommit, journalPathFor, recoverStages, stagePathFor, stagePrefixFor };