@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
package/dist/registry.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { panic } from "better-result";
|
|
2
|
+
import { FOLIO_AGENT_TOOL_NAMES } from "@stll/folio-agents/types";
|
|
3
|
+
import { getFolioToolDefinitions } from "@stll/folio-agents/tools";
|
|
4
|
+
//#region src/registry.ts
|
|
5
|
+
/**
|
|
6
|
+
* The one tool vocabulary behind both surfaces. Every entry is an MCP tool
|
|
7
|
+
* (its `name`, description, and envelope-wrapped input schema) and one or
|
|
8
|
+
* more `folio` commands whose flags are generated from the same schema. Tools
|
|
9
|
+
* that exist in `@stll/folio-agents` reuse its definitions verbatim; the file
|
|
10
|
+
* envelope (`path`, `fileVersion`, ...) is added by each surface.
|
|
11
|
+
*/
|
|
12
|
+
const toolAccess = (tool) => {
|
|
13
|
+
switch (tool.type) {
|
|
14
|
+
case "agentRead": return "read";
|
|
15
|
+
case "agentWrite":
|
|
16
|
+
case "resolveChanges": return "write";
|
|
17
|
+
case "compare": return "readOrWrite";
|
|
18
|
+
default: return panic("Unhandled tool type", { unreachable: tool });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const agentDefinitions = new Map(getFolioToolDefinitions().map((definition) => [definition.name, definition]));
|
|
22
|
+
const isJsonObjectSchema = (schema) => typeof schema === "object" && schema !== null && "type" in schema && schema.type === "object" && "properties" in schema && typeof schema.properties === "object" && schema.properties !== null && "required" in schema && Array.isArray(schema.required);
|
|
23
|
+
const agentDefinition = (name) => {
|
|
24
|
+
const definition = agentDefinitions.get(name) ?? panic(`@stll/folio-agents has no tool named ${name}`);
|
|
25
|
+
const schema = definition.inputSchema;
|
|
26
|
+
if (!isJsonObjectSchema(schema)) return panic(`@stll/folio-agents tool ${name} has no object input schema`);
|
|
27
|
+
return {
|
|
28
|
+
description: definition.description,
|
|
29
|
+
schema
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const agentArgsSchema = (schema, extraProperties) => ({
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
...schema.properties,
|
|
36
|
+
...extraProperties
|
|
37
|
+
},
|
|
38
|
+
required: schema.required,
|
|
39
|
+
additionalProperties: false
|
|
40
|
+
});
|
|
41
|
+
const agentRead = ({ agentTool, commands, extraProperties = {} }) => {
|
|
42
|
+
const { description, schema } = agentDefinition(agentTool);
|
|
43
|
+
return {
|
|
44
|
+
type: "agentRead",
|
|
45
|
+
name: agentTool,
|
|
46
|
+
agentTool,
|
|
47
|
+
description,
|
|
48
|
+
argsSchema: agentArgsSchema(schema, extraProperties),
|
|
49
|
+
commands
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const agentWrite = ({ agentTool, commands, editMode }) => {
|
|
53
|
+
const { description, schema } = agentDefinition(agentTool);
|
|
54
|
+
return {
|
|
55
|
+
type: "agentWrite",
|
|
56
|
+
name: agentTool,
|
|
57
|
+
agentTool,
|
|
58
|
+
editMode,
|
|
59
|
+
description,
|
|
60
|
+
argsSchema: agentArgsSchema(schema, {}),
|
|
61
|
+
commands
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/** Largest page `read_document` returns on any surface. */
|
|
65
|
+
const MAX_READ_BLOCKS = 1e3;
|
|
66
|
+
/**
|
|
67
|
+
* Paging arguments the file surface adds to `read_document`: the agents tool
|
|
68
|
+
* returns the whole body, which a large contract makes too big for one reply.
|
|
69
|
+
*/
|
|
70
|
+
const READ_DOCUMENT_PAGING_PROPERTIES = {
|
|
71
|
+
maxBlocks: {
|
|
72
|
+
type: "integer",
|
|
73
|
+
minimum: 1,
|
|
74
|
+
maximum: MAX_READ_BLOCKS,
|
|
75
|
+
description: "Blocks per page. The response carries `nextCursor` when more remain."
|
|
76
|
+
},
|
|
77
|
+
cursor: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "`nextCursor` from the previous page. A cursor is bound to the fileVersion it was issued for."
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const RESOLVE_CHANGE_ACTIONS = ["accept", "reject"];
|
|
83
|
+
const RESOLVE_CHANGES_TOOL = {
|
|
84
|
+
type: "resolveChanges",
|
|
85
|
+
name: "resolve_changes",
|
|
86
|
+
description: "Accept or reject pending tracked changes. Pass the ids from `read_changes`, or `all: true` for every change in every story; exactly one of the two. Accepting keeps an insertion and drops a deletion; rejecting does the reverse.",
|
|
87
|
+
argsSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
action: {
|
|
91
|
+
type: "string",
|
|
92
|
+
enum: RESOLVE_CHANGE_ACTIONS,
|
|
93
|
+
description: "accept or reject."
|
|
94
|
+
},
|
|
95
|
+
ids: {
|
|
96
|
+
type: "array",
|
|
97
|
+
items: { type: "string" },
|
|
98
|
+
minItems: 1,
|
|
99
|
+
description: "Change ids from read_changes."
|
|
100
|
+
},
|
|
101
|
+
all: {
|
|
102
|
+
type: "boolean",
|
|
103
|
+
description: "Resolve every pending change."
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
required: ["action"],
|
|
107
|
+
additionalProperties: false
|
|
108
|
+
},
|
|
109
|
+
commands: [{
|
|
110
|
+
name: "accept",
|
|
111
|
+
summary: "Accept tracked changes by id, or all of them",
|
|
112
|
+
preset: { action: "accept" },
|
|
113
|
+
flagNames: { ids: "id" }
|
|
114
|
+
}, {
|
|
115
|
+
name: "reject",
|
|
116
|
+
summary: "Reject tracked changes by id, or all of them",
|
|
117
|
+
preset: { action: "reject" },
|
|
118
|
+
flagNames: { ids: "id" }
|
|
119
|
+
}]
|
|
120
|
+
};
|
|
121
|
+
const FOLIO_FILE_TOOLS = [
|
|
122
|
+
agentRead({
|
|
123
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.readDocument,
|
|
124
|
+
commands: [{
|
|
125
|
+
name: "read",
|
|
126
|
+
summary: "Print the document's blocks with ids and text hashes"
|
|
127
|
+
}],
|
|
128
|
+
extraProperties: READ_DOCUMENT_PAGING_PROPERTIES
|
|
129
|
+
}),
|
|
130
|
+
agentRead({
|
|
131
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.getDocumentOutline,
|
|
132
|
+
commands: [{
|
|
133
|
+
name: "outline",
|
|
134
|
+
summary: "Print the heading outline and section handles"
|
|
135
|
+
}]
|
|
136
|
+
}),
|
|
137
|
+
agentRead({
|
|
138
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.readSection,
|
|
139
|
+
commands: [{
|
|
140
|
+
name: "section",
|
|
141
|
+
summary: "Print one section's blocks by its outline handle"
|
|
142
|
+
}]
|
|
143
|
+
}),
|
|
144
|
+
agentRead({
|
|
145
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.listStories,
|
|
146
|
+
commands: [{
|
|
147
|
+
name: "stories",
|
|
148
|
+
summary: "List header, footer, and note stories"
|
|
149
|
+
}]
|
|
150
|
+
}),
|
|
151
|
+
agentRead({
|
|
152
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.readStory,
|
|
153
|
+
commands: [{
|
|
154
|
+
name: "story",
|
|
155
|
+
summary: "Print one story by its handle"
|
|
156
|
+
}]
|
|
157
|
+
}),
|
|
158
|
+
agentRead({
|
|
159
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.findText,
|
|
160
|
+
commands: [{
|
|
161
|
+
name: "find",
|
|
162
|
+
summary: "Find exact text and print range handles"
|
|
163
|
+
}]
|
|
164
|
+
}),
|
|
165
|
+
agentRead({
|
|
166
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.readComments,
|
|
167
|
+
commands: [{
|
|
168
|
+
name: "comments",
|
|
169
|
+
summary: "Print comment threads"
|
|
170
|
+
}]
|
|
171
|
+
}),
|
|
172
|
+
agentRead({
|
|
173
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.readChanges,
|
|
174
|
+
commands: [{
|
|
175
|
+
name: "changes",
|
|
176
|
+
summary: "Print pending tracked changes"
|
|
177
|
+
}]
|
|
178
|
+
}),
|
|
179
|
+
agentWrite({
|
|
180
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.suggestChanges,
|
|
181
|
+
editMode: "tracked-or-direct",
|
|
182
|
+
commands: [{
|
|
183
|
+
name: "suggest",
|
|
184
|
+
summary: "Apply a batch of edit operations as tracked changes",
|
|
185
|
+
inputArrayProperty: "operations"
|
|
186
|
+
}]
|
|
187
|
+
}),
|
|
188
|
+
agentWrite({
|
|
189
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.addComment,
|
|
190
|
+
editMode: "fixed",
|
|
191
|
+
commands: [{
|
|
192
|
+
name: "comment",
|
|
193
|
+
summary: "Comment on a block, optionally quoting text in it"
|
|
194
|
+
}]
|
|
195
|
+
}),
|
|
196
|
+
agentWrite({
|
|
197
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.replyComment,
|
|
198
|
+
editMode: "fixed",
|
|
199
|
+
commands: [{
|
|
200
|
+
name: "reply",
|
|
201
|
+
summary: "Reply to a comment thread"
|
|
202
|
+
}]
|
|
203
|
+
}),
|
|
204
|
+
agentWrite({
|
|
205
|
+
agentTool: FOLIO_AGENT_TOOL_NAMES.resolveComment,
|
|
206
|
+
editMode: "fixed",
|
|
207
|
+
commands: [{
|
|
208
|
+
name: "resolve",
|
|
209
|
+
summary: "Resolve or reopen a comment thread"
|
|
210
|
+
}]
|
|
211
|
+
}),
|
|
212
|
+
RESOLVE_CHANGES_TOOL,
|
|
213
|
+
{
|
|
214
|
+
type: "compare",
|
|
215
|
+
name: "compare_documents",
|
|
216
|
+
description: "Compare a base `.docx` (`path`) with a revised one (`revisedPath`). Without a destination, returns the block-level differences. With one, writes the base package carrying the differences as tracked changes (a redline) there; the inputs are never modified.",
|
|
217
|
+
argsSchema: {
|
|
218
|
+
type: "object",
|
|
219
|
+
properties: {
|
|
220
|
+
revisedPath: {
|
|
221
|
+
type: "string",
|
|
222
|
+
description: "The revised .docx."
|
|
223
|
+
},
|
|
224
|
+
revisedFileVersion: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "Refuse unless the revised file still has this SHA-256 fileVersion."
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
required: ["revisedPath"],
|
|
230
|
+
additionalProperties: false
|
|
231
|
+
},
|
|
232
|
+
commands: [{
|
|
233
|
+
name: "compare",
|
|
234
|
+
summary: "Diff two files, or write their redline with -o",
|
|
235
|
+
positionals: [{
|
|
236
|
+
name: "revised",
|
|
237
|
+
property: "revisedPath"
|
|
238
|
+
}]
|
|
239
|
+
}]
|
|
240
|
+
}
|
|
241
|
+
];
|
|
242
|
+
const toolsByName = new Map(FOLIO_FILE_TOOLS.map((tool) => [tool.name, tool]));
|
|
243
|
+
const findFileTool = (name) => toolsByName.get(name);
|
|
244
|
+
const commandsByName = /* @__PURE__ */ new Map();
|
|
245
|
+
for (const tool of FOLIO_FILE_TOOLS) for (const command of tool.commands) {
|
|
246
|
+
if (commandsByName.has(command.name)) panic(`two tools declare the command ${command.name}`);
|
|
247
|
+
commandsByName.set(command.name, {
|
|
248
|
+
tool,
|
|
249
|
+
command
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const findCommand = (name) => commandsByName.get(name);
|
|
253
|
+
const listCommands = () => [...commandsByName.values()];
|
|
254
|
+
//#endregion
|
|
255
|
+
export { FOLIO_FILE_TOOLS, MAX_READ_BLOCKS, RESOLVE_CHANGE_ACTIONS, findCommand, findFileTool, listCommands, toolAccess };
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { LoadedFile } from "./document.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { DisplayList } from "@stll/folio-core/display-list/types";
|
|
5
|
+
import { FontsourceFaces } from "@stll/folio-core/fonts/fontsourceFaces";
|
|
6
|
+
//#region src/render.d.ts
|
|
7
|
+
declare const RENDER_FORMATS: readonly ["pdf", "png", "html"];
|
|
8
|
+
type RenderFormat = (typeof RENDER_FORMATS)[number];
|
|
9
|
+
/** The CLI's `@fontsource` faces, shared by every render in the process. */
|
|
10
|
+
declare const cliFontFaces: () => FontsourceFaces;
|
|
11
|
+
/** The document's display list, measured with the CLI's faces. */
|
|
12
|
+
declare const buildDisplayList: (file: LoadedFile) => Promise<Result<DisplayList, FolioCliError>>;
|
|
13
|
+
/** Refuse page numbers the document does not have; `undefined` keeps every page. */
|
|
14
|
+
declare const checkPages: (list: DisplayList, pages: readonly number[] | undefined) => Result<DisplayList, FolioCliError>;
|
|
15
|
+
/** Standalone HTML for the list, with its faces inlined. */
|
|
16
|
+
declare const displayListHtml: (list: DisplayList, title: string) => string;
|
|
17
|
+
type RenderPdfOptions = {
|
|
18
|
+
file: LoadedFile;
|
|
19
|
+
pages: readonly number[] | undefined;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
};
|
|
22
|
+
type RenderedOutput = {
|
|
23
|
+
bytes: Uint8Array;
|
|
24
|
+
pageCount: number;
|
|
25
|
+
};
|
|
26
|
+
declare const renderPdf: ({ file, pages, timestamp }: RenderPdfOptions) => Promise<Result<RenderedOutput, FolioCliError>>;
|
|
27
|
+
type RenderPngOptions = {
|
|
28
|
+
file: LoadedFile;
|
|
29
|
+
page: number;
|
|
30
|
+
scale: number;
|
|
31
|
+
};
|
|
32
|
+
/** One page as a PNG: the DOM backend's page, screenshotted in headless Chromium. */
|
|
33
|
+
declare const renderPng: ({ file, page, scale }: RenderPngOptions) => Promise<Result<RenderedOutput, FolioCliError>>;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { RENDER_FORMATS, RenderFormat, RenderedOutput, buildDisplayList, checkPages, cliFontFaces, displayListHtml, renderPdf, renderPng };
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { selectDisplayPages } from "@stll/folio-core/display-list/selectDisplayPages";
|
|
7
|
+
import { renderDisplayListToHtml } from "@stll/folio-core/display-list/html/renderDisplayListToHtml";
|
|
8
|
+
import { buildDocxDisplayList, writeDisplayListPdf } from "@stll/folio-core/export-pdf";
|
|
9
|
+
import { createFontsourceFaces } from "@stll/folio-core/fonts/fontsourceFaces";
|
|
10
|
+
//#region src/render.ts
|
|
11
|
+
/**
|
|
12
|
+
* Render a document with folio's own layout and painters, headlessly: PDF
|
|
13
|
+
* from the PDF backend, HTML from the DOM backend, and PNG by screenshotting
|
|
14
|
+
* that HTML in Chromium. Every format paints the one display list built from
|
|
15
|
+
* one layout, measured with the `@fontsource` faces the CLI installs.
|
|
16
|
+
*
|
|
17
|
+
* PNG needs a browser. `playwright-core` is an optional peer dependency and
|
|
18
|
+
* is imported only when a PNG is asked for.
|
|
19
|
+
*/
|
|
20
|
+
const RENDER_FORMATS = [
|
|
21
|
+
"pdf",
|
|
22
|
+
"png",
|
|
23
|
+
"html"
|
|
24
|
+
];
|
|
25
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
26
|
+
/** The installed `@fontsource/<name>` directory, or `null` when it is not installed. */
|
|
27
|
+
const fontsourceDirectory = (packageName) => {
|
|
28
|
+
const resolved = Result.try(() => requireFromHere.resolve(`@fontsource/${packageName}/package.json`));
|
|
29
|
+
return resolved.isOk() ? path.dirname(resolved.value) : null;
|
|
30
|
+
};
|
|
31
|
+
let faces;
|
|
32
|
+
/** The CLI's `@fontsource` faces, shared by every render in the process. */
|
|
33
|
+
const cliFontFaces = () => {
|
|
34
|
+
faces ??= createFontsourceFaces({ read: (packageName, relativePath) => {
|
|
35
|
+
const directory = fontsourceDirectory(packageName);
|
|
36
|
+
if (directory === null) return null;
|
|
37
|
+
const filePath = path.join(directory, relativePath);
|
|
38
|
+
return existsSync(filePath) ? new Uint8Array(readFileSync(filePath)) : null;
|
|
39
|
+
} });
|
|
40
|
+
return faces;
|
|
41
|
+
};
|
|
42
|
+
const renderError = (message) => cliError({
|
|
43
|
+
code: FOLIO_CLI_ERROR_CODES.invalidDocument,
|
|
44
|
+
message
|
|
45
|
+
});
|
|
46
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
47
|
+
/** The document's display list, measured with the CLI's faces. */
|
|
48
|
+
const buildDisplayList = async (file) => {
|
|
49
|
+
const built = await buildDocxDisplayList(file.bytes.slice().buffer, { fonts: cliFontFaces().source });
|
|
50
|
+
return built.isOk() ? Result.ok(built.value.list) : Result.err(renderError(`${file.path} could not be laid out: ${built.error.message}`));
|
|
51
|
+
};
|
|
52
|
+
/** Refuse page numbers the document does not have; `undefined` keeps every page. */
|
|
53
|
+
const checkPages = (list, pages) => {
|
|
54
|
+
if (pages === void 0) return Result.ok(list);
|
|
55
|
+
const missing = pages.filter((page) => !Number.isInteger(page) || page < 1 || page > list.pages.length);
|
|
56
|
+
if (missing.length > 0) return Result.err(cliError({
|
|
57
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
58
|
+
message: `The document has ${list.pages.length} page${list.pages.length === 1 ? "" : "s"}; no page ${missing.join(", ")}.`
|
|
59
|
+
}));
|
|
60
|
+
return Result.ok(selectDisplayPages(list, pages.map((page) => page - 1)));
|
|
61
|
+
};
|
|
62
|
+
/** Standalone HTML for the list, with its faces inlined. */
|
|
63
|
+
const displayListHtml = (list, title) => renderDisplayListToHtml(list, {
|
|
64
|
+
title,
|
|
65
|
+
fontFaceCss: cliFontFaces().fontFaceCss({ families: list.fonts.map((face) => face.family) }),
|
|
66
|
+
pageGapPx: 16,
|
|
67
|
+
canvasColor: "#e8e8e8"
|
|
68
|
+
});
|
|
69
|
+
const renderPdf = async ({ file, pages, timestamp }) => {
|
|
70
|
+
const list = await buildDisplayList(file);
|
|
71
|
+
if (list.isErr()) return Result.err(list.error);
|
|
72
|
+
const selected = checkPages(list.value, pages);
|
|
73
|
+
if (selected.isErr()) return Result.err(selected.error);
|
|
74
|
+
const exported = await writeDisplayListPdf(selected.value, {
|
|
75
|
+
fonts: cliFontFaces().source,
|
|
76
|
+
timestamp,
|
|
77
|
+
producer: "folio"
|
|
78
|
+
});
|
|
79
|
+
return exported.isOk() ? Result.ok({
|
|
80
|
+
bytes: exported.value.bytes,
|
|
81
|
+
pageCount: exported.value.pageCount
|
|
82
|
+
}) : Result.err(renderError(`${file.path} could not be exported: ${exported.error.message}`));
|
|
83
|
+
};
|
|
84
|
+
const hasChromium = (module) => typeof module === "object" && module !== null && "chromium" in module && typeof module.chromium === "object" && module.chromium !== null && "launch" in module.chromium;
|
|
85
|
+
const rasterUnavailable = (detail) => cliError({
|
|
86
|
+
code: FOLIO_CLI_ERROR_CODES.rendererUnavailable,
|
|
87
|
+
message: `PNG rendering needs Chromium through playwright-core: ${detail}`,
|
|
88
|
+
hint: "Install it next to the CLI (npm install playwright-core) and its browser (npx playwright-core install chromium), or render a PDF."
|
|
89
|
+
});
|
|
90
|
+
/** One page as a PNG: the DOM backend's page, screenshotted in headless Chromium. */
|
|
91
|
+
const renderPng = async ({ file, page, scale }) => {
|
|
92
|
+
const list = await buildDisplayList(file);
|
|
93
|
+
if (list.isErr()) return Result.err(list.error);
|
|
94
|
+
const selected = checkPages(list.value, [page]);
|
|
95
|
+
if (selected.isErr()) return Result.err(selected.error);
|
|
96
|
+
const imported = await Result.tryPromise(() => import("playwright-core"));
|
|
97
|
+
if (imported.isErr()) return Result.err(rasterUnavailable("the package is not installed"));
|
|
98
|
+
const module = imported.value;
|
|
99
|
+
if (!hasChromium(module)) return Result.err(rasterUnavailable("the package has no chromium"));
|
|
100
|
+
const shot = await Result.tryPromise({
|
|
101
|
+
try: async () => {
|
|
102
|
+
const browser = await module.chromium.launch({ headless: true });
|
|
103
|
+
try {
|
|
104
|
+
const tab = await browser.newPage({ deviceScaleFactor: scale });
|
|
105
|
+
await tab.setContent(displayListHtml(selected.value, path.basename(file.path)), { waitUntil: "load" });
|
|
106
|
+
await tab.evaluate("document.fonts.ready");
|
|
107
|
+
return await tab.locator(".layout-page").first().screenshot({ type: "png" });
|
|
108
|
+
} finally {
|
|
109
|
+
await browser.close();
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
catch: (error) => rasterUnavailable(describe(error))
|
|
113
|
+
});
|
|
114
|
+
return shot.isOk() ? Result.ok({
|
|
115
|
+
bytes: shot.value,
|
|
116
|
+
pageCount: 1
|
|
117
|
+
}) : Result.err(shot.error);
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { RENDER_FORMATS, buildDisplayList, checkPages, cliFontFaces, displayListHtml, renderPdf, renderPng };
|
package/dist/roots.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/roots.d.ts
|
|
4
|
+
/** Real paths of the allowed root directories. */
|
|
5
|
+
type AllowedRoots = readonly string[];
|
|
6
|
+
/** Resolve each root to its real path; every root must be an existing directory. */
|
|
7
|
+
declare const resolveRoots: (roots: readonly string[]) => Promise<Result<AllowedRoots, FolioCliError>>;
|
|
8
|
+
type CheckWithinRootsOptions = {
|
|
9
|
+
roots: AllowedRoots;
|
|
10
|
+
/** As the caller passed it; a relative path resolves against the first root. */
|
|
11
|
+
target: string;
|
|
12
|
+
/** Which argument named the path, for the refusal message. */
|
|
13
|
+
argument: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Refuse a path outside every allowed root, returning the resolved path the
|
|
17
|
+
* call should use.
|
|
18
|
+
*/
|
|
19
|
+
declare const checkWithinRoots: ({ roots, target, argument }: CheckWithinRootsOptions) => Promise<Result<string, FolioCliError>>;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { AllowedRoots, checkWithinRoots, resolveRoots };
|
package/dist/roots.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import { realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/roots.ts
|
|
6
|
+
/**
|
|
7
|
+
* Allowed roots for the MCP server: every path a tool call reads or writes
|
|
8
|
+
* must resolve, through symlinks, inside one of them. A destination that does
|
|
9
|
+
* not exist yet is checked through its parent directory.
|
|
10
|
+
*
|
|
11
|
+
* The check resolves a path once; the write path re-checks what it opens
|
|
12
|
+
* (device and inode) and the directory's real path before its rename. A
|
|
13
|
+
* local process renaming directories under a root while a call runs can
|
|
14
|
+
* still race the check; the package README lists this as a known limit.
|
|
15
|
+
*/
|
|
16
|
+
/** Resolve each root to its real path; every root must be an existing directory. */
|
|
17
|
+
const resolveRoots = async (roots) => {
|
|
18
|
+
const resolved = [];
|
|
19
|
+
for (const root of roots) {
|
|
20
|
+
const real = await Result.tryPromise(() => realpath(path.resolve(root)));
|
|
21
|
+
const info = real.isOk() ? await Result.tryPromise(() => stat(real.value)) : null;
|
|
22
|
+
if (!real.isOk() || info === null || !info.isOk() || !info.value.isDirectory()) return Result.err(cliError({
|
|
23
|
+
code: FOLIO_CLI_ERROR_CODES.usage,
|
|
24
|
+
message: `--root ${root} is not an existing directory.`
|
|
25
|
+
}));
|
|
26
|
+
resolved.push(real.value);
|
|
27
|
+
}
|
|
28
|
+
return Result.ok(resolved);
|
|
29
|
+
};
|
|
30
|
+
const isWithin = (root, candidate) => {
|
|
31
|
+
const relative = path.relative(root, candidate);
|
|
32
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
33
|
+
};
|
|
34
|
+
/** The real path of `target`, or of its parent joined with its name when it does not exist. */
|
|
35
|
+
const realTarget = async (target) => {
|
|
36
|
+
const real = await Result.tryPromise(() => realpath(target));
|
|
37
|
+
if (real.isOk()) return real.value;
|
|
38
|
+
const parent = await Result.tryPromise(() => realpath(path.dirname(target)));
|
|
39
|
+
return parent.isOk() ? path.join(parent.value, path.basename(target)) : null;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Refuse a path outside every allowed root, returning the resolved path the
|
|
43
|
+
* call should use.
|
|
44
|
+
*/
|
|
45
|
+
const checkWithinRoots = async ({ roots, target, argument }) => {
|
|
46
|
+
const base = roots.at(0) ?? process.cwd();
|
|
47
|
+
const absolute = path.resolve(base, target);
|
|
48
|
+
const real = await realTarget(absolute);
|
|
49
|
+
if (real !== null && roots.some((root) => isWithin(root, real))) return Result.ok(real);
|
|
50
|
+
return Result.err(cliError({
|
|
51
|
+
code: FOLIO_CLI_ERROR_CODES.outsideRoot,
|
|
52
|
+
message: `${argument} ${JSON.stringify(target)} is outside the allowed roots.`,
|
|
53
|
+
hint: `Use a path under ${roots.join(", ")}, or restart the server with another --root.`
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { checkWithinRoots, resolveRoots };
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Result } from "better-result";
|
|
2
|
+
//#region src/serve.d.ts
|
|
3
|
+
declare const PREVIEW_HOST = "127.0.0.1";
|
|
4
|
+
type PreviewServerOptions = {
|
|
5
|
+
documentPath: string;
|
|
6
|
+
/** 0 picks a free port. */
|
|
7
|
+
port: number;
|
|
8
|
+
};
|
|
9
|
+
type PreviewServer = {
|
|
10
|
+
/** The preview URL, token included. */
|
|
11
|
+
url: string;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/** Serve the preview until {@link PreviewServer.close}. */
|
|
15
|
+
declare const startPreviewServer: ({ documentPath, port }: PreviewServerOptions) => Promise<Result<PreviewServer, Error>>;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { PREVIEW_HOST, PreviewServer, PreviewServerOptions, startPreviewServer };
|