@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
package/dist/cli.js ADDED
@@ -0,0 +1,440 @@
1
+ import { EXIT_CODES, FOLIO_CLI_ERROR_CODES, cliError, exitCodeEntries, exitCodeForError } from "./errors.js";
2
+ import { CLI_READ_BOUNDS, executeReadTool } from "./execute-read.js";
3
+ import { executeWriteTool } from "./execute-write.js";
4
+ import { coerceFlag, flagsForSchema, readInputSource } from "./flags.js";
5
+ import { serveFolioMcp } from "./mcp.js";
6
+ import { OUTPUT_FORMATS, failureEnvelope, isOutputFormat, renderEnvelope, successEnvelope } from "./output.js";
7
+ import { version } from "./package.js";
8
+ import { runRender, runServe } from "./preview-commands.js";
9
+ import { resolveAuthor, resolveTransactionDate } from "./provenance.js";
10
+ import { findCommand, listCommands, toolAccess } from "./registry.js";
11
+ import { resolveRoots } from "./roots.js";
12
+ import { Result } from "better-result";
13
+ import { parseArgs } from "node:util";
14
+ //#region src/cli.ts
15
+ /**
16
+ * `folio`: the command line over the file tool registry. Each registry
17
+ * command parses its generated flags, runs the same executor the MCP server
18
+ * uses, and prints the shared envelope.
19
+ */
20
+ const COMMON_FLAGS = [
21
+ {
22
+ flag: "output",
23
+ description: `Output format: ${OUTPUT_FORMATS.join(" or ")} (default: text on a terminal, json otherwise).`,
24
+ option: { type: "string" }
25
+ },
26
+ {
27
+ flag: "input",
28
+ description: "All arguments as a JSON object: inline, @file.json, or - for stdin.",
29
+ option: { type: "string" }
30
+ },
31
+ {
32
+ flag: "expect-version",
33
+ description: "Refuse unless the file's SHA-256 fileVersion is this value.",
34
+ option: { type: "string" }
35
+ },
36
+ {
37
+ flag: "help",
38
+ description: "Show help for this command.",
39
+ option: {
40
+ type: "boolean",
41
+ short: "h"
42
+ }
43
+ }
44
+ ];
45
+ const WRITE_FLAGS = [
46
+ {
47
+ flag: "in-place",
48
+ description: "Write the change to the file itself, keeping a backup in .folio/backups.",
49
+ option: { type: "boolean" }
50
+ },
51
+ {
52
+ flag: "out",
53
+ description: "Write the result to this path instead (refused if it exists, unless --overwrite).",
54
+ option: {
55
+ type: "string",
56
+ short: "o"
57
+ }
58
+ },
59
+ {
60
+ flag: "overwrite",
61
+ description: "Let -o replace an existing file; needs --expect-destination-version. The replaced file is backed up.",
62
+ option: { type: "boolean" }
63
+ },
64
+ {
65
+ flag: "expect-destination-version",
66
+ description: "The fileVersion of the file -o --overwrite replaces; refused if it changed.",
67
+ option: { type: "string" }
68
+ },
69
+ {
70
+ flag: "no-expect-version",
71
+ description: "Change the file without naming its fileVersion. A change otherwise needs --expect-version.",
72
+ option: { type: "boolean" }
73
+ },
74
+ {
75
+ flag: "author",
76
+ description: "Author of the change (default: FOLIO_AUTHOR, then git user.name).",
77
+ option: { type: "string" }
78
+ },
79
+ {
80
+ flag: "date",
81
+ description: "ISO-8601 timestamp for the change (default: now), for reproducible output.",
82
+ option: { type: "string" }
83
+ },
84
+ {
85
+ flag: "allow-repack",
86
+ description: "Allow rewriting the whole package when the edit cannot be patched in.",
87
+ option: { type: "boolean" }
88
+ },
89
+ {
90
+ flag: "tx-id",
91
+ description: "Idempotency key: re-running a committed transaction returns its receipt.",
92
+ option: { type: "string" }
93
+ },
94
+ {
95
+ flag: "force",
96
+ description: "Take the write lease over from another live holder.",
97
+ option: { type: "boolean" }
98
+ },
99
+ {
100
+ flag: "journal",
101
+ description: "Journal file (default: .folio/journal.jsonl beside the destination).",
102
+ option: { type: "string" }
103
+ }
104
+ ];
105
+ const DIRECT_FLAG = {
106
+ flag: "direct",
107
+ description: "Edit the text directly instead of as tracked changes.",
108
+ option: { type: "boolean" }
109
+ };
110
+ const usageError = (message, hint) => cliError({
111
+ code: FOLIO_CLI_ERROR_CODES.usage,
112
+ message,
113
+ hint
114
+ });
115
+ const writeFlagsFor = ({ tool }) => {
116
+ if (toolAccess(tool) === "read") return [];
117
+ return tool.type === "agentWrite" && tool.editMode === "tracked-or-direct" ? [...WRITE_FLAGS, DIRECT_FLAG] : WRITE_FLAGS;
118
+ };
119
+ const generatedFlags = ({ tool, command }) => {
120
+ const bound = /* @__PURE__ */ new Set([...Object.keys(command.preset ?? {}), ...(command.positionals ?? []).map(({ property }) => property)]);
121
+ return flagsForSchema(tool.argsSchema, command.flagNames).filter(({ property }) => !bound.has(property));
122
+ };
123
+ const parseOptions = (resolved) => {
124
+ const options = {};
125
+ for (const { flag, option } of [...COMMON_FLAGS, ...writeFlagsFor(resolved)]) options[flag] = option;
126
+ for (const { flag, valueType } of generatedFlags(resolved)) options[flag] = valueType === "boolean" ? { type: "boolean" } : {
127
+ type: "string",
128
+ ...valueType === "strings" && { multiple: true }
129
+ };
130
+ return options;
131
+ };
132
+ const wrapText = (value, indent) => value.replaceAll("\n", `\n${indent}`);
133
+ const usageLine = ({ command }) => [
134
+ "folio",
135
+ command.name,
136
+ "<file>",
137
+ ...(command.positionals ?? []).map(({ name }) => `<${name}>`),
138
+ "[flags]"
139
+ ].join(" ");
140
+ const rootHelp = () => {
141
+ const commands = listCommands().map(({ command }) => ` ${command.name.padEnd(12)}${command.summary}`);
142
+ const exitCodes = exitCodeEntries().map(({ code, meaning }) => ` ${String(code).padEnd(4)}${meaning}`);
143
+ return [
144
+ `folio ${version}: read, review, and redline .docx files.`,
145
+ "",
146
+ "Usage: folio <command> <file> [flags]",
147
+ "",
148
+ "Commands:",
149
+ ...commands,
150
+ ` ${"render".padEnd(12)}Render pages to PDF, PNG, or HTML`,
151
+ ` ${"serve".padEnd(12)}Serve a read-only live preview on 127.0.0.1`,
152
+ ` ${"mcp".padEnd(12)}Serve these tools over MCP on stdio`,
153
+ "",
154
+ "Every command prints { ok, data } or { ok, error: { code, message, hint } }.",
155
+ "A change is written only with --in-place or -o <path>, as tracked changes unless --direct.",
156
+ "Run folio <command> --help for its flags.",
157
+ "",
158
+ "Exit codes:",
159
+ ...exitCodes,
160
+ ""
161
+ ].join("\n");
162
+ };
163
+ const commandHelp = (resolved) => {
164
+ const flags = [...generatedFlags(resolved).map(({ flag, description, valueType }) => ({
165
+ name: valueType === "boolean" ? `--${flag}` : `--${flag} <${valueType}>`,
166
+ description
167
+ })), ...[...writeFlagsFor(resolved), ...COMMON_FLAGS].map(({ flag, description, option }) => ({
168
+ name: `${option.short === void 0 ? "" : `-${option.short}, `}--${flag}${option.type === "boolean" ? "" : " <value>"}`,
169
+ description
170
+ }))];
171
+ return [
172
+ `Usage: ${usageLine(resolved)}`,
173
+ "",
174
+ resolved.command.summary,
175
+ "",
176
+ wrapText(resolved.tool.description, ""),
177
+ "",
178
+ "Flags:",
179
+ ...flags.map(({ name, description }) => ` ${name}\n ${wrapText(description, " ")}`),
180
+ ""
181
+ ].join("\n");
182
+ };
183
+ const stringValue = (value) => typeof value === "string" ? value : void 0;
184
+ const flagValue = (value) => {
185
+ if (!Array.isArray(value)) return value;
186
+ return value.filter((entry) => typeof entry === "string");
187
+ };
188
+ const buildArguments = async ({ resolved, values, positionals, io }) => {
189
+ const { command } = resolved;
190
+ const [file, ...rest] = positionals;
191
+ const named = command.positionals ?? [];
192
+ if (file === void 0 || rest.length < named.length) return Result.err(usageError(`Usage: ${usageLine(resolved)}.`));
193
+ if (rest.length > named.length) return Result.err(usageError(`Unexpected argument ${JSON.stringify(rest[named.length])}.`));
194
+ let args = {};
195
+ const input = stringValue(values["input"]);
196
+ if (input !== void 0) {
197
+ const parsed = await readInputSource({
198
+ source: input,
199
+ readStdin: io.readStdin
200
+ });
201
+ if (parsed.isErr()) return Result.err(parsed.error);
202
+ if (Array.isArray(parsed.value)) {
203
+ if (command.inputArrayProperty === void 0) return Result.err(usageError("--input must be a JSON object for this command."));
204
+ args = { [command.inputArrayProperty]: parsed.value };
205
+ } else args = { ...parsed.value };
206
+ }
207
+ for (const flag of generatedFlags(resolved)) {
208
+ const raw = flagValue(values[flag.flag]);
209
+ if (raw === void 0) continue;
210
+ const coerced = coerceFlag({
211
+ flag,
212
+ raw
213
+ });
214
+ if (coerced.isErr()) return Result.err(coerced.error);
215
+ args[flag.property] = coerced.value;
216
+ }
217
+ named.forEach(({ property }, index) => {
218
+ args[property] = rest[index];
219
+ });
220
+ return Result.ok({
221
+ file,
222
+ expectVersion: stringValue(values["expect-version"]),
223
+ args: {
224
+ ...args,
225
+ ...command.preset
226
+ }
227
+ });
228
+ };
229
+ /** `--in-place` or `-o`, exactly one; `null` when neither is given. */
230
+ const destinationFrom = (values) => {
231
+ const inPlace = values["in-place"] === true;
232
+ const out = stringValue(values["out"]);
233
+ if (inPlace && out !== void 0) return Result.err(usageError("Pass --in-place or -o <path>, not both."));
234
+ const expectedVersion = stringValue(values["expect-destination-version"]);
235
+ if ((values["overwrite"] === true || expectedVersion !== void 0) && out === void 0) return Result.err(usageError("--overwrite and --expect-destination-version only apply to -o <path>."));
236
+ if (inPlace) return Result.ok({ type: "inPlace" });
237
+ if (out !== void 0) return Result.ok({
238
+ type: "file",
239
+ path: out,
240
+ overwrite: values["overwrite"] === true,
241
+ expectedVersion
242
+ });
243
+ return Result.ok(null);
244
+ };
245
+ const defaultFormat = (io) => io.isTTY ? "text" : "json";
246
+ /** The `--output` value named in raw argv, so a parse failure still prints in it. */
247
+ const requestedFormat = (argv, io) => {
248
+ const index = argv.indexOf("--output");
249
+ const value = argv.find((arg) => arg.startsWith("--output="))?.slice(9) ?? (index === -1 ? void 0 : argv[index + 1]);
250
+ return isOutputFormat(value) ? value : defaultFormat(io);
251
+ };
252
+ const emit = ({ io, format, tool, result }) => {
253
+ const rendered = renderEnvelope({
254
+ envelope: result.isOk() ? successEnvelope(result.value) : failureEnvelope(result.error),
255
+ format,
256
+ tool
257
+ });
258
+ if (rendered.stdout !== "") io.stdout(rendered.stdout);
259
+ if (rendered.stderr !== "") io.stderr(rendered.stderr);
260
+ return result.isOk() ? EXIT_CODES.ok : exitCodeForError(result.error.code);
261
+ };
262
+ const execute = async ({ resolved, values, built, io }) => {
263
+ const { tool, command } = resolved;
264
+ const call = {
265
+ path: built.file,
266
+ fileVersion: built.expectVersion,
267
+ args: built.args
268
+ };
269
+ const access = toolAccess(tool);
270
+ if (access === "read") return await executeReadTool(tool, call, CLI_READ_BOUNDS);
271
+ const destination = destinationFrom(values);
272
+ if (destination.isErr()) return destination;
273
+ if (destination.value === null) {
274
+ if (access === "readOrWrite") return await executeReadTool(tool, call, CLI_READ_BOUNDS);
275
+ return Result.err(usageError(`folio ${command.name} changes the document: pass --in-place or -o <path>.`, "--in-place keeps a backup in .folio/backups beside the file."));
276
+ }
277
+ const author = resolveAuthor({
278
+ explicit: stringValue(values["author"]),
279
+ env: io.env,
280
+ cwd: io.cwd
281
+ });
282
+ if (author.isErr()) return author;
283
+ const date = resolveTransactionDate(stringValue(values["date"]));
284
+ if (date.isErr()) return date;
285
+ const waived = values["no-expect-version"] === true;
286
+ if (waived && built.expectVersion !== void 0) return Result.err(usageError("Pass --expect-version or --no-expect-version, not both."));
287
+ return await executeWriteTool(tool, call, {
288
+ sourcePrecondition: waived ? "waived" : "required",
289
+ destination: destination.value,
290
+ author: author.value,
291
+ date: date.value,
292
+ repack: values["allow-repack"] === true ? "allow" : "refuse",
293
+ force: values["force"] === true,
294
+ txId: stringValue(values["tx-id"]),
295
+ journalPath: stringValue(values["journal"]),
296
+ mode: values["direct"] === true ? "direct" : "tracked-changes"
297
+ });
298
+ };
299
+ const runCommand = async (resolved, rest, io) => {
300
+ const fallbackFormat = requestedFormat(rest, io);
301
+ const parsed = Result.try({
302
+ try: () => parseArgs({
303
+ args: [...rest],
304
+ options: parseOptions(resolved),
305
+ allowPositionals: true,
306
+ strict: true
307
+ }),
308
+ catch: (error) => usageError(error instanceof Error ? error.message : String(error), `Run folio ${resolved.command.name} --help.`)
309
+ });
310
+ if (parsed.isErr()) return emit({
311
+ io,
312
+ format: fallbackFormat,
313
+ tool: resolved.tool.name,
314
+ result: parsed
315
+ });
316
+ const { values, positionals } = parsed.value;
317
+ if (values["help"] === true) {
318
+ io.stdout(commandHelp(resolved));
319
+ return EXIT_CODES.ok;
320
+ }
321
+ const output = values["output"];
322
+ if (output !== void 0 && !isOutputFormat(output)) return emit({
323
+ io,
324
+ format: defaultFormat(io),
325
+ tool: resolved.tool.name,
326
+ result: Result.err(usageError(`--output must be ${OUTPUT_FORMATS.join(" or ")}.`))
327
+ });
328
+ const format = output ?? defaultFormat(io);
329
+ const built = await buildArguments({
330
+ resolved,
331
+ values,
332
+ positionals,
333
+ io
334
+ });
335
+ if (built.isErr()) return emit({
336
+ io,
337
+ format,
338
+ tool: resolved.tool.name,
339
+ result: built
340
+ });
341
+ const result = await execute({
342
+ resolved,
343
+ values,
344
+ built: built.value,
345
+ io
346
+ });
347
+ return emit({
348
+ io,
349
+ format,
350
+ tool: resolved.tool.name,
351
+ result
352
+ });
353
+ };
354
+ const MCP_HELP = [
355
+ "Usage: folio mcp [--root <dir>]... [--author <name>]",
356
+ "",
357
+ "Serve the folio tools over MCP on stdin/stdout. Diagnostics go to stderr.",
358
+ "",
359
+ "Flags:",
360
+ " --root <dir>",
361
+ " A directory tool calls may read and write under (repeatable; default: the current directory).",
362
+ " --author <name>",
363
+ " Author of every change (default: FOLIO_AUTHOR, then git user.name). Without one, changes are refused.",
364
+ " -h, --help",
365
+ " Show this help.",
366
+ ""
367
+ ].join("\n");
368
+ const runMcp = async (rest, io) => {
369
+ const fail = (error) => {
370
+ const hint = error.hint === void 0 ? "" : `hint: ${error.hint}\n`;
371
+ io.stderr(`error: ${error.message}\n${hint}`);
372
+ return exitCodeForError(error.code);
373
+ };
374
+ const parsed = Result.try({
375
+ try: () => parseArgs({
376
+ args: [...rest],
377
+ options: {
378
+ root: {
379
+ type: "string",
380
+ multiple: true
381
+ },
382
+ author: { type: "string" },
383
+ help: {
384
+ type: "boolean",
385
+ short: "h"
386
+ }
387
+ },
388
+ strict: true
389
+ }),
390
+ catch: (error) => usageError(error instanceof Error ? error.message : String(error))
391
+ });
392
+ if (parsed.isErr()) return fail(parsed.error);
393
+ const { values } = parsed.value;
394
+ if (values.help === true) {
395
+ io.stdout(MCP_HELP);
396
+ return EXIT_CODES.ok;
397
+ }
398
+ const roots = await resolveRoots(values.root ?? [io.cwd]);
399
+ if (roots.isErr()) return fail(roots.error);
400
+ if (io.stdio === void 0) return fail(usageError("folio mcp needs stdin and stdout streams."));
401
+ const author = resolveAuthor({
402
+ explicit: values.author,
403
+ env: io.env,
404
+ cwd: io.cwd
405
+ });
406
+ if (author.isErr()) io.stderr(`folio mcp: ${author.error.message} Changes will be refused.\n`);
407
+ io.stderr(`folio mcp ${version}: roots ${roots.value.join(", ")}\n`);
408
+ await serveFolioMcp({
409
+ input: io.stdio.input,
410
+ output: io.stdio.output,
411
+ roots: roots.value,
412
+ author: author.isOk() ? author.value : void 0
413
+ });
414
+ return EXIT_CODES.ok;
415
+ };
416
+ /** Run `folio` with the given arguments (without the executable); resolves to the exit code. */
417
+ const runFolioCli = async (argv, io) => {
418
+ const [name, ...rest] = argv;
419
+ if (name === void 0 || name === "help" || name === "--help" || name === "-h") {
420
+ io.stdout(rootHelp());
421
+ return EXIT_CODES.ok;
422
+ }
423
+ if (name === "--version" || name === "-V") {
424
+ io.stdout(`${version}\n`);
425
+ return EXIT_CODES.ok;
426
+ }
427
+ if (name === "mcp") return await runMcp(rest, io);
428
+ if (name === "render") return await runRender(rest, io);
429
+ if (name === "serve") return await runServe(rest, io);
430
+ const resolved = findCommand(name);
431
+ if (resolved === void 0) return emit({
432
+ io,
433
+ format: requestedFormat(rest, io),
434
+ tool: name,
435
+ result: Result.err(usageError(`Unknown command ${JSON.stringify(name)}.`, "Run folio --help."))
436
+ });
437
+ return await runCommand(resolved, rest, io);
438
+ };
439
+ //#endregion
440
+ export { runFolioCli };
@@ -0,0 +1,43 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { Result } from "better-result";
3
+ import { FolioDocxReviewer } from "@stll/folio-core/server";
4
+ //#region src/document.d.ts
5
+ /** Largest `.docx` the CLI opens; larger inputs are refused before reading. */
6
+ declare const MAX_DOCUMENT_BYTES: number;
7
+ /** A file's version: the lowercase hex SHA-256 of its bytes. */
8
+ declare const fileVersionOf: (bytes: Uint8Array) => string;
9
+ declare const isFileVersion: (value: unknown) => value is string;
10
+ /** Which file a path named when it was read: device and inode. */
11
+ type FileIdentity = {
12
+ dev: number;
13
+ ino: number;
14
+ };
15
+ declare const sameFile: (left: FileIdentity, right: FileIdentity) => boolean;
16
+ /** The bytes of one input file and the version they hash to. */
17
+ type LoadedFile = {
18
+ /** Real path (symlinks resolved) the bytes were read from. */
19
+ path: string;
20
+ bytes: Uint8Array<ArrayBuffer>;
21
+ fileVersion: string;
22
+ identity: FileIdentity;
23
+ /** Hard links to the file; a write refuses a file with more than one. */
24
+ links: number;
25
+ };
26
+ /**
27
+ * `O_NOFOLLOW` where the platform has it: opening a path whose last
28
+ * component is a symlink fails instead of following it.
29
+ */
30
+ declare const NO_FOLLOW: number;
31
+ /** The `code` of a Node.js file-system error, when there is one. */
32
+ declare const errnoCode: (error: unknown) => string | undefined;
33
+ /** Read one input file, refusing directories and files over the size bound. */
34
+ declare const readDocumentFile: (inputPath: string) => Promise<Result<LoadedFile, FolioCliError>>;
35
+ /**
36
+ * Parse a loaded file into a headless reviewer. `author` is required for a
37
+ * reviewer that will author changes; reads pass none and never write.
38
+ */
39
+ declare const openReviewer: (file: LoadedFile, author?: string) => Promise<Result<FolioDocxReviewer, FolioCliError>>;
40
+ /** Refuse when the caller's expected version is not the file's current one. */
41
+ declare const checkExpectedVersion: (file: LoadedFile, expected: string | undefined) => Result<void, FolioCliError>;
42
+ //#endregion
43
+ export { FileIdentity, LoadedFile, MAX_DOCUMENT_BYTES, NO_FOLLOW, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
@@ -0,0 +1,127 @@
1
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { Result } from "better-result";
3
+ import { createHash } from "node:crypto";
4
+ import { constants } from "node:fs";
5
+ import { open, realpath, stat } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { FolioDocxReviewer } from "@stll/folio-core/server";
8
+ //#region src/document.ts
9
+ /** Largest `.docx` the CLI opens; larger inputs are refused before reading. */
10
+ const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
11
+ const FILE_VERSION_PATTERN = /^[0-9a-f]{64}$/u;
12
+ /** A file's version: the lowercase hex SHA-256 of its bytes. */
13
+ const fileVersionOf = (bytes) => createHash("sha256").update(bytes).digest("hex");
14
+ const isFileVersion = (value) => typeof value === "string" && FILE_VERSION_PATTERN.test(value);
15
+ const sameFile = (left, right) => left.dev === right.dev && left.ino === right.ino;
16
+ /**
17
+ * `O_NOFOLLOW` where the platform has it: opening a path whose last
18
+ * component is a symlink fails instead of following it.
19
+ */
20
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
21
+ /** The `code` of a Node.js file-system error, when there is one. */
22
+ const errnoCode = (error) => {
23
+ if (typeof error !== "object" || error === null || !("code" in error)) return;
24
+ return typeof error.code === "string" ? error.code : void 0;
25
+ };
26
+ const describeError = (error) => error instanceof Error ? error.message : String(error);
27
+ const fileSystemError = (filePath, error) => errnoCode(error) === "ENOENT" ? cliError({
28
+ code: FOLIO_CLI_ERROR_CODES.notFound,
29
+ message: `No file at ${filePath}.`
30
+ }) : cliError({
31
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
32
+ message: `Cannot read ${filePath}: ${describeError(error)}`
33
+ });
34
+ /**
35
+ * Read through one handle: the path is resolved, stat'ed, opened without
36
+ * following a final symlink, and the handle must be the same file the stat
37
+ * saw, so a swap between the check and the read is refused.
38
+ */
39
+ const readThroughHandle = async (real) => {
40
+ const checked = await Result.tryPromise({
41
+ try: () => stat(real),
42
+ catch: (error) => fileSystemError(real, error)
43
+ });
44
+ if (checked.isErr()) return Result.err(checked.error);
45
+ if (!checked.value.isFile()) return Result.err(cliError({
46
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
47
+ message: `${real} is not a file.`
48
+ }));
49
+ const read = await Result.tryPromise({
50
+ try: async () => {
51
+ const handle = await open(real, constants.O_RDONLY | NO_FOLLOW);
52
+ try {
53
+ const opened = await handle.stat();
54
+ const identity = {
55
+ dev: opened.dev,
56
+ ino: opened.ino
57
+ };
58
+ if (!opened.isFile() || !sameFile(identity, checked.value)) return "swapped";
59
+ if (opened.size > 67108864) return "tooLarge";
60
+ return {
61
+ bytes: new Uint8Array(await handle.readFile()),
62
+ identity,
63
+ links: opened.nlink
64
+ };
65
+ } finally {
66
+ await handle.close();
67
+ }
68
+ },
69
+ catch: (error) => fileSystemError(real, error)
70
+ });
71
+ if (read.isErr()) return Result.err(read.error);
72
+ if (read.value === "swapped") return Result.err(cliError({
73
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
74
+ message: `${real} changed while it was being opened.`,
75
+ hint: "Retry once nothing else is replacing the file."
76
+ }));
77
+ if (read.value === "tooLarge") return Result.err(cliError({
78
+ code: FOLIO_CLI_ERROR_CODES.tooLarge,
79
+ message: `${real} is over the ${MAX_DOCUMENT_BYTES}-byte limit.`
80
+ }));
81
+ return Result.ok(read.value);
82
+ };
83
+ /** Read one input file, refusing directories and files over the size bound. */
84
+ const readDocumentFile = async (inputPath) => {
85
+ const absolute = path.resolve(inputPath);
86
+ const real = await Result.tryPromise({
87
+ try: () => realpath(absolute),
88
+ catch: (error) => fileSystemError(absolute, error)
89
+ });
90
+ if (real.isErr()) return Result.err(real.error);
91
+ const opened = await readThroughHandle(real.value);
92
+ if (opened.isErr()) return Result.err(opened.error);
93
+ const { bytes, identity, links } = opened.value;
94
+ return Result.ok({
95
+ path: real.value,
96
+ bytes,
97
+ fileVersion: fileVersionOf(bytes),
98
+ identity,
99
+ links
100
+ });
101
+ };
102
+ /**
103
+ * Parse a loaded file into a headless reviewer. `author` is required for a
104
+ * reviewer that will author changes; reads pass none and never write.
105
+ */
106
+ const openReviewer = async (file, author) => await Result.tryPromise({
107
+ try: () => FolioDocxReviewer.fromBuffer(file.bytes.slice().buffer, { ...author !== void 0 && { author } }),
108
+ catch: (error) => cliError({
109
+ code: FOLIO_CLI_ERROR_CODES.invalidDocument,
110
+ message: `${file.path} is not a readable .docx package: ${describeError(error)}`
111
+ })
112
+ });
113
+ /** Refuse when the caller's expected version is not the file's current one. */
114
+ const checkExpectedVersion = (file, expected) => {
115
+ if (expected === void 0 || expected === file.fileVersion) return Result.ok();
116
+ return Result.err(cliError({
117
+ code: FOLIO_CLI_ERROR_CODES.staleVersion,
118
+ message: `${file.path} changed since it was read: expected version ${expected}, found ${file.fileVersion}.`,
119
+ hint: "Re-read the document and retry against its current fileVersion.",
120
+ details: {
121
+ expected,
122
+ actual: file.fileVersion
123
+ }
124
+ }));
125
+ };
126
+ //#endregion
127
+ export { MAX_DOCUMENT_BYTES, NO_FOLLOW, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
@@ -0,0 +1,67 @@
1
+ //#region src/errors.d.ts
2
+ /**
3
+ * Process exit classes. The numbers match the stella command line's table so
4
+ * a script driving both branches on one set of codes; folio uses the subset a
5
+ * local file tool can produce.
6
+ */
7
+ declare const EXIT_CODES: {
8
+ readonly ok: 0;
9
+ readonly unexpected: 1;
10
+ readonly validation: 2;
11
+ readonly notFound: 6;
12
+ readonly permissionDenied: 8;
13
+ readonly conflict: 10;
14
+ };
15
+ type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
16
+ /** Every exit code with its meaning, ordered numerically, for `--help`. */
17
+ declare const exitCodeEntries: () => readonly {
18
+ code: number;
19
+ meaning: string;
20
+ }[];
21
+ /** Machine codes carried in `error.code` of a failure envelope. */
22
+ declare const FOLIO_CLI_ERROR_CODES: {
23
+ readonly usage: "usage_error";
24
+ readonly invalidInput: "invalid_input";
25
+ readonly invalidDocument: "invalid_document";
26
+ readonly tooLarge: "too_large";
27
+ readonly notFound: "not_found";
28
+ readonly outsideRoot: "outside_root";
29
+ readonly unsafePath: "unsafe_path";
30
+ readonly invalidDestination: "invalid_destination";
31
+ readonly staleVersion: "stale_version";
32
+ readonly staleTarget: "stale_target";
33
+ readonly ambiguousTarget: "ambiguous_target";
34
+ readonly operationRejected: "operation_rejected";
35
+ readonly repackRequired: "repack_required";
36
+ readonly authorRequired: "author_required";
37
+ readonly locked: "locked";
38
+ readonly destinationExists: "destination_exists";
39
+ readonly transactionConflict: "transaction_conflict";
40
+ readonly integrityFailed: "integrity_failed";
41
+ readonly rendererUnavailable: "renderer_unavailable";
42
+ readonly internal: "internal_error";
43
+ };
44
+ type FolioCliErrorCode = (typeof FOLIO_CLI_ERROR_CODES)[keyof typeof FOLIO_CLI_ERROR_CODES];
45
+ declare const exitCodeForError: (code: FolioCliErrorCode) => ExitCode;
46
+ declare const FolioCliError_base: import("better-result").TaggedErrorClass<"FolioCliError">;
47
+ /**
48
+ * Every expected failure of a command or tool call. `hint` names the next
49
+ * step (re-read, pass a flag); `details` carries structured context such as
50
+ * the skipped operations of a refused batch.
51
+ */
52
+ declare class FolioCliError extends FolioCliError_base<{
53
+ code: FolioCliErrorCode;
54
+ message: string;
55
+ hint?: string;
56
+ details?: unknown;
57
+ }> {}
58
+ type CliErrorOptions = {
59
+ code: FolioCliErrorCode;
60
+ message: string;
61
+ hint?: string | undefined;
62
+ details?: unknown;
63
+ };
64
+ /** Build a {@link FolioCliError} without spelling absent optional fields. */
65
+ declare const cliError: ({ code, message, hint, details }: CliErrorOptions) => FolioCliError;
66
+ //#endregion
67
+ export { EXIT_CODES, ExitCode, FOLIO_CLI_ERROR_CODES, FolioCliError, FolioCliErrorCode, cliError, exitCodeEntries, exitCodeForError };