@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/serve.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readDocumentFile } from "./document.js";
|
|
2
|
+
import { journalPathFor } from "./journal.js";
|
|
3
|
+
import { buildDisplayList, displayListHtml } from "./render.js";
|
|
4
|
+
import { SIDECAR_DIRECTORY, inspectPath, readSidecarFile } from "./sidecar.js";
|
|
5
|
+
import { Result } from "better-result";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { watch } from "node:fs";
|
|
8
|
+
import { stat } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { escapeHtmlText } from "@stll/folio-core/display-list/html/renderDisplayListToHtml";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
//#region src/serve.ts
|
|
13
|
+
/**
|
|
14
|
+
* `folio serve`: a read-only live preview of one document on 127.0.0.1.
|
|
15
|
+
*
|
|
16
|
+
* The page is the DOM backend's rendering of the document's display list,
|
|
17
|
+
* the same pages `folio render` writes. The server watches the file (and the
|
|
18
|
+
* `.folio/journal.jsonl` a folio write appends to) and tells the page over
|
|
19
|
+
* server-sent events when the file's version changes, so an edit made by the
|
|
20
|
+
* CLI, the MCP server or any other program shows up without a reload.
|
|
21
|
+
*
|
|
22
|
+
* It never writes: not the document, not a lock, not the journal. It binds
|
|
23
|
+
* to 127.0.0.1 only, every URL carries a random token, requests whose `Host`
|
|
24
|
+
* is not the loopback address it bound are refused (so a web page cannot reach
|
|
25
|
+
* it through a rebound name), and only GET and HEAD are answered.
|
|
26
|
+
*/
|
|
27
|
+
const PREVIEW_HOST = "127.0.0.1";
|
|
28
|
+
/** How often the file is re-checked when a watch event may have been missed. */
|
|
29
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
30
|
+
/** The latest `toVersion` the journal committed for this document, if any. */
|
|
31
|
+
const journalToVersion = async (documentPath) => {
|
|
32
|
+
const journalPath = journalPathFor(documentPath, void 0);
|
|
33
|
+
const parent = await inspectPath(path.dirname(journalPath));
|
|
34
|
+
if (parent.isErr() || parent.value.type !== "directory") return null;
|
|
35
|
+
const bytes = await readSidecarFile(journalPath);
|
|
36
|
+
if (bytes.isErr()) return null;
|
|
37
|
+
const lines = new TextDecoder().decode(bytes.value).split("\n");
|
|
38
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
39
|
+
const parsed = Result.try(() => JSON.parse(lines[index] ?? ""));
|
|
40
|
+
if (!parsed.isOk() || typeof parsed.value !== "object" || parsed.value === null) continue;
|
|
41
|
+
const entry = { ...parsed.value };
|
|
42
|
+
if (entry["type"] === "commit" && entry["path"] === documentPath) return typeof entry["toVersion"] === "string" ? entry["toVersion"] : null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
const SHELL_STYLE = "html, body { margin: 0; height: 100%; background: #e8e8e8; } iframe { border: 0; width: 100%; height: 100%; display: block; }";
|
|
47
|
+
const shellHtml = (title, nonce) => [
|
|
48
|
+
"<!doctype html>",
|
|
49
|
+
`<html><head><meta charset="utf-8"><title>${escapeHtmlText(title)}</title>`,
|
|
50
|
+
`<style nonce="${nonce}">${SHELL_STYLE}</style></head><body>`,
|
|
51
|
+
"<iframe id=\"document\" sandbox src=\"document.html\" title=\"document\"></iframe>",
|
|
52
|
+
`<script nonce="${nonce}">`,
|
|
53
|
+
"const frame = document.getElementById('document');",
|
|
54
|
+
"let version = null;",
|
|
55
|
+
"const events = new EventSource('events');",
|
|
56
|
+
"events.onmessage = (event) => {",
|
|
57
|
+
" const next = JSON.parse(event.data).fileVersion;",
|
|
58
|
+
" if (version !== null && next !== version) frame.src = 'document.html?v=' + next;",
|
|
59
|
+
" version = next;",
|
|
60
|
+
"};",
|
|
61
|
+
"<\/script></body></html>"
|
|
62
|
+
].join("\n");
|
|
63
|
+
const errorHtml = (message) => `<!doctype html><html><head><meta charset="utf-8"><title>folio</title></head><body><pre>${escapeHtmlText(message)}</pre></body></html>`;
|
|
64
|
+
const SECURITY_HEADERS = {
|
|
65
|
+
"Cache-Control": "no-store",
|
|
66
|
+
"Referrer-Policy": "no-referrer",
|
|
67
|
+
"X-Content-Type-Options": "nosniff",
|
|
68
|
+
"Cross-Origin-Resource-Policy": "same-origin"
|
|
69
|
+
};
|
|
70
|
+
/** The rendered document may contain inline styles and data URLs, and nothing that runs. */
|
|
71
|
+
const DOCUMENT_CSP = "default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'";
|
|
72
|
+
/** Serve the preview until {@link PreviewServer.close}. */
|
|
73
|
+
const startPreviewServer = async ({ documentPath, port }) => {
|
|
74
|
+
const initial = await readDocumentFile(documentPath);
|
|
75
|
+
if (initial.isErr()) return Result.err(new Error(initial.error.message));
|
|
76
|
+
const realPath = initial.value.path;
|
|
77
|
+
const title = path.basename(realPath);
|
|
78
|
+
const prefix = `/${randomBytes(24).toString("base64url")}/`;
|
|
79
|
+
let stamp = null;
|
|
80
|
+
let rendered = null;
|
|
81
|
+
let rendering = null;
|
|
82
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
83
|
+
/** Render when the file changed since the last render; one render at a time. */
|
|
84
|
+
const current = async () => {
|
|
85
|
+
const info = await Result.tryPromise(() => stat(realPath));
|
|
86
|
+
const next = info.isOk() ? {
|
|
87
|
+
size: info.value.size,
|
|
88
|
+
modifiedMs: info.value.mtimeMs,
|
|
89
|
+
ino: info.value.ino
|
|
90
|
+
} : null;
|
|
91
|
+
if (rendered !== null && next !== null && stamp !== null && next.size === stamp.size && next.modifiedMs === stamp.modifiedMs && next.ino === stamp.ino && rendered !== null) return rendered;
|
|
92
|
+
rendering ??= (async () => {
|
|
93
|
+
const file = await readDocumentFile(realPath);
|
|
94
|
+
if (file.isErr()) return {
|
|
95
|
+
type: "error",
|
|
96
|
+
fileVersion: null,
|
|
97
|
+
message: file.error.message
|
|
98
|
+
};
|
|
99
|
+
if (rendered !== null && rendered.fileVersion === file.value.fileVersion) return rendered;
|
|
100
|
+
const list = await buildDisplayList(file.value);
|
|
101
|
+
if (list.isErr()) return {
|
|
102
|
+
type: "error",
|
|
103
|
+
fileVersion: file.value.fileVersion,
|
|
104
|
+
message: list.error.message
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
type: "page",
|
|
108
|
+
fileVersion: file.value.fileVersion,
|
|
109
|
+
html: displayListHtml(list.value, title),
|
|
110
|
+
pageCount: list.value.pages.length
|
|
111
|
+
};
|
|
112
|
+
})();
|
|
113
|
+
const result = await rendering;
|
|
114
|
+
rendering = null;
|
|
115
|
+
const changed = rendered === null || rendered.fileVersion !== result.fileVersion;
|
|
116
|
+
rendered = result;
|
|
117
|
+
stamp = next;
|
|
118
|
+
if (changed) {
|
|
119
|
+
const message = `data: ${JSON.stringify({ fileVersion: result.fileVersion })}\n\n`;
|
|
120
|
+
for (const listener of listeners) listener.write(message);
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
};
|
|
124
|
+
const check = () => {
|
|
125
|
+
current();
|
|
126
|
+
};
|
|
127
|
+
let boundPort = port;
|
|
128
|
+
const allowedHosts = () => /* @__PURE__ */ new Set([`${PREVIEW_HOST}:${String(boundPort)}`, `localhost:${String(boundPort)}`]);
|
|
129
|
+
const respond = (response, status, body, headers = {}) => {
|
|
130
|
+
response.writeHead(status, {
|
|
131
|
+
...SECURITY_HEADERS,
|
|
132
|
+
...headers
|
|
133
|
+
});
|
|
134
|
+
response.end(body);
|
|
135
|
+
};
|
|
136
|
+
const handle = async (request, response) => {
|
|
137
|
+
if (!allowedHosts().has(request.headers.host ?? "")) {
|
|
138
|
+
respond(response, 403, "forbidden", { "Content-Type": "text/plain" });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
142
|
+
respond(response, 405, "method not allowed", {
|
|
143
|
+
"Content-Type": "text/plain",
|
|
144
|
+
Allow: "GET, HEAD"
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const url = new URL(request.url ?? "/", `http://${PREVIEW_HOST}`);
|
|
149
|
+
if (!url.pathname.startsWith(prefix)) {
|
|
150
|
+
respond(response, 404, "not found", { "Content-Type": "text/plain" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const route = url.pathname.slice(prefix.length);
|
|
154
|
+
if (route === "") {
|
|
155
|
+
const nonce = randomBytes(16).toString("base64");
|
|
156
|
+
respond(response, 200, shellHtml(title, nonce), {
|
|
157
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
158
|
+
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; frame-src 'self'; connect-src 'self'`
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (route === "document.html") {
|
|
163
|
+
const page = await current();
|
|
164
|
+
respond(response, 200, page.type === "page" ? page.html : errorHtml(page.message), {
|
|
165
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
166
|
+
"Content-Security-Policy": DOCUMENT_CSP
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (route === "version") {
|
|
171
|
+
const page = await current();
|
|
172
|
+
respond(response, 200, JSON.stringify({
|
|
173
|
+
path: realPath,
|
|
174
|
+
fileVersion: page.fileVersion,
|
|
175
|
+
journalToVersion: await journalToVersion(realPath),
|
|
176
|
+
...page.type === "page" ? { pageCount: page.pageCount } : { error: page.message }
|
|
177
|
+
}), { "Content-Type": "application/json" });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (route === "events") {
|
|
181
|
+
response.writeHead(200, {
|
|
182
|
+
...SECURITY_HEADERS,
|
|
183
|
+
"Content-Type": "text/event-stream",
|
|
184
|
+
Connection: "keep-alive"
|
|
185
|
+
});
|
|
186
|
+
listeners.add(response);
|
|
187
|
+
request.on("close", () => listeners.delete(response));
|
|
188
|
+
const page = await current();
|
|
189
|
+
response.write(`data: ${JSON.stringify({ fileVersion: page.fileVersion })}\n\n`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
respond(response, 404, "not found", { "Content-Type": "text/plain" });
|
|
193
|
+
};
|
|
194
|
+
const server = createServer((request, response) => {
|
|
195
|
+
handle(request, response).catch((error) => {
|
|
196
|
+
respond(response, 500, error instanceof Error ? error.message : "error", { "Content-Type": "text/plain" });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
if ((await Result.tryPromise(() => new Promise((resolve, reject) => {
|
|
200
|
+
server.once("error", reject);
|
|
201
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
202
|
+
}))).isErr()) return Result.err(/* @__PURE__ */ new Error(`Cannot listen on ${PREVIEW_HOST}:${String(port)}.`));
|
|
203
|
+
const address = server.address();
|
|
204
|
+
boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
205
|
+
const watchers = [];
|
|
206
|
+
const directory = path.dirname(realPath);
|
|
207
|
+
const watched = /* @__PURE__ */ new Set([
|
|
208
|
+
path.basename(realPath),
|
|
209
|
+
SIDECAR_DIRECTORY,
|
|
210
|
+
"journal.jsonl"
|
|
211
|
+
]);
|
|
212
|
+
for (const target of [directory, path.join(directory, SIDECAR_DIRECTORY)]) {
|
|
213
|
+
const watcher = Result.try(() => watch(target, (_event, name) => {
|
|
214
|
+
if (name === null || watched.has(String(name))) check();
|
|
215
|
+
}));
|
|
216
|
+
if (watcher.isOk()) watchers.push(watcher.value);
|
|
217
|
+
}
|
|
218
|
+
const poll = setInterval(check, POLL_INTERVAL_MS);
|
|
219
|
+
check();
|
|
220
|
+
return Result.ok({
|
|
221
|
+
url: `http://${PREVIEW_HOST}:${String(boundPort)}${prefix}`,
|
|
222
|
+
close: async () => {
|
|
223
|
+
clearInterval(poll);
|
|
224
|
+
for (const watcher of watchers) watcher.close();
|
|
225
|
+
for (const listener of listeners) listener.end();
|
|
226
|
+
await new Promise((resolve) => {
|
|
227
|
+
server.close(() => resolve());
|
|
228
|
+
server.closeAllConnections();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
export { PREVIEW_HOST, startPreviewServer };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { FileIdentity } from "./document.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { FileHandle } from "node:fs/promises";
|
|
5
|
+
//#region src/sidecar.d.ts
|
|
6
|
+
declare const SIDECAR_DIRECTORY = ".folio";
|
|
7
|
+
declare const unsafePath: (message: string, hint?: string) => FolioCliError;
|
|
8
|
+
/** What a path names, without following a final symlink. */
|
|
9
|
+
type PathEntry = {
|
|
10
|
+
type: "missing";
|
|
11
|
+
} | {
|
|
12
|
+
type: "file";
|
|
13
|
+
identity: FileIdentity;
|
|
14
|
+
links: number;
|
|
15
|
+
modifiedMs: number;
|
|
16
|
+
} | {
|
|
17
|
+
type: "directory";
|
|
18
|
+
identity: FileIdentity;
|
|
19
|
+
} | {
|
|
20
|
+
type: "other";
|
|
21
|
+
};
|
|
22
|
+
declare const inspectPath: (filePath: string) => Promise<Result<PathEntry, FolioCliError>>;
|
|
23
|
+
/**
|
|
24
|
+
* `<document directory>/.folio/<segments...>`, each level checked or created
|
|
25
|
+
* private. The document's own directory is the caller's choice and is not
|
|
26
|
+
* inspected.
|
|
27
|
+
*/
|
|
28
|
+
declare const sidecarDirectory: (documentPath: string, ...segments: readonly string[]) => Promise<Result<string, FolioCliError>>;
|
|
29
|
+
/** Check the directory a sidecar file lives in; create it only when it is `.folio` itself. */
|
|
30
|
+
declare const ensureSidecarParent: (filePath: string) => Promise<Result<void, FolioCliError>>;
|
|
31
|
+
type OpenedSidecar = {
|
|
32
|
+
handle: FileHandle;
|
|
33
|
+
identity: FileIdentity;
|
|
34
|
+
size: number;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Open a sidecar file without following a final symlink, and refuse it
|
|
38
|
+
* unless it is a regular file with exactly one link.
|
|
39
|
+
*/
|
|
40
|
+
declare const openSidecarFile: (filePath: string, flags: number) => Promise<Result<OpenedSidecar, FolioCliError>>;
|
|
41
|
+
/** Read a whole sidecar file through {@link openSidecarFile}. */
|
|
42
|
+
declare const readSidecarFile: (filePath: string) => Promise<Result<Uint8Array<ArrayBuffer>, FolioCliError>>;
|
|
43
|
+
/** Create a new sidecar file (never an existing one), write it, and flush it to disk. */
|
|
44
|
+
declare const writeNewSidecarFile: (filePath: string, bytes: Uint8Array) => Promise<Result<FileIdentity, FolioCliError>>;
|
|
45
|
+
/**
|
|
46
|
+
* Flush a directory's entries (a create or rename) to disk. Platforms that
|
|
47
|
+
* cannot open a directory for syncing report nothing; every other failure
|
|
48
|
+
* is returned.
|
|
49
|
+
*/
|
|
50
|
+
declare const syncDirectory: (directory: string) => Promise<Result<void, FolioCliError>>;
|
|
51
|
+
//#endregion
|
|
52
|
+
export { PathEntry, SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile };
|
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { NO_FOLLOW, errnoCode } from "./document.js";
|
|
2
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { lstat, mkdir, open } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
//#region src/sidecar.ts
|
|
8
|
+
/**
|
|
9
|
+
* The one boundary every file folio keeps beside a document goes through:
|
|
10
|
+
* the lock, the stage, the journal, and backups. None of them follows a
|
|
11
|
+
* symlink or writes through a hard link. The `.folio` directory and its
|
|
12
|
+
* subdirectories are created private (0700), and an existing one that is a
|
|
13
|
+
* symlink or not a directory is refused rather than followed.
|
|
14
|
+
*/
|
|
15
|
+
const SIDECAR_DIRECTORY = ".folio";
|
|
16
|
+
const PRIVATE_DIRECTORY_MODE = 448;
|
|
17
|
+
const PRIVATE_FILE_MODE = 384;
|
|
18
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
19
|
+
const unsafePath = (message, hint) => cliError({
|
|
20
|
+
code: FOLIO_CLI_ERROR_CODES.unsafePath,
|
|
21
|
+
message,
|
|
22
|
+
hint
|
|
23
|
+
});
|
|
24
|
+
const fileSystemError = (message, error) => cliError({
|
|
25
|
+
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
26
|
+
message: `${message}: ${describe(error)}`
|
|
27
|
+
});
|
|
28
|
+
const inspectPath = async (filePath) => {
|
|
29
|
+
const info = await Result.tryPromise(() => lstat(filePath));
|
|
30
|
+
if (info.isErr()) return errnoCode(info.error.cause) === "ENOENT" ? Result.ok({ type: "missing" }) : Result.err(fileSystemError(`Cannot inspect ${filePath}`, info.error.cause));
|
|
31
|
+
const stats = info.value;
|
|
32
|
+
const identity = {
|
|
33
|
+
dev: stats.dev,
|
|
34
|
+
ino: stats.ino
|
|
35
|
+
};
|
|
36
|
+
if (stats.isFile()) return Result.ok({
|
|
37
|
+
type: "file",
|
|
38
|
+
identity,
|
|
39
|
+
links: stats.nlink,
|
|
40
|
+
modifiedMs: stats.mtimeMs
|
|
41
|
+
});
|
|
42
|
+
if (stats.isDirectory()) return Result.ok({
|
|
43
|
+
type: "directory",
|
|
44
|
+
identity
|
|
45
|
+
});
|
|
46
|
+
return Result.ok({ type: "other" });
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Create `directory` private when missing; refuse it when it exists as a
|
|
50
|
+
* symlink or anything but a directory. Its parent must already exist.
|
|
51
|
+
*/
|
|
52
|
+
const ensurePrivateDirectory = async (directory) => {
|
|
53
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
54
|
+
const entry = await inspectPath(directory);
|
|
55
|
+
if (entry.isErr()) return Result.err(entry.error);
|
|
56
|
+
if (entry.value.type === "directory") return Result.ok();
|
|
57
|
+
if (entry.value.type !== "missing") return Result.err(unsafePath(`${directory} is not a plain directory; folio does not write through it.`, "Remove or rename it (it may be a symlink), then retry."));
|
|
58
|
+
const made = await Result.tryPromise(() => mkdir(directory, { mode: PRIVATE_DIRECTORY_MODE }));
|
|
59
|
+
if (made.isOk()) return Result.ok();
|
|
60
|
+
if (errnoCode(made.error.cause) !== "EEXIST") return Result.err(fileSystemError(`Cannot create ${directory}`, made.error.cause));
|
|
61
|
+
}
|
|
62
|
+
return Result.err(unsafePath(`${directory} changed while it was being created.`));
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* `<document directory>/.folio/<segments...>`, each level checked or created
|
|
66
|
+
* private. The document's own directory is the caller's choice and is not
|
|
67
|
+
* inspected.
|
|
68
|
+
*/
|
|
69
|
+
const sidecarDirectory = async (documentPath, ...segments) => {
|
|
70
|
+
let directory = path.join(path.dirname(documentPath), SIDECAR_DIRECTORY);
|
|
71
|
+
const top = await ensurePrivateDirectory(directory);
|
|
72
|
+
if (top.isErr()) return Result.err(top.error);
|
|
73
|
+
for (const segment of segments) {
|
|
74
|
+
if (segment === "" || segment === "." || segment === ".." || segment.includes(path.sep)) return Result.err(unsafePath(`${JSON.stringify(segment)} is not a plain name.`));
|
|
75
|
+
directory = path.join(directory, segment);
|
|
76
|
+
const level = await ensurePrivateDirectory(directory);
|
|
77
|
+
if (level.isErr()) return Result.err(level.error);
|
|
78
|
+
}
|
|
79
|
+
return Result.ok(directory);
|
|
80
|
+
};
|
|
81
|
+
/** Check the directory a sidecar file lives in; create it only when it is `.folio` itself. */
|
|
82
|
+
const ensureSidecarParent = async (filePath) => {
|
|
83
|
+
const parent = path.dirname(filePath);
|
|
84
|
+
if (path.basename(parent) === ".folio") return await ensurePrivateDirectory(parent);
|
|
85
|
+
const entry = await inspectPath(parent);
|
|
86
|
+
if (entry.isErr()) return Result.err(entry.error);
|
|
87
|
+
return entry.value.type === "directory" ? Result.ok() : Result.err(unsafePath(`${parent} is not a plain directory.`));
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Open a sidecar file without following a final symlink, and refuse it
|
|
91
|
+
* unless it is a regular file with exactly one link.
|
|
92
|
+
*/
|
|
93
|
+
const openSidecarFile = async (filePath, flags) => {
|
|
94
|
+
const opened = await Result.tryPromise(() => open(filePath, flags | NO_FOLLOW, PRIVATE_FILE_MODE));
|
|
95
|
+
if (opened.isErr()) {
|
|
96
|
+
const code = errnoCode(opened.error.cause);
|
|
97
|
+
return code === "ELOOP" || code === "EMLINK" ? Result.err(unsafePath(`${filePath} is a symlink; folio does not follow it.`)) : Result.err(fileSystemError(`Cannot open ${filePath}`, opened.error.cause));
|
|
98
|
+
}
|
|
99
|
+
const handle = opened.value;
|
|
100
|
+
const info = await Result.tryPromise(() => handle.stat());
|
|
101
|
+
if (info.isErr() || !info.value.isFile() || info.value.nlink !== 1) {
|
|
102
|
+
await handle.close();
|
|
103
|
+
return Result.err(unsafePath(`${filePath} is not a regular file with a single link; folio does not use it.`));
|
|
104
|
+
}
|
|
105
|
+
return Result.ok({
|
|
106
|
+
handle,
|
|
107
|
+
identity: {
|
|
108
|
+
dev: info.value.dev,
|
|
109
|
+
ino: info.value.ino
|
|
110
|
+
},
|
|
111
|
+
size: info.value.size
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
/** Read a whole sidecar file through {@link openSidecarFile}. */
|
|
115
|
+
const readSidecarFile = async (filePath) => {
|
|
116
|
+
const opened = await openSidecarFile(filePath, constants.O_RDONLY);
|
|
117
|
+
if (opened.isErr()) return Result.err(opened.error);
|
|
118
|
+
const { handle } = opened.value;
|
|
119
|
+
const read = await Result.tryPromise(async () => new Uint8Array(await handle.readFile()));
|
|
120
|
+
await handle.close();
|
|
121
|
+
return read.isOk() ? Result.ok(read.value) : Result.err(fileSystemError(`Cannot read ${filePath}`, read.error.cause));
|
|
122
|
+
};
|
|
123
|
+
/** Create a new sidecar file (never an existing one), write it, and flush it to disk. */
|
|
124
|
+
const writeNewSidecarFile = async (filePath, bytes) => {
|
|
125
|
+
const opened = await openSidecarFile(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
126
|
+
if (opened.isErr()) return Result.err(opened.error);
|
|
127
|
+
const { handle, identity } = opened.value;
|
|
128
|
+
const written = await Result.tryPromise(async () => {
|
|
129
|
+
try {
|
|
130
|
+
await handle.writeFile(bytes);
|
|
131
|
+
await handle.sync();
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
return written.isOk() ? Result.ok(identity) : Result.err(fileSystemError(`Cannot write ${filePath}`, written.error.cause));
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Flush a directory's entries (a create or rename) to disk. Platforms that
|
|
140
|
+
* cannot open a directory for syncing report nothing; every other failure
|
|
141
|
+
* is returned.
|
|
142
|
+
*/
|
|
143
|
+
const syncDirectory = async (directory) => {
|
|
144
|
+
if (process.platform === "win32") return Result.ok();
|
|
145
|
+
const synced = await Result.tryPromise(async () => {
|
|
146
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
147
|
+
try {
|
|
148
|
+
await handle.sync();
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return synced.isOk() ? Result.ok() : Result.err(fileSystemError(`Cannot flush ${directory}`, synced.error.cause));
|
|
154
|
+
};
|
|
155
|
+
//#endregion
|
|
156
|
+
export { SIDECAR_DIRECTORY, ensureSidecarParent, inspectPath, openSidecarFile, readSidecarFile, sidecarDirectory, syncDirectory, unsafePath, writeNewSidecarFile };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { FileIdentity } from "./document.js";
|
|
3
|
+
import { JournalCommit } from "./journal.js";
|
|
4
|
+
import { ChangedPart } from "./package-parts.js";
|
|
5
|
+
import { Result } from "better-result";
|
|
6
|
+
//#region src/transaction.d.ts
|
|
7
|
+
/** Backups kept per document. */
|
|
8
|
+
declare const MAX_BACKUPS = 20;
|
|
9
|
+
type WriteDestination = {
|
|
10
|
+
type: "inPlace";
|
|
11
|
+
} | {
|
|
12
|
+
type: "file";
|
|
13
|
+
path: string;
|
|
14
|
+
/** Allow replacing an existing file; its version must then be `expectedVersion`. */
|
|
15
|
+
overwrite: boolean;
|
|
16
|
+
expectedVersion: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** `.folio/backups/<document name>`, created private and never through a symlink. */
|
|
19
|
+
declare const backupDirectoryFor: (documentPath: string) => Promise<Result<string, FolioCliError>>;
|
|
20
|
+
/**
|
|
21
|
+
* Keep the newest `keep` backups in one document's backup directory. Only
|
|
22
|
+
* regular files named `<version>.docx` count; nothing else is touched.
|
|
23
|
+
*/
|
|
24
|
+
declare const pruneBackups: (directory: string, keep: number) => Promise<Result<void, FolioCliError>>;
|
|
25
|
+
type CommitRequest = {
|
|
26
|
+
txId: string;
|
|
27
|
+
destinationPath: string;
|
|
28
|
+
destination: WriteDestination;
|
|
29
|
+
/** The file the transaction read; re-read under the lease before anything is written. */
|
|
30
|
+
sourcePath: string;
|
|
31
|
+
sourceIdentity: FileIdentity;
|
|
32
|
+
fromVersion: string;
|
|
33
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
34
|
+
changedParts: readonly ChangedPart[];
|
|
35
|
+
journalPath: string;
|
|
36
|
+
/** Refuses unless the transaction still holds the destination's lease. */
|
|
37
|
+
verifyLease: () => Promise<Result<void, FolioCliError>>;
|
|
38
|
+
/** The journal line, completed with the stage, versions, and backup here. */
|
|
39
|
+
entry: Omit<JournalCommit, "type" | "path" | "toVersion" | "destinationVersionBefore" | "stage" | "receipt">;
|
|
40
|
+
/** The receipt returned to the caller; completed with `toVersion` and `backup`. */
|
|
41
|
+
receipt: Readonly<Record<string, unknown>>;
|
|
42
|
+
};
|
|
43
|
+
type CommitResult = {
|
|
44
|
+
toVersion: string;
|
|
45
|
+
backup: string | undefined;
|
|
46
|
+
receipt: Readonly<Record<string, unknown>>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Commit under a lease the caller already holds. Refuses when the source
|
|
50
|
+
* changed since it was read, when a new destination already exists, or when
|
|
51
|
+
* an existing destination is not the version the caller named.
|
|
52
|
+
*/
|
|
53
|
+
declare const commitTransaction: (request: CommitRequest) => Promise<Result<CommitResult, FolioCliError>>;
|
|
54
|
+
//#endregion
|
|
55
|
+
export { CommitRequest, CommitResult, MAX_BACKUPS, WriteDestination, backupDirectoryFor, commitTransaction, pruneBackups };
|