@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.
- package/README.md +298 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +30 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +440 -0
- package/dist/document.d.ts +43 -0
- package/dist/document.js +127 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +92 -0
- package/dist/execute-read.d.ts +64 -0
- package/dist/execute-read.js +220 -0
- package/dist/execute-write.d.ts +36 -0
- package/dist/execute-write.js +449 -0
- package/dist/flags.d.ts +38 -0
- package/dist/flags.js +102 -0
- package/dist/journal.d.ts +82 -0
- package/dist/journal.js +212 -0
- package/dist/lock.d.ts +55 -0
- package/dist/lock.js +157 -0
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +310 -0
- package/dist/output-file.d.ts +14 -0
- package/dist/output-file.js +60 -0
- package/dist/output.d.ts +33 -0
- package/dist/output.js +77 -0
- package/dist/package-parts.d.ts +24 -0
- package/dist/package-parts.js +172 -0
- package/dist/package.js +4 -0
- package/dist/preview-commands.d.ts +8 -0
- package/dist/preview-commands.js +226 -0
- package/dist/provenance.d.ts +17 -0
- package/dist/provenance.js +53 -0
- package/dist/registry.d.ts +69 -0
- package/dist/registry.js +255 -0
- package/dist/render.d.ts +35 -0
- package/dist/render.js +120 -0
- package/dist/roots.d.ts +21 -0
- package/dist/roots.js +57 -0
- package/dist/serve.d.ts +17 -0
- package/dist/serve.js +234 -0
- package/dist/sidecar.d.ts +52 -0
- package/dist/sidecar.js +156 -0
- package/dist/transaction.d.ts +55 -0
- package/dist/transaction.js +218 -0
- package/package.json +85 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FolioDocxReviewer } from "@stll/folio-core/server";
|
|
5
|
+
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
6
|
+
import JSZip from "jszip";
|
|
7
|
+
//#region src/package-parts.ts
|
|
8
|
+
/**
|
|
9
|
+
* Package-level facts a transaction reports and checks: which parts a save
|
|
10
|
+
* changed, the first revision id free for new revisions, and whether the
|
|
11
|
+
* parts it changed are well formed with every relationship reference
|
|
12
|
+
* resolved.
|
|
13
|
+
*/
|
|
14
|
+
const WORDPROCESSINGML_NAMESPACES = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
|
|
15
|
+
const RELATIONSHIP_NAMESPACES = ["http://schemas.openxmlformats.org/officeDocument/2006/relationships", "http://purl.oclc.org/ooxml/officeDocument/relationships"];
|
|
16
|
+
const loadZip = (bytes) => Result.tryPromise({
|
|
17
|
+
try: () => JSZip.loadAsync(bytes),
|
|
18
|
+
catch: (error) => cliError({
|
|
19
|
+
code: FOLIO_CLI_ERROR_CODES.integrityFailed,
|
|
20
|
+
message: `The package is not a readable ZIP archive: ${error instanceof Error ? error.message : String(error)}`
|
|
21
|
+
})
|
|
22
|
+
});
|
|
23
|
+
const partEntries = (zip) => new Map(Object.entries(zip.files).filter(([, entry]) => !entry.dir));
|
|
24
|
+
const sameBytes = (left, right) => Buffer.compare(left, right) === 0;
|
|
25
|
+
/** Parts added, removed, or whose uncompressed content differs, sorted by name. */
|
|
26
|
+
const diffPackages = async (before, after) => {
|
|
27
|
+
const beforeZip = await loadZip(before);
|
|
28
|
+
if (beforeZip.isErr()) return Result.err(beforeZip.error);
|
|
29
|
+
const afterZip = await loadZip(after);
|
|
30
|
+
if (afterZip.isErr()) return Result.err(afterZip.error);
|
|
31
|
+
const beforeParts = partEntries(beforeZip.value);
|
|
32
|
+
const afterParts = partEntries(afterZip.value);
|
|
33
|
+
const changes = [];
|
|
34
|
+
for (const [part, entry] of afterParts) {
|
|
35
|
+
const previous = beforeParts.get(part);
|
|
36
|
+
if (previous === void 0) {
|
|
37
|
+
changes.push({
|
|
38
|
+
part,
|
|
39
|
+
change: "added"
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const [left, right] = await Promise.all([previous.async("uint8array"), entry.async("uint8array")]);
|
|
44
|
+
if (!sameBytes(left, right)) changes.push({
|
|
45
|
+
part,
|
|
46
|
+
change: "modified"
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const part of beforeParts.keys()) if (!afterParts.has(part)) changes.push({
|
|
50
|
+
part,
|
|
51
|
+
change: "removed"
|
|
52
|
+
});
|
|
53
|
+
return Result.ok(changes.toSorted((left, right) => left.part.localeCompare(right.part)));
|
|
54
|
+
};
|
|
55
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
56
|
+
/** Prefixes a part's namespace declarations bind to any of `namespaces`. */
|
|
57
|
+
const prefixesBoundTo = (xml, namespaces) => {
|
|
58
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
59
|
+
for (const match of xml.matchAll(/\sxmlns:([A-Za-z_][\w.-]*)\s*=\s*"([^"]*)"/gu)) {
|
|
60
|
+
const [, prefix, uri] = match;
|
|
61
|
+
if (prefix !== void 0 && uri !== void 0 && namespaces.includes(uri)) prefixes.add(prefix);
|
|
62
|
+
}
|
|
63
|
+
return prefixes;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The first revision id above every WordprocessingML `id` attribute in the
|
|
67
|
+
* package, so new revisions never collide with an existing one.
|
|
68
|
+
*/
|
|
69
|
+
const nextRevisionIdSeed = async (bytes) => {
|
|
70
|
+
const zip = await loadZip(bytes);
|
|
71
|
+
if (zip.isErr()) return Result.err(zip.error);
|
|
72
|
+
let highest = 0;
|
|
73
|
+
for (const [part, entry] of partEntries(zip.value)) {
|
|
74
|
+
if (!part.endsWith(".xml")) continue;
|
|
75
|
+
const xml = await entry.async("string");
|
|
76
|
+
for (const prefix of prefixesBoundTo(xml, WORDPROCESSINGML_NAMESPACES)) {
|
|
77
|
+
const pattern = new RegExp(`\\s${escapeRegExp(prefix)}:id\\s*=\\s*"(\\d+)"`, "gu");
|
|
78
|
+
for (const [, id] of xml.matchAll(pattern)) highest = Math.max(highest, Number(id));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return Result.ok(highest + 1);
|
|
82
|
+
};
|
|
83
|
+
const relationshipParser = new XMLParser({
|
|
84
|
+
ignoreAttributes: false,
|
|
85
|
+
attributeNamePrefix: "",
|
|
86
|
+
removeNSPrefix: true,
|
|
87
|
+
isArray: (name) => name === "Relationship"
|
|
88
|
+
});
|
|
89
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
const parseRelationships = (xml) => {
|
|
91
|
+
const parsed = relationshipParser.parse(xml);
|
|
92
|
+
const root = isRecord(parsed) ? parsed["Relationships"] : void 0;
|
|
93
|
+
const entries = isRecord(root) ? root["Relationship"] : void 0;
|
|
94
|
+
if (!Array.isArray(entries)) return [];
|
|
95
|
+
return entries.filter(isRecord).map((entry) => ({
|
|
96
|
+
id: typeof entry["Id"] === "string" ? entry["Id"] : "",
|
|
97
|
+
target: typeof entry["Target"] === "string" ? entry["Target"] : "",
|
|
98
|
+
external: entry["TargetMode"] === "External"
|
|
99
|
+
}));
|
|
100
|
+
};
|
|
101
|
+
/** `word/document.xml` -> `word/_rels/document.xml.rels`; `_rels/.rels` belongs to the package. */
|
|
102
|
+
const relationshipsPartFor = (part) => {
|
|
103
|
+
const directory = path.posix.dirname(part);
|
|
104
|
+
const base = path.posix.basename(part);
|
|
105
|
+
return directory === "." ? `_rels/${base}.rels` : `${directory}/_rels/${base}.rels`;
|
|
106
|
+
};
|
|
107
|
+
/** The part a `.rels` part describes (`""` for the package-level `_rels/.rels`). */
|
|
108
|
+
const sourcePartFor = (relsPart) => {
|
|
109
|
+
const directory = path.posix.dirname(path.posix.dirname(relsPart));
|
|
110
|
+
const base = path.posix.basename(relsPart, ".rels");
|
|
111
|
+
return directory === "." ? base : `${directory}/${base}`;
|
|
112
|
+
};
|
|
113
|
+
const resolveTarget = (relsPart, target) => {
|
|
114
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
115
|
+
const sourceDirectory = path.posix.dirname(sourcePartFor(relsPart));
|
|
116
|
+
return path.posix.normalize(path.posix.join(sourceDirectory === "." ? "" : sourceDirectory, target));
|
|
117
|
+
};
|
|
118
|
+
const integrityError = (message) => cliError({
|
|
119
|
+
code: FOLIO_CLI_ERROR_CODES.integrityFailed,
|
|
120
|
+
message
|
|
121
|
+
});
|
|
122
|
+
const checkChangedPart = async ({ zip, parts, part }) => {
|
|
123
|
+
const entry = parts.get(part);
|
|
124
|
+
if (entry === void 0 || !(part.endsWith(".xml") || part.endsWith(".rels"))) return null;
|
|
125
|
+
const xml = await entry.async("string");
|
|
126
|
+
if (XMLValidator.validate(xml) !== true) return `${part} is not well-formed XML.`;
|
|
127
|
+
if (part.endsWith(".rels")) {
|
|
128
|
+
const lowerCaseParts = new Set([...parts.keys()].map((name) => name.toLowerCase()));
|
|
129
|
+
for (const { id, target, external } of parseRelationships(xml)) {
|
|
130
|
+
if (external) continue;
|
|
131
|
+
const resolved = resolveTarget(part, target);
|
|
132
|
+
if (!lowerCaseParts.has(resolved.toLowerCase())) return `${part} relationship ${id} targets ${resolved}, which the package does not contain.`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const prefixes = prefixesBoundTo(xml, RELATIONSHIP_NAMESPACES);
|
|
137
|
+
if (prefixes.size === 0) return null;
|
|
138
|
+
const relsXml = await zip.file(relationshipsPartFor(part))?.async("string");
|
|
139
|
+
const ids = new Set(parseRelationships(relsXml ?? "").map(({ id }) => id));
|
|
140
|
+
for (const prefix of prefixes) {
|
|
141
|
+
const pattern = new RegExp(`\\s${escapeRegExp(prefix)}:[A-Za-z]+\\s*=\\s*"([^"]*)"`, "gu");
|
|
142
|
+
for (const [, id] of xml.matchAll(pattern)) if (id !== void 0 && !ids.has(id)) return `${part} references relationship ${id}, which ${relationshipsPartFor(part)} does not define.`;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Check what a save wrote before it replaces anything: the package opens,
|
|
148
|
+
* every changed XML part is well formed, every relationship id a changed
|
|
149
|
+
* part references resolves, every internal target a changed `.rels` part
|
|
150
|
+
* names exists, and the package parses as a document again.
|
|
151
|
+
*/
|
|
152
|
+
const checkPackageIntegrity = async (bytes, changedParts) => {
|
|
153
|
+
const zip = await loadZip(bytes);
|
|
154
|
+
if (zip.isErr()) return Result.err(zip.error);
|
|
155
|
+
const parts = partEntries(zip.value);
|
|
156
|
+
for (const { part, change } of changedParts) {
|
|
157
|
+
if (change === "removed") continue;
|
|
158
|
+
const problem = await checkChangedPart({
|
|
159
|
+
zip: zip.value,
|
|
160
|
+
parts,
|
|
161
|
+
part
|
|
162
|
+
});
|
|
163
|
+
if (problem !== null) return Result.err(integrityError(`The saved package failed validation: ${problem}`));
|
|
164
|
+
}
|
|
165
|
+
const reopened = await Result.tryPromise({
|
|
166
|
+
try: () => FolioDocxReviewer.fromBuffer(bytes.slice().buffer),
|
|
167
|
+
catch: (error) => integrityError(`The saved package does not reopen: ${error instanceof Error ? error.message : String(error)}`)
|
|
168
|
+
});
|
|
169
|
+
return reopened.isErr() ? Result.err(reopened.error) : Result.ok();
|
|
170
|
+
};
|
|
171
|
+
//#endregion
|
|
172
|
+
export { checkPackageIntegrity, diffPackages, nextRevisionIdSeed };
|
package/dist/package.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FolioCliIo } from "./cli.js";
|
|
2
|
+
//#region src/preview-commands.d.ts
|
|
3
|
+
declare const RENDER_HELP: string;
|
|
4
|
+
declare const runRender: (rest: readonly string[], io: FolioCliIo) => Promise<number>;
|
|
5
|
+
declare const SERVE_HELP: string;
|
|
6
|
+
declare const runServe: (rest: readonly string[], io: FolioCliIo) => Promise<number>;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { RENDER_HELP, SERVE_HELP, runRender, runServe };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { checkExpectedVersion, readDocumentFile } from "./document.js";
|
|
2
|
+
import { EXIT_CODES, FOLIO_CLI_ERROR_CODES, cliError, exitCodeForError } from "./errors.js";
|
|
3
|
+
import { writeOutputFile } from "./output-file.js";
|
|
4
|
+
import { OUTPUT_FORMATS, failureEnvelope, isOutputFormat, renderEnvelope, successEnvelope } from "./output.js";
|
|
5
|
+
import { resolveTransactionDate } from "./provenance.js";
|
|
6
|
+
import { buildDisplayList, checkPages, displayListHtml, renderPdf, renderPng } from "./render.js";
|
|
7
|
+
import { startPreviewServer } from "./serve.js";
|
|
8
|
+
import { Result } from "better-result";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
//#region src/preview-commands.ts
|
|
12
|
+
/**
|
|
13
|
+
* `folio render` and `folio serve`: the document as its pages, from folio's
|
|
14
|
+
* own layout and painters. Neither changes the document.
|
|
15
|
+
*/
|
|
16
|
+
const usageError = (message, hint) => cliError({
|
|
17
|
+
code: FOLIO_CLI_ERROR_CODES.usage,
|
|
18
|
+
message,
|
|
19
|
+
hint
|
|
20
|
+
});
|
|
21
|
+
const emit = (io, format, result) => {
|
|
22
|
+
const rendered = renderEnvelope({
|
|
23
|
+
envelope: result.isOk() ? successEnvelope(result.value) : failureEnvelope(result.error),
|
|
24
|
+
format,
|
|
25
|
+
tool: "render"
|
|
26
|
+
});
|
|
27
|
+
if (rendered.stdout !== "") io.stdout(rendered.stdout);
|
|
28
|
+
if (rendered.stderr !== "") io.stderr(rendered.stderr);
|
|
29
|
+
return result.isOk() ? EXIT_CODES.ok : exitCodeForError(result.error.code);
|
|
30
|
+
};
|
|
31
|
+
const formatFrom = (value, io) => {
|
|
32
|
+
if (value === void 0) return Result.ok(io.isTTY ? "text" : "json");
|
|
33
|
+
return isOutputFormat(value) ? Result.ok(value) : Result.err(usageError(`--output must be ${OUTPUT_FORMATS.join(" or ")}.`));
|
|
34
|
+
};
|
|
35
|
+
/** The output format an extension names, or `null` for any other. */
|
|
36
|
+
const renderFormatOf = (target) => {
|
|
37
|
+
switch (path.extname(target).toLowerCase()) {
|
|
38
|
+
case ".pdf": return "pdf";
|
|
39
|
+
case ".png": return "png";
|
|
40
|
+
case ".html": return "html";
|
|
41
|
+
default: return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const positiveInteger = (value, flag) => {
|
|
45
|
+
if (value === void 0) return Result.ok(void 0);
|
|
46
|
+
const parsed = Number(value);
|
|
47
|
+
return Number.isInteger(parsed) && parsed >= 1 ? Result.ok(parsed) : Result.err(usageError(`${flag} expects a whole number from 1.`));
|
|
48
|
+
};
|
|
49
|
+
const RENDER_HELP = [
|
|
50
|
+
"Usage: folio render <file> -o <out.pdf|out.png|out.html> [--page <n>]",
|
|
51
|
+
"",
|
|
52
|
+
"Render the document with folio's layout: a PDF of every page (or --page n), an HTML",
|
|
53
|
+
"document of its pages, or one page as a PNG (page 1 unless --page). PNG needs Chromium",
|
|
54
|
+
"through playwright-core. The document is never changed.",
|
|
55
|
+
"",
|
|
56
|
+
"Flags:",
|
|
57
|
+
" -o, --out <path> Output file; its extension picks the format",
|
|
58
|
+
" --page <n> Only this page (1-based)",
|
|
59
|
+
" --scale <n> PNG pixels per CSS pixel (default 2)",
|
|
60
|
+
" --overwrite Replace an existing output file",
|
|
61
|
+
" --expect-version <sha> Refuse unless the document has this fileVersion",
|
|
62
|
+
" --date <iso> PDF creation date (default: now)",
|
|
63
|
+
" --output <json|text> Envelope format",
|
|
64
|
+
""
|
|
65
|
+
].join("\n");
|
|
66
|
+
const renderOptions = {
|
|
67
|
+
out: {
|
|
68
|
+
type: "string",
|
|
69
|
+
short: "o"
|
|
70
|
+
},
|
|
71
|
+
page: { type: "string" },
|
|
72
|
+
scale: { type: "string" },
|
|
73
|
+
overwrite: { type: "boolean" },
|
|
74
|
+
"expect-version": { type: "string" },
|
|
75
|
+
date: { type: "string" },
|
|
76
|
+
output: { type: "string" },
|
|
77
|
+
help: {
|
|
78
|
+
type: "boolean",
|
|
79
|
+
short: "h"
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const runRender = async (rest, io) => {
|
|
83
|
+
const parsed = Result.try({
|
|
84
|
+
try: () => parseArgs({
|
|
85
|
+
args: [...rest],
|
|
86
|
+
options: renderOptions,
|
|
87
|
+
allowPositionals: true,
|
|
88
|
+
strict: true
|
|
89
|
+
}),
|
|
90
|
+
catch: (error) => usageError(error instanceof Error ? error.message : String(error), "Run folio render --help.")
|
|
91
|
+
});
|
|
92
|
+
if (parsed.isErr()) return emit(io, io.isTTY ? "text" : "json", parsed);
|
|
93
|
+
const { values, positionals } = parsed.value;
|
|
94
|
+
if (values.help === true) {
|
|
95
|
+
io.stdout(RENDER_HELP);
|
|
96
|
+
return EXIT_CODES.ok;
|
|
97
|
+
}
|
|
98
|
+
const format = formatFrom(values.output, io);
|
|
99
|
+
if (format.isErr()) return emit(io, io.isTTY ? "text" : "json", format);
|
|
100
|
+
const result = await render({
|
|
101
|
+
values,
|
|
102
|
+
positionals
|
|
103
|
+
});
|
|
104
|
+
return emit(io, format.value, result);
|
|
105
|
+
};
|
|
106
|
+
const render = async ({ values, positionals }) => {
|
|
107
|
+
const [input, ...extra] = positionals;
|
|
108
|
+
if (input === void 0 || extra.length > 0) return Result.err(usageError("Usage: folio render <file> -o <out.pdf|out.png|out.html>."));
|
|
109
|
+
if (values.out === void 0) return Result.err(usageError("folio render needs -o <out.pdf|out.png|out.html>."));
|
|
110
|
+
const kind = renderFormatOf(values.out);
|
|
111
|
+
if (kind === null) return Result.err(usageError("-o must end in .pdf, .png, or .html."));
|
|
112
|
+
const page = positiveInteger(values.page, "--page");
|
|
113
|
+
if (page.isErr()) return Result.err(page.error);
|
|
114
|
+
const scale = positiveInteger(values.scale, "--scale");
|
|
115
|
+
if (scale.isErr()) return Result.err(scale.error);
|
|
116
|
+
const date = resolveTransactionDate(values.date);
|
|
117
|
+
if (date.isErr()) return Result.err(date.error);
|
|
118
|
+
const file = await readDocumentFile(input);
|
|
119
|
+
if (file.isErr()) return Result.err(file.error);
|
|
120
|
+
const version = checkExpectedVersion(file.value, values["expect-version"]);
|
|
121
|
+
if (version.isErr()) return Result.err(version.error);
|
|
122
|
+
const pages = page.value === void 0 ? void 0 : [page.value];
|
|
123
|
+
let output;
|
|
124
|
+
switch (kind) {
|
|
125
|
+
case "pdf":
|
|
126
|
+
output = await renderPdf({
|
|
127
|
+
file: file.value,
|
|
128
|
+
pages,
|
|
129
|
+
timestamp: date.value
|
|
130
|
+
});
|
|
131
|
+
break;
|
|
132
|
+
case "png":
|
|
133
|
+
output = await renderPng({
|
|
134
|
+
file: file.value,
|
|
135
|
+
page: page.value ?? 1,
|
|
136
|
+
scale: scale.value ?? 2
|
|
137
|
+
});
|
|
138
|
+
break;
|
|
139
|
+
case "html": {
|
|
140
|
+
const list = await buildDisplayList(file.value);
|
|
141
|
+
const selected = list.isOk() ? checkPages(list.value, pages) : list;
|
|
142
|
+
output = selected.isOk() ? Result.ok({
|
|
143
|
+
bytes: new TextEncoder().encode(displayListHtml(selected.value, path.basename(file.value.path))),
|
|
144
|
+
pageCount: selected.value.pages.length
|
|
145
|
+
}) : Result.err(selected.error);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
default: return kind;
|
|
149
|
+
}
|
|
150
|
+
if (output.isErr()) return Result.err(output.error);
|
|
151
|
+
const written = await writeOutputFile({
|
|
152
|
+
target: values.out,
|
|
153
|
+
bytes: output.value.bytes,
|
|
154
|
+
overwrite: values.overwrite === true,
|
|
155
|
+
input: file.value.identity
|
|
156
|
+
});
|
|
157
|
+
if (written.isErr()) return Result.err(written.error);
|
|
158
|
+
return Result.ok({
|
|
159
|
+
path: file.value.path,
|
|
160
|
+
fileVersion: file.value.fileVersion,
|
|
161
|
+
output: written.value,
|
|
162
|
+
format: kind,
|
|
163
|
+
pageCount: output.value.pageCount,
|
|
164
|
+
bytes: output.value.bytes.byteLength
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const SERVE_HELP = [
|
|
168
|
+
"Usage: folio serve <file> [--port <n>]",
|
|
169
|
+
"",
|
|
170
|
+
"Serve a read-only live preview of the document on 127.0.0.1. The page follows the file:",
|
|
171
|
+
"when its version changes (a folio write, or any other program), the preview re-renders.",
|
|
172
|
+
"The URL carries a random token; the server never writes. Stop it with Ctrl-C.",
|
|
173
|
+
"",
|
|
174
|
+
"Flags:",
|
|
175
|
+
" --port <n> Port on 127.0.0.1 (default: a free one)",
|
|
176
|
+
" --output <json|text> Envelope format for the startup line",
|
|
177
|
+
""
|
|
178
|
+
].join("\n");
|
|
179
|
+
const runServe = async (rest, io) => {
|
|
180
|
+
const parsed = Result.try({
|
|
181
|
+
try: () => parseArgs({
|
|
182
|
+
args: [...rest],
|
|
183
|
+
options: {
|
|
184
|
+
port: { type: "string" },
|
|
185
|
+
output: { type: "string" },
|
|
186
|
+
help: {
|
|
187
|
+
type: "boolean",
|
|
188
|
+
short: "h"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
allowPositionals: true,
|
|
192
|
+
strict: true
|
|
193
|
+
}),
|
|
194
|
+
catch: (error) => usageError(error instanceof Error ? error.message : String(error), "Run folio serve --help.")
|
|
195
|
+
});
|
|
196
|
+
if (parsed.isErr()) return emit(io, io.isTTY ? "text" : "json", parsed);
|
|
197
|
+
const { values, positionals } = parsed.value;
|
|
198
|
+
if (values.help === true) {
|
|
199
|
+
io.stdout(SERVE_HELP);
|
|
200
|
+
return EXIT_CODES.ok;
|
|
201
|
+
}
|
|
202
|
+
const format = formatFrom(values.output, io);
|
|
203
|
+
if (format.isErr()) return emit(io, io.isTTY ? "text" : "json", format);
|
|
204
|
+
const [input, ...extra] = positionals;
|
|
205
|
+
if (input === void 0 || extra.length > 0) return emit(io, format.value, Result.err(usageError("Usage: folio serve <file> [--port <n>].")));
|
|
206
|
+
const port = values.port === void 0 ? 0 : Number(values.port);
|
|
207
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) return emit(io, format.value, Result.err(usageError("--port expects a port number.")));
|
|
208
|
+
if (io.untilInterrupted === void 0) return emit(io, format.value, Result.err(usageError("folio serve needs to run until interrupted.")));
|
|
209
|
+
const started = await startPreviewServer({
|
|
210
|
+
documentPath: input,
|
|
211
|
+
port
|
|
212
|
+
});
|
|
213
|
+
if (started.isErr()) return emit(io, format.value, Result.err(cliError({
|
|
214
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
215
|
+
message: started.error.message
|
|
216
|
+
})));
|
|
217
|
+
const exit = emit(io, format.value, Result.ok({
|
|
218
|
+
url: started.value.url,
|
|
219
|
+
path: path.resolve(input)
|
|
220
|
+
}));
|
|
221
|
+
await io.untilInterrupted();
|
|
222
|
+
await started.value.close();
|
|
223
|
+
return exit;
|
|
224
|
+
};
|
|
225
|
+
//#endregion
|
|
226
|
+
export { RENDER_HELP, SERVE_HELP, runRender, runServe };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/provenance.d.ts
|
|
4
|
+
declare const AUTHOR_ENV = "FOLIO_AUTHOR";
|
|
5
|
+
type ResolveAuthorOptions = {
|
|
6
|
+
/** `--author`, or the MCP server's configured author. */
|
|
7
|
+
explicit: string | undefined;
|
|
8
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
/** Directory whose git configuration supplies the fallback. */
|
|
10
|
+
cwd: string;
|
|
11
|
+
};
|
|
12
|
+
/** `--author`, then `FOLIO_AUTHOR`, then git `user.name`; refuses when none is set. */
|
|
13
|
+
declare const resolveAuthor: ({ explicit, env, cwd }: ResolveAuthorOptions) => Result<string, FolioCliError>;
|
|
14
|
+
/** The transaction timestamp: `--date` when given (for reproducible output), else now. */
|
|
15
|
+
declare const resolveTransactionDate: (explicit: string | undefined, now?: () => Date) => Result<string, FolioCliError>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { AUTHOR_ENV, resolveAuthor, resolveTransactionDate };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
//#region src/provenance.ts
|
|
5
|
+
/**
|
|
6
|
+
* Who and when a transaction records. Every revision, comment, and reply a
|
|
7
|
+
* transaction authors carries one author and one UTC timestamp; neither is
|
|
8
|
+
* ever invented.
|
|
9
|
+
*/
|
|
10
|
+
const AUTHOR_ENV = "FOLIO_AUTHOR";
|
|
11
|
+
const nonEmpty = (value) => {
|
|
12
|
+
const trimmed = value?.trim();
|
|
13
|
+
return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
14
|
+
};
|
|
15
|
+
const gitUserName = (cwd, env) => {
|
|
16
|
+
const childEnv = {};
|
|
17
|
+
for (const [key, value] of Object.entries(env)) if (value !== void 0) childEnv[key] = value;
|
|
18
|
+
const run = spawnSync("git", ["config", "user.name"], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: childEnv,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
timeout: 2e3,
|
|
23
|
+
stdio: [
|
|
24
|
+
"ignore",
|
|
25
|
+
"pipe",
|
|
26
|
+
"ignore"
|
|
27
|
+
]
|
|
28
|
+
});
|
|
29
|
+
return run.status === 0 ? nonEmpty(run.stdout) : void 0;
|
|
30
|
+
};
|
|
31
|
+
/** `--author`, then `FOLIO_AUTHOR`, then git `user.name`; refuses when none is set. */
|
|
32
|
+
const resolveAuthor = ({ explicit, env, cwd }) => {
|
|
33
|
+
const author = nonEmpty(explicit) ?? nonEmpty(env["FOLIO_AUTHOR"]) ?? gitUserName(cwd, env);
|
|
34
|
+
return author === void 0 ? Result.err(cliError({
|
|
35
|
+
code: FOLIO_CLI_ERROR_CODES.authorRequired,
|
|
36
|
+
message: "No author is configured for this change.",
|
|
37
|
+
hint: `Pass --author, set ${AUTHOR_ENV}, or configure git user.name.`
|
|
38
|
+
})) : Result.ok(author);
|
|
39
|
+
};
|
|
40
|
+
/** An ISO-8601 UTC timestamp at second precision, as revision dates are written. */
|
|
41
|
+
const toRevisionDate = (date) => date.toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
42
|
+
/** The transaction timestamp: `--date` when given (for reproducible output), else now. */
|
|
43
|
+
const resolveTransactionDate = (explicit, now = () => /* @__PURE__ */ new Date()) => {
|
|
44
|
+
if (explicit === void 0) return Result.ok(toRevisionDate(now()));
|
|
45
|
+
const parsed = new Date(explicit);
|
|
46
|
+
return Number.isNaN(parsed.getTime()) ? Result.err(cliError({
|
|
47
|
+
code: FOLIO_CLI_ERROR_CODES.usage,
|
|
48
|
+
message: `--date ${JSON.stringify(explicit)} is not an ISO-8601 date.`,
|
|
49
|
+
hint: "Use a form like 2026-01-31T09:00:00Z."
|
|
50
|
+
})) : Result.ok(toRevisionDate(parsed));
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
export { AUTHOR_ENV, resolveAuthor, resolveTransactionDate };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { FolioAgentToolName } from "@stll/folio-agents/types";
|
|
2
|
+
//#region src/registry.d.ts
|
|
3
|
+
type JsonObjectSchema = {
|
|
4
|
+
readonly type: "object";
|
|
5
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
6
|
+
readonly required: readonly string[];
|
|
7
|
+
readonly additionalProperties: false;
|
|
8
|
+
};
|
|
9
|
+
/** A positional argument after `<file>` that sets one tool argument. */
|
|
10
|
+
type FolioPositionalSpec = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly property: string;
|
|
13
|
+
};
|
|
14
|
+
/** One `folio <name>` command. */
|
|
15
|
+
type FolioCommandSpec = {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly summary: string;
|
|
18
|
+
/** Arguments the command implies (`accept` sets `action: "accept"`). */
|
|
19
|
+
readonly preset?: Readonly<Record<string, unknown>>;
|
|
20
|
+
/** Positionals after `<file>`, in order. */
|
|
21
|
+
readonly positionals?: readonly FolioPositionalSpec[];
|
|
22
|
+
/** Flag spellings that differ from the kebab-cased property (`ids` as `--id`). */
|
|
23
|
+
readonly flagNames?: Readonly<Record<string, string>>;
|
|
24
|
+
/** A bare JSON array passed to `--input` fills this property. */
|
|
25
|
+
readonly inputArrayProperty?: string;
|
|
26
|
+
};
|
|
27
|
+
type FolioToolSpecBase = {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly description: string;
|
|
30
|
+
/** The tool's own arguments, without the file envelope. */
|
|
31
|
+
readonly argsSchema: JsonObjectSchema;
|
|
32
|
+
readonly commands: readonly FolioCommandSpec[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* How a tool runs. `agentRead` and `agentWrite` run a `@stll/folio-agents`
|
|
36
|
+
* tool against one file (a write commits a transaction); `resolveChanges`
|
|
37
|
+
* accepts or rejects tracked changes; `compare` diffs two files and, given a
|
|
38
|
+
* destination, writes a redline.
|
|
39
|
+
*/
|
|
40
|
+
type FolioFileToolSpec = (FolioToolSpecBase & {
|
|
41
|
+
readonly type: "agentRead";
|
|
42
|
+
readonly agentTool: FolioAgentToolName;
|
|
43
|
+
}) | (FolioToolSpecBase & {
|
|
44
|
+
readonly type: "agentWrite";
|
|
45
|
+
readonly agentTool: FolioAgentToolName;
|
|
46
|
+
/** Whether the caller may choose direct edits over tracked changes. */
|
|
47
|
+
readonly editMode: "tracked-or-direct" | "fixed";
|
|
48
|
+
}) | (FolioToolSpecBase & {
|
|
49
|
+
readonly type: "resolveChanges";
|
|
50
|
+
}) | (FolioToolSpecBase & {
|
|
51
|
+
readonly type: "compare";
|
|
52
|
+
});
|
|
53
|
+
/** Whether a tool call may write, which decides its envelope and flags. */
|
|
54
|
+
type FolioToolAccess = "read" | "write" | "readOrWrite";
|
|
55
|
+
declare const toolAccess: (tool: FolioFileToolSpec) => FolioToolAccess;
|
|
56
|
+
/** Largest page `read_document` returns on any surface. */
|
|
57
|
+
declare const MAX_READ_BLOCKS = 1000;
|
|
58
|
+
declare const RESOLVE_CHANGE_ACTIONS: readonly ["accept", "reject"];
|
|
59
|
+
type ResolveChangeAction = (typeof RESOLVE_CHANGE_ACTIONS)[number];
|
|
60
|
+
declare const FOLIO_FILE_TOOLS: readonly FolioFileToolSpec[];
|
|
61
|
+
declare const findFileTool: (name: string) => FolioFileToolSpec | undefined;
|
|
62
|
+
type FolioResolvedCommand = {
|
|
63
|
+
tool: FolioFileToolSpec;
|
|
64
|
+
command: FolioCommandSpec;
|
|
65
|
+
};
|
|
66
|
+
declare const findCommand: (name: string) => FolioResolvedCommand | undefined;
|
|
67
|
+
declare const listCommands: () => readonly FolioResolvedCommand[];
|
|
68
|
+
//#endregion
|
|
69
|
+
export { FOLIO_FILE_TOOLS, FolioCommandSpec, FolioFileToolSpec, FolioPositionalSpec, FolioResolvedCommand, FolioToolAccess, JsonObjectSchema, MAX_READ_BLOCKS, RESOLVE_CHANGE_ACTIONS, ResolveChangeAction, findCommand, findFileTool, listCommands, toolAccess };
|