@blocofy/cli 0.9.0 → 0.11.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.
@@ -0,0 +1,137 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { folderConflicts, resolveContained } from "./page-files.mjs";
6
+ import { ASSET_FILES_PREFIX } from "./site-state.mjs";
7
+
8
+ /**
9
+ * CF-T4 — the site-state tree on a real filesystem: a read that refuses symlinks (lstat every entry) and
10
+ * an all-or-nothing staged write. Kept separate from `lib/site-state.mjs` (pure, read-free) and from
11
+ * `lib/page-files.mjs` (PS-19 `pages/**` specifically) because a site-state tree has its own root shape
12
+ * (`blocofy-site.json`, `site/`, `theme/`, `pages/`, `media/`) and its own binary member (`media/files/<sha256>`).
13
+ */
14
+
15
+ export class SiteStateFsError extends Error {
16
+ constructor(code, message, diagnostics = []) {
17
+ super(message);
18
+ this.name = "SiteStateFsError";
19
+ this.code = code;
20
+ this.diagnostics = diagnostics;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Read every file under `root`. Returns `{ root, files, assets, diagnostics }`:
26
+ * - `files`: relPath → utf8 content, for every path EXCEPT `media/files/<sha256>` (those are binary
27
+ * asset bytes, never decoded as text — the request body never carries them either, contract §A1).
28
+ * - `assets`: `{ path, sha256, abs, bytes }` for every `media/files/<sha256>` entry found.
29
+ * - `diagnostics`: a symlink or a non-regular-file entry (`SITE_STATE_SYMLINK` / `SITE_STATE_INVALID_PATH`),
30
+ * never thrown — callers that must refuse on any diagnostic (export, plan, apply) check the array.
31
+ * A dot-prefixed entry AT THE ROOT (`.git`, `.blocofy`, `.DS_Store`, …) is silently skipped: those are
32
+ * project tooling, not part of the site-state tree. A dot-prefixed entry found INSIDE a known folder
33
+ * (`theme/assets/.hidden.css`) is walked into and reported by `checkSiteStatePath`, same as the platform.
34
+ */
35
+ export function readSiteStateTree(root) {
36
+ const realRoot = realpathSync(root);
37
+ const files = {};
38
+ const assets = [];
39
+ const diagnostics = [];
40
+ const walk = (abs, relParts) => {
41
+ let names;
42
+ try {
43
+ names = readdirSync(abs).sort();
44
+ } catch (error) {
45
+ diagnostics.push({ level: "error", code: "SITE_STATE_INVALID_PATH", message: `could not read directory (${error?.message ?? error})`, path: relParts.join("/") || "." });
46
+ return;
47
+ }
48
+ for (const name of names) {
49
+ if (relParts.length === 0 && name.startsWith(".")) continue;
50
+ const childAbs = join(abs, name);
51
+ const childRel = [...relParts, name].join("/");
52
+ const st = lstatSync(childAbs);
53
+ if (st.isSymbolicLink()) {
54
+ diagnostics.push({ level: "error", code: "SITE_STATE_SYMLINK", message: "symbolic links are not read", path: childRel });
55
+ continue;
56
+ }
57
+ if (st.isDirectory()) {
58
+ walk(childAbs, [...relParts, name]);
59
+ continue;
60
+ }
61
+ if (!st.isFile()) {
62
+ diagnostics.push({ level: "error", code: "SITE_STATE_INVALID_PATH", message: "not a regular file", path: childRel });
63
+ continue;
64
+ }
65
+ if (childRel.startsWith(ASSET_FILES_PREFIX)) {
66
+ assets.push({ path: childRel, sha256: childRel.slice(ASSET_FILES_PREFIX.length), abs: childAbs, bytes: st.size });
67
+ } else {
68
+ files[childRel] = readFileSync(childAbs, "utf8");
69
+ }
70
+ }
71
+ };
72
+ walk(realRoot, []);
73
+ return { root: realRoot, files, assets, diagnostics };
74
+ }
75
+
76
+ /** sha256 (lowercase hex) of a `Buffer`. */
77
+ export function hashBuffer(buffer) {
78
+ return createHash("sha256").update(buffer).digest("hex");
79
+ }
80
+
81
+ /** sha256 (lowercase hex) of a local file's actual bytes. */
82
+ export function hashFile(abs) {
83
+ return hashBuffer(readFileSync(abs));
84
+ }
85
+
86
+ /**
87
+ * Staged write of a site-state tree. `entries` is `[relPath, content]`, content a string or a `Buffer`
88
+ * (asset bytes). Every path is validated (traversal/absolute/backslash/symlink-in-ancestor) before the
89
+ * first byte is written; then every file is staged into a temp directory under `root`, and only when
90
+ * every file is staged are they renamed into place. Mirrors `page-files.mjs`'s `stagedWrite`,
91
+ * generalized to binary content.
92
+ *
93
+ * WHAT THIS DOES AND DOES NOT GUARANTEE. A failure anywhere BEFORE the rename phase - a bad path, a
94
+ * symlinked ancestor, a full disk while staging - leaves the destination exactly as it was. The rename
95
+ * phase itself is not atomic across files: renames land one at a time, across directories, and an I/O
96
+ * failure part-way through leaves the ones already renamed in place and throws "Stopped after writing
97
+ * N of M files". The caller is `site export`, a local read-then-write that is safe to re-run, so a
98
+ * partial tree is recoverable by running it again. Do not reuse this for a remote mutation, or for
99
+ * anything where a half-applied tree is not recoverable, without adding a real commit step.
100
+ */
101
+ export function stagedWriteTree(root, entries) {
102
+ const errors = folderConflicts(entries.map(([rel]) => rel));
103
+ const targets = [];
104
+ for (const [rel, content] of entries) {
105
+ const r = resolveContained(root, rel);
106
+ if (r.error) errors.push({ level: "error", code: r.error.code, message: r.error.reason, path: rel });
107
+ else targets.push([rel, r.target, content]);
108
+ }
109
+ if (errors.length > 0) {
110
+ throw new SiteStateFsError("SITE_STATE_WRITE_FAILED", "Refusing to write outside the target directory; nothing was written.", errors);
111
+ }
112
+
113
+ mkdirSync(root, { recursive: true });
114
+ const staging = mkdtempSync(join(root, ".blocofy-site-state-staging-"));
115
+ try {
116
+ targets.forEach(([, , content], i) => writeFileSync(join(staging, String(i)), content));
117
+ } catch (error) {
118
+ rmSync(staging, { recursive: true, force: true });
119
+ throw new SiteStateFsError("SITE_STATE_WRITE_FAILED", `Could not stage files (${error?.message ?? error}); nothing was written.`);
120
+ }
121
+ let written = 0;
122
+ try {
123
+ targets.forEach(([, target], i) => {
124
+ mkdirSync(dirname(target), { recursive: true });
125
+ renameSync(join(staging, String(i)), target);
126
+ written += 1;
127
+ });
128
+ } catch (error) {
129
+ throw new SiteStateFsError(
130
+ "SITE_STATE_WRITE_FAILED",
131
+ `Stopped after writing ${written} of ${targets.length} files (${error?.message ?? error}); written: ${targets.slice(0, written).map(([rel]) => rel).join(", ") || "none"}.`,
132
+ );
133
+ } finally {
134
+ rmSync(staging, { recursive: true, force: true });
135
+ }
136
+ return written;
137
+ }