@stll/folio-cli 0.1.1 → 0.2.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 +24 -2
- package/dist/cli.js +11 -0
- package/dist/document.d.ts +2 -16
- package/dist/document.js +1 -14
- package/dist/editor-lease.d.ts +114 -0
- package/dist/editor-lease.js +294 -0
- package/dist/execute-read.d.ts +3 -1
- package/dist/execute-read.js +1 -1
- package/dist/execute-write.d.ts +29 -1
- package/dist/execute-write.js +111 -26
- package/dist/file-system.d.ts +23 -0
- package/dist/file-system.js +23 -0
- package/dist/journal.d.ts +17 -1
- package/dist/journal.js +37 -2
- package/dist/lock.d.ts +25 -2
- package/dist/lock.js +133 -27
- package/dist/output-file.d.ts +2 -1
- package/dist/output-file.js +2 -2
- package/dist/package.js +1 -1
- package/dist/save.d.ts +48 -0
- package/dist/save.js +354 -0
- package/dist/serve.js +42 -26
- package/dist/sidecar.d.ts +1 -1
- package/dist/sidecar.js +1 -1
- package/dist/transaction.d.ts +2 -1
- package/dist/transaction.js +2 -1
- package/package.json +11 -3
package/dist/output-file.js
CHANGED
|
@@ -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
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 {
|
|
2
|
+
import { latestCommitFor } from "./journal.js";
|
|
3
3
|
import { buildDisplayList, displayListHtml } from "./render.js";
|
|
4
|
-
import { SIDECAR_DIRECTORY
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
176
|
-
...page.type === "page" ? {
|
|
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
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";
|
package/dist/transaction.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FolioCliError } from "./errors.js";
|
|
2
|
-
import { FileIdentity } from "./
|
|
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";
|
package/dist/transaction.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
3
|
+
"version": "0.2.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.
|
|
66
|
-
"@stll/folio-core": "^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"
|