@stll/folio-cli 0.3.1 → 0.3.3

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.
@@ -16,7 +16,12 @@ type LoadedFile = {
16
16
  /** Hard links to the file; a write refuses a file with more than one. */
17
17
  links: number;
18
18
  };
19
- /** Read one input file, refusing directories and files over the size bound. */
19
+ /**
20
+ * Read one input file, refusing directories, files over the size bound, and
21
+ * anything that is not a WordprocessingML package. Every command and tool
22
+ * that takes a document reads it here, so none can treat an archive without
23
+ * a main document part as an empty document.
24
+ */
20
25
  declare const readDocumentFile: (inputPath: string) => Promise<Result<LoadedFile, FolioCliError>>;
21
26
  /**
22
27
  * Parse a loaded file into a headless reviewer. `author` is required for a
package/dist/document.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
2
  import { NO_FOLLOW, errnoCode, fileVersionOf, sameFile } from "./file-system.js";
3
+ import { checkWordprocessingPackage } from "./main-document-part.js";
3
4
  import { Result } from "better-result";
4
5
  import { constants } from "node:fs";
5
6
  import { open, realpath, stat } from "node:fs/promises";
@@ -67,7 +68,12 @@ const readThroughHandle = async (real) => {
67
68
  }));
68
69
  return Result.ok(read.value);
69
70
  };
70
- /** Read one input file, refusing directories and files over the size bound. */
71
+ /**
72
+ * Read one input file, refusing directories, files over the size bound, and
73
+ * anything that is not a WordprocessingML package. Every command and tool
74
+ * that takes a document reads it here, so none can treat an archive without
75
+ * a main document part as an empty document.
76
+ */
71
77
  const readDocumentFile = async (inputPath) => {
72
78
  const absolute = path.resolve(inputPath);
73
79
  const real = await Result.tryPromise({
@@ -78,6 +84,8 @@ const readDocumentFile = async (inputPath) => {
78
84
  const opened = await readThroughHandle(real.value);
79
85
  if (opened.isErr()) return Result.err(opened.error);
80
86
  const { bytes, identity, links } = opened.value;
87
+ const checked = await checkWordprocessingPackage(real.value, bytes);
88
+ if (checked.isErr()) return Result.err(checked.error);
81
89
  return Result.ok({
82
90
  path: real.value,
83
91
  bytes,
@@ -1,5 +1,5 @@
1
+ import { AcquiredLease, PublicLockHolder } from "./lock.js";
1
2
  import { FolioCliError } from "./errors.js";
2
- import { AcquiredLease, LockHolder } from "./lock.js";
3
3
  import { Result } from "better-result";
4
4
  //#region src/editor-lease.d.ts
5
5
  /** How long an editor's lease lasts without renewal. */
@@ -24,6 +24,10 @@ type FlushRequest = {
24
24
  /** After this the writer has stopped waiting; the request is void. */
25
25
  deadline: string;
26
26
  };
27
+ /** Request metadata safe to include in a refused editor lease response. */
28
+ type PublicFlushRequest = Omit<FlushRequest, "leaseToken"> & {
29
+ leaseToken?: never;
30
+ };
27
31
  declare const flushRequestPathFor: (documentPath: string, id: string) => string;
28
32
  /**
29
33
  * The flush requests beside `documentPath` whose writer is still waiting.
@@ -39,13 +43,13 @@ type FlushOutcome =
39
43
  /** The holder released within the wait; `versionBefore` is the file's version when asked. */
40
44
  {
41
45
  type: "flushed";
42
- holder: LockHolder;
46
+ holder: PublicLockHolder;
43
47
  versionBefore: string | null;
44
48
  } |
45
49
  /** The holder never released; the lease was taken by the ordinary rules (stale, or `force`). */
46
50
  {
47
51
  type: "timedOut";
48
- holder: LockHolder;
52
+ holder: PublicLockHolder;
49
53
  };
50
54
  type LeaseForWrite = {
51
55
  lease: AcquiredLease;
@@ -111,4 +115,4 @@ type WatchFlushRequestsOptions = {
111
115
  */
112
116
  declare const watchFlushRequests: ({ documentPath, token, onRequest, pollMs }: WatchFlushRequestsOptions) => FlushWatcher;
113
117
  //#endregion
114
- export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, FlushOutcome, FlushRequest, FlushWatcher, LeaseForWrite, LeaseKeeper, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
118
+ export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, FlushOutcome, FlushRequest, FlushWatcher, LeaseForWrite, LeaseKeeper, PublicFlushRequest, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
@@ -1,6 +1,6 @@
1
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
2
  import { fileVersionOf } from "./file-system.js";
3
- import { DEFAULT_LEASE_OWNER, acquireLease, readLease } from "./lock.js";
3
+ import { DEFAULT_LEASE_OWNER, acquireLease, publicLockHolder, readLease } from "./lock.js";
4
4
  import { readSidecarFile, writeNewSidecarFile } from "./sidecar.js";
5
5
  import { Result } from "better-result";
6
6
  import { watch } from "node:fs";
@@ -62,6 +62,15 @@ const EDITOR_RENEW_MS = 10 * 1e3;
62
62
  const DEFAULT_FLUSH_WAIT_MS = 5 * 1e3;
63
63
  /** How often a writer retries the lease, and an editor re-checks for requests. */
64
64
  const FLUSH_POLL_MS = 100;
65
+ const publicFlushRequest = ({ id, owner, pid, host, txId, requestedAt, deadline }) => ({
66
+ id,
67
+ owner,
68
+ pid,
69
+ host,
70
+ txId,
71
+ requestedAt,
72
+ deadline
73
+ });
65
74
  const flushPrefixFor = (documentPath) => `.${path.basename(documentPath)}.folio-flush-`;
66
75
  const flushRequestPathFor = (documentPath, id) => path.join(path.dirname(documentPath), `${flushPrefixFor(documentPath)}${id}`);
67
76
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -172,7 +181,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
172
181
  lease: attempt.value,
173
182
  flush: {
174
183
  type: "flushed",
175
- holder,
184
+ holder: publicLockHolder(holder),
176
185
  versionBefore
177
186
  }
178
187
  });
@@ -191,7 +200,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
191
200
  lease: last.value,
192
201
  flush: {
193
202
  type: "timedOut",
194
- holder
203
+ holder: publicLockHolder(holder)
195
204
  }
196
205
  });
197
206
  if (last.error.code !== FOLIO_CLI_ERROR_CODES.locked) return Result.err(last.error);
@@ -200,7 +209,7 @@ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT
200
209
  message: `${documentPath} is held by ${holder.owner} (pid ${holder.pid} on ${holder.host}), which did not save and release it within ${String(flushWaitMs)} ms.`,
201
210
  hint: "Save or close the document in the editor and retry, or pass --force to take the lease over.",
202
211
  details: {
203
- holder,
212
+ holder: publicLockHolder(holder),
204
213
  flush: "timedOut",
205
214
  flushWaitMs
206
215
  }
@@ -220,7 +229,7 @@ const acquireEditorLease = async ({ documentPath, owner, txId = `editor-${random
220
229
  code: FOLIO_CLI_ERROR_CODES.locked,
221
230
  message: `A write to ${documentPath} is waiting to take the lease.`,
222
231
  hint: "Retry after it commits, then reload the document.",
223
- details: { pending }
232
+ details: { pending: pending.map(publicFlushRequest) }
224
233
  }));
225
234
  return await acquireLease({
226
235
  documentPath,
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { PublicLockHolder } from "./lock.js";
2
+ import { PublicFlushRequest } from "./editor-lease.js";
1
3
  //#region src/errors.d.ts
2
4
  /**
3
5
  * Process exit classes. The numbers match the stella command line's table so
@@ -43,6 +45,14 @@ declare const FOLIO_CLI_ERROR_CODES: {
43
45
  };
44
46
  type FolioCliErrorCode = (typeof FOLIO_CLI_ERROR_CODES)[keyof typeof FOLIO_CLI_ERROR_CODES];
45
47
  declare const exitCodeForError: (code: FolioCliErrorCode) => ExitCode;
48
+ /** Public error context. Lease-bearing fields cannot accept internal records. */
49
+ type PublicErrorDetails = {
50
+ readonly [key: string]: unknown;
51
+ readonly holder?: PublicLockHolder;
52
+ readonly pending?: readonly PublicFlushRequest[];
53
+ readonly token?: never;
54
+ readonly leaseToken?: never;
55
+ };
46
56
  declare const FolioCliError_base: import("better-result").TaggedErrorClass<"FolioCliError">;
47
57
  /**
48
58
  * Every expected failure of a command or tool call. `hint` names the next
@@ -53,15 +63,15 @@ declare class FolioCliError extends FolioCliError_base<{
53
63
  code: FolioCliErrorCode;
54
64
  message: string;
55
65
  hint?: string;
56
- details?: unknown;
66
+ details?: PublicErrorDetails;
57
67
  }> {}
58
68
  type CliErrorOptions = {
59
69
  code: FolioCliErrorCode;
60
70
  message: string;
61
71
  hint?: string | undefined;
62
- details?: unknown;
72
+ details?: PublicErrorDetails;
63
73
  };
64
74
  /** Build a {@link FolioCliError} without spelling absent optional fields. */
65
75
  declare const cliError: ({ code, message, hint, details }: CliErrorOptions) => FolioCliError;
66
76
  //#endregion
67
- export { EXIT_CODES, ExitCode, FOLIO_CLI_ERROR_CODES, FolioCliError, FolioCliErrorCode, cliError, exitCodeEntries, exitCodeForError };
77
+ export { EXIT_CODES, ExitCode, FOLIO_CLI_ERROR_CODES, FolioCliError, FolioCliErrorCode, PublicErrorDetails, cliError, exitCodeEntries, exitCodeForError };
package/dist/lock.d.ts CHANGED
@@ -19,6 +19,11 @@ type LockHolder = {
19
19
  /** The holder saves and releases when a write asks it to (`editor-lease.ts`). */
20
20
  acceptsFlush?: true;
21
21
  };
22
+ /** Holder metadata safe to return to a caller that does not own the lease. */
23
+ type PublicLockHolder = Omit<LockHolder, "token"> & {
24
+ token?: never;
25
+ };
26
+ declare const publicLockHolder: ({ owner, pid, host, txId, acquiredAt, expiresAt, acceptsFlush }: LockHolder) => PublicLockHolder;
22
27
  declare const lockPathFor: (documentPath: string) => string;
23
28
  /** What is beside the document: no lock, or a lock with its parsed holder when it has one. */
24
29
  type LeaseState = {
@@ -75,4 +80,4 @@ declare const adoptLease: (documentPath: string, token: string) => Promise<Resul
75
80
  */
76
81
  declare const acquireLease: ({ documentPath, txId, force, owner, leaseMs, acceptsFlush, now }: AcquireLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
77
82
  //#endregion
78
- export { AcquiredLease, DEFAULT_LEASE_OWNER, LeaseState, LockHolder, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
83
+ export { AcquiredLease, DEFAULT_LEASE_OWNER, LeaseState, LockHolder, PublicLockHolder, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, publicLockHolder, readLease };
package/dist/lock.js CHANGED
@@ -32,6 +32,15 @@ const DEFAULT_LEASE_OWNER = "folio-cli";
32
32
  const OWNER_PATTERN = /^[\w.-]{1,64}$/u;
33
33
  /** A lease owner is 1 to 64 letters, digits, '.', '_' or '-'. */
34
34
  const isLeaseOwner = (value) => OWNER_PATTERN.test(value);
35
+ const publicLockHolder = ({ owner, pid, host, txId, acquiredAt, expiresAt, acceptsFlush }) => ({
36
+ owner,
37
+ pid,
38
+ host,
39
+ txId,
40
+ acquiredAt,
41
+ expiresAt,
42
+ ...acceptsFlush === true && { acceptsFlush }
43
+ });
35
44
  const lockPathFor = (documentPath) => path.join(path.dirname(documentPath), `.${path.basename(documentPath)}.folio-lock`);
36
45
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37
46
  const parseHolder = (text) => {
@@ -95,7 +104,7 @@ const lockedError = (documentPath, state) => cliError({
95
104
  code: FOLIO_CLI_ERROR_CODES.locked,
96
105
  message: `${documentPath} is being written by another process${state.type === "held" ? ` (${state.holder.owner}, pid ${state.holder.pid} on ${state.holder.host})` : ""}.`,
97
106
  hint: "Retry after it finishes, or pass --force to take the lease over.",
98
- details: state.type === "held" ? { holder: state.holder } : void 0
107
+ ...state.type === "held" && { details: { holder: publicLockHolder(state.holder) } }
99
108
  });
100
109
  const lostError = (documentPath) => cliError({
101
110
  code: FOLIO_CLI_ERROR_CODES.locked,
@@ -214,7 +223,7 @@ const adoptLease = async (documentPath, token) => {
214
223
  code: FOLIO_CLI_ERROR_CODES.locked,
215
224
  message: `${documentPath} is not held under that lease token.`,
216
225
  hint: "The lease expired or was taken over; acquire it again and retry.",
217
- details: current.type === "held" ? { holder: current.holder } : void 0
226
+ ...current.type === "held" && { details: { holder: publicLockHolder(current.holder) } }
218
227
  }));
219
228
  const { holder } = current;
220
229
  const leaseMs = Math.max(Date.parse(holder.expiresAt) - Date.parse(holder.acquiredAt), 0);
@@ -260,4 +269,4 @@ const acquireLease = async ({ documentPath, txId, force, owner = DEFAULT_LEASE_O
260
269
  return Result.err(lockedError(documentPath, { type: "free" }));
261
270
  };
262
271
  //#endregion
263
- export { DEFAULT_LEASE_OWNER, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, readLease };
272
+ export { DEFAULT_LEASE_OWNER, TRANSACTION_LEASE_MS, acquireLease, adoptLease, isLeaseOwner, isStaleLease, lockPathFor, lockSwapPathFor, publicLockHolder, readLease };
@@ -0,0 +1,26 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { Result } from "better-result";
3
+ //#region src/main-document-part.d.ts
4
+ /** Why a file is not a readable WordprocessingML package. */
5
+ declare const INVALID_PACKAGE_REASONS: {
6
+ readonly notZip: "notZip";
7
+ readonly duplicatePartName: "duplicatePartName";
8
+ readonly contentTypesMissing: "contentTypesMissing";
9
+ readonly contentTypesInvalid: "contentTypesInvalid";
10
+ readonly relationshipsMissing: "relationshipsMissing";
11
+ readonly relationshipsInvalid: "relationshipsInvalid";
12
+ readonly mainRelationshipMissing: "mainRelationshipMissing";
13
+ readonly mainRelationshipAmbiguous: "mainRelationshipAmbiguous";
14
+ readonly mainPartMissing: "mainPartMissing";
15
+ readonly mainPartContentType: "mainPartContentType";
16
+ readonly mainPartRoot: "mainPartRoot";
17
+ readonly mainPartLocation: "mainPartLocation";
18
+ };
19
+ type InvalidPackageReason = (typeof INVALID_PACKAGE_REASONS)[keyof typeof INVALID_PACKAGE_REASONS];
20
+ /**
21
+ * Refuse, as `invalid_document`, a file that is not a WordprocessingML
22
+ * package with a main document part folio reads.
23
+ */
24
+ declare const checkWordprocessingPackage: (filePath: string, bytes: Uint8Array) => Promise<Result<void, FolioCliError>>;
25
+ //#endregion
26
+ export { INVALID_PACKAGE_REASONS, InvalidPackageReason, checkWordprocessingPackage };
@@ -0,0 +1,323 @@
1
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { Result } from "better-result";
3
+ import path from "node:path";
4
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
5
+ import JSZip from "jszip";
6
+ //#region src/main-document-part.ts
7
+ /**
8
+ * Whether a file is a WordprocessingML package at all, decided the way the
9
+ * Open Packaging Conventions say to find its main part: the package
10
+ * relationship of the office-document type names the part, the part exists,
11
+ * `[Content_Types].xml` gives it a WordprocessingML main-document type, and
12
+ * its root is a `document` in a WordprocessingML namespace.
13
+ *
14
+ * The folio parser reads whatever `.docx` it is handed and reports a missing
15
+ * main part as a warning, so an archive holding no document opened as an
16
+ * empty one. Checked where a file enters the CLI, a ZIP of unrelated entries
17
+ * is refused before a read reports it as an empty document or a save commits
18
+ * it over a real one.
19
+ *
20
+ * Every name is resolved by namespace URI, never by prefix, and both the
21
+ * Transitional and the Strict relationship and WordprocessingML namespaces are
22
+ * accepted.
23
+ */
24
+ const CONTENT_TYPES_PART = "[Content_Types].xml";
25
+ const PACKAGE_RELATIONSHIPS_PART = "_rels/.rels";
26
+ /** The one main part the folio parser reads. */
27
+ const READ_MAIN_DOCUMENT_PART = "word/document.xml";
28
+ const CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types";
29
+ const PACKAGE_RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/relationships";
30
+ /** The office-document relationship type: Transitional, then Strict. */
31
+ const OFFICE_DOCUMENT_RELATIONSHIP_TYPES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"]);
32
+ const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
33
+ /** A document, a template, and their macro-enabled forms; Strict uses the same types. */
34
+ const MAIN_DOCUMENT_CONTENT_TYPES = /* @__PURE__ */ new Set([
35
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
36
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml",
37
+ "application/vnd.ms-word.document.macroEnabled.main+xml",
38
+ "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
39
+ ]);
40
+ /** `[Content_Types].xml` and `_rels/.rels` are indexes; this bounds their inflation. */
41
+ const MAX_PACKAGE_INDEX_BYTES = 1024 * 1024;
42
+ /** How much of the main part is inflated to find its root element. */
43
+ const MAX_ROOT_SCAN_BYTES = 64 * 1024;
44
+ /** Why a file is not a readable WordprocessingML package. */
45
+ const INVALID_PACKAGE_REASONS = {
46
+ notZip: "notZip",
47
+ duplicatePartName: "duplicatePartName",
48
+ contentTypesMissing: "contentTypesMissing",
49
+ contentTypesInvalid: "contentTypesInvalid",
50
+ relationshipsMissing: "relationshipsMissing",
51
+ relationshipsInvalid: "relationshipsInvalid",
52
+ mainRelationshipMissing: "mainRelationshipMissing",
53
+ mainRelationshipAmbiguous: "mainRelationshipAmbiguous",
54
+ mainPartMissing: "mainPartMissing",
55
+ mainPartContentType: "mainPartContentType",
56
+ mainPartRoot: "mainPartRoot",
57
+ mainPartLocation: "mainPartLocation"
58
+ };
59
+ const refuse = (reason, message) => Result.err({
60
+ reason,
61
+ message
62
+ });
63
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
64
+ const orderedParser = new XMLParser({
65
+ preserveOrder: true,
66
+ ignoreAttributes: false,
67
+ attributeNamePrefix: "",
68
+ parseAttributeValue: false,
69
+ parseTagValue: false,
70
+ processEntities: false,
71
+ ignoreDeclaration: true,
72
+ ignorePiTags: true
73
+ });
74
+ const splitName = (qualified) => {
75
+ const colon = qualified.indexOf(":");
76
+ return colon === -1 ? {
77
+ prefix: "",
78
+ localName: qualified
79
+ } : {
80
+ prefix: qualified.slice(0, colon),
81
+ localName: qualified.slice(colon + 1)
82
+ };
83
+ };
84
+ const stringAttributes = (value) => {
85
+ const attributes = {};
86
+ if (!isRecord(value)) return attributes;
87
+ for (const [name, attribute] of Object.entries(value)) if (typeof attribute === "string") attributes[name] = attribute;
88
+ return attributes;
89
+ };
90
+ /** The element children of one ordered node list, with namespaces in scope resolved. */
91
+ const elementsOf = (nodes, scope) => {
92
+ if (!Array.isArray(nodes)) return [];
93
+ const elements = [];
94
+ for (const node of nodes) {
95
+ if (!isRecord(node)) continue;
96
+ const name = Object.keys(node).find((key) => key !== ":@" && key !== "#text");
97
+ if (name === void 0) continue;
98
+ const attributes = stringAttributes(node[":@"]);
99
+ const inner = new Map(scope);
100
+ for (const [attribute, value] of Object.entries(attributes)) if (attribute === "xmlns") inner.set("", value);
101
+ else if (attribute.startsWith("xmlns:")) inner.set(attribute.slice(6), value);
102
+ const { prefix, localName } = splitName(name);
103
+ elements.push({
104
+ namespace: inner.get(prefix),
105
+ localName,
106
+ attributes,
107
+ children: elementsOf(node[name], inner)
108
+ });
109
+ }
110
+ return elements;
111
+ };
112
+ /** The root element of a well-formed XML part, or `null`. */
113
+ const parseRoot = (xml) => {
114
+ if (/<!DOCTYPE/iu.test(xml) || XMLValidator.validate(xml) !== true) return null;
115
+ const parsed = orderedParser.parse(xml);
116
+ const [root] = elementsOf(parsed, /* @__PURE__ */ new Map());
117
+ return root ?? null;
118
+ };
119
+ const childrenNamed = (element, namespace, localName) => element.children.filter((child) => child.namespace === namespace && child.localName === localName);
120
+ /** Part names compare case-insensitively (ECMA-376 Part 2, 9.1.1.1). */
121
+ const partKey = (name) => name.toLowerCase();
122
+ /** Inflate at most `limit` bytes of an entry; `truncated` when it holds more. */
123
+ const inflatePrefix = (entry, limit) => new Promise((resolve, reject) => {
124
+ const decoder = new TextDecoder();
125
+ let text = "";
126
+ let size = 0;
127
+ const stream = entry.internalStream("uint8array");
128
+ stream.on("data", (chunk) => {
129
+ const room = limit - size;
130
+ size += chunk.length;
131
+ text += decoder.decode(chunk.subarray(0, Math.max(0, room)), { stream: true });
132
+ if (size > limit) {
133
+ stream.pause();
134
+ resolve({
135
+ text: text + decoder.decode(),
136
+ truncated: true
137
+ });
138
+ }
139
+ }).on("error", reject).on("end", () => resolve({
140
+ text: text + decoder.decode(),
141
+ truncated: false
142
+ })).resume();
143
+ });
144
+ const readIndexPart = async ({ parts, name, missing, invalid }) => {
145
+ const entry = parts.get(partKey(name));
146
+ if (entry === void 0) return refuse(missing, `it has no ${name}.`);
147
+ const inflated = await Result.tryPromise({
148
+ try: () => inflatePrefix(entry, MAX_PACKAGE_INDEX_BYTES),
149
+ catch: () => ({
150
+ reason: invalid,
151
+ message: `its ${name} cannot be inflated.`
152
+ })
153
+ });
154
+ if (inflated.isErr()) return Result.err(inflated.error);
155
+ if (inflated.value.truncated) return refuse(invalid, `its ${name} is over ${String(MAX_PACKAGE_INDEX_BYTES)} bytes.`);
156
+ const root = parseRoot(inflated.value.text);
157
+ return root === null ? refuse(invalid, `its ${name} is not well-formed XML.`) : Result.ok(root);
158
+ };
159
+ /** The part a package-relative relationship target names, without its leading `/`. */
160
+ const resolvePackageTarget = (target) => {
161
+ const decoded = Result.try({
162
+ try: () => decodeURIComponent(target),
163
+ catch: () => null
164
+ });
165
+ if (decoded.isErr()) return null;
166
+ const resolved = path.posix.normalize(path.posix.join("/", decoded.value));
167
+ return resolved === "/" ? null : resolved.slice(1);
168
+ };
169
+ const mainPartName = (relationships) => {
170
+ if (relationships.namespace !== PACKAGE_RELATIONSHIPS_NAMESPACE || relationships.localName !== "Relationships") return refuse(INVALID_PACKAGE_REASONS.relationshipsInvalid, `its ${PACKAGE_RELATIONSHIPS_PART} is not a relationships part.`);
171
+ const main = childrenNamed(relationships, PACKAGE_RELATIONSHIPS_NAMESPACE, "Relationship").filter(({ attributes }) => OFFICE_DOCUMENT_RELATIONSHIP_TYPES.has(attributes["Type"] ?? "") && attributes["TargetMode"] !== "External");
172
+ const [only, ...others] = main;
173
+ if (only === void 0) return refuse(INVALID_PACKAGE_REASONS.mainRelationshipMissing, "no package relationship names a main document part.");
174
+ if (others.length > 0) return refuse(INVALID_PACKAGE_REASONS.mainRelationshipAmbiguous, `${String(main.length)} package relationships name a main document part.`);
175
+ const part = resolvePackageTarget(only.attributes["Target"] ?? "");
176
+ return part === null ? refuse(INVALID_PACKAGE_REASONS.relationshipsInvalid, "the main document relationship has no usable target.") : Result.ok(part);
177
+ };
178
+ /** An `Override` for the part wins over the `Default` for its extension. */
179
+ const contentTypeOf = ({ contentTypes, part }) => {
180
+ const partName = partKey(`/${part}`);
181
+ const override = childrenNamed(contentTypes, CONTENT_TYPES_NAMESPACE, "Override").find(({ attributes }) => partKey(attributes["PartName"] ?? "") === partName);
182
+ if (override !== void 0) return override.attributes["ContentType"];
183
+ const extension = partKey(path.posix.extname(part).slice(1));
184
+ return childrenNamed(contentTypes, CONTENT_TYPES_NAMESPACE, "Default").find(({ attributes }) => partKey(attributes["Extension"] ?? "") === extension)?.attributes["ContentType"];
185
+ };
186
+ /** The first element's name and namespace declarations, read from its start tag. */
187
+ const rootOfPrefix = (xml) => {
188
+ const body = xml.replace(/^?(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->)*/u, "");
189
+ const match = /^<([A-Za-z_][\w.:-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/u.exec(body);
190
+ if (match === null) return null;
191
+ const [, name = "", attributeText = ""] = match;
192
+ const scope = /* @__PURE__ */ new Map();
193
+ for (const [, attribute = "", double, single] of attributeText.matchAll(/([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)) {
194
+ const value = double ?? single ?? "";
195
+ if (attribute === "xmlns") scope.set("", value);
196
+ else if (attribute.startsWith("xmlns:")) scope.set(attribute.slice(6), value);
197
+ }
198
+ const { prefix, localName } = splitName(name);
199
+ return {
200
+ namespace: scope.get(prefix),
201
+ localName,
202
+ attributes: {},
203
+ children: []
204
+ };
205
+ };
206
+ const ZIP_END_SIGNATURE = 101010256;
207
+ const ZIP64_END_SIGNATURE = 101075792;
208
+ const ZIP64_LOCATOR_SIGNATURE = 117853008;
209
+ const ZIP_CENTRAL_MEMBER_SIGNATURE = 33639248;
210
+ const ZIP_END_LENGTH = 22;
211
+ const ZIP_CENTRAL_MEMBER_LENGTH = 46;
212
+ const ZIP_MAX_COMMENT_LENGTH = 65535;
213
+ /** Read central-directory names before JSZip stores members by name. */
214
+ const checkRawMemberNames = (bytes) => {
215
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
216
+ let end = -1;
217
+ for (let offset = bytes.length - ZIP_END_LENGTH; offset >= Math.max(0, bytes.length - ZIP_END_LENGTH - ZIP_MAX_COMMENT_LENGTH); offset -= 1) if (view.getUint32(offset, true) === ZIP_END_SIGNATURE && offset + ZIP_END_LENGTH + view.getUint16(offset + 20, true) === bytes.length) {
218
+ end = offset;
219
+ break;
220
+ }
221
+ if (end < 0) return refuse(INVALID_PACKAGE_REASONS.notZip, "it has no readable ZIP directory.");
222
+ let count = view.getUint16(end + 10, true);
223
+ let size = view.getUint32(end + 12, true);
224
+ let directoryEnd = end;
225
+ if (count === 65535 || size === 4294967295 || view.getUint32(end + 16, true) === 4294967295) {
226
+ const locator = end - 20;
227
+ if (locator < 0 || view.getUint32(locator, true) !== ZIP64_LOCATOR_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP64 directory is missing.");
228
+ const zip64Offset = Number(view.getBigUint64(locator + 8, true));
229
+ if (!Number.isSafeInteger(zip64Offset) || zip64Offset < 0 || zip64Offset + 56 > locator || view.getUint32(zip64Offset, true) !== ZIP64_END_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP64 directory is invalid.");
230
+ count = Number(view.getBigUint64(zip64Offset + 32, true));
231
+ size = Number(view.getBigUint64(zip64Offset + 40, true));
232
+ directoryEnd = zip64Offset;
233
+ }
234
+ if (!Number.isSafeInteger(count) || !Number.isSafeInteger(size) || size > directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
235
+ const seen = /* @__PURE__ */ new Set();
236
+ let offset = directoryEnd - size;
237
+ for (let index = 0; index < count; index += 1) {
238
+ if (offset + ZIP_CENTRAL_MEMBER_LENGTH > directoryEnd || view.getUint32(offset, true) !== ZIP_CENTRAL_MEMBER_SIGNATURE) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
239
+ const nameLength = view.getUint16(offset + 28, true);
240
+ const extraLength = view.getUint16(offset + 30, true);
241
+ const commentLength = view.getUint16(offset + 32, true);
242
+ const next = offset + ZIP_CENTRAL_MEMBER_LENGTH + nameLength + extraLength + commentLength;
243
+ if (next > directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
244
+ const name = Buffer.from(bytes.subarray(offset + ZIP_CENTRAL_MEMBER_LENGTH, offset + ZIP_CENTRAL_MEMBER_LENGTH + nameLength)).toString("hex");
245
+ if (seen.has(name)) return refuse(INVALID_PACKAGE_REASONS.duplicatePartName, "two ZIP entries have the same name.");
246
+ seen.add(name);
247
+ offset = next;
248
+ }
249
+ if (offset !== directoryEnd) return refuse(INVALID_PACKAGE_REASONS.notZip, "its ZIP directory is invalid.");
250
+ return Result.ok();
251
+ };
252
+ const checkPackage = async (bytes) => {
253
+ const rawNames = checkRawMemberNames(bytes);
254
+ if (rawNames.isErr()) return rawNames;
255
+ const zip = await Result.tryPromise({
256
+ try: () => JSZip.loadAsync(bytes),
257
+ catch: () => ({
258
+ reason: INVALID_PACKAGE_REASONS.notZip,
259
+ message: "it is not a readable ZIP archive."
260
+ })
261
+ });
262
+ if (zip.isErr()) return Result.err(zip.error);
263
+ const parts = /* @__PURE__ */ new Map();
264
+ for (const [name, entry] of Object.entries(zip.value.files)) {
265
+ if (entry.dir) continue;
266
+ const key = partKey(name.replace(/^\//u, ""));
267
+ if (parts.has(key)) return refuse(INVALID_PACKAGE_REASONS.duplicatePartName, `two entries share the part name ${name}.`);
268
+ parts.set(key, entry);
269
+ }
270
+ const contentTypes = await readIndexPart({
271
+ parts,
272
+ name: CONTENT_TYPES_PART,
273
+ missing: INVALID_PACKAGE_REASONS.contentTypesMissing,
274
+ invalid: INVALID_PACKAGE_REASONS.contentTypesInvalid
275
+ });
276
+ if (contentTypes.isErr()) return Result.err(contentTypes.error);
277
+ if (contentTypes.value.namespace !== CONTENT_TYPES_NAMESPACE || contentTypes.value.localName !== "Types") return refuse(INVALID_PACKAGE_REASONS.contentTypesInvalid, `its ${CONTENT_TYPES_PART} is not a content types part.`);
278
+ const relationships = await readIndexPart({
279
+ parts,
280
+ name: PACKAGE_RELATIONSHIPS_PART,
281
+ missing: INVALID_PACKAGE_REASONS.relationshipsMissing,
282
+ invalid: INVALID_PACKAGE_REASONS.relationshipsInvalid
283
+ });
284
+ if (relationships.isErr()) return Result.err(relationships.error);
285
+ const part = mainPartName(relationships.value);
286
+ if (part.isErr()) return Result.err(part.error);
287
+ const entry = parts.get(partKey(part.value));
288
+ if (entry === void 0) return refuse(INVALID_PACKAGE_REASONS.mainPartMissing, `its main document part /${part.value} is missing.`);
289
+ const contentType = contentTypeOf({
290
+ contentTypes: contentTypes.value,
291
+ part: part.value
292
+ });
293
+ if (contentType === void 0 || !MAIN_DOCUMENT_CONTENT_TYPES.has(contentType)) return refuse(INVALID_PACKAGE_REASONS.mainPartContentType, `its main part /${part.value} has content type ${contentType ?? "(none)"}, not a WordprocessingML document.`);
294
+ const prefix = await Result.tryPromise({
295
+ try: () => inflatePrefix(entry, MAX_ROOT_SCAN_BYTES),
296
+ catch: () => ({
297
+ reason: INVALID_PACKAGE_REASONS.mainPartRoot,
298
+ message: `its main part /${part.value} cannot be inflated.`
299
+ })
300
+ });
301
+ if (prefix.isErr()) return Result.err(prefix.error);
302
+ const root = rootOfPrefix(prefix.value.text);
303
+ if (root === null || root.localName !== "document" || !WORDPROCESSINGML_NAMESPACES.has(root.namespace ?? "")) return refuse(INVALID_PACKAGE_REASONS.mainPartRoot, `its main part /${part.value} is not a WordprocessingML document.`);
304
+ if (partKey(part.value) !== partKey(READ_MAIN_DOCUMENT_PART)) return refuse(INVALID_PACKAGE_REASONS.mainPartLocation, `its main document is /${part.value}; folio reads it only at /${READ_MAIN_DOCUMENT_PART}.`);
305
+ return Result.ok();
306
+ };
307
+ /**
308
+ * Refuse, as `invalid_document`, a file that is not a WordprocessingML
309
+ * package with a main document part folio reads.
310
+ */
311
+ const checkWordprocessingPackage = async (filePath, bytes) => {
312
+ const checked = await checkPackage(bytes);
313
+ if (checked.isOk()) return Result.ok();
314
+ const { reason, message } = checked.error;
315
+ return Result.err(cliError({
316
+ code: FOLIO_CLI_ERROR_CODES.invalidDocument,
317
+ message: `${filePath} is not a .docx package: ${message}`,
318
+ hint: "Pass a .docx saved by a word processor or by folio.",
319
+ details: { reason }
320
+ }));
321
+ };
322
+ //#endregion
323
+ export { INVALID_PACKAGE_REASONS, checkWordprocessingPackage };
package/dist/output.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FolioCliError, FolioCliErrorCode } from "./errors.js";
1
+ import { FolioCliError, FolioCliErrorCode, PublicErrorDetails } from "./errors.js";
2
2
  //#region src/output.d.ts
3
3
  declare const OUTPUT_FORMATS: readonly ["json", "text"];
4
4
  type OutputFormat = (typeof OUTPUT_FORMATS)[number];
@@ -7,7 +7,7 @@ type FailureBody = {
7
7
  code: FolioCliErrorCode;
8
8
  message: string;
9
9
  hint?: string;
10
- details?: unknown;
10
+ details?: PublicErrorDetails;
11
11
  };
12
12
  type Envelope = {
13
13
  ok: true;
@@ -17,7 +17,8 @@ declare const nextRevisionIdSeed: (bytes: Uint8Array) => Promise<Result<number,
17
17
  * Check what a save wrote before it replaces anything: the package opens,
18
18
  * every changed XML part is well formed, every relationship id a changed
19
19
  * part references resolves, every internal target a changed `.rels` part
20
- * names exists, and the package parses as a document again.
20
+ * names exists, the package still has the main document part folio reads,
21
+ * and it parses as a document again.
21
22
  */
22
23
  declare const checkPackageIntegrity: (bytes: Uint8Array<ArrayBuffer>, changedParts: readonly ChangedPart[]) => Promise<Result<void, FolioCliError>>;
23
24
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { checkWordprocessingPackage } from "./main-document-part.js";
2
3
  import { Result } from "better-result";
3
4
  import path from "node:path";
4
5
  import { FolioDocxReviewer } from "@stll/folio-core/server";
@@ -147,7 +148,8 @@ const checkChangedPart = async ({ zip, parts, part }) => {
147
148
  * Check what a save wrote before it replaces anything: the package opens,
148
149
  * every changed XML part is well formed, every relationship id a changed
149
150
  * part references resolves, every internal target a changed `.rels` part
150
- * names exists, and the package parses as a document again.
151
+ * names exists, the package still has the main document part folio reads,
152
+ * and it parses as a document again.
151
153
  */
152
154
  const checkPackageIntegrity = async (bytes, changedParts) => {
153
155
  const zip = await loadZip(bytes);
@@ -162,6 +164,8 @@ const checkPackageIntegrity = async (bytes, changedParts) => {
162
164
  });
163
165
  if (problem !== null) return Result.err(integrityError(`The saved package failed validation: ${problem}`));
164
166
  }
167
+ const mainPart = await checkWordprocessingPackage("The saved package", bytes);
168
+ if (mainPart.isErr()) return Result.err(integrityError(mainPart.error.message));
165
169
  const reopened = await Result.tryPromise({
166
170
  try: () => FolioDocxReviewer.fromBuffer(bytes.slice().buffer),
167
171
  catch: (error) => integrityError(`The saved package does not reopen: ${error instanceof Error ? error.message : String(error)}`)
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.3.1";
2
+ var version = "0.3.3";
3
3
  //#endregion
4
4
  export { version };
@@ -210,10 +210,7 @@ const runServe = async (rest, io) => {
210
210
  documentPath: input,
211
211
  port
212
212
  });
213
- if (started.isErr()) return emit(io, format.value, Result.err(cliError({
214
- code: FOLIO_CLI_ERROR_CODES.invalidInput,
215
- message: started.error.message
216
- })));
213
+ if (started.isErr()) return emit(io, format.value, started);
217
214
  const exit = emit(io, format.value, Result.ok({
218
215
  url: started.value.url,
219
216
  path: path.resolve(input)
package/dist/serve.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { FolioCliError } from "./errors.js";
1
2
  import { Result } from "better-result";
2
3
  //#region src/serve.d.ts
3
4
  declare const PREVIEW_HOST = "127.0.0.1";
@@ -12,6 +13,6 @@ type PreviewServer = {
12
13
  close: () => Promise<void>;
13
14
  };
14
15
  /** Serve the preview until {@link PreviewServer.close}. */
15
- declare const startPreviewServer: ({ documentPath, port }: PreviewServerOptions) => Promise<Result<PreviewServer, Error>>;
16
+ declare const startPreviewServer: ({ documentPath, port }: PreviewServerOptions) => Promise<Result<PreviewServer, FolioCliError>>;
16
17
  //#endregion
17
18
  export { PREVIEW_HOST, PreviewServer, PreviewServerOptions, startPreviewServer };
package/dist/serve.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readDocumentFile } from "./document.js";
2
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
3
  import { latestCommitFor } from "./journal.js";
3
4
  import { buildDisplayList, displayListHtml } from "./render.js";
4
5
  import { SIDECAR_DIRECTORY } from "./sidecar.js";
@@ -81,7 +82,7 @@ const DOCUMENT_CSP = "default-src 'none'; img-src data:; font-src data:; style-s
81
82
  /** Serve the preview until {@link PreviewServer.close}. */
82
83
  const startPreviewServer = async ({ documentPath, port }) => {
83
84
  const initial = await readDocumentFile(documentPath);
84
- if (initial.isErr()) return Result.err(new Error(initial.error.message));
85
+ if (initial.isErr()) return Result.err(initial.error);
85
86
  const realPath = initial.value.path;
86
87
  const title = path.basename(realPath);
87
88
  const prefix = `/${randomBytes(24).toString("base64url")}/`;
@@ -215,7 +216,10 @@ const startPreviewServer = async ({ documentPath, port }) => {
215
216
  if ((await Result.tryPromise(() => new Promise((resolve, reject) => {
216
217
  server.once("error", reject);
217
218
  server.listen(port, "127.0.0.1", () => resolve());
218
- }))).isErr()) return Result.err(/* @__PURE__ */ new Error(`Cannot listen on ${PREVIEW_HOST}:${String(port)}.`));
219
+ }))).isErr()) return Result.err(cliError({
220
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
221
+ message: `Cannot listen on ${PREVIEW_HOST}:${String(port)}.`
222
+ }));
219
223
  const address = server.address();
220
224
  boundPort = typeof address === "object" && address !== null ? address.port : port;
221
225
  const watchers = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",
@@ -70,11 +70,11 @@
70
70
  "@fontsource/source-sans-3": "^5.2.9",
71
71
  "@fontsource/tinos": "^5.2.7",
72
72
  "@modelcontextprotocol/server": "2.0.0",
73
- "@stll/folio-agents": "^0.14.2",
74
- "@stll/folio-core": "^0.52.0",
73
+ "@stll/folio-agents": "^0.15.1",
74
+ "@stll/folio-core": "^0.54.0",
75
75
  "better-result": "3.0.1",
76
76
  "fast-xml-parser": "^5.10.1",
77
- "jszip": "3.10.1"
77
+ "jszip": "3.10.2"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@modelcontextprotocol/client": "2.0.0",