@stll/folio-cli 0.1.1 → 0.3.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.
@@ -1,10 +1,10 @@
1
- import { NO_FOLLOW, errnoCode, sameFile } from "./document.js";
2
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { NO_FOLLOW, errnoCode, sameFile } from "./file-system.js";
3
3
  import { Result } from "better-result";
4
- import { randomUUID } from "node:crypto";
5
4
  import { constants } from "node:fs";
6
5
  import { open, realpath, rename, rm, stat } from "node:fs/promises";
7
6
  import path from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
8
  //#region src/output-file.ts
9
9
  /**
10
10
  * Write a rendered file (PDF, PNG, HTML) next to where the caller asked: to a
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.1.1";
2
+ var version = "0.3.0";
3
3
  //#endregion
4
4
  export { version };
package/dist/save.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { FolioCliIo } from "./cli.js";
2
+ import { FolioCliError } from "./errors.js";
3
+ import { WriteDestination } from "./transaction.js";
4
+ import "./execute-write.js";
5
+ import { Result } from "better-result";
6
+ //#region src/save.d.ts
7
+ /** How the editor produced the bytes, as it reports it. */
8
+ declare const EDITOR_SAVE_STRATEGIES: readonly ["selective", "full-repack", "unspecified"];
9
+ type EditorSaveStrategy = (typeof EDITOR_SAVE_STRATEGIES)[number];
10
+ type SaveDocumentOptions = {
11
+ /** The document the editor opened. */
12
+ path: string;
13
+ /** The whole package the editor serialized. */
14
+ bytes: Uint8Array;
15
+ /** The fileVersion the editor loaded (or last saved). */
16
+ expectedVersion: string;
17
+ author: string;
18
+ /** The lease owner a transient lease records (default `folio-cli`). */
19
+ owner?: string;
20
+ /** Which editor saved, for the journal (`vscode`, `herdr`; default `cli`). */
21
+ surface?: string;
22
+ saveStrategy?: EditorSaveStrategy;
23
+ /** Idempotency key; generated when absent. */
24
+ txId?: string;
25
+ /** After waiting for another editor to flush, take the lease over. */
26
+ force?: boolean;
27
+ /** Default in place. */
28
+ destination?: WriteDestination;
29
+ /** Save under a lease the caller holds (an editor's long-lived lease) instead of taking one. */
30
+ leaseToken?: string;
31
+ /** How long to wait for another editor holding the lease to save and release. */
32
+ flushWaitMs?: number;
33
+ /** The transaction's UTC timestamp (default now). */
34
+ date?: string;
35
+ };
36
+ /**
37
+ * Commit an editor's serialized package as one transaction and return its
38
+ * receipt (`fileVersion`, `backup`, `changedParts`, ...). Refuses a stale
39
+ * `expectedVersion`, a lease another live holder keeps, bytes that do not
40
+ * open as a document, and anything over 64 MiB, writing nothing.
41
+ */
42
+ declare const saveDocumentBytes: (options: SaveDocumentOptions) => Promise<Result<Readonly<Record<string, unknown>>, FolioCliError>>;
43
+ declare const SAVE_HELP: string;
44
+ /** `--flush-wait <ms>`: a whole number of milliseconds from 0. */
45
+ declare const parseFlushWait: (value: string | undefined) => Result<number | undefined, FolioCliError>;
46
+ declare const runSave: (rest: readonly string[], io: FolioCliIo) => Promise<number>;
47
+ //#endregion
48
+ export { EDITOR_SAVE_STRATEGIES, EditorSaveStrategy, SAVE_HELP, SaveDocumentOptions, parseFlushWait, runSave, saveDocumentBytes };
package/dist/save.js ADDED
@@ -0,0 +1,354 @@
1
+ import { MAX_DOCUMENT_BYTES, checkExpectedVersion, isFileVersion, readDocumentFile } from "./document.js";
2
+ import { acquireLeaseForWrite } from "./editor-lease.js";
3
+ import { EXIT_CODES, FOLIO_CLI_ERROR_CODES, cliError, exitCodeForError } from "./errors.js";
4
+ import { EDITOR_SAVE_TOOL, TX_ID_PATTERN, canonicalJson, resolveTarget } from "./execute-write.js";
5
+ import { fileVersionOf, sameFile } from "./file-system.js";
6
+ import { findCommit, journalPathFor, recoverStages } from "./journal.js";
7
+ import { adoptLease, isLeaseOwner } from "./lock.js";
8
+ import { OUTPUT_FORMATS, failureEnvelope, isOutputFormat, renderEnvelope, successEnvelope } from "./output.js";
9
+ import { diffPackages } from "./package-parts.js";
10
+ import { resolveAuthor, resolveTransactionDate } from "./provenance.js";
11
+ import { unsafePath } from "./sidecar.js";
12
+ import { commitTransaction } from "./transaction.js";
13
+ import { Result } from "better-result";
14
+ import { parseArgs } from "node:util";
15
+ import { FolioDocxReviewer } from "@stll/folio-core/server";
16
+ import { createHash, randomUUID } from "node:crypto";
17
+ //#region src/save.ts
18
+ /**
19
+ * `folio save`: commit a whole package an editor serialized (a selective
20
+ * patch where the editor could, a full repack otherwise) with the same
21
+ * transaction as a tool call: under the lease, after crash recovery, against
22
+ * the version the editor loaded, staged, checked, backed up, journaled
23
+ * (`tool: "editor_save"`), and renamed into place.
24
+ *
25
+ * Unlike a tool call, a save may replace every part: the editor has to be
26
+ * able to save any edit, and the backup and journal line keep the previous
27
+ * version recoverable; the host warns the user. It is not an MCP tool, so
28
+ * an agent never replaces a whole package.
29
+ */
30
+ /** How the editor produced the bytes, as it reports it. */
31
+ const EDITOR_SAVE_STRATEGIES = [
32
+ "selective",
33
+ "full-repack",
34
+ "unspecified"
35
+ ];
36
+ const isEditorSaveStrategy = (value) => EDITOR_SAVE_STRATEGIES.some((strategy) => strategy === value);
37
+ const invalidInput = (message, hint) => cliError({
38
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
39
+ message,
40
+ hint
41
+ });
42
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
43
+ const NAME_PATTERN = /^[\w.-]{1,64}$/u;
44
+ const checkOptions = (options) => {
45
+ if (options.txId !== void 0 && !TX_ID_PATTERN.test(options.txId)) return Result.err(invalidInput("txId must be 1 to 128 letters, digits, '.', '_' or '-'."));
46
+ if (options.owner !== void 0 && !isLeaseOwner(options.owner)) return Result.err(invalidInput("owner must be 1 to 64 letters, digits, '.', '_' or '-'."));
47
+ if (options.surface !== void 0 && !NAME_PATTERN.test(options.surface)) return Result.err(invalidInput("surface must be 1 to 64 letters, digits, '.', '_' or '-'."));
48
+ if (!isFileVersion(options.expectedVersion)) return Result.err(invalidInput("expectedVersion must be a 64-character hex fileVersion."));
49
+ if (options.bytes.byteLength > 67108864) return Result.err(cliError({
50
+ code: FOLIO_CLI_ERROR_CODES.tooLarge,
51
+ message: `The saved package is over the ${MAX_DOCUMENT_BYTES}-byte limit.`
52
+ }));
53
+ return Result.ok();
54
+ };
55
+ /** The bytes must open as a document before anything is staged. */
56
+ const checkParses = async (bytes) => {
57
+ const parsed = await Result.tryPromise({
58
+ try: () => FolioDocxReviewer.fromBuffer(bytes.slice().buffer),
59
+ catch: (error) => cliError({
60
+ code: FOLIO_CLI_ERROR_CODES.invalidDocument,
61
+ message: `The saved bytes are not a readable .docx package: ${error instanceof Error ? error.message : String(error)}`,
62
+ hint: "Nothing was written; the editor's serializer produced a broken package."
63
+ })
64
+ });
65
+ return parsed.isErr() ? Result.err(parsed.error) : Result.ok();
66
+ };
67
+ const leaseForSave = async (documentPath, txId, options) => {
68
+ if (options.leaseToken !== void 0) {
69
+ const adopted = await adoptLease(documentPath, options.leaseToken);
70
+ return adopted.isOk() ? Result.ok({
71
+ lease: adopted.value,
72
+ flush: { type: "notAsked" }
73
+ }) : Result.err(adopted.error);
74
+ }
75
+ return await acquireLeaseForWrite({
76
+ documentPath,
77
+ txId,
78
+ force: options.force === true,
79
+ owner: options.owner ?? "folio-cli",
80
+ ...options.flushWaitMs !== void 0 && { flushWaitMs: options.flushWaitMs }
81
+ });
82
+ };
83
+ /**
84
+ * Commit an editor's serialized package as one transaction and return its
85
+ * receipt (`fileVersion`, `backup`, `changedParts`, ...). Refuses a stale
86
+ * `expectedVersion`, a lease another live holder keeps, bytes that do not
87
+ * open as a document, and anything over 64 MiB, writing nothing.
88
+ */
89
+ const saveDocumentBytes = async (options) => {
90
+ const checked = checkOptions(options);
91
+ if (checked.isErr()) return Result.err(checked.error);
92
+ const date = resolveTransactionDate(options.date);
93
+ if (date.isErr()) return Result.err(date.error);
94
+ const destination = options.destination ?? { type: "inPlace" };
95
+ const txId = options.txId ?? randomUUID();
96
+ const surface = options.surface ?? "cli";
97
+ const saveStrategy = options.saveStrategy ?? "unspecified";
98
+ const owner = options.owner ?? "folio-cli";
99
+ const bytes = new Uint8Array(options.bytes);
100
+ const parses = await checkParses(bytes);
101
+ if (parses.isErr()) return Result.err(parses.error);
102
+ const target = await resolveTarget({
103
+ sourcePath: options.path,
104
+ destination
105
+ });
106
+ if (target.isErr()) return Result.err(target.error);
107
+ const { sourcePath, sourceIdentity, destinationPath } = target.value;
108
+ const inPlace = destinationPath === sourcePath;
109
+ const journalPath = journalPathFor(destinationPath, void 0);
110
+ const acquired = await leaseForSave(destinationPath, txId, options);
111
+ if (acquired.isErr()) return Result.err(acquired.error);
112
+ const { lease, flush } = acquired.value;
113
+ try {
114
+ const recovered = await recoverStages({
115
+ documentPath: destinationPath,
116
+ journalPath,
117
+ now: date.value
118
+ });
119
+ if (recovered.isErr()) return Result.err(recovered.error);
120
+ const toVersion = fileVersionOf(bytes);
121
+ const requestHash = createHash("sha256").update(canonicalJson({
122
+ tool: EDITOR_SAVE_TOOL,
123
+ toVersion,
124
+ source: sourcePath,
125
+ destination: destinationPath
126
+ })).digest("hex");
127
+ if (options.txId !== void 0) {
128
+ const found = await findCommit(journalPath, txId);
129
+ if (found.isErr()) return Result.err(found.error);
130
+ const prior = found.value;
131
+ if (prior !== void 0) return prior.requestHash === requestHash ? Result.ok({
132
+ ...prior.receipt,
133
+ status: "replayed"
134
+ }) : Result.err(cliError({
135
+ code: FOLIO_CLI_ERROR_CODES.transactionConflict,
136
+ message: `Transaction ${txId} already committed a different save.`,
137
+ hint: "Use a new txId for a new save."
138
+ }));
139
+ }
140
+ const source = await readDocumentFile(sourcePath);
141
+ if (source.isErr()) return Result.err(source.error);
142
+ const expectedIdentity = inPlace && (flush.type !== "notAsked" || recovered.value.some(({ action }) => action === "rolledForward")) ? source.value.identity : sourceIdentity;
143
+ if (!sameFile(source.value.identity, expectedIdentity)) return Result.err(unsafePath(`${sourcePath} was replaced while the save started.`));
144
+ if (inPlace && source.value.links > 1) return Result.err(unsafePath(`${sourcePath} has other hard links; an in-place save would detach them.`, "Save to a new file with -o instead."));
145
+ const version = checkExpectedVersion(source.value, options.expectedVersion);
146
+ if (version.isErr()) return Result.err(cliError({
147
+ code: version.error.code,
148
+ message: version.error.message,
149
+ hint: "Reload the document (it changed on disk) and save again.",
150
+ details: {
151
+ ...isRecord(version.error.details) ? version.error.details : {},
152
+ ...recovered.value.length > 0 && { recovered: recovered.value }
153
+ }
154
+ }));
155
+ const base = {
156
+ txId,
157
+ tool: EDITOR_SAVE_TOOL,
158
+ path: destinationPath,
159
+ fromVersion: source.value.fileVersion,
160
+ ...!inPlace && { source: {
161
+ path: sourcePath,
162
+ fileVersion: source.value.fileVersion
163
+ } },
164
+ author: options.author,
165
+ time: date.value,
166
+ owner,
167
+ surface,
168
+ saveStrategy
169
+ };
170
+ if (inPlace && toVersion === source.value.fileVersion) return Result.ok({
171
+ ...base,
172
+ status: "unchanged",
173
+ fileVersion: toVersion,
174
+ changedParts: []
175
+ });
176
+ const changedParts = await diffPackages(source.value.bytes, bytes);
177
+ if (changedParts.isErr()) return Result.err(changedParts.error);
178
+ const receipt = {
179
+ ...base,
180
+ status: "committed",
181
+ changedParts: changedParts.value,
182
+ ...recovered.value.length > 0 && { recovered: recovered.value }
183
+ };
184
+ const committed = await commitTransaction({
185
+ txId,
186
+ destinationPath,
187
+ destination,
188
+ sourcePath,
189
+ sourceIdentity: expectedIdentity,
190
+ fromVersion: source.value.fileVersion,
191
+ bytes,
192
+ verifyLease: lease.verify,
193
+ changedParts: changedParts.value,
194
+ journalPath,
195
+ entry: {
196
+ txId,
197
+ requestHash,
198
+ tool: EDITOR_SAVE_TOOL,
199
+ fromVersion: source.value.fileVersion,
200
+ author: options.author,
201
+ time: date.value,
202
+ ops: {
203
+ surface,
204
+ saveStrategy,
205
+ owner
206
+ },
207
+ receipts: []
208
+ },
209
+ receipt
210
+ });
211
+ return committed.isErr() ? Result.err(committed.error) : Result.ok(committed.value.receipt);
212
+ } finally {
213
+ await lease.release();
214
+ }
215
+ };
216
+ const usageError = (message, hint) => cliError({
217
+ code: FOLIO_CLI_ERROR_CODES.usage,
218
+ message,
219
+ hint
220
+ });
221
+ const SAVE_HELP = [
222
+ "Usage: folio save <file> --from <saved.docx> --expect-version <sha> [flags]",
223
+ "",
224
+ "Commit a whole package an editor serialized as a folio transaction: under the write",
225
+ "lease, against the version the editor loaded, checked, backed up to .folio/backups,",
226
+ "journaled (tool \"editor_save\"), and renamed into place atomically. Unlike the tool",
227
+ "commands it may replace every part. It is not offered over MCP.",
228
+ "",
229
+ "An editor holding the lease long-lived saves under it with --lease-token. Any other",
230
+ "save that finds such an editor asks it to save and release first, waiting up to",
231
+ "--flush-wait ms; its own --expect-version then no longer matches and it is refused.",
232
+ "",
233
+ "Flags:",
234
+ " --from <path> The package to commit (required)",
235
+ " --expect-version <sha> The fileVersion the editor loaded (required)",
236
+ " -o, --out <path> Save to this path instead of in place",
237
+ " --overwrite Let -o replace an existing file",
238
+ " --expect-destination-version <sha> The fileVersion of the file -o --overwrite replaces",
239
+ " --author <name> Author (default: FOLIO_AUTHOR, then git user.name)",
240
+ " --owner <name> Lease owner to record (default: folio-cli)",
241
+ " --surface <name> Which editor saved, for the journal (default: cli)",
242
+ ` --save-strategy <kind> ${EDITOR_SAVE_STRATEGIES.join(", ")}`,
243
+ " --lease-token <token> Save under this held lease instead of taking one",
244
+ " --flush-wait <ms> Wait for another editor to flush (default 5000)",
245
+ " --tx-id <key> Idempotency key",
246
+ " --force After the wait, take the lease over",
247
+ " --date <iso> Transaction timestamp (default: now)",
248
+ " --output <json|text> Envelope format",
249
+ ""
250
+ ].join("\n");
251
+ const saveOptions = {
252
+ from: { type: "string" },
253
+ "expect-version": { type: "string" },
254
+ out: {
255
+ type: "string",
256
+ short: "o"
257
+ },
258
+ overwrite: { type: "boolean" },
259
+ "expect-destination-version": { type: "string" },
260
+ author: { type: "string" },
261
+ owner: { type: "string" },
262
+ surface: { type: "string" },
263
+ "save-strategy": { type: "string" },
264
+ "lease-token": { type: "string" },
265
+ "flush-wait": { type: "string" },
266
+ "tx-id": { type: "string" },
267
+ force: { type: "boolean" },
268
+ date: { type: "string" },
269
+ output: { type: "string" },
270
+ help: {
271
+ type: "boolean",
272
+ short: "h"
273
+ }
274
+ };
275
+ /** `--flush-wait <ms>`: a whole number of milliseconds from 0. */
276
+ const parseFlushWait = (value) => {
277
+ if (value === void 0) return Result.ok(void 0);
278
+ const parsed = Number(value);
279
+ return Number.isInteger(parsed) && parsed >= 0 ? Result.ok(parsed) : Result.err(usageError("--flush-wait expects a whole number of milliseconds."));
280
+ };
281
+ const parseSaveArgs = (rest) => parseArgs({
282
+ args: [...rest],
283
+ options: saveOptions,
284
+ allowPositionals: true,
285
+ strict: true
286
+ });
287
+ const runSaveValues = async (values, positionals, io) => {
288
+ const [file, ...extra] = positionals;
289
+ if (file === void 0 || extra.length > 0) return Result.err(usageError("Usage: folio save <file> --from <saved.docx> --expect-version <sha>."));
290
+ if (values.from === void 0) return Result.err(usageError("folio save needs --from <saved.docx>."));
291
+ if (values["expect-version"] === void 0) return Result.err(usageError("folio save needs --expect-version <fileVersion> of the file the editor loaded."));
292
+ const saveStrategy = values["save-strategy"];
293
+ if (saveStrategy !== void 0 && !isEditorSaveStrategy(saveStrategy)) return Result.err(usageError(`--save-strategy must be ${EDITOR_SAVE_STRATEGIES.join(", ")}.`));
294
+ const expectedDestination = values["expect-destination-version"];
295
+ if ((values.overwrite === true || expectedDestination !== void 0) && values.out === void 0) return Result.err(usageError("--overwrite and --expect-destination-version only apply to -o <path>."));
296
+ const flushWaitMs = parseFlushWait(values["flush-wait"]);
297
+ if (flushWaitMs.isErr()) return Result.err(flushWaitMs.error);
298
+ const author = resolveAuthor({
299
+ explicit: values.author,
300
+ env: io.env,
301
+ cwd: io.cwd
302
+ });
303
+ if (author.isErr()) return Result.err(author.error);
304
+ const saved = await readDocumentFile(values.from);
305
+ if (saved.isErr()) return Result.err(saved.error);
306
+ return await saveDocumentBytes({
307
+ path: file,
308
+ bytes: saved.value.bytes,
309
+ expectedVersion: values["expect-version"],
310
+ author: author.value,
311
+ ...values.owner !== void 0 && { owner: values.owner },
312
+ ...values.surface !== void 0 && { surface: values.surface },
313
+ ...saveStrategy !== void 0 && { saveStrategy },
314
+ ...values["tx-id"] !== void 0 && { txId: values["tx-id"] },
315
+ ...values["lease-token"] !== void 0 && { leaseToken: values["lease-token"] },
316
+ ...flushWaitMs.value !== void 0 && { flushWaitMs: flushWaitMs.value },
317
+ ...values.date !== void 0 && { date: values.date },
318
+ force: values.force === true,
319
+ destination: values.out === void 0 ? { type: "inPlace" } : {
320
+ type: "file",
321
+ path: values.out,
322
+ overwrite: values.overwrite === true,
323
+ expectedVersion: expectedDestination
324
+ }
325
+ });
326
+ };
327
+ const emit = (io, format, result) => {
328
+ const rendered = renderEnvelope({
329
+ envelope: result.isOk() ? successEnvelope(result.value) : failureEnvelope(result.error),
330
+ format,
331
+ tool: EDITOR_SAVE_TOOL
332
+ });
333
+ if (rendered.stdout !== "") io.stdout(rendered.stdout);
334
+ if (rendered.stderr !== "") io.stderr(rendered.stderr);
335
+ return result.isOk() ? EXIT_CODES.ok : exitCodeForError(result.error.code);
336
+ };
337
+ const runSave = async (rest, io) => {
338
+ const fallback = io.isTTY ? "text" : "json";
339
+ const parsed = Result.try({
340
+ try: () => parseSaveArgs(rest),
341
+ catch: (error) => usageError(error instanceof Error ? error.message : String(error), "Run folio save --help.")
342
+ });
343
+ if (parsed.isErr()) return emit(io, fallback, parsed);
344
+ const { values, positionals } = parsed.value;
345
+ if (values.help === true) {
346
+ io.stdout(SAVE_HELP);
347
+ return EXIT_CODES.ok;
348
+ }
349
+ const output = values.output;
350
+ if (output !== void 0 && !isOutputFormat(output)) return emit(io, fallback, Result.err(usageError(`--output must be ${OUTPUT_FORMATS.join(" or ")}.`)));
351
+ return emit(io, output ?? fallback, await runSaveValues(values, positionals, io));
352
+ };
353
+ //#endregion
354
+ export { EDITOR_SAVE_STRATEGIES, SAVE_HELP, parseFlushWait, runSave, saveDocumentBytes };
package/dist/serve.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { readDocumentFile } from "./document.js";
2
- import { journalPathFor } from "./journal.js";
2
+ import { latestCommitFor } from "./journal.js";
3
3
  import { buildDisplayList, displayListHtml } from "./render.js";
4
- import { SIDECAR_DIRECTORY, inspectPath, readSidecarFile } from "./sidecar.js";
4
+ import { SIDECAR_DIRECTORY } from "./sidecar.js";
5
5
  import { Result } from "better-result";
6
- import { randomBytes } from "node:crypto";
7
6
  import { watch } from "node:fs";
8
7
  import { stat } from "node:fs/promises";
9
8
  import path from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
10
  import { escapeHtmlText } from "@stll/folio-core/display-list/html/renderDisplayListToHtml";
11
11
  import { createServer } from "node:http";
12
12
  //#region src/serve.ts
@@ -27,35 +27,44 @@ import { createServer } from "node:http";
27
27
  const PREVIEW_HOST = "127.0.0.1";
28
28
  /** How often the file is re-checked when a watch event may have been missed. */
29
29
  const POLL_INTERVAL_MS = 1e3;
30
- /** The latest `toVersion` the journal committed for this document, if any. */
31
- const journalToVersion = async (documentPath) => {
32
- const journalPath = journalPathFor(documentPath, void 0);
33
- const parent = await inspectPath(path.dirname(journalPath));
34
- if (parent.isErr() || parent.value.type !== "directory") return null;
35
- const bytes = await readSidecarFile(journalPath);
36
- if (bytes.isErr()) return null;
37
- const lines = new TextDecoder().decode(bytes.value).split("\n");
38
- for (let index = lines.length - 1; index >= 0; index--) {
39
- const parsed = Result.try(() => JSON.parse(lines[index] ?? ""));
40
- if (!parsed.isOk() || typeof parsed.value !== "object" || parsed.value === null) continue;
41
- const entry = { ...parsed.value };
42
- if (entry["type"] === "commit" && entry["path"] === documentPath) return typeof entry["toVersion"] === "string" ? entry["toVersion"] : null;
43
- }
44
- return null;
45
- };
46
- const SHELL_STYLE = "html, body { margin: 0; height: 100%; background: #e8e8e8; } iframe { border: 0; width: 100%; height: 100%; display: block; }";
47
- const shellHtml = (title, nonce) => [
30
+ const SHELL_STYLE = "html, body { margin: 0; height: 100%; overflow: hidden; background: #e8e8e8; } iframe { border: 0; width: 100%; height: 100%; display: block; }";
31
+ /**
32
+ * `document.html` lays its pages out at their real pixel size with no zoom,
33
+ * so a pane narrower than the page would otherwise clip the left margin and
34
+ * scroll horizontally. The shell scales the iframe down (never up) to the
35
+ * widest page, keeping it centered and fully visible; `fit` reruns on resize
36
+ * and whenever a live reload reports a new page width.
37
+ */
38
+ const shellHtml = (title, nonce, pageWidthPx) => [
48
39
  "<!doctype html>",
49
40
  `<html><head><meta charset="utf-8"><title>${escapeHtmlText(title)}</title>`,
50
41
  `<style nonce="${nonce}">${SHELL_STYLE}</style></head><body>`,
51
42
  "<iframe id=\"document\" sandbox src=\"document.html\" title=\"document\"></iframe>",
52
43
  `<script nonce="${nonce}">`,
53
44
  "const frame = document.getElementById('document');",
45
+ `let pageWidthPx = ${pageWidthPx !== null && pageWidthPx > 0 ? String(pageWidthPx) : "null"};`,
54
46
  "let version = null;",
47
+ "frame.style.transformOrigin = 'top left';",
48
+ "const fit = () => {",
49
+ " if (!pageWidthPx) return;",
50
+ " const scale = Math.min(1, document.documentElement.clientWidth / pageWidthPx);",
51
+ " const inverse = (100 / scale) + '%';",
52
+ " frame.style.width = inverse;",
53
+ " frame.style.height = inverse;",
54
+ " frame.style.transform = 'scale(' + scale + ')';",
55
+ "};",
56
+ "fit();",
57
+ "window.addEventListener('resize', fit);",
55
58
  "const events = new EventSource('events');",
56
59
  "events.onmessage = (event) => {",
57
60
  " const next = JSON.parse(event.data).fileVersion;",
58
- " if (version !== null && next !== version) frame.src = 'document.html?v=' + next;",
61
+ " if (version !== null && next !== version) {",
62
+ " fetch('version').then((response) => response.json()).then((info) => {",
63
+ " pageWidthPx = info.pageWidthPx > 0 ? info.pageWidthPx : null;",
64
+ " fit();",
65
+ " }).catch(() => {});",
66
+ " frame.src = 'document.html?v=' + next;",
67
+ " }",
59
68
  " version = next;",
60
69
  "};",
61
70
  "<\/script></body></html>"
@@ -103,11 +112,14 @@ const startPreviewServer = async ({ documentPath, port }) => {
103
112
  fileVersion: file.value.fileVersion,
104
113
  message: list.error.message
105
114
  };
115
+ let pageWidthPx = 0;
116
+ for (const page of list.value.pages) pageWidthPx = Math.max(pageWidthPx, page.widthPx);
106
117
  return {
107
118
  type: "page",
108
119
  fileVersion: file.value.fileVersion,
109
120
  html: displayListHtml(list.value, title),
110
- pageCount: list.value.pages.length
121
+ pageCount: list.value.pages.length,
122
+ pageWidthPx
111
123
  };
112
124
  })();
113
125
  const result = await rendering;
@@ -153,7 +165,8 @@ const startPreviewServer = async ({ documentPath, port }) => {
153
165
  const route = url.pathname.slice(prefix.length);
154
166
  if (route === "") {
155
167
  const nonce = randomBytes(16).toString("base64");
156
- respond(response, 200, shellHtml(title, nonce), {
168
+ const page = await current();
169
+ respond(response, 200, shellHtml(title, nonce, page.type === "page" ? page.pageWidthPx : null), {
157
170
  "Content-Type": "text/html; charset=utf-8",
158
171
  "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; frame-src 'self'; connect-src 'self'`
159
172
  });
@@ -172,8 +185,11 @@ const startPreviewServer = async ({ documentPath, port }) => {
172
185
  respond(response, 200, JSON.stringify({
173
186
  path: realPath,
174
187
  fileVersion: page.fileVersion,
175
- journalToVersion: await journalToVersion(realPath),
176
- ...page.type === "page" ? { pageCount: page.pageCount } : { error: page.message }
188
+ journalToVersion: (await latestCommitFor(realPath))?.toVersion ?? null,
189
+ ...page.type === "page" ? {
190
+ pageCount: page.pageCount,
191
+ pageWidthPx: page.pageWidthPx
192
+ } : { error: page.message }
177
193
  }), { "Content-Type": "application/json" });
178
194
  return;
179
195
  }
package/dist/sidecar.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FolioCliError } from "./errors.js";
2
- import { FileIdentity } from "./document.js";
2
+ import { FileIdentity } from "./file-system.js";
3
3
  import { Result } from "better-result";
4
4
  import { FileHandle } from "node:fs/promises";
5
5
  //#region src/sidecar.d.ts
package/dist/sidecar.js CHANGED
@@ -1,5 +1,5 @@
1
- import { NO_FOLLOW, errnoCode } from "./document.js";
2
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { NO_FOLLOW, errnoCode } from "./file-system.js";
3
3
  import { Result } from "better-result";
4
4
  import { constants } from "node:fs";
5
5
  import { lstat, mkdir, open } from "node:fs/promises";
@@ -1,5 +1,6 @@
1
1
  import { FolioCliError } from "./errors.js";
2
- import { FileIdentity } from "./document.js";
2
+ import { FileIdentity } from "./file-system.js";
3
+ import "./document.js";
3
4
  import { JournalCommit } from "./journal.js";
4
5
  import { ChangedPart } from "./package-parts.js";
5
6
  import { Result } from "better-result";
@@ -1,5 +1,6 @@
1
- import { fileVersionOf, readDocumentFile, sameFile } from "./document.js";
1
+ import { readDocumentFile } from "./document.js";
2
2
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
3
+ import { fileVersionOf, sameFile } from "./file-system.js";
3
4
  import { appendJournal, stagePathFor } from "./journal.js";
4
5
  import { checkPackageIntegrity } from "./package-parts.js";
5
6
  import { inspectPath, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile } from "./sidecar.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Command line and stdio MCP server for reading, reviewing, and redlining .docx files with folio: tracked-change edits, comments, and comparisons written atomically with version preconditions.",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,6 +40,14 @@
40
40
  ".": {
41
41
  "types": "./dist/cli.d.ts",
42
42
  "import": "./dist/cli.js"
43
+ },
44
+ "./editor-lease": {
45
+ "types": "./dist/editor-lease.d.ts",
46
+ "import": "./dist/editor-lease.js"
47
+ },
48
+ "./save": {
49
+ "types": "./dist/save.d.ts",
50
+ "import": "./dist/save.js"
43
51
  }
44
52
  },
45
53
  "publishConfig": {
@@ -62,8 +70,8 @@
62
70
  "@fontsource/source-sans-3": "^5.2.9",
63
71
  "@fontsource/tinos": "^5.2.7",
64
72
  "@modelcontextprotocol/server": "2.0.0",
65
- "@stll/folio-agents": "^0.14.0",
66
- "@stll/folio-core": "^0.50.0",
73
+ "@stll/folio-agents": "^0.14.1",
74
+ "@stll/folio-core": "^0.51.0",
67
75
  "better-result": "3.0.1",
68
76
  "fast-xml-parser": "^5.10.1",
69
77
  "jszip": "3.10.1"