@stll/folio-cli 0.0.1-placeholder.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +298 -0
  2. package/dist/bin.d.ts +1 -0
  3. package/dist/bin.js +30 -0
  4. package/dist/cli.d.ts +25 -0
  5. package/dist/cli.js +440 -0
  6. package/dist/document.d.ts +43 -0
  7. package/dist/document.js +127 -0
  8. package/dist/errors.d.ts +67 -0
  9. package/dist/errors.js +92 -0
  10. package/dist/execute-read.d.ts +64 -0
  11. package/dist/execute-read.js +220 -0
  12. package/dist/execute-write.d.ts +36 -0
  13. package/dist/execute-write.js +449 -0
  14. package/dist/flags.d.ts +38 -0
  15. package/dist/flags.js +102 -0
  16. package/dist/journal.d.ts +82 -0
  17. package/dist/journal.js +212 -0
  18. package/dist/lock.d.ts +55 -0
  19. package/dist/lock.js +157 -0
  20. package/dist/mcp.d.ts +28 -0
  21. package/dist/mcp.js +310 -0
  22. package/dist/output-file.d.ts +14 -0
  23. package/dist/output-file.js +60 -0
  24. package/dist/output.d.ts +33 -0
  25. package/dist/output.js +77 -0
  26. package/dist/package-parts.d.ts +24 -0
  27. package/dist/package-parts.js +172 -0
  28. package/dist/package.js +4 -0
  29. package/dist/preview-commands.d.ts +8 -0
  30. package/dist/preview-commands.js +226 -0
  31. package/dist/provenance.d.ts +17 -0
  32. package/dist/provenance.js +53 -0
  33. package/dist/registry.d.ts +69 -0
  34. package/dist/registry.js +255 -0
  35. package/dist/render.d.ts +35 -0
  36. package/dist/render.js +120 -0
  37. package/dist/roots.d.ts +21 -0
  38. package/dist/roots.js +57 -0
  39. package/dist/serve.d.ts +17 -0
  40. package/dist/serve.js +234 -0
  41. package/dist/sidecar.d.ts +52 -0
  42. package/dist/sidecar.js +156 -0
  43. package/dist/transaction.d.ts +55 -0
  44. package/dist/transaction.js +218 -0
  45. package/package.json +85 -3
@@ -0,0 +1,218 @@
1
+ import { fileVersionOf, readDocumentFile, sameFile } from "./document.js";
2
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
3
+ import { appendJournal, stagePathFor } from "./journal.js";
4
+ import { checkPackageIntegrity } from "./package-parts.js";
5
+ import { inspectPath, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile } from "./sidecar.js";
6
+ import { Result } from "better-result";
7
+ import { readdir, realpath, rename, rm } from "node:fs/promises";
8
+ import path from "node:path";
9
+ //#region src/transaction.ts
10
+ /**
11
+ * Commit one transaction's bytes to its destination: under the document's
12
+ * lease, re-check the source and destination versions, stage the package
13
+ * beside the destination, validate it, back up whatever the write replaces,
14
+ * confirm the lease is still ours, journal the commit, and rename the stage
15
+ * into place. Every step before the rename leaves the destination untouched.
16
+ */
17
+ /** Backups kept per document. */
18
+ const MAX_BACKUPS = 20;
19
+ const fileSystemError = (message, error) => cliError({
20
+ code: FOLIO_CLI_ERROR_CODES.internal,
21
+ message: `${message}: ${error instanceof Error ? error.message : String(error)}`
22
+ });
23
+ const BACKUP_NAME = /^[0-9a-f]{64}\.docx$/u;
24
+ /** `.folio/backups/<document name>`, created private and never through a symlink. */
25
+ const backupDirectoryFor = (documentPath) => sidecarDirectory(documentPath, "backups", path.basename(documentPath));
26
+ /**
27
+ * Keep the newest `keep` backups in one document's backup directory. Only
28
+ * regular files named `<version>.docx` count; nothing else is touched.
29
+ */
30
+ const pruneBackups = async (directory, keep) => {
31
+ const names = await Result.tryPromise(() => readdir(directory));
32
+ if (names.isErr()) return Result.err(fileSystemError(`Cannot list ${directory}`, names.error.cause));
33
+ const backups = [];
34
+ for (const name of names.value.filter((entry) => BACKUP_NAME.test(entry))) {
35
+ const entry = await inspectPath(path.join(directory, name));
36
+ if (entry.isErr()) return Result.err(entry.error);
37
+ if (entry.value.type === "file") backups.push({
38
+ name,
39
+ modifiedMs: entry.value.modifiedMs
40
+ });
41
+ }
42
+ const stale = backups.toSorted((left, right) => right.modifiedMs - left.modifiedMs || right.name.localeCompare(left.name)).slice(keep);
43
+ for (const { name } of stale) await rm(path.join(directory, name), { force: true });
44
+ return Result.ok();
45
+ };
46
+ /**
47
+ * Write the bytes a transaction replaces to
48
+ * `.folio/backups/<document name>/<version>.docx`, flushed with its
49
+ * directory. A backup of the same version already there is kept when it
50
+ * holds those bytes.
51
+ */
52
+ const backUp = async ({ documentPath, version, bytes }) => {
53
+ const directory = await backupDirectoryFor(documentPath);
54
+ if (directory.isErr()) return Result.err(directory.error);
55
+ const backupPath = path.join(directory.value, `${version}.docx`);
56
+ const existing = await inspectPath(backupPath);
57
+ if (existing.isErr()) return Result.err(existing.error);
58
+ if (existing.value.type !== "missing") {
59
+ const kept = await readSidecarFile(backupPath);
60
+ if (kept.isErr()) return Result.err(kept.error);
61
+ if (fileVersionOf(kept.value) !== version) return Result.err(unsafePath(`${backupPath} does not hold the version its name says.`));
62
+ } else {
63
+ const written = await writeNewSidecarFile(backupPath, bytes);
64
+ if (written.isErr()) return Result.err(written.error);
65
+ const synced = await syncDirectory(directory.value);
66
+ if (synced.isErr()) return Result.err(synced.error);
67
+ }
68
+ const pruned = await pruneBackups(directory.value, 20);
69
+ return pruned.isErr() ? Result.err(pruned.error) : Result.ok(backupPath);
70
+ };
71
+ /** The destination as it is now: missing, or a regular single-link file read without following a symlink. */
72
+ const readDestination = async (destinationPath) => {
73
+ const entry = await inspectPath(destinationPath);
74
+ if (entry.isErr()) return Result.err(entry.error);
75
+ if (entry.value.type === "missing") return Result.ok({ type: "missing" });
76
+ if (entry.value.type !== "file" || entry.value.links !== 1) return Result.err(unsafePath(`${destinationPath} is not a regular file with a single link; folio does not replace it.`));
77
+ const bytes = await readSidecarFile(destinationPath);
78
+ if (bytes.isErr()) return Result.err(bytes.error);
79
+ return Result.ok({
80
+ type: "file",
81
+ identity: entry.value.identity,
82
+ version: fileVersionOf(bytes.value),
83
+ bytes: bytes.value
84
+ });
85
+ };
86
+ const staleError = (message, expected, actual) => cliError({
87
+ code: FOLIO_CLI_ERROR_CODES.staleVersion,
88
+ message,
89
+ hint: "Re-read the document and retry against its current fileVersion.",
90
+ details: {
91
+ expected,
92
+ actual
93
+ }
94
+ });
95
+ /** The replaced destination must be exactly the version the caller named. */
96
+ const checkDestination = (request, existing) => {
97
+ const { destination, destinationPath } = request;
98
+ if (destination.type === "inPlace" || existing.type === "missing") return destination.type === "file" && destination.expectedVersion !== void 0 ? Result.err(staleError(`${destinationPath} does not exist, but a destination version was expected.`, destination.expectedVersion, null)) : Result.ok();
99
+ if (!destination.overwrite) return Result.err(cliError({
100
+ code: FOLIO_CLI_ERROR_CODES.destinationExists,
101
+ message: `${destinationPath} already exists.`,
102
+ hint: "Pass --overwrite with --expect-destination-version to replace it, or choose another -o path."
103
+ }));
104
+ if (destination.expectedVersion === void 0) return Result.err(cliError({
105
+ code: FOLIO_CLI_ERROR_CODES.invalidInput,
106
+ message: `Replacing ${destinationPath} needs its current fileVersion.`,
107
+ hint: "Read it (folio read) and pass --expect-destination-version (MCP: expectedDestinationVersion)."
108
+ }));
109
+ return destination.expectedVersion === existing.version ? Result.ok() : Result.err(staleError(`${destinationPath} changed since it was read; nothing was written.`, destination.expectedVersion, existing.version));
110
+ };
111
+ const sameEntry = (entry, identity) => entry.type === "file" && entry.links === 1 && sameFile(entry.identity, identity);
112
+ /**
113
+ * Just before the rename: the directory still resolves where it did, the
114
+ * stage is still the file written, and the destination is still the file
115
+ * checked (or still absent).
116
+ */
117
+ const checkBeforeRename = async (guard) => {
118
+ const parent = await Result.tryPromise(() => realpath(path.dirname(guard.destinationPath)));
119
+ if (parent.isErr() || parent.value !== guard.parentRealPath) return Result.err(unsafePath(`${path.dirname(guard.destinationPath)} moved during the write.`));
120
+ const stage = await inspectPath(guard.stagePath);
121
+ if (stage.isErr()) return Result.err(stage.error);
122
+ if (!sameEntry(stage.value, guard.stageIdentity)) return Result.err(unsafePath(`${guard.stagePath} was replaced during the write.`));
123
+ const destination = await inspectPath(guard.destinationPath);
124
+ if (destination.isErr()) return Result.err(destination.error);
125
+ return (guard.existing.type === "missing" ? destination.value.type === "missing" : sameEntry(destination.value, guard.existing.identity)) ? Result.ok() : Result.err(unsafePath(`${guard.destinationPath} was replaced during the write.`));
126
+ };
127
+ /**
128
+ * Commit under a lease the caller already holds. Refuses when the source
129
+ * changed since it was read, when a new destination already exists, or when
130
+ * an existing destination is not the version the caller named.
131
+ */
132
+ const commitTransaction = async (request) => {
133
+ const { txId, destinationPath, sourcePath, fromVersion, bytes } = request;
134
+ const source = await readDocumentFile(sourcePath);
135
+ const sourceVersion = source.isOk() ? source.value.fileVersion : null;
136
+ if (sourceVersion !== fromVersion || !source.isOk() || !sameFile(source.value.identity, request.sourceIdentity)) return Result.err(staleError(`${sourcePath} changed while this transaction ran; nothing was written.`, fromVersion, sourceVersion));
137
+ const existing = await readDestination(destinationPath);
138
+ if (existing.isErr()) return Result.err(existing.error);
139
+ const destinationChecked = checkDestination(request, existing.value);
140
+ if (destinationChecked.isErr()) return Result.err(destinationChecked.error);
141
+ const destinationVersionBefore = existing.value.type === "file" ? existing.value.version : null;
142
+ const parentRealPath = await Result.tryPromise(() => realpath(path.dirname(destinationPath)));
143
+ if (parentRealPath.isErr()) return Result.err(fileSystemError(`Cannot resolve ${destinationPath}`, parentRealPath.error.cause));
144
+ const stagePath = stagePathFor(destinationPath, txId);
145
+ const staged = await writeNewSidecarFile(stagePath, bytes);
146
+ if (staged.isErr()) return Result.err(staged.error);
147
+ const discardStage = async (error) => {
148
+ await rm(stagePath, { force: true });
149
+ return Result.err(error);
150
+ };
151
+ const valid = await checkPackageIntegrity(bytes, request.changedParts);
152
+ if (valid.isErr()) return await discardStage(valid.error);
153
+ let backup;
154
+ if (existing.value.type === "file") {
155
+ const backedUp = await backUp({
156
+ documentPath: destinationPath,
157
+ version: existing.value.version,
158
+ bytes: existing.value.bytes
159
+ });
160
+ if (backedUp.isErr()) return await discardStage(backedUp.error);
161
+ backup = backedUp.value;
162
+ }
163
+ const stillOurs = await request.verifyLease();
164
+ if (stillOurs.isErr()) return await discardStage(stillOurs.error);
165
+ const toVersion = fileVersionOf(bytes);
166
+ const receipt = {
167
+ ...request.receipt,
168
+ fileVersion: toVersion,
169
+ ...backup !== void 0 && { backup }
170
+ };
171
+ const journaled = await appendJournal(request.journalPath, {
172
+ type: "commit",
173
+ ...request.entry,
174
+ path: destinationPath,
175
+ toVersion,
176
+ destinationVersionBefore,
177
+ stage: path.basename(stagePath),
178
+ receipt
179
+ });
180
+ if (journaled.isErr()) return await discardStage(journaled.error);
181
+ const abandon = async (reason, error) => {
182
+ await rm(stagePath, { force: true });
183
+ const recorded = await appendJournal(request.journalPath, {
184
+ type: "recovery",
185
+ txId,
186
+ path: destinationPath,
187
+ time: request.entry.time,
188
+ action: "discarded",
189
+ reason
190
+ });
191
+ return Result.err(recorded.isErr() ? recorded.error : error);
192
+ };
193
+ const fenced = await request.verifyLease();
194
+ if (fenced.isErr()) return await abandon("leaseLost", fenced.error);
195
+ const guarded = await checkBeforeRename({
196
+ parentRealPath: parentRealPath.value,
197
+ stagePath,
198
+ stageIdentity: staged.value,
199
+ destinationPath,
200
+ existing: existing.value
201
+ });
202
+ if (guarded.isErr()) return await abandon("renameRefused", guarded.error);
203
+ const renamed = await Result.tryPromise(() => rename(stagePath, destinationPath));
204
+ if (renamed.isErr()) return Result.err(fileSystemError(`Cannot move the staged package into ${destinationPath}`, renamed.error.cause));
205
+ const synced = await syncDirectory(path.dirname(destinationPath));
206
+ if (synced.isErr()) return Result.err(cliError({
207
+ code: FOLIO_CLI_ERROR_CODES.internal,
208
+ message: `${destinationPath} was replaced, but its directory could not be flushed to disk: ${synced.error.message}`,
209
+ details: { receipt }
210
+ }));
211
+ return Result.ok({
212
+ toVersion,
213
+ backup,
214
+ receipt
215
+ });
216
+ };
217
+ //#endregion
218
+ export { MAX_BACKUPS, backupDirectoryFor, commitTransaction, pruneBackups };
package/package.json CHANGED
@@ -1,6 +1,88 @@
1
1
  {
2
2
  "name": "@stll/folio-cli",
3
- "version": "0.0.1-placeholder.0",
4
- "description": "Placeholder to bootstrap npm trusted publishing. Real releases are published from stella/folio via GitHub Actions.",
5
- "license": "Apache-2.0"
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "cli",
7
+ "docx",
8
+ "mcp",
9
+ "ooxml",
10
+ "redline",
11
+ "tracked-changes",
12
+ "word"
13
+ ],
14
+ "homepage": "https://github.com/stella/folio",
15
+ "bugs": {
16
+ "url": "https://github.com/stella/folio/issues"
17
+ },
18
+ "license": "Apache-2.0",
19
+ "author": "STLL",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/stella/folio.git",
23
+ "directory": "packages/cli"
24
+ },
25
+ "bin": {
26
+ "folio": "./dist/bin.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "skills",
31
+ "README.md",
32
+ "LICENSE",
33
+ "NOTICE.md"
34
+ ],
35
+ "type": "module",
36
+ "sideEffects": [
37
+ "./src/bin.ts"
38
+ ],
39
+ "exports": {
40
+ ".": {
41
+ "types": "./dist/cli.d.ts",
42
+ "import": "./dist/cli.js"
43
+ }
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "rm -rf dist && tsdown",
50
+ "prepack": "bun run build",
51
+ "pack:dry-run": "bun pm pack --dry-run",
52
+ "typecheck": "bun ../../scripts/tsc-native.ts --noEmit -p tsconfig.build.json",
53
+ "test": "bun test src"
54
+ },
55
+ "dependencies": {
56
+ "@fontsource/arimo": "^5.2.8",
57
+ "@fontsource/caladea": "^5.2.8",
58
+ "@fontsource/carlito": "^5.2.8",
59
+ "@fontsource/cousine": "^5.2.7",
60
+ "@fontsource/lato": "^5.2.7",
61
+ "@fontsource/noto-sans-arabic": "^5.2.7",
62
+ "@fontsource/source-sans-3": "^5.2.9",
63
+ "@fontsource/tinos": "^5.2.7",
64
+ "@modelcontextprotocol/server": "2.0.0",
65
+ "@stll/folio-agents": "^0.14.0",
66
+ "@stll/folio-core": "^0.50.0",
67
+ "better-result": "3.0.1",
68
+ "fast-xml-parser": "^5.10.1",
69
+ "jszip": "3.10.1"
70
+ },
71
+ "devDependencies": {
72
+ "@modelcontextprotocol/client": "2.0.0",
73
+ "playwright-core": "1.61.1"
74
+ },
75
+ "peerDependencies": {
76
+ "playwright-core": ">=1.50.0"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "playwright-core": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "engines": {
84
+ "node": ">=22"
85
+ },
86
+ "main": "./dist/cli.js",
87
+ "types": "./dist/cli.d.ts"
6
88
  }