@blocofy/cli 0.9.0 → 0.10.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 +347 -51
- package/bin/blocofy.mjs +1164 -260
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +129 -30
- package/lib/credentials.mjs +216 -60
- package/lib/http.mjs +60 -0
- package/lib/local-theme.mjs +7 -2
- package/lib/media-uses.mjs +27 -16
- package/lib/messages.mjs +42 -2
- package/lib/output.mjs +53 -0
- package/lib/page-files.mjs +12 -2
- package/lib/secret-store.mjs +133 -0
- package/lib/site-migrate.mjs +132 -0
- package/lib/site-state-client.mjs +92 -0
- package/lib/site-state-fs.mjs +137 -0
- package/lib/site-state.mjs +682 -0
- package/lib/target.mjs +427 -0
- package/lib/theme-sync.mjs +113 -80
- package/package.json +1 -1
|
@@ -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
|
+
}
|