@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.
- 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
package/dist/journal.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { errnoCode, fileVersionOf, sameFile } from "./document.js";
|
|
2
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
|
+
import { checkPackageIntegrity } from "./package-parts.js";
|
|
4
|
+
import { SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, syncDirectory } from "./sidecar.js";
|
|
5
|
+
import { Result } from "better-result";
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { readdir, rename, rm } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
//#region src/journal.ts
|
|
10
|
+
/**
|
|
11
|
+
* The transaction journal: `.folio/journal.jsonl` beside the document (or
|
|
12
|
+
* `--journal`), one JSON line per committed transaction plus one per crash
|
|
13
|
+
* recovery. A `commit` line is written, and synced, after the staged package
|
|
14
|
+
* validates and before it is renamed into place, so the line is the commit
|
|
15
|
+
* point: a stage left behind by a crash is rolled forward when its line
|
|
16
|
+
* names this document, the stage is the validated package it recorded, and
|
|
17
|
+
* the destination is still what the transaction replaced. Anything else is
|
|
18
|
+
* discarded. The journal, like every sidecar file, is never read or written
|
|
19
|
+
* through a symlink or a hard link.
|
|
20
|
+
*/
|
|
21
|
+
const RECOVERY_DISCARD_REASONS = [
|
|
22
|
+
"notJournaled",
|
|
23
|
+
"otherDocument",
|
|
24
|
+
"notRegularFile",
|
|
25
|
+
"stageChanged",
|
|
26
|
+
"invalidPackage",
|
|
27
|
+
"destinationMoved",
|
|
28
|
+
"leaseLost",
|
|
29
|
+
"renameRefused"
|
|
30
|
+
];
|
|
31
|
+
const journalPathFor = (documentPath, override) => override === void 0 ? path.join(path.dirname(documentPath), SIDECAR_DIRECTORY, "journal.jsonl") : path.resolve(override);
|
|
32
|
+
const journalError = (message, error) => cliError({
|
|
33
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
34
|
+
message: `${message}: ${error instanceof Error ? error.message : String(error)}`
|
|
35
|
+
});
|
|
36
|
+
/** Append one line and sync it to disk before returning. */
|
|
37
|
+
const appendJournal = async (journalPath, entry) => {
|
|
38
|
+
const parent = await ensureSidecarParent(journalPath);
|
|
39
|
+
if (parent.isErr()) return Result.err(parent.error);
|
|
40
|
+
const opened = await openSidecarFile(journalPath, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT);
|
|
41
|
+
if (opened.isErr()) return Result.err(opened.error);
|
|
42
|
+
const { handle } = opened.value;
|
|
43
|
+
const written = await Result.tryPromise(async () => {
|
|
44
|
+
try {
|
|
45
|
+
await handle.writeFile(`${JSON.stringify(entry)}\n`);
|
|
46
|
+
await handle.sync();
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
if (written.isErr()) return Result.err(journalError(`Cannot append to ${journalPath}`, written.error.cause));
|
|
52
|
+
return await syncDirectory(path.dirname(journalPath));
|
|
53
|
+
};
|
|
54
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
const parseCommit = (line) => {
|
|
56
|
+
const parsed = Result.try(() => JSON.parse(line));
|
|
57
|
+
if (parsed.isErr() || !isRecord(parsed.value)) return null;
|
|
58
|
+
const { type, txId, path: committedPath, requestHash, toVersion, destinationVersionBefore, receipt } = parsed.value;
|
|
59
|
+
return type === "commit" && typeof txId === "string" && typeof committedPath === "string" && typeof requestHash === "string" && typeof toVersion === "string" && (typeof destinationVersionBefore === "string" || destinationVersionBefore === null) && isRecord(receipt) ? {
|
|
60
|
+
txId,
|
|
61
|
+
path: committedPath,
|
|
62
|
+
requestHash,
|
|
63
|
+
toVersion,
|
|
64
|
+
destinationVersionBefore,
|
|
65
|
+
receipt
|
|
66
|
+
} : null;
|
|
67
|
+
};
|
|
68
|
+
const isDiscardRecord = (line, txId) => {
|
|
69
|
+
const parsed = Result.try(() => JSON.parse(line));
|
|
70
|
+
return parsed.isOk() && isRecord(parsed.value) && parsed.value["type"] === "recovery" && parsed.value["txId"] === txId && parsed.value["action"] === "discarded";
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The committed transaction with `txId`, if the journal has one and recovery
|
|
74
|
+
* did not discard it. Torn or foreign lines (a crash mid-append) are skipped.
|
|
75
|
+
* A journal (or `.folio`) that is a symlink or hard link is refused.
|
|
76
|
+
*/
|
|
77
|
+
const findCommit = async (journalPath, txId) => {
|
|
78
|
+
const journal = await inspectPath(journalPath);
|
|
79
|
+
if (journal.isErr()) return Result.err(journal.error);
|
|
80
|
+
if (journal.value.type === "missing") {
|
|
81
|
+
const parent = await inspectPath(path.dirname(journalPath));
|
|
82
|
+
if (parent.isErr()) return Result.err(parent.error);
|
|
83
|
+
return parent.value.type === "missing" || parent.value.type === "directory" ? Result.ok(void 0) : Result.err(cliError({
|
|
84
|
+
code: FOLIO_CLI_ERROR_CODES.unsafePath,
|
|
85
|
+
message: `${path.dirname(journalPath)} is not a plain directory.`
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
const parent = await ensureSidecarParent(journalPath);
|
|
89
|
+
if (parent.isErr()) return Result.err(parent.error);
|
|
90
|
+
const bytes = await readSidecarFile(journalPath);
|
|
91
|
+
if (bytes.isErr()) return Result.err(bytes.error);
|
|
92
|
+
let found;
|
|
93
|
+
for (const line of new TextDecoder().decode(bytes.value).split("\n")) {
|
|
94
|
+
if (!line.includes(txId)) continue;
|
|
95
|
+
if (isDiscardRecord(line, txId)) return Result.ok(void 0);
|
|
96
|
+
const commit = parseCommit(line);
|
|
97
|
+
if (commit?.txId === txId) found ??= commit;
|
|
98
|
+
}
|
|
99
|
+
return Result.ok(found);
|
|
100
|
+
};
|
|
101
|
+
const stagePrefixFor = (documentPath) => `.${path.basename(documentPath)}.folio-stage-`;
|
|
102
|
+
const stagePathFor = (documentPath, txId) => path.join(path.dirname(documentPath), `${stagePrefixFor(documentPath)}${txId}`);
|
|
103
|
+
/** The destination's current version, read without following a symlink; `null` when missing. */
|
|
104
|
+
const destinationVersion = async (documentPath) => {
|
|
105
|
+
const entry = await inspectPath(documentPath);
|
|
106
|
+
if (entry.isErr()) return Result.err(entry.error);
|
|
107
|
+
if (entry.value.type === "missing") return Result.ok(null);
|
|
108
|
+
const bytes = await readSidecarFile(documentPath);
|
|
109
|
+
return bytes.isErr() ? Result.err(bytes.error) : Result.ok(fileVersionOf(bytes.value));
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Whether a journaled stage may complete its rename: its line names this
|
|
113
|
+
* document, it is a regular single-link file (a symlink is never followed),
|
|
114
|
+
* its bytes are the version the line recorded and still parse as a package,
|
|
115
|
+
* and the destination is still what the transaction replaced.
|
|
116
|
+
*/
|
|
117
|
+
const judgeStage = async ({ documentPath, stagePath, commit }) => {
|
|
118
|
+
if (commit.path !== documentPath) return Result.ok({
|
|
119
|
+
type: "discard",
|
|
120
|
+
reason: "otherDocument"
|
|
121
|
+
});
|
|
122
|
+
const entry = await inspectPath(stagePath);
|
|
123
|
+
if (entry.isErr()) return Result.err(entry.error);
|
|
124
|
+
if (entry.value.type !== "file" || entry.value.links !== 1) return Result.ok({
|
|
125
|
+
type: "discard",
|
|
126
|
+
reason: "notRegularFile"
|
|
127
|
+
});
|
|
128
|
+
const opened = await openSidecarFile(stagePath, constants.O_RDONLY);
|
|
129
|
+
if (opened.isErr()) return Result.ok({
|
|
130
|
+
type: "discard",
|
|
131
|
+
reason: "notRegularFile"
|
|
132
|
+
});
|
|
133
|
+
const { handle, identity } = opened.value;
|
|
134
|
+
const read = await Result.tryPromise(async () => new Uint8Array(await handle.readFile()));
|
|
135
|
+
await handle.close();
|
|
136
|
+
if (read.isErr() || !sameFile(identity, entry.value.identity)) return Result.ok({
|
|
137
|
+
type: "discard",
|
|
138
|
+
reason: "stageChanged"
|
|
139
|
+
});
|
|
140
|
+
if (fileVersionOf(read.value) !== commit.toVersion) return Result.ok({
|
|
141
|
+
type: "discard",
|
|
142
|
+
reason: "stageChanged"
|
|
143
|
+
});
|
|
144
|
+
if ((await checkPackageIntegrity(read.value, [])).isErr()) return Result.ok({
|
|
145
|
+
type: "discard",
|
|
146
|
+
reason: "invalidPackage"
|
|
147
|
+
});
|
|
148
|
+
const current = await destinationVersion(documentPath);
|
|
149
|
+
if (current.isErr()) return Result.err(current.error);
|
|
150
|
+
return current.value === commit.destinationVersionBefore ? Result.ok({ type: "rollForward" }) : Result.ok({
|
|
151
|
+
type: "discard",
|
|
152
|
+
reason: "destinationMoved"
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Settle every stage a crashed transaction left beside `documentPath`. The
|
|
157
|
+
* caller holds the document's lease, so no live transaction owns a stage.
|
|
158
|
+
* Removing a discarded stage unlinks the name only; a symlink's target is
|
|
159
|
+
* never touched.
|
|
160
|
+
*/
|
|
161
|
+
const recoverStages = async ({ documentPath, journalPath, now }) => {
|
|
162
|
+
const directory = path.dirname(documentPath);
|
|
163
|
+
const prefix = stagePrefixFor(documentPath);
|
|
164
|
+
const listed = await Result.tryPromise(() => readdir(directory));
|
|
165
|
+
if (listed.isErr()) return errnoCode(listed.error.cause) === "ENOENT" ? Result.ok([]) : Result.err(journalError(`Cannot list ${directory}`, listed.error.cause));
|
|
166
|
+
const actions = [];
|
|
167
|
+
for (const stage of listed.value.filter((name) => name.startsWith(prefix))) {
|
|
168
|
+
const txId = stage.slice(prefix.length);
|
|
169
|
+
const stagePath = path.join(directory, stage);
|
|
170
|
+
const commit = await findCommit(journalPath, txId);
|
|
171
|
+
if (commit.isErr()) return Result.err(commit.error);
|
|
172
|
+
if (commit.value === void 0) {
|
|
173
|
+
await rm(stagePath, { force: true });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const verdict = await judgeStage({
|
|
177
|
+
documentPath,
|
|
178
|
+
stagePath,
|
|
179
|
+
commit: commit.value
|
|
180
|
+
});
|
|
181
|
+
if (verdict.isErr()) return Result.err(verdict.error);
|
|
182
|
+
let action;
|
|
183
|
+
if (verdict.value.type === "rollForward") {
|
|
184
|
+
const renamed = await Result.tryPromise(() => rename(stagePath, documentPath));
|
|
185
|
+
if (renamed.isErr()) return Result.err(journalError(`Cannot complete ${stage}`, renamed.error.cause));
|
|
186
|
+
const synced = await syncDirectory(directory);
|
|
187
|
+
if (synced.isErr()) return Result.err(synced.error);
|
|
188
|
+
action = {
|
|
189
|
+
txId,
|
|
190
|
+
action: "rolledForward"
|
|
191
|
+
};
|
|
192
|
+
} else {
|
|
193
|
+
await rm(stagePath, { force: true });
|
|
194
|
+
action = {
|
|
195
|
+
txId,
|
|
196
|
+
action: "discarded",
|
|
197
|
+
reason: verdict.value.reason
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const recorded = await appendJournal(journalPath, {
|
|
201
|
+
type: "recovery",
|
|
202
|
+
path: documentPath,
|
|
203
|
+
time: now,
|
|
204
|
+
...action
|
|
205
|
+
});
|
|
206
|
+
if (recorded.isErr()) return Result.err(recorded.error);
|
|
207
|
+
actions.push(action);
|
|
208
|
+
}
|
|
209
|
+
return Result.ok(actions);
|
|
210
|
+
};
|
|
211
|
+
//#endregion
|
|
212
|
+
export { RECOVERY_DISCARD_REASONS, appendJournal, findCommit, journalPathFor, recoverStages, stagePathFor, stagePrefixFor };
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
//#region src/lock.d.ts
|
|
4
|
+
/** How long a command-line transaction's lease lasts without renewal. */
|
|
5
|
+
declare const TRANSACTION_LEASE_MS: number;
|
|
6
|
+
type LockHolder = {
|
|
7
|
+
owner: string;
|
|
8
|
+
pid: number;
|
|
9
|
+
host: string;
|
|
10
|
+
txId: string;
|
|
11
|
+
/** Random per acquisition; the fencing check compares it. */
|
|
12
|
+
token: string;
|
|
13
|
+
acquiredAt: string;
|
|
14
|
+
expiresAt: string;
|
|
15
|
+
};
|
|
16
|
+
declare const lockPathFor: (documentPath: string) => string;
|
|
17
|
+
/** What is beside the document: no lock, or a lock with its parsed holder when it has one. */
|
|
18
|
+
type LeaseState = {
|
|
19
|
+
type: "free";
|
|
20
|
+
} | {
|
|
21
|
+
type: "held";
|
|
22
|
+
holder: LockHolder;
|
|
23
|
+
modifiedMs: number;
|
|
24
|
+
} | {
|
|
25
|
+
type: "unreadable";
|
|
26
|
+
modifiedMs: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Whether a lock no longer protects anything. One that cannot be read is
|
|
30
|
+
* held until it is older than a whole lease, so a lock another process is
|
|
31
|
+
* writing is never taken for abandoned.
|
|
32
|
+
*/
|
|
33
|
+
declare const isStaleLease: (state: LeaseState, now: Date) => boolean;
|
|
34
|
+
/** The lease currently beside `documentPath`. A symlinked lock reads as unreadable. */
|
|
35
|
+
declare const readLease: (documentPath: string) => Promise<LeaseState>;
|
|
36
|
+
type AcquiredLease = {
|
|
37
|
+
holder: LockHolder;
|
|
38
|
+
/** Refuse unless the lock still carries this lease's token. */
|
|
39
|
+
verify: () => Promise<Result<void, FolioCliError>>;
|
|
40
|
+
release: () => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
type AcquireLeaseOptions = {
|
|
43
|
+
documentPath: string;
|
|
44
|
+
txId: string;
|
|
45
|
+
/** Take the lease over even when another live holder has it. */
|
|
46
|
+
force: boolean;
|
|
47
|
+
now?: Date;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Take the lease for one transaction. A stale lease is replaced; a live or
|
|
51
|
+
* unreadable one refuses with `locked` unless `force`.
|
|
52
|
+
*/
|
|
53
|
+
declare const acquireLease: ({ documentPath, txId, force, now }: AcquireLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { AcquiredLease, LeaseState, LockHolder, TRANSACTION_LEASE_MS, acquireLease, isStaleLease, lockPathFor, readLease };
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { errnoCode } from "./document.js";
|
|
2
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
|
+
import { inspectPath, readSidecarFile, writeNewSidecarFile } from "./sidecar.js";
|
|
4
|
+
import { Result } from "better-result";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { link, rename, rm } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
//#region src/lock.ts
|
|
10
|
+
/**
|
|
11
|
+
* Cooperative write lease: `.<name>.folio-lock` beside the document, holding
|
|
12
|
+
* the owner's pid, host, expiry, and a random token. A folio write takes it
|
|
13
|
+
* for the whole transaction and checks, just before it journals and renames,
|
|
14
|
+
* that the lock still carries its token; a writer whose lease was taken over
|
|
15
|
+
* stops there. A long-lived holder (an editor session) renews it.
|
|
16
|
+
*
|
|
17
|
+
* The lock appears complete or not at all: it is written to a private
|
|
18
|
+
* temporary file and hard-linked into place, which fails when a lock exists.
|
|
19
|
+
* A lease is stale once it expires or, on the same host, once its process has
|
|
20
|
+
* exited. A lock that cannot be parsed is treated as held until it is older
|
|
21
|
+
* than a lease. Reads never consult it.
|
|
22
|
+
*/
|
|
23
|
+
/** How long a command-line transaction's lease lasts without renewal. */
|
|
24
|
+
const TRANSACTION_LEASE_MS = 300 * 1e3;
|
|
25
|
+
const lockPathFor = (documentPath) => path.join(path.dirname(documentPath), `.${path.basename(documentPath)}.folio-lock`);
|
|
26
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
const parseHolder = (text) => {
|
|
28
|
+
const parsed = Result.try(() => JSON.parse(text));
|
|
29
|
+
if (parsed.isErr() || !isRecord(parsed.value)) return null;
|
|
30
|
+
const { owner, pid, host, txId, token, acquiredAt, expiresAt } = parsed.value;
|
|
31
|
+
return typeof owner === "string" && typeof pid === "number" && typeof host === "string" && typeof txId === "string" && typeof token === "string" && typeof acquiredAt === "string" && typeof expiresAt === "string" ? {
|
|
32
|
+
owner,
|
|
33
|
+
pid,
|
|
34
|
+
host,
|
|
35
|
+
txId,
|
|
36
|
+
token,
|
|
37
|
+
acquiredAt,
|
|
38
|
+
expiresAt
|
|
39
|
+
} : null;
|
|
40
|
+
};
|
|
41
|
+
const processIsAlive = (pid) => {
|
|
42
|
+
const probe = Result.try(() => process.kill(pid, 0));
|
|
43
|
+
return probe.isOk() || errnoCode(probe.error.cause) === "EPERM";
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Whether a lock no longer protects anything. One that cannot be read is
|
|
47
|
+
* held until it is older than a whole lease, so a lock another process is
|
|
48
|
+
* writing is never taken for abandoned.
|
|
49
|
+
*/
|
|
50
|
+
const isStaleLease = (state, now) => {
|
|
51
|
+
switch (state.type) {
|
|
52
|
+
case "free": return true;
|
|
53
|
+
case "unreadable": return state.modifiedMs + TRANSACTION_LEASE_MS <= now.getTime();
|
|
54
|
+
case "held": {
|
|
55
|
+
const expires = Date.parse(state.holder.expiresAt);
|
|
56
|
+
if (Number.isNaN(expires) || expires <= now.getTime()) return true;
|
|
57
|
+
return state.holder.host === hostname() && !processIsAlive(state.holder.pid);
|
|
58
|
+
}
|
|
59
|
+
default: return state;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** The lease currently beside `documentPath`. A symlinked lock reads as unreadable. */
|
|
63
|
+
const readLease = async (documentPath) => {
|
|
64
|
+
const lockPath = lockPathFor(documentPath);
|
|
65
|
+
const entry = await inspectPath(lockPath);
|
|
66
|
+
if (entry.isErr()) return {
|
|
67
|
+
type: "unreadable",
|
|
68
|
+
modifiedMs: Date.now()
|
|
69
|
+
};
|
|
70
|
+
if (entry.value.type === "missing") return { type: "free" };
|
|
71
|
+
const modifiedMs = entry.value.type === "file" ? entry.value.modifiedMs : Date.now();
|
|
72
|
+
const bytes = await readSidecarFile(lockPath);
|
|
73
|
+
const holder = bytes.isOk() ? parseHolder(new TextDecoder().decode(bytes.value)) : null;
|
|
74
|
+
return holder === null ? {
|
|
75
|
+
type: "unreadable",
|
|
76
|
+
modifiedMs
|
|
77
|
+
} : {
|
|
78
|
+
type: "held",
|
|
79
|
+
holder,
|
|
80
|
+
modifiedMs
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const lockedError = (documentPath, state) => cliError({
|
|
84
|
+
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
85
|
+
message: `${documentPath} is being written by another process${state.type === "held" ? ` (${state.holder.owner}, pid ${state.holder.pid} on ${state.holder.host})` : ""}.`,
|
|
86
|
+
hint: "Retry after it finishes, or pass --force to take the lease over.",
|
|
87
|
+
details: state.type === "held" ? { holder: state.holder } : void 0
|
|
88
|
+
});
|
|
89
|
+
const fileSystemError = (message, error) => cliError({
|
|
90
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
91
|
+
message: `${message}: ${error instanceof Error ? error.message : String(error)}`
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Put a complete lock in place: write it to a private temporary file, then
|
|
95
|
+
* `link` it to the lock path (fails when one exists) or, taking over,
|
|
96
|
+
* `rename` it there. The lock path never names a partly written file.
|
|
97
|
+
*/
|
|
98
|
+
const placeLock = async (lockPath, contents, mode) => {
|
|
99
|
+
const temporary = `${lockPath}.${randomUUID()}.tmp`;
|
|
100
|
+
const written = await writeNewSidecarFile(temporary, contents);
|
|
101
|
+
if (written.isErr()) return Result.err(written.error);
|
|
102
|
+
try {
|
|
103
|
+
const placed = await Result.tryPromise(() => mode === "create" ? link(temporary, lockPath) : rename(temporary, lockPath));
|
|
104
|
+
if (placed.isOk()) return Result.ok({ type: "placed" });
|
|
105
|
+
return errnoCode(placed.error.cause) === "EEXIST" ? Result.ok({ type: "exists" }) : Result.err(fileSystemError(`Cannot create ${lockPath}`, placed.error.cause));
|
|
106
|
+
} finally {
|
|
107
|
+
await rm(temporary, { force: true });
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Take the lease for one transaction. A stale lease is replaced; a live or
|
|
112
|
+
* unreadable one refuses with `locked` unless `force`.
|
|
113
|
+
*/
|
|
114
|
+
const acquireLease = async ({ documentPath, txId, force, now = /* @__PURE__ */ new Date() }) => {
|
|
115
|
+
const lockPath = lockPathFor(documentPath);
|
|
116
|
+
const holder = {
|
|
117
|
+
owner: "folio-cli",
|
|
118
|
+
pid: process.pid,
|
|
119
|
+
host: hostname(),
|
|
120
|
+
txId,
|
|
121
|
+
token: randomUUID(),
|
|
122
|
+
acquiredAt: now.toISOString(),
|
|
123
|
+
expiresAt: new Date(now.getTime() + TRANSACTION_LEASE_MS).toISOString()
|
|
124
|
+
};
|
|
125
|
+
const contents = new TextEncoder().encode(`${JSON.stringify(holder)}\n`);
|
|
126
|
+
const holds = async () => {
|
|
127
|
+
const current = await readLease(documentPath);
|
|
128
|
+
return current.type === "held" && current.holder.token === holder.token;
|
|
129
|
+
};
|
|
130
|
+
const lease = {
|
|
131
|
+
holder,
|
|
132
|
+
verify: async () => await holds() ? Result.ok() : Result.err(cliError({
|
|
133
|
+
code: FOLIO_CLI_ERROR_CODES.locked,
|
|
134
|
+
message: `Another process took over the write lease on ${documentPath}; nothing was written.`,
|
|
135
|
+
hint: "Re-read the document and retry."
|
|
136
|
+
})),
|
|
137
|
+
release: async () => {
|
|
138
|
+
if (await holds()) await rm(lockPath, { force: true });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
142
|
+
const placed = await placeLock(lockPath, contents, "create");
|
|
143
|
+
if (placed.isErr()) return Result.err(placed.error);
|
|
144
|
+
if (placed.value.type === "placed") return Result.ok(lease);
|
|
145
|
+
const existing = await readLease(documentPath);
|
|
146
|
+
if (force) {
|
|
147
|
+
const replaced = await placeLock(lockPath, contents, "replace");
|
|
148
|
+
if (replaced.isErr()) return Result.err(replaced.error);
|
|
149
|
+
return Result.ok(lease);
|
|
150
|
+
}
|
|
151
|
+
if (!isStaleLease(existing, now)) return Result.err(lockedError(documentPath, existing));
|
|
152
|
+
await rm(lockPath, { force: true });
|
|
153
|
+
}
|
|
154
|
+
return Result.err(lockedError(documentPath, { type: "free" }));
|
|
155
|
+
};
|
|
156
|
+
//#endregion
|
|
157
|
+
export { TRANSACTION_LEASE_MS, acquireLease, isStaleLease, lockPathFor, readLease };
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { FolioFileToolSpec, JsonObjectSchema } from "./registry.js";
|
|
2
|
+
import { FolioReadBounds } from "./execute-read.js";
|
|
3
|
+
import { AllowedRoots } from "./roots.js";
|
|
4
|
+
import { Readable, Writable } from "node:stream";
|
|
5
|
+
import { Server, Tool } from "@modelcontextprotocol/server";
|
|
6
|
+
//#region src/mcp.d.ts
|
|
7
|
+
/** Limits on what one MCP tool call returns. */
|
|
8
|
+
declare const MCP_READ_BOUNDS: FolioReadBounds;
|
|
9
|
+
type FolioMcpServerOptions = {
|
|
10
|
+
roots: AllowedRoots;
|
|
11
|
+
/** Author for every change; `undefined` refuses writes with `author_required`. */
|
|
12
|
+
author: string | undefined;
|
|
13
|
+
bounds?: FolioReadBounds;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
};
|
|
16
|
+
/** The tool's MCP input schema: its own arguments plus the file envelope. */
|
|
17
|
+
declare const mcpInputSchema: (tool: FolioFileToolSpec) => JsonObjectSchema;
|
|
18
|
+
declare const listMcpTools: () => Tool[];
|
|
19
|
+
/** A low-level MCP server exposing the file tools; connect it to a transport. */
|
|
20
|
+
declare const createFolioMcpServer: (options: FolioMcpServerOptions) => Server;
|
|
21
|
+
type ServeOptions = FolioMcpServerOptions & {
|
|
22
|
+
input: Readable;
|
|
23
|
+
output: Writable;
|
|
24
|
+
};
|
|
25
|
+
/** Serve the file tools over stdio until the client disconnects. */
|
|
26
|
+
declare const serveFolioMcp: ({ input, output, ...options }: ServeOptions) => Promise<void>;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { FolioMcpServerOptions, MCP_READ_BOUNDS, createFolioMcpServer, listMcpTools, mcpInputSchema, serveFolioMcp };
|