@blocofy/cli 0.8.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 +359 -38
- package/bin/blocofy.mjs +1260 -234
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +375 -48
- 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 +318 -0
- package/lib/page-path-codec.mjs +193 -0
- 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,132 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { folderConflicts, resolveContained } from "./page-files.mjs";
|
|
5
|
+
import { THEME_DIRS } from "./local-theme.mjs";
|
|
6
|
+
import { MANIFEST_PATH, THEME_SETTINGS_PATH, checkSiteStatePath, ownerOfPath } from "./site-state.mjs";
|
|
7
|
+
import { readSiteStateTree } from "./site-state-fs.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CF-T4 — `blocofy site migrate`: turns a directory produced by the OLDER separate pulls (`theme pull`'s
|
|
11
|
+
* theme-dirs-at-root layout, `pages pull`'s canonical `pages/<locale>/…/index.json`, `settings pull`'s
|
|
12
|
+
* `config/settings.json`) into the site-state v1 tree layout (contract §A1). Purely local, zero network:
|
|
13
|
+
* this reuses `readSiteStateTree` — the same symlink-refusing walk `site export`/`site validate` use — to
|
|
14
|
+
* read, and the same contained-path helpers (`resolveContained`, `folderConflicts`) `pages migrate-layout`
|
|
15
|
+
* writes with. No new fs primitives, no identity/target call.
|
|
16
|
+
*
|
|
17
|
+
* `pages/**` files are NEVER moved — a site state uses the exact canonical location `pages pull` already
|
|
18
|
+
* writes (contract §A1). A page still at the OLD flat layout (`pages/<slug>.json`) is left alone with a
|
|
19
|
+
* note pointing at `blocofy pages migrate-layout`, which already owns that reasoning (locale resolution,
|
|
20
|
+
* legacy-vs-canonical detection); duplicating it here would drift.
|
|
21
|
+
*
|
|
22
|
+
* This never invents the owners a directory of separate pulls never had — site/locales.json, globals,
|
|
23
|
+
* media-policy, content-model, translations, navigation, theme/chrome/**, blocofy-site.json itself. Those
|
|
24
|
+
* can only come from the server; `needsExport` says whether this tree is missing its manifest for exactly
|
|
25
|
+
* that reason, so the caller can point at `blocofy site export`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const SETTINGS_SOURCE = "config/settings.json";
|
|
29
|
+
const SETTINGS_SCHEMA_SOURCE = "config/settings_schema.json";
|
|
30
|
+
const SETTINGS_SCHEMA_TARGET = "theme/config/settings_schema.json";
|
|
31
|
+
|
|
32
|
+
/** Old-format relative path → its site-state v1 target, or null when this command does not know how to move it. */
|
|
33
|
+
function mapOldPath(path) {
|
|
34
|
+
if (path === SETTINGS_SOURCE) return THEME_SETTINGS_PATH;
|
|
35
|
+
if (path === SETTINGS_SCHEMA_SOURCE) return SETTINGS_SCHEMA_TARGET;
|
|
36
|
+
const top = path.split("/")[0];
|
|
37
|
+
return THEME_DIRS.has(top) ? `theme/${path}` : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only plan: every move this tree needs, every file left alone (and why), every refusal. Never
|
|
42
|
+
* touches disk beyond the read. `moves` is empty and `refused` true together only on a hard error
|
|
43
|
+
* (conflict, symlink, unreadable entry) — never on "nothing to migrate".
|
|
44
|
+
*/
|
|
45
|
+
export function planSiteMigrate(dir) {
|
|
46
|
+
const tree = readSiteStateTree(dir);
|
|
47
|
+
const errors = [...tree.diagnostics];
|
|
48
|
+
const moves = [];
|
|
49
|
+
const untouched = [];
|
|
50
|
+
const claimed = new Map();
|
|
51
|
+
|
|
52
|
+
for (const path of Object.keys(tree.files).sort()) {
|
|
53
|
+
if (path === MANIFEST_PATH || ownerOfPath(path) !== null) {
|
|
54
|
+
untouched.push({ path, reason: "already in the site-state tree layout" });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const target = mapOldPath(path);
|
|
58
|
+
if (target === null) {
|
|
59
|
+
untouched.push({
|
|
60
|
+
path,
|
|
61
|
+
reason: path.startsWith("pages/")
|
|
62
|
+
? "old flat page layout — run `blocofy pages migrate-layout` first"
|
|
63
|
+
: "not part of the site-state tree — left alone",
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const refusal = checkSiteStatePath(target);
|
|
68
|
+
if (refusal) {
|
|
69
|
+
errors.push({ level: "error", code: refusal.code, message: `${path} → ${target}: ${refusal.reason}`, path });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const existing = tree.files[target];
|
|
73
|
+
if (existing !== undefined) {
|
|
74
|
+
if (existing === tree.files[path]) {
|
|
75
|
+
untouched.push({ path, reason: `${target} already holds the same content; left in place (remove ${path} yourself)` });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
errors.push({ level: "error", code: "SITE_STATE_MIGRATE_CONFLICT", message: `${target} already exists with different content than ${path}`, path });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const claimant = claimed.get(target);
|
|
82
|
+
if (claimant) {
|
|
83
|
+
errors.push({ level: "error", code: "SITE_STATE_MIGRATE_CONFLICT", message: `${path} and ${claimant} would both move to ${target}`, path });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
claimed.set(target, path);
|
|
87
|
+
moves.push({ from: path, to: target, content: tree.files[path] });
|
|
88
|
+
}
|
|
89
|
+
for (const asset of tree.assets) untouched.push({ path: asset.path, reason: "already in the site-state tree layout" });
|
|
90
|
+
|
|
91
|
+
moves.sort((a, b) => (a.from < b.from ? -1 : 1));
|
|
92
|
+
const remaining = Object.keys(tree.files).filter((p) => !moves.some((m) => m.from === p));
|
|
93
|
+
errors.push(...folderConflicts([...remaining, ...moves.map((m) => m.to)]));
|
|
94
|
+
|
|
95
|
+
const refused = errors.length > 0;
|
|
96
|
+
const needsExport = tree.files[MANIFEST_PATH] === undefined;
|
|
97
|
+
return { root: tree.root, moves, untouched, diagnostics: errors, refused, needsExport };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Performs `moves` (from `planSiteMigrate`) on `root`: every target is revalidated (contained, not already
|
|
102
|
+
* present) before the first byte is written; then each file is written+renamed into place and only then is
|
|
103
|
+
* its old location removed. A mid-loop fs failure (real I/O error, not a logic conflict — those were already
|
|
104
|
+
* refused by the plan) leaves everything moved so far in place and throws, naming how many completed.
|
|
105
|
+
*/
|
|
106
|
+
export function writeSiteMigrate(root, moves) {
|
|
107
|
+
for (const m of moves) {
|
|
108
|
+
const r = resolveContained(root, m.to);
|
|
109
|
+
if (r.error) throw new Error(`internal: ${m.to} is not a valid site-state target (${r.error.reason})`);
|
|
110
|
+
if (existsSync(r.target)) throw new Error(`${m.to} appeared during migration; stopped before moving it.`);
|
|
111
|
+
}
|
|
112
|
+
let moved = 0;
|
|
113
|
+
for (const m of moves) {
|
|
114
|
+
const { target } = resolveContained(root, m.to);
|
|
115
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
116
|
+
const temp = `${target}.blocofy-tmp`;
|
|
117
|
+
writeFileSync(temp, m.content, { flag: "wx" });
|
|
118
|
+
renameSync(temp, target);
|
|
119
|
+
const { target: legacy } = resolveContained(root, m.from);
|
|
120
|
+
rmSync(legacy);
|
|
121
|
+
moved += 1;
|
|
122
|
+
}
|
|
123
|
+
return moved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `blocofy site migrate`: plan, and with `write: true` perform it. Any refusal moves zero files. */
|
|
127
|
+
export function migrateSiteState({ dir, write = false }) {
|
|
128
|
+
const plan = planSiteMigrate(dir);
|
|
129
|
+
if (!write || plan.refused) return { ...plan, moved: 0 };
|
|
130
|
+
const moved = writeSiteMigrate(plan.root, plan.moves);
|
|
131
|
+
return { ...plan, moved };
|
|
132
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { CliRefusal } from "./media-uses.mjs";
|
|
2
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CF-T4 — the v1 `site-state` HTTP client: `GET /api/v1/site-state`, `POST …/plan`, `…/apply`, `…/publish`,
|
|
6
|
+
* and `POST /api/v1/media` (asset upload, reused for `awaiting_assets`). Bearer `blcf_live_…` API key —
|
|
7
|
+
* same authentication and error-envelope shape as `lib/media-uses.mjs`, whose `CliRefusal` this reuses so
|
|
8
|
+
* `bin/blocofy.mjs`'s exit-code mapping (`error instanceof CliRefusal` → 2) needs no second branch.
|
|
9
|
+
*
|
|
10
|
+
* Retries (CF-T3, `lib/http.mjs`): every one of these calls is safe to resend blindly.
|
|
11
|
+
* - export/plan are reads.
|
|
12
|
+
* - apply and publish are idempotent BY DESIGN (contract §A3): every step re-reads before writing and
|
|
13
|
+
* no-ops when the target already matches, and "publishing the same state twice rewrites nothing".
|
|
14
|
+
* - a media upload has no idempotency key in this contract; a duplicate upload after a connection reset
|
|
15
|
+
* is at worst a second file with the SAME bytes, which the next plan's sha256 match treats identically
|
|
16
|
+
* to the first (harmless, unlike creating a second logical record).
|
|
17
|
+
* A 4xx is a server refusal (`CliRefusal`, exit 2 — see `bin/blocofy.mjs`'s `exitCodeFor`). A final
|
|
18
|
+
* 5xx/network failure after retries throws a plain `Error` with `.status` (exit 1).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { CliRefusal };
|
|
22
|
+
|
|
23
|
+
function url(apiUrl, path) {
|
|
24
|
+
return `${String(apiUrl).replace(/\/+$/, "")}${path}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A response → its JSON body, or the standard refusal/error thrown (shared by every call below). */
|
|
28
|
+
async function readJsonOrThrow(res) {
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let json;
|
|
31
|
+
try {
|
|
32
|
+
json = text ? JSON.parse(text) : null;
|
|
33
|
+
} catch {
|
|
34
|
+
json = null;
|
|
35
|
+
}
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
if (json === null) throw new Error(`HTTP ${res.status}: empty or non-JSON response`);
|
|
38
|
+
return json;
|
|
39
|
+
}
|
|
40
|
+
if (res.status >= 400 && res.status < 500) {
|
|
41
|
+
const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
|
|
42
|
+
throw new CliRefusal(res.status, error);
|
|
43
|
+
}
|
|
44
|
+
const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
|
|
45
|
+
throw Object.assign(new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`), { status: res.status });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function requestJson(method, target, { apiKey, onRetry = null, body }) {
|
|
49
|
+
const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
|
|
50
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
51
|
+
const res = await fetchWithRetry(target, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }, { onRetry });
|
|
52
|
+
return readJsonOrThrow(res);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `GET /api/v1/site-state` → `{schema_version, manifest, files, assets, diagnostics}`. */
|
|
56
|
+
export function fetchSiteStateExport({ apiUrl, apiKey, onRetry = null }) {
|
|
57
|
+
return requestJson("GET", url(apiUrl, "/api/v1/site-state"), { apiKey, onRetry });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `POST /api/v1/site-state/plan` → the plan (read-only; no side effects). */
|
|
61
|
+
export function planSiteState({ apiUrl, apiKey, body, onRetry = null }) {
|
|
62
|
+
return requestJson("POST", url(apiUrl, "/api/v1/site-state/plan"), { apiKey, onRetry, body });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `POST /api/v1/site-state/apply` → `{status, plan_hash, applied, not_applied, report, theme_source}`. */
|
|
66
|
+
export function applySiteState({ apiUrl, apiKey, body, onRetry = null }) {
|
|
67
|
+
return requestJson("POST", url(apiUrl, "/api/v1/site-state/apply"), { apiKey, onRetry, body });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `POST /api/v1/site-state/publish` → `{status:"published", plan_hash, target_instance, swapped, navigation, globals}`. */
|
|
71
|
+
export function publishSiteState({ apiUrl, apiKey, body, onRetry = null }) {
|
|
72
|
+
return requestJson("POST", url(apiUrl, "/api/v1/site-state/publish"), { apiKey, onRetry, body });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Download one exported asset's bytes from its own delivery URL (not the v1 API — no auth header). */
|
|
76
|
+
export async function downloadAssetBytes({ url: assetUrl, onRetry = null }) {
|
|
77
|
+
const res = await fetchWithRetry(assetUrl, {}, { onRetry });
|
|
78
|
+
if (!res.ok) throw new Error(`could not download ${assetUrl} (HTTP ${res.status})`);
|
|
79
|
+
return Buffer.from(await res.arrayBuffer());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `POST /api/v1/media` (multipart `file` field) → `{file: {...}}`. Used to satisfy `assets_missing`. */
|
|
83
|
+
export async function uploadMediaAsset({ apiUrl, apiKey, filename, content, mime = "application/octet-stream", onRetry = null }) {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.set("file", new Blob([content], { type: mime }), filename);
|
|
86
|
+
const res = await fetchWithRetry(
|
|
87
|
+
url(apiUrl, "/api/v1/media"),
|
|
88
|
+
{ method: "POST", headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }, body: form },
|
|
89
|
+
{ onRetry },
|
|
90
|
+
);
|
|
91
|
+
return readJsonOrThrow(res);
|
|
92
|
+
}
|
|
@@ -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
|
+
}
|