@stll/folio-cli 0.0.1-placeholder.0 → 0.1.1
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
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { executeReadTool } from "./execute-read.js";
|
|
3
|
+
import { executeWriteTool } from "./execute-write.js";
|
|
4
|
+
import { failureEnvelope, successEnvelope } from "./output.js";
|
|
5
|
+
import { version } from "./package.js";
|
|
6
|
+
import { FOLIO_FILE_TOOLS, findFileTool, toolAccess } from "./registry.js";
|
|
7
|
+
import { checkWithinRoots } from "./roots.js";
|
|
8
|
+
import { Result, panic } from "better-result";
|
|
9
|
+
import { FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA } from "@stll/folio-agents/operation-schema";
|
|
10
|
+
import { ProtocolError, ProtocolErrorCode, Server } from "@modelcontextprotocol/server";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
12
|
+
//#region src/mcp.ts
|
|
13
|
+
/**
|
|
14
|
+
* `folio mcp`: the file tool registry as a Model Context Protocol server over
|
|
15
|
+
* stdio. The protocol owns stdout; diagnostics go to stderr. Every tool takes
|
|
16
|
+
* the file envelope (`path`, `fileVersion`, and for writes `destination`,
|
|
17
|
+
* `txId`, ...) beside its own arguments, every path must resolve inside an
|
|
18
|
+
* allowed root, and results are the same `{ ok, data | error }` envelope the
|
|
19
|
+
* command line prints.
|
|
20
|
+
*/
|
|
21
|
+
/** Limits on what one MCP tool call returns. */
|
|
22
|
+
const MCP_READ_BOUNDS = {
|
|
23
|
+
defaultMaxBlocks: 200,
|
|
24
|
+
maxMatches: 100,
|
|
25
|
+
maxResponseBytes: 256 * 1024
|
|
26
|
+
};
|
|
27
|
+
const ABOUT_URI = "folio://about";
|
|
28
|
+
const OPERATIONS_SCHEMA_URI = "folio://schema/operations";
|
|
29
|
+
const INSTRUCTIONS = "Tools read and change .docx files on disk. Start with get_document_outline or read_document to get block ids and the file's fileVersion; find_text returns exact range handles. Mutating tools require the fileVersion you read and refuse (stale_version) when the file changed; re-read and retry. Edits are tracked changes unless mode is direct. Without destination a change is written in place with a backup; with destination it goes to that new file. Results are { ok, data } or { ok: false, error: { code, message, hint } }.";
|
|
30
|
+
const PATH_PROPERTY = {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "The .docx file, absolute or relative to the first allowed root."
|
|
33
|
+
};
|
|
34
|
+
const envelopeProperties = (tool) => {
|
|
35
|
+
const access = toolAccess(tool);
|
|
36
|
+
const properties = {
|
|
37
|
+
path: PATH_PROPERTY,
|
|
38
|
+
fileVersion: {
|
|
39
|
+
type: "string",
|
|
40
|
+
pattern: "^[0-9a-f]{64}$",
|
|
41
|
+
description: {
|
|
42
|
+
write: "The file's fileVersion (SHA-256) from your latest read. Required: a change to a file that moved on is refused.",
|
|
43
|
+
readOrWrite: "The file's fileVersion (SHA-256) from your latest read; required with `destination`.",
|
|
44
|
+
read: "When given, refuse unless the file still has this fileVersion."
|
|
45
|
+
}[access]
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (access === "read") return properties;
|
|
49
|
+
Object.assign(properties, {
|
|
50
|
+
destination: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: access === "write" ? "Write the result to this .docx instead of changing `path` in place. It must not start with a dot or sit inside .folio." : "Write the redline to this .docx. Without it, only the differences are returned."
|
|
53
|
+
},
|
|
54
|
+
overwrite: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "Let `destination` replace an existing .docx; needs `expectedDestinationVersion`. The replaced file is backed up."
|
|
57
|
+
},
|
|
58
|
+
expectedDestinationVersion: {
|
|
59
|
+
type: "string",
|
|
60
|
+
pattern: "^[0-9a-f]{64}$",
|
|
61
|
+
description: "The fileVersion of the existing file `destination` replaces."
|
|
62
|
+
},
|
|
63
|
+
txId: {
|
|
64
|
+
type: "string",
|
|
65
|
+
pattern: "^[\\w.-]{1,128}$",
|
|
66
|
+
description: "Idempotency key: repeating a committed call returns its original receipt."
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (access === "write") Object.assign(properties, { allowRepack: {
|
|
70
|
+
type: "boolean",
|
|
71
|
+
description: "Allow rewriting the whole package when the change cannot be patched in (e.g. paragraphs added or removed)."
|
|
72
|
+
} });
|
|
73
|
+
if (tool.type === "agentWrite" && tool.editMode === "tracked-or-direct") Object.assign(properties, { mode: {
|
|
74
|
+
type: "string",
|
|
75
|
+
enum: ["tracked", "direct"],
|
|
76
|
+
description: "tracked (default) records tracked changes; direct edits the text."
|
|
77
|
+
} });
|
|
78
|
+
return properties;
|
|
79
|
+
};
|
|
80
|
+
/** The tool's MCP input schema: its own arguments plus the file envelope. */
|
|
81
|
+
const mcpInputSchema = (tool) => {
|
|
82
|
+
const envelope = envelopeProperties(tool);
|
|
83
|
+
for (const key of Object.keys(envelope)) if (key in tool.argsSchema.properties) panic(`${tool.name} declares ${key}, which the file envelope also uses`);
|
|
84
|
+
const access = toolAccess(tool);
|
|
85
|
+
return {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
...envelope,
|
|
89
|
+
...tool.argsSchema.properties
|
|
90
|
+
},
|
|
91
|
+
required: [
|
|
92
|
+
"path",
|
|
93
|
+
...access === "write" ? ["fileVersion"] : [],
|
|
94
|
+
...tool.argsSchema.required
|
|
95
|
+
],
|
|
96
|
+
additionalProperties: false
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const isJsonValue = (value) => {
|
|
100
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
101
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
102
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
103
|
+
return typeof value === "object" && Object.values(value).every(isJsonValue);
|
|
104
|
+
};
|
|
105
|
+
/** The registry schemas are JSON by construction; a value that is not is a registry bug. */
|
|
106
|
+
const toJsonObject = (properties) => {
|
|
107
|
+
const object = {};
|
|
108
|
+
for (const [key, value] of Object.entries(properties)) object[key] = isJsonValue(value) ? value : panic(`Schema property ${key} is not JSON`);
|
|
109
|
+
return object;
|
|
110
|
+
};
|
|
111
|
+
const toMcpInputSchema = ({ properties, required }) => ({
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: toJsonObject(properties),
|
|
114
|
+
required: [...required],
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
});
|
|
117
|
+
const listMcpTools = () => FOLIO_FILE_TOOLS.map((tool) => ({
|
|
118
|
+
name: tool.name,
|
|
119
|
+
description: tool.description,
|
|
120
|
+
inputSchema: toMcpInputSchema(mcpInputSchema(tool)),
|
|
121
|
+
annotations: {
|
|
122
|
+
readOnlyHint: toolAccess(tool) === "read",
|
|
123
|
+
destructiveHint: toolAccess(tool) !== "read",
|
|
124
|
+
openWorldHint: false
|
|
125
|
+
}
|
|
126
|
+
}));
|
|
127
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
const invalidInput = (message) => cliError({
|
|
129
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
130
|
+
message
|
|
131
|
+
});
|
|
132
|
+
const ENVELOPE_KEYS = /* @__PURE__ */ new Set([
|
|
133
|
+
"path",
|
|
134
|
+
"fileVersion",
|
|
135
|
+
"destination",
|
|
136
|
+
"overwrite",
|
|
137
|
+
"expectedDestinationVersion",
|
|
138
|
+
"txId",
|
|
139
|
+
"allowRepack",
|
|
140
|
+
"mode"
|
|
141
|
+
]);
|
|
142
|
+
const optionalString = (value, name) => value === void 0 || typeof value === "string" ? Result.ok(value) : Result.err(invalidInput(`${name} must be a string.`));
|
|
143
|
+
const toRevisionDate = (date) => date.toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
144
|
+
const runTool = async (tool, rawArgs, context) => {
|
|
145
|
+
if (rawArgs !== void 0 && !isRecord(rawArgs)) return Result.err(invalidInput("Tool arguments must be an object."));
|
|
146
|
+
const input = rawArgs ?? {};
|
|
147
|
+
const args = {};
|
|
148
|
+
for (const [key, value] of Object.entries(input)) if (!ENVELOPE_KEYS.has(key)) args[key] = value;
|
|
149
|
+
const rawPath = input["path"];
|
|
150
|
+
if (typeof rawPath !== "string" || rawPath === "") return Result.err(invalidInput("path is required."));
|
|
151
|
+
const filePath = await checkWithinRoots({
|
|
152
|
+
roots: context.roots,
|
|
153
|
+
target: rawPath,
|
|
154
|
+
argument: "path"
|
|
155
|
+
});
|
|
156
|
+
if (filePath.isErr()) return filePath;
|
|
157
|
+
const fileVersion = optionalString(input["fileVersion"], "fileVersion");
|
|
158
|
+
if (fileVersion.isErr()) return fileVersion;
|
|
159
|
+
const destination = optionalString(input["destination"], "destination");
|
|
160
|
+
if (destination.isErr()) return destination;
|
|
161
|
+
const txId = optionalString(input["txId"], "txId");
|
|
162
|
+
if (txId.isErr()) return txId;
|
|
163
|
+
if (tool.type === "compare") {
|
|
164
|
+
const revised = args["revisedPath"];
|
|
165
|
+
if (typeof revised !== "string") return Result.err(invalidInput("revisedPath is required."));
|
|
166
|
+
const revisedPath = await checkWithinRoots({
|
|
167
|
+
roots: context.roots,
|
|
168
|
+
target: revised,
|
|
169
|
+
argument: "revisedPath"
|
|
170
|
+
});
|
|
171
|
+
if (revisedPath.isErr()) return revisedPath;
|
|
172
|
+
args["revisedPath"] = revisedPath.value;
|
|
173
|
+
}
|
|
174
|
+
const call = {
|
|
175
|
+
path: filePath.value,
|
|
176
|
+
fileVersion: fileVersion.value,
|
|
177
|
+
args
|
|
178
|
+
};
|
|
179
|
+
const access = toolAccess(tool);
|
|
180
|
+
if (access === "read" || access === "readOrWrite" && destination.value === void 0) return await executeReadTool(tool, call, context.bounds);
|
|
181
|
+
if (context.author === void 0) return Result.err(cliError({
|
|
182
|
+
code: FOLIO_CLI_ERROR_CODES.authorRequired,
|
|
183
|
+
message: "This server has no author configured for changes.",
|
|
184
|
+
hint: "Restart folio mcp with --author, FOLIO_AUTHOR, or a git user.name."
|
|
185
|
+
}));
|
|
186
|
+
const expectedDestinationVersion = optionalString(input["expectedDestinationVersion"], "expectedDestinationVersion");
|
|
187
|
+
if (expectedDestinationVersion.isErr()) return expectedDestinationVersion;
|
|
188
|
+
let target = { type: "inPlace" };
|
|
189
|
+
if (destination.value !== void 0) {
|
|
190
|
+
const checked = await checkWithinRoots({
|
|
191
|
+
roots: context.roots,
|
|
192
|
+
target: destination.value,
|
|
193
|
+
argument: "destination"
|
|
194
|
+
});
|
|
195
|
+
if (checked.isErr()) return checked;
|
|
196
|
+
target = {
|
|
197
|
+
type: "file",
|
|
198
|
+
path: checked.value,
|
|
199
|
+
overwrite: input["overwrite"] === true,
|
|
200
|
+
expectedVersion: expectedDestinationVersion.value
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return await executeWriteTool(tool, call, {
|
|
204
|
+
sourcePrecondition: "required",
|
|
205
|
+
destination: target,
|
|
206
|
+
author: context.author,
|
|
207
|
+
date: toRevisionDate(context.now()),
|
|
208
|
+
repack: input["allowRepack"] === true ? "allow" : "refuse",
|
|
209
|
+
force: false,
|
|
210
|
+
txId: txId.value,
|
|
211
|
+
journalPath: void 0,
|
|
212
|
+
mode: input["mode"] === "direct" ? "direct" : "tracked-changes"
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
const toCallToolResult = (result) => {
|
|
216
|
+
const envelope = result.isOk() ? successEnvelope(result.value) : failureEnvelope(result.error);
|
|
217
|
+
return {
|
|
218
|
+
content: [{
|
|
219
|
+
type: "text",
|
|
220
|
+
text: JSON.stringify(envelope)
|
|
221
|
+
}],
|
|
222
|
+
isError: !envelope.ok
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
const aboutText = (roots) => [
|
|
226
|
+
`# folio ${version}`,
|
|
227
|
+
"",
|
|
228
|
+
`Allowed roots: ${roots.join(", ")}`,
|
|
229
|
+
"",
|
|
230
|
+
"- fileVersion is the SHA-256 of the file's bytes; changes require the one you read.",
|
|
231
|
+
"- Block ids are the paragraph's w14:paraId (blockIdSource: package) or derived from text and",
|
|
232
|
+
" position (synthetic), valid only for the fileVersion they were read at.",
|
|
233
|
+
"- Offsets in range handles are UTF-16 code units into the block text read_document returns.",
|
|
234
|
+
"- A change writes atomically in place or to destination (a plain .docx, never a dotfile or inside",
|
|
235
|
+
" .folio); replacing an existing file needs overwrite plus expectedDestinationVersion. Whatever is",
|
|
236
|
+
" replaced is backed up in .folio/backups/<name>/. Changes are journaled in .folio/journal.jsonl and",
|
|
237
|
+
" refuse a full package repack unless allowRepack is true.",
|
|
238
|
+
"- A batch lands whole or not at all: stale_target, ambiguous_target, and other refusals write nothing.",
|
|
239
|
+
""
|
|
240
|
+
].join("\n");
|
|
241
|
+
const readResource = (uri, roots) => {
|
|
242
|
+
if (uri === ABOUT_URI) return { contents: [{
|
|
243
|
+
uri,
|
|
244
|
+
mimeType: "text/markdown",
|
|
245
|
+
text: aboutText(roots)
|
|
246
|
+
}] };
|
|
247
|
+
if (uri === OPERATIONS_SCHEMA_URI) return { contents: [{
|
|
248
|
+
uri,
|
|
249
|
+
mimeType: "application/schema+json",
|
|
250
|
+
text: JSON.stringify(FOLIO_DOCUMENT_OPERATION_BATCH_JSON_SCHEMA)
|
|
251
|
+
}] };
|
|
252
|
+
return null;
|
|
253
|
+
};
|
|
254
|
+
/** A low-level MCP server exposing the file tools; connect it to a transport. */
|
|
255
|
+
const createFolioMcpServer = (options) => {
|
|
256
|
+
const context = {
|
|
257
|
+
...options,
|
|
258
|
+
bounds: options.bounds ?? MCP_READ_BOUNDS,
|
|
259
|
+
now: options.now ?? (() => /* @__PURE__ */ new Date())
|
|
260
|
+
};
|
|
261
|
+
const server = new Server({
|
|
262
|
+
name: "folio",
|
|
263
|
+
version
|
|
264
|
+
}, {
|
|
265
|
+
capabilities: {
|
|
266
|
+
tools: {},
|
|
267
|
+
resources: {}
|
|
268
|
+
},
|
|
269
|
+
instructions: INSTRUCTIONS
|
|
270
|
+
});
|
|
271
|
+
server.setRequestHandler("tools/list", () => ({ tools: listMcpTools() }));
|
|
272
|
+
server.setRequestHandler("tools/call", async (request) => {
|
|
273
|
+
const tool = findFileTool(request.params.name);
|
|
274
|
+
if (tool === void 0) return toCallToolResult(Result.err(invalidInput(`Unknown tool ${JSON.stringify(request.params.name)}.`)));
|
|
275
|
+
const result = await Result.tryPromise({
|
|
276
|
+
try: () => runTool(tool, request.params.arguments, context),
|
|
277
|
+
catch: (error) => cliError({
|
|
278
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
279
|
+
message: error instanceof Error ? error.message : String(error)
|
|
280
|
+
})
|
|
281
|
+
});
|
|
282
|
+
return toCallToolResult(result.isOk() ? result.value : result);
|
|
283
|
+
});
|
|
284
|
+
server.setRequestHandler("resources/list", () => ({ resources: [{
|
|
285
|
+
uri: ABOUT_URI,
|
|
286
|
+
name: "about",
|
|
287
|
+
mimeType: "text/markdown"
|
|
288
|
+
}, {
|
|
289
|
+
uri: OPERATIONS_SCHEMA_URI,
|
|
290
|
+
name: "operations-schema",
|
|
291
|
+
mimeType: "application/schema+json"
|
|
292
|
+
}] }));
|
|
293
|
+
server.setRequestHandler("resources/read", (request) => {
|
|
294
|
+
const resource = readResource(request.params.uri, context.roots);
|
|
295
|
+
if (resource === null) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown resource: ${request.params.uri}`);
|
|
296
|
+
return resource;
|
|
297
|
+
});
|
|
298
|
+
return server;
|
|
299
|
+
};
|
|
300
|
+
/** Serve the file tools over stdio until the client disconnects. */
|
|
301
|
+
const serveFolioMcp = async ({ input, output, ...options }) => {
|
|
302
|
+
const server = createFolioMcpServer(options);
|
|
303
|
+
const closed = new Promise((resolve) => {
|
|
304
|
+
server.onclose = () => resolve();
|
|
305
|
+
});
|
|
306
|
+
await server.connect(new StdioServerTransport(input, output));
|
|
307
|
+
await closed;
|
|
308
|
+
};
|
|
309
|
+
//#endregion
|
|
310
|
+
export { MCP_READ_BOUNDS, createFolioMcpServer, listMcpTools, mcpInputSchema, serveFolioMcp };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { FileIdentity } from "./document.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
//#region src/output-file.d.ts
|
|
5
|
+
type WriteOutputOptions = {
|
|
6
|
+
target: string;
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
overwrite: boolean;
|
|
9
|
+
/** The document rendered, which the output must not replace. */
|
|
10
|
+
input: FileIdentity;
|
|
11
|
+
};
|
|
12
|
+
declare const writeOutputFile: ({ target, bytes, overwrite, input }: WriteOutputOptions) => Promise<Result<string, FolioCliError>>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { writeOutputFile };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NO_FOLLOW, errnoCode, sameFile } from "./document.js";
|
|
2
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { open, realpath, rename, rm, stat } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
//#region src/output-file.ts
|
|
9
|
+
/**
|
|
10
|
+
* Write a rendered file (PDF, PNG, HTML) next to where the caller asked: to a
|
|
11
|
+
* temporary name in the same directory, flushed, then renamed into place, so
|
|
12
|
+
* a reader never sees half a file. An existing file is replaced only with
|
|
13
|
+
* `overwrite`, and never when it is the input document.
|
|
14
|
+
*/
|
|
15
|
+
const fileSystemError = (message, error) => cliError({
|
|
16
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
17
|
+
message: `${message}: ${error instanceof Error ? error.message : String(error)}`
|
|
18
|
+
});
|
|
19
|
+
const writeOutputFile = async ({ target, bytes, overwrite, input }) => {
|
|
20
|
+
const absolute = path.resolve(target);
|
|
21
|
+
const directory = await Result.tryPromise(() => realpath(path.dirname(absolute)));
|
|
22
|
+
if (directory.isErr()) return Result.err(cliError({
|
|
23
|
+
code: FOLIO_CLI_ERROR_CODES.notFound,
|
|
24
|
+
message: `No directory ${path.dirname(absolute)}.`
|
|
25
|
+
}));
|
|
26
|
+
const destination = path.join(directory.value, path.basename(absolute));
|
|
27
|
+
const existing = await Result.tryPromise(() => stat(destination));
|
|
28
|
+
if (existing.isOk()) {
|
|
29
|
+
if (sameFile({
|
|
30
|
+
dev: existing.value.dev,
|
|
31
|
+
ino: existing.value.ino
|
|
32
|
+
}, input)) return Result.err(cliError({
|
|
33
|
+
code: FOLIO_CLI_ERROR_CODES.invalidDestination,
|
|
34
|
+
message: `${destination} is the document being rendered.`
|
|
35
|
+
}));
|
|
36
|
+
if (!overwrite) return Result.err(cliError({
|
|
37
|
+
code: FOLIO_CLI_ERROR_CODES.destinationExists,
|
|
38
|
+
message: `${destination} already exists.`,
|
|
39
|
+
hint: "Pass --overwrite to replace it."
|
|
40
|
+
}));
|
|
41
|
+
} else if (errnoCode(existing.error.cause) !== "ENOENT") return Result.err(fileSystemError(`Cannot inspect ${destination}`, existing.error.cause));
|
|
42
|
+
const temporary = path.join(directory.value, `.${path.basename(absolute)}.${randomUUID()}.tmp`);
|
|
43
|
+
const written = await Result.tryPromise(async () => {
|
|
44
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 420);
|
|
45
|
+
try {
|
|
46
|
+
await handle.writeFile(bytes);
|
|
47
|
+
await handle.sync();
|
|
48
|
+
} finally {
|
|
49
|
+
await handle.close();
|
|
50
|
+
}
|
|
51
|
+
await rename(temporary, destination);
|
|
52
|
+
});
|
|
53
|
+
if (written.isErr()) {
|
|
54
|
+
await rm(temporary, { force: true });
|
|
55
|
+
return Result.err(fileSystemError(`Cannot write ${destination}`, written.error.cause));
|
|
56
|
+
}
|
|
57
|
+
return Result.ok(destination);
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
export { writeOutputFile };
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FolioCliError, FolioCliErrorCode } from "./errors.js";
|
|
2
|
+
//#region src/output.d.ts
|
|
3
|
+
declare const OUTPUT_FORMATS: readonly ["json", "text"];
|
|
4
|
+
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
5
|
+
declare const isOutputFormat: (value: unknown) => value is OutputFormat;
|
|
6
|
+
type FailureBody = {
|
|
7
|
+
code: FolioCliErrorCode;
|
|
8
|
+
message: string;
|
|
9
|
+
hint?: string;
|
|
10
|
+
details?: unknown;
|
|
11
|
+
};
|
|
12
|
+
type Envelope = {
|
|
13
|
+
ok: true;
|
|
14
|
+
data: unknown;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
error: FailureBody;
|
|
18
|
+
};
|
|
19
|
+
declare const successEnvelope: (data: unknown) => Envelope;
|
|
20
|
+
declare const failureEnvelope: ({ code, message, hint, details }: FolioCliError) => Envelope;
|
|
21
|
+
type RenderedOutput = {
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
};
|
|
25
|
+
type RenderEnvelopeOptions = {
|
|
26
|
+
envelope: Envelope;
|
|
27
|
+
format: OutputFormat;
|
|
28
|
+
/** The tool that produced the data, for its text rendering. */
|
|
29
|
+
tool: string;
|
|
30
|
+
};
|
|
31
|
+
declare const renderEnvelope: ({ envelope, format, tool }: RenderEnvelopeOptions) => RenderedOutput;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { Envelope, FailureBody, OUTPUT_FORMATS, OutputFormat, RenderedOutput, failureEnvelope, isOutputFormat, renderEnvelope, successEnvelope };
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region src/output.ts
|
|
2
|
+
const OUTPUT_FORMATS = ["json", "text"];
|
|
3
|
+
const isOutputFormat = (value) => OUTPUT_FORMATS.some((format) => format === value);
|
|
4
|
+
const successEnvelope = (data) => ({
|
|
5
|
+
ok: true,
|
|
6
|
+
data
|
|
7
|
+
});
|
|
8
|
+
const failureEnvelope = ({ code, message, hint, details }) => ({
|
|
9
|
+
ok: false,
|
|
10
|
+
error: {
|
|
11
|
+
code,
|
|
12
|
+
message,
|
|
13
|
+
...hint !== void 0 && { hint },
|
|
14
|
+
...details !== void 0 && { details }
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
19
|
+
const renderBlocks = (blocks) => blocks.filter(isRecord).map((block) => `[${text(block["blockId"])}] ${text(block["text"])}`);
|
|
20
|
+
const renderComments = (comments) => {
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const comment of comments.filter(isRecord)) {
|
|
23
|
+
const status = comment["resolved"] === true ? " (resolved)" : "";
|
|
24
|
+
lines.push(`#${text(comment["id"])} ${text(comment["author"])}${status}: ${text(comment["text"])}`);
|
|
25
|
+
const replies = Array.isArray(comment["replies"]) ? comment["replies"] : [];
|
|
26
|
+
for (const reply of replies.filter(isRecord)) lines.push(` #${text(reply["id"])} ${text(reply["author"])}: ${text(reply["text"])}`);
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
};
|
|
30
|
+
const renderChanges = (changes) => changes.filter(isRecord).map((change) => `#${String(change["id"])} ${text(change["type"])} by ${text(change["author"])}: ${text(change["text"])}`);
|
|
31
|
+
const renderMatches = (matches) => matches.filter(isRecord).map((match) => `[${text(match["blockId"]) || "story"}] ${text(match["context"])}`);
|
|
32
|
+
/** Readable lines for a tool result, or `null` to fall back to pretty JSON. */
|
|
33
|
+
const renderResult = (tool, result) => {
|
|
34
|
+
if (tool === "read_document" && isRecord(result) && Array.isArray(result["blocks"])) {
|
|
35
|
+
const lines = renderBlocks(result["blocks"]);
|
|
36
|
+
if (typeof result["nextCursor"] === "string") lines.push(`(more: --cursor ${result["nextCursor"]})`);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
if (tool === "read_section" && isRecord(result) && Array.isArray(result["blocks"])) return renderBlocks(result["blocks"]);
|
|
40
|
+
if (tool === "find_text" && isRecord(result) && Array.isArray(result["matches"])) return [...renderMatches(result["matches"]), `(${String(result["totalMatches"])} matches)`];
|
|
41
|
+
if (tool === "read_comments" && Array.isArray(result)) return renderComments(result);
|
|
42
|
+
if (tool === "read_changes" && Array.isArray(result)) return renderChanges(result);
|
|
43
|
+
if (tool === "compare_documents" && isRecord(result) && typeof result["text"] === "string") return [result["text"]];
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const shortVersion = (value) => text(value).slice(0, 12);
|
|
47
|
+
/** One line per committed or replayed transaction. */
|
|
48
|
+
const renderReceipt = (receipt) => {
|
|
49
|
+
const parts = Array.isArray(receipt["changedParts"]) ? receipt["changedParts"].length : 0;
|
|
50
|
+
const backup = typeof receipt["backup"] === "string" ? `\nbackup: ${receipt["backup"]}` : "";
|
|
51
|
+
return `${text(receipt["status"])} ${text(receipt["txId"])}: ${text(receipt["path"])} ${shortVersion(receipt["fromVersion"])} -> ${shortVersion(receipt["fileVersion"])} (${text(receipt["saveStrategy"])}, ${parts} part${parts === 1 ? "" : "s"} changed)${backup}\n`;
|
|
52
|
+
};
|
|
53
|
+
const renderData = (tool, data) => {
|
|
54
|
+
if (!isRecord(data)) return `${JSON.stringify(data, null, 2)}\n`;
|
|
55
|
+
if (typeof data["txId"] === "string" && typeof data["status"] === "string") return renderReceipt(data);
|
|
56
|
+
const header = typeof data["path"] === "string" && typeof data["fileVersion"] === "string" ? [`${data["path"]} @ ${data["fileVersion"]}`] : [];
|
|
57
|
+
const lines = renderResult(tool, data["result"]);
|
|
58
|
+
if (lines === null) return `${JSON.stringify(data, null, 2)}\n`;
|
|
59
|
+
return `${[...header, ...lines].join("\n")}\n`;
|
|
60
|
+
};
|
|
61
|
+
const renderEnvelope = ({ envelope, format, tool }) => {
|
|
62
|
+
if (format === "json") return {
|
|
63
|
+
stdout: `${JSON.stringify(envelope)}\n`,
|
|
64
|
+
stderr: ""
|
|
65
|
+
};
|
|
66
|
+
if (envelope.ok) return {
|
|
67
|
+
stdout: renderData(tool, envelope.data),
|
|
68
|
+
stderr: ""
|
|
69
|
+
};
|
|
70
|
+
const hint = envelope.error.hint === void 0 ? "" : `hint: ${envelope.error.hint}\n`;
|
|
71
|
+
return {
|
|
72
|
+
stdout: "",
|
|
73
|
+
stderr: `error: ${envelope.error.message}\n${hint}`
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { OUTPUT_FORMATS, failureEnvelope, isOutputFormat, renderEnvelope, successEnvelope };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/package-parts.d.ts
|
|
4
|
+
type PartChange = "added" | "modified" | "removed";
|
|
5
|
+
type ChangedPart = {
|
|
6
|
+
part: string;
|
|
7
|
+
change: PartChange;
|
|
8
|
+
};
|
|
9
|
+
/** Parts added, removed, or whose uncompressed content differs, sorted by name. */
|
|
10
|
+
declare const diffPackages: (before: Uint8Array, after: Uint8Array) => Promise<Result<ChangedPart[], FolioCliError>>;
|
|
11
|
+
/**
|
|
12
|
+
* The first revision id above every WordprocessingML `id` attribute in the
|
|
13
|
+
* package, so new revisions never collide with an existing one.
|
|
14
|
+
*/
|
|
15
|
+
declare const nextRevisionIdSeed: (bytes: Uint8Array) => Promise<Result<number, FolioCliError>>;
|
|
16
|
+
/**
|
|
17
|
+
* Check what a save wrote before it replaces anything: the package opens,
|
|
18
|
+
* every changed XML part is well formed, every relationship id a changed
|
|
19
|
+
* part references resolves, every internal target a changed `.rels` part
|
|
20
|
+
* names exists, and the package parses as a document again.
|
|
21
|
+
*/
|
|
22
|
+
declare const checkPackageIntegrity: (bytes: Uint8Array<ArrayBuffer>, changedParts: readonly ChangedPart[]) => Promise<Result<void, FolioCliError>>;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { ChangedPart, PartChange, checkPackageIntegrity, diffPackages, nextRevisionIdSeed };
|