@stll/folio-cli 0.0.1-placeholder.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +298 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +30 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +440 -0
- package/dist/document.d.ts +43 -0
- package/dist/document.js +127 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +92 -0
- package/dist/execute-read.d.ts +64 -0
- package/dist/execute-read.js +220 -0
- package/dist/execute-write.d.ts +36 -0
- package/dist/execute-write.js +449 -0
- package/dist/flags.d.ts +38 -0
- package/dist/flags.js +102 -0
- package/dist/journal.d.ts +82 -0
- package/dist/journal.js +212 -0
- package/dist/lock.d.ts +55 -0
- package/dist/lock.js +157 -0
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +310 -0
- package/dist/output-file.d.ts +14 -0
- package/dist/output-file.js +60 -0
- package/dist/output.d.ts +33 -0
- package/dist/output.js +77 -0
- package/dist/package-parts.d.ts +24 -0
- package/dist/package-parts.js +172 -0
- package/dist/package.js +4 -0
- package/dist/preview-commands.d.ts +8 -0
- package/dist/preview-commands.js +226 -0
- package/dist/provenance.d.ts +17 -0
- package/dist/provenance.js +53 -0
- package/dist/registry.d.ts +69 -0
- package/dist/registry.js +255 -0
- package/dist/render.d.ts +35 -0
- package/dist/render.js +120 -0
- package/dist/roots.d.ts +21 -0
- package/dist/roots.js +57 -0
- package/dist/serve.d.ts +17 -0
- package/dist/serve.js +234 -0
- package/dist/sidecar.d.ts +52 -0
- package/dist/sidecar.js +156 -0
- package/dist/transaction.d.ts +55 -0
- package/dist/transaction.js +218 -0
- package/package.json +85 -3
|
@@ -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.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
}
|