@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.
@@ -8,8 +8,13 @@ import { extname, join, relative, sep } from "node:path";
8
8
  */
9
9
 
10
10
  // Mirror of the platform's theme-paths.ts THEME_DIRS (incl. `layer`, added M5 B1 — 0.5.0 closes the
11
- // drift where a pulled layer/ file was silently dropped from the next push).
12
- export const THEME_DIRS = new Set(["layout", "section", "block", "layer", "partial", "asset", "template"]);
11
+ // drift where a pulled layer/ file was silently dropped from the next push; `locales`, added CF-T5, for
12
+ // legacy locale files `locales/<tag>.json` / `locales/<tag>.default.json`). `locales` is not a Liquid
13
+ // kind (raw content, like `asset` — no `.liquid` stripped on push or re-added on pull). The CLI does not
14
+ // validate the tag regex or require a `.json` extension under `locales/` — it reads/writes/sends whatever
15
+ // is there and lets the platform's own write-time validation (tag format, JSON-ness) reject a bad file;
16
+ // duplicating that check here would drift from the server's rules.
17
+ export const THEME_DIRS = new Set(["layout", "section", "block", "layer", "partial", "asset", "template", "locales"]);
13
18
  const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template"]);
14
19
 
15
20
  /**
@@ -7,13 +7,18 @@
7
7
  *
8
8
  * Authentication is a `blcf_live_…` API key (Bearer). The dev `bcf_…` token is NOT accepted for v1.
9
9
  *
10
- * NO RETRIES. A decision batch is applied atomically on the server but a lost response is only
11
- * safe to replay with the SAME idempotency keys, which is the caller's decision, not this
12
- * client's — so a 5xx/network failure surfaces once and the command exits 1. A 4xx is a server
10
+ * RETRIES (CF-T3, lib/http.mjs: 429/502/503/504 + network errors, Retry-After honoured). The GET is a
11
+ * read and always retried. A decision POST is applied atomically on the server, and a lost response is
12
+ * only safe to replay with the SAME idempotency keys — so the POST is retried ONLY when every decision
13
+ * item carries an `idempotency_key` (the CLI adds a UUID to every item lacking one BEFORE the first
14
+ * attempt, so each retry resends the identical body and the server replays instead of applying twice).
15
+ * A batch with an unkeyed item is sent once. A final 5xx/network failure exits 1. A 4xx is a server
13
16
  * REFUSAL (`CliRefusal`): the `{ error }` envelope is carried verbatim for the command to print
14
17
  * as JSON on stderr with exit code 2.
15
18
  */
16
19
 
20
+ import { fetchWithRetry } from "./http.mjs";
21
+
17
22
  export const API_KEY_PREFIX = "blcf_live_";
18
23
  export const DEFAULT_API_URL = "https://app.blocofy.com";
19
24
 
@@ -36,14 +41,19 @@ function mediaUsesUrl(apiUrl, page) {
36
41
  return `${String(apiUrl).replace(/\/+$/, "")}/api/v1/pages/${encodeURIComponent(page)}/media-uses`;
37
42
  }
38
43
 
39
- async function request(method, { apiUrl, apiKey, page }, body) {
44
+ /** True when a replay of this decision batch is idempotent on the server. */
45
+ export function everyDecisionKeyed(decisions) {
46
+ return Array.isArray(decisions) && decisions.length > 0 && decisions.every((d) => d && typeof d === "object" && typeof d.idempotency_key === "string" && d.idempotency_key !== "");
47
+ }
48
+
49
+ async function request(method, { apiUrl, apiKey, page, onRetry = null, retry = true }, body) {
40
50
  const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
41
51
  if (body !== undefined) headers["content-type"] = "application/json";
42
- const res = await fetch(mediaUsesUrl(apiUrl, page), {
43
- method,
44
- headers,
45
- body: body === undefined ? undefined : JSON.stringify(body),
46
- });
52
+ const res = await fetchWithRetry(
53
+ mediaUsesUrl(apiUrl, page),
54
+ { method, headers, body: body === undefined ? undefined : JSON.stringify(body) },
55
+ { onRetry, retries: retry ? undefined : 0 },
56
+ );
47
57
  const text = await res.text();
48
58
  let json;
49
59
  try {
@@ -59,24 +69,25 @@ async function request(method, { apiUrl, apiKey, page }, body) {
59
69
  const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
60
70
  throw new CliRefusal(res.status, error);
61
71
  }
62
- // 5xx (no retry) — the message never includes the request headers.
72
+ // 5xx (after any retries) — the message never includes the request headers.
63
73
  const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
64
- throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`);
74
+ throw Object.assign(new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`), { status: res.status });
65
75
  }
66
76
 
67
77
  /** GET the page's media-use view (the newest draft's `revision{id,version}` is inside). */
68
- export function fetchPageMediaUses({ apiUrl, apiKey, page }) {
69
- return request("GET", { apiUrl, apiKey, page });
78
+ export function fetchPageMediaUses({ apiUrl, apiKey, page, onRetry = null }) {
79
+ return request("GET", { apiUrl, apiKey, page, onRetry });
70
80
  }
71
81
 
72
82
  /**
73
83
  * POST a decision batch. The body is exactly `{ expected_revision_id, expected_version, decisions }`
74
- * — items are sent as given (idempotency keys are the caller's responsibility).
84
+ * — items are sent as given (idempotency keys are the caller's responsibility). Retried only when
85
+ * `everyDecisionKeyed(decisions)`.
75
86
  */
76
- export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions }) {
87
+ export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions, onRetry = null }) {
77
88
  return request(
78
89
  "POST",
79
- { apiUrl, apiKey, page },
90
+ { apiUrl, apiKey, page, onRetry, retry: everyDecisionKeyed(decisions) },
80
91
  { expected_revision_id: expectedRevisionId, expected_version: expectedVersion, decisions },
81
92
  );
82
93
  }
package/lib/messages.mjs CHANGED
@@ -36,9 +36,11 @@ export function statusLine(session) {
36
36
  * (ve `theme dev` taslak senkronu) sessizce retry ETMESİN; stderr'e kısa bir satır
37
37
  * yazılır. `onRetry({ attempt, retries, reason })` payload'ından string üretir.
38
38
  */
39
- export function retryNotice({ attempt, retries, reason }) {
39
+ export function retryNotice({ attempt, retries, reason, waitMs }) {
40
40
  const why = reason ? ` (${reason})` : "";
41
- return `Network error${why} — retrying (${attempt}/${retries})…`;
41
+ const what = typeof reason === "string" && reason.startsWith("HTTP ") ? "Server temporarily unavailable" : "Network error";
42
+ const wait = Number.isFinite(waitMs) ? ` in ${Math.round(waitMs / 100) / 10}s` : "";
43
+ return `${what}${why} — retrying${wait} (${attempt}/${retries})…`;
42
44
  }
43
45
 
44
46
  /**
@@ -57,3 +59,41 @@ export function githubNote(session) {
57
59
  (session.githubConnected === false ? " Connect a GitHub repo for two-way sync." : "")
58
60
  );
59
61
  }
62
+
63
+ /**
64
+ * CF-T9 — `blocofy status` health advice (pure). Never an imperative one-line fix: making another theme live
65
+ * replaces what every visitor sees, so the advice names the themes involved, the missing pages, why it happens,
66
+ * and SAFE next steps (preview first); `theme publish` is only mentioned as a separate, deliberate decision.
67
+ * Returns stderr lines ([] when healthy). Tolerates older servers without pages_by_instance/orphan_missing_slugs.
68
+ */
69
+ export function healthAdvice(s) {
70
+ if (!s || (s.health !== "pages_split" && s.health !== "live_instance_empty")) return [];
71
+ const liveId = s.live_theme_instance?.id ?? null;
72
+ const names = new Map((Array.isArray(s.drafts) ? s.drafts : []).map((d) => [String(d.id), d.name]));
73
+ const holders = (Array.isArray(s.pages_by_instance) ? s.pages_by_instance : [])
74
+ .filter((p) => p && p.theme_instance != null && String(p.theme_instance) !== String(liveId) && p.count > 0)
75
+ .map((p) => ({ id: p.theme_instance, name: names.get(String(p.theme_instance)) ?? null, count: p.count }));
76
+ const label = (h) => `${h.id}${h.name ? ` "${h.name}"` : ""} (${h.count} published page${h.count === 1 ? "" : "s"})`;
77
+ const missing = Array.isArray(s.orphan_missing_slugs) ? s.orphan_missing_slugs : [];
78
+ const handle = holders.length === 1 ? String(holders[0].id) : "<handle>";
79
+
80
+ const out = [];
81
+ if (s.health === "live_instance_empty") {
82
+ out.push(" ⚠ The live theme has no published pages, so visitors get \"page not found\".");
83
+ } else {
84
+ out.push(` ⚠ ${missing.length || s.orphaned_pages || "Some"} published page(s) cannot be reached by visitors.`);
85
+ if (missing.length) out.push(` Missing on the live theme: ${missing.join(", ")}`);
86
+ }
87
+ if (holders.length) {
88
+ out.push(` Published pages are held by ${holders.length === 1 ? "a theme that is" : "themes that are"} not live: ${holders.map(label).join(", ")}`);
89
+ } else {
90
+ out.push(" Published pages are held by a theme that is not live (this server does not say which; see the panel's theme library).");
91
+ }
92
+ out.push(" Why: pages belong to a theme. Pages published on a theme that is not live are not shown on the site.");
93
+ out.push(" Safe next steps:");
94
+ out.push(` 1. Preview that theme without changing the site: blocofy theme pull <empty-dir> --instance ${handle}`);
95
+ out.push(" or in the panel: Theme → Theme library → \"Open in editor\".");
96
+ out.push(" 2. Then decide: recreate the missing pages on the live theme, or make that theme live.");
97
+ out.push(` Note: \`blocofy theme publish --instance ${handle}\` REPLACES the live theme for every visitor — a separate, deliberate decision.`);
98
+ return out;
99
+ }
package/lib/output.mjs ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * CF-T2 (contract C2) — shared output for target display and refusals. Everything here goes to stderr (stdout
3
+ * stays the command's own result, e.g. `--json` payloads). Known secrets are registered once and redacted from
4
+ * every string this module prints, as a last line of defence.
5
+ */
6
+
7
+ const secrets = new Set();
8
+
9
+ export function registerSecret(value) {
10
+ if (typeof value === "string" && value.length >= 8) secrets.add(value);
11
+ }
12
+
13
+ export function redact(text) {
14
+ let out = String(text);
15
+ for (const s of secrets) out = out.split(s).join("[redacted]");
16
+ return out;
17
+ }
18
+
19
+ /** The target block as data (used for `--json` and for `blocofy target`). */
20
+ export function targetData({ site, url, contextName, bindingLabel, operation }) {
21
+ return {
22
+ site: { id: site.id, slug: site.slug ?? null, name: site.name ?? null, domain: site.domain ?? null },
23
+ url: url ?? null,
24
+ context: contextName,
25
+ binding: bindingLabel,
26
+ operation,
27
+ };
28
+ }
29
+
30
+ export function formatTargetBlock(t) {
31
+ const name = t.site.name ?? t.site.slug ?? String(t.site.id);
32
+ return [
33
+ `Target: ${name} · ${t.site.id} · ${t.site.domain ?? t.url ?? "?"}`,
34
+ `Context: ${t.context}`,
35
+ `Binding: ${t.binding}`,
36
+ `Operation: ${t.operation}`,
37
+ ].join("\n");
38
+ }
39
+
40
+ export function printTarget(t, { json = false, stream = process.stderr } = {}) {
41
+ stream.write(redact(json ? JSON.stringify({ target: t }) : formatTargetBlock(t)) + "\n");
42
+ }
43
+
44
+ /** `{"error":{code,message,details}}` under --json, `error [CODE]: message` otherwise. */
45
+ export function printError(error, { json = false, stream = process.stderr } = {}) {
46
+ const envelope = { error: { code: error.code ?? "ERROR", message: error.message ?? String(error), details: error.details ?? {} } };
47
+ stream.write(redact(json ? JSON.stringify(envelope) : `error [${envelope.error.code}]: ${envelope.error.message}`) + "\n");
48
+ }
49
+
50
+ /** One warning line: `{"warning":{code,message}}` under --json, `warning [CODE]: message` otherwise. */
51
+ export function printWarning(warning, { json = false, stream = process.stderr } = {}) {
52
+ stream.write(redact(json ? JSON.stringify({ warning: { code: warning.code, message: warning.message } }) : `warning [${warning.code}]: ${warning.message}`) + "\n");
53
+ }
@@ -20,7 +20,7 @@ import {
20
20
 
21
21
  export const PAGE_META_KEYS = ["title", "seo_title", "seo_description", "og_image", "canonical_url"];
22
22
  const NULLABLE_STRING_KEYS = [...PAGE_META_KEYS, "status", "template"];
23
- const V2_KEY_ORDER = ["format_version", "slug", "title", "status", "seo_title", "seo_description", "og_image", "canonical_url", "locale", "template", "data"];
23
+ const V2_KEY_ORDER = ["format_version", "base_revision", "slug", "title", "status", "seo_title", "seo_description", "og_image", "canonical_url", "locale", "template", "data"];
24
24
 
25
25
  /** A refusal the CLI prints with its stable code. `diagnostics` are platform-shaped findings. */
26
26
  export class PagesCliError extends Error {
@@ -34,6 +34,9 @@ export class PagesCliError extends Error {
34
34
  }
35
35
  }
36
36
 
37
+ /** CF-T3 — the fingerprint `blocofy pages pull` stamps (mirror of the platform's `page-files/base-revision.ts`). */
38
+ export const BASE_REVISION_PATTERN = /^r1_[0-9a-f]{32}$/;
39
+
37
40
  const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
38
41
  const isCanonicalLocale = (v) => typeof v === "string" && canonicalizeLocaleCode(v) === v;
39
42
 
@@ -63,6 +66,11 @@ export function classifyPageFile(path, content, ctx = {}) {
63
66
  for (const key of NULLABLE_STRING_KEYS) {
64
67
  if (key in json && json[key] !== null && typeof json[key] !== "string") return fail("PAGES_INVALID_JSON", `${key} must be a string or null`, { slug });
65
68
  }
69
+ // CF-T3 — absent/null is judged by the push (it needs the server state); anything else must be a pulled value.
70
+ if (json.base_revision != null && (typeof json.base_revision !== "string" || !BASE_REVISION_PATTERN.test(json.base_revision))) {
71
+ return fail("PAGES_INVALID_JSON", "base_revision is not a value `blocofy pages pull` wrote", { slug });
72
+ }
73
+
66
74
  const hasLocale = Object.prototype.hasOwnProperty.call(json, "locale");
67
75
  let locale = null;
68
76
  let localeSource;
@@ -302,7 +310,9 @@ export function stagedWrite(root, entries, { writeFile = writeFileSync, rename =
302
310
 
303
311
  export function formatDiagnostic(d) {
304
312
  const where = d.path ? `\n ${d.path}` : d.slug ? `\n ${d.locale ? `locale "${d.locale}", ` : ""}slug "${d.slug}"` : "";
305
- return `${d.level} [${d.code}]:\n ${d.message}${where}`;
313
+ // CF-T3 — a stale or base-less file shows what it was pulled at and what the page is now.
314
+ const revs = d.current_revision ? `\n base_revision ${d.base_revision ?? "(none)"} → current ${d.current_revision}` : "";
315
+ return `${d.level} [${d.code}]:\n ${d.message}${where}${revs}`;
306
316
  }
307
317
 
308
318
  export { PageFileError, pageFilePath };
@@ -0,0 +1,133 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { dirname } from "node:path";
5
+
6
+ /**
7
+ * CF-T1 (contract C1) — where credential SECRETS live. `credentials.json` only records which store holds a
8
+ * context's secret (`secret: { store: "file" | "keychain" }`); the value itself is in one of:
9
+ *
10
+ * file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key } }`.
11
+ * keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api`.
12
+ * Opt-in only (`BLOCOFY_SECRET_STORE=keychain` or `login --keychain`); other platforms → error.
13
+ *
14
+ * Both stores expose `get(context, kind)`, `set(context, kind, value)`, `remove(context)` with kind `dev|api`.
15
+ * No method ever puts a secret into an error message.
16
+ */
17
+
18
+ export const KEYCHAIN_SERVICE = "blocofy-cli";
19
+ const FIELD = { dev: "dev_token", api: "api_key" };
20
+
21
+ export class SecretStoreError extends Error {
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.name = "SecretStoreError";
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ /** Atomic JSON write (unique tmp in the same dir + rename) with 0600 permissions. */
30
+ export function writeJsonAtomic(path, value) {
31
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
32
+ chmodSync(dirname(path), 0o700); // an older, looser directory is tightened too
33
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
34
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
35
+ chmodSync(tmp, 0o600);
36
+ renameSync(tmp, path);
37
+ }
38
+
39
+ export function createFileSecretStore(path) {
40
+ const read = () => {
41
+ let raw;
42
+ try {
43
+ raw = readFileSync(path, "utf8");
44
+ } catch (error) {
45
+ if (error?.code === "ENOENT") return {};
46
+ throw error;
47
+ }
48
+ try {
49
+ const data = JSON.parse(raw);
50
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
51
+ } catch {
52
+ /* fall through */
53
+ }
54
+ throw new SecretStoreError("CREDENTIALS_CORRUPT", `The secrets file is not valid JSON: ${path}. Nothing was written.`);
55
+ };
56
+ return {
57
+ name: "file",
58
+ get(context, kind) {
59
+ const v = read()[context]?.[FIELD[kind]];
60
+ return typeof v === "string" && v ? v : null;
61
+ },
62
+ set(context, kind, value) {
63
+ const data = read();
64
+ data[context] = { ...(data[context] ?? {}), [FIELD[kind]]: value };
65
+ writeJsonAtomic(path, data);
66
+ },
67
+ remove(context, kind = null) {
68
+ const data = read();
69
+ if (!(context in data)) return;
70
+ if (kind) {
71
+ delete data[context][FIELD[kind]];
72
+ if (Object.keys(data[context]).length === 0) delete data[context];
73
+ } else {
74
+ delete data[context];
75
+ }
76
+ writeJsonAtomic(path, data);
77
+ },
78
+ };
79
+ }
80
+
81
+ /** Default exec: `spawnSync` with the secret-bearing command on STDIN (never argv — argv is visible in `ps`). */
82
+ export function defaultExec(command, args, { input } = {}) {
83
+ const r = spawnSync(command, args, { input, encoding: "utf8" });
84
+ return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error ?? null };
85
+ }
86
+
87
+ const quote = (s) => `"${String(s).replace(/["\\]/g, (c) => `\\${c}`)}"`;
88
+
89
+ /**
90
+ * macOS Keychain adapter. `exec(command, args, { input })` is injectable so tests never touch a real keychain.
91
+ * Writes go through `security -i` (commands read from stdin) so the secret is not in the process argv.
92
+ */
93
+ export function createKeychainSecretStore({ exec = defaultExec, platform = process.platform } = {}) {
94
+ const ensure = () => {
95
+ if (platform !== "darwin") {
96
+ throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The keychain secret store is only available on macOS. Use the default file store (unset BLOCOFY_SECRET_STORE, omit --keychain).");
97
+ }
98
+ };
99
+ const account = (context, kind) => `${context}:${kind}`;
100
+ return {
101
+ name: "keychain",
102
+ get(context, kind) {
103
+ ensure();
104
+ const r = exec("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account(context, kind), "-w"]);
105
+ if (r.status !== 0) return null;
106
+ const v = r.stdout.replace(/\r?\n$/, "");
107
+ return v || null;
108
+ },
109
+ set(context, kind, value) {
110
+ ensure();
111
+ // Review M6: `security -i` reads one command per line; a CR/LF/NUL in the secret would start a new command.
112
+ if (/[\r\n\0]/.test(String(value)) || /[\r\n\0]/.test(account(context, kind))) {
113
+ throw new SecretStoreError("SECRET_STORE_INVALID_SECRET", "The secret contains a line break or NUL character and cannot be stored in the macOS keychain. Nothing was saved.");
114
+ }
115
+ const input = `add-generic-password -U -s ${quote(KEYCHAIN_SERVICE)} -a ${quote(account(context, kind))} -w ${quote(value)}\n`;
116
+ const r = exec("security", ["-i"], { input });
117
+ if (r.status !== 0) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not save the secret to the macOS keychain (security exit ${r.status}). Nothing was saved.`);
118
+ },
119
+ remove(context, kind = null) {
120
+ ensure();
121
+ for (const k of kind ? [kind] : ["dev", "api"]) {
122
+ exec("security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account(context, k)]);
123
+ }
124
+ },
125
+ };
126
+ }
127
+
128
+ /** `store` name → adapter. Unknown names are a usage error. */
129
+ export function secretStoreFor(name, { filePath, exec, platform } = {}) {
130
+ if (name === "file") return createFileSecretStore(filePath);
131
+ if (name === "keychain") return createKeychainSecretStore({ exec, platform });
132
+ throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use "file" or "keychain").`);
133
+ }
@@ -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
+ }