@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.
@@ -1,92 +1,248 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync, chmodSync, mkdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
+ import { SecretStoreError, secretStoreFor, writeJsonAtomic } from "./secret-store.mjs";
6
+
5
7
  /**
6
- * Blocofy CLI credentials. `blocofy login` writes the platform URL + dev token to
7
- * `~/.blocofy/credentials.json` (0600); `blocofy theme dev` reads it. Environment
8
- * variables (BLOCOFY_URL/BLOCOFY_TOKEN) take precedence over the file (CI/agents).
9
- * The token is stored in plaintext on disk (the usual dev-CLI pattern), so the file
10
- * is locked down to 0600.
8
+ * CF-T1 (contract C1) — Blocofy CLI credential store v2.
9
+ *
10
+ * `~/.blocofy/credentials.json` (0600, dir 0700, atomic tmp+rename) holds NAMED CONTEXTS and no secrets:
11
+ *
12
+ * { "schema_version": 2, "current_context": "<name>|null",
13
+ * "contexts": { "<name>": { platform_origin, site: {id,slug,name,domain}|null,
14
+ * dev: { url, secret: { store } }?, api: { url, secret: { store } }?, verified_at } } }
11
15
  *
12
- * 0.8.0: the same file also holds the v1 API pair `{ apiUrl, apiKey }` written by
13
- * `blocofy login --api-key`. The two pairs coexist: `saveCredentials(patch)` MERGES the
14
- * patch into the existing JSON (an unreadable/corrupt file counts as `{}`), so an API login
15
- * keeps the dev pair and a later dev login keeps the API pair. `loadCredentials()` (dev
16
- * pair) is unchanged; `loadApiCredentials()` resolves the v1 pair (BLOCOFY_API_KEY +
17
- * BLOCOFY_API_URL first, then the file).
16
+ * Secrets live in a secret store (lib/secret-store.mjs): `~/.blocofy/secrets.json` by default, the macOS keychain
17
+ * on opt-in. A pre-v2 flat file `{ url, token, apiUrl, apiKey }` is migrated losslessly on first read into
18
+ * `contexts.default` (unverified, `site: null`); the original bytes are kept in `credentials.v1.bak.json`
19
+ * (rollback: copy it back over credentials.json). A corrupt or half file is never rewritten: CREDENTIALS_CORRUPT.
20
+ *
21
+ * Environment variables form an ephemeral "env" context: BLOCOFY_URL+BLOCOFY_TOKEN and/or
22
+ * BLOCOFY_API_URL+BLOCOFY_API_KEY. A half-set pair is a loud error (never a silent fall-through to the file).
18
23
  */
19
24
 
20
- const DIR_NAME = ".blocofy";
21
- const FILE_NAME = "credentials.json";
25
+ export const SCHEMA_VERSION = 2;
26
+ export const ENV_CONTEXT = "env";
27
+
28
+ /** A local credential problem (exit 1). Messages carry paths, never file content or secrets. */
29
+ export class CredentialsError extends Error {
30
+ constructor(code, message, details = {}) {
31
+ super(message);
32
+ this.name = "CredentialsError";
33
+ this.code = code;
34
+ this.details = details;
35
+ this.exitCode = 1;
36
+ }
37
+ }
38
+
39
+ // Resolved per call (not at import): tests redirect HOME, and os.homedir() honours $HOME on POSIX.
40
+ const dir = () => join(homedir(), ".blocofy");
41
+ export const credentialsPath = () => join(dir(), "credentials.json");
42
+ export const secretsPath = () => join(dir(), "secrets.json");
43
+ export const backupPath = () => join(dir(), "credentials.v1.bak.json");
44
+
45
+ const stripSlash = (url) => String(url).replace(/\/+$/, "");
46
+ const isStr = (v) => typeof v === "string" && v.length > 0;
47
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
48
+
49
+ export const emptyStore = () => ({ schema_version: SCHEMA_VERSION, current_context: null, contexts: {} });
50
+
51
+ function corrupt(reason) {
52
+ return new CredentialsError("CREDENTIALS_CORRUPT", `The credentials file cannot be used (${reason}): ${credentialsPath()}. Nothing was written. Fix or remove it, then run \`blocofy login\`.`, { path: credentialsPath() });
53
+ }
22
54
 
23
- // Resolved per call (not at import): tests redirect HOME to a temp dir, and os.homedir()
24
- // honours $HOME on POSIX.
25
- function dir() {
26
- return join(homedir(), DIR_NAME);
55
+ function validPair(pair) {
56
+ return pair === undefined || (isObj(pair) && isStr(pair.url) && isObj(pair.secret) && (pair.secret.store === "file" || pair.secret.store === "keychain"));
27
57
  }
28
58
 
29
- export function credentialsPath() {
30
- return join(dir(), FILE_NAME);
59
+ function validV2(data) {
60
+ if (data.schema_version !== SCHEMA_VERSION || !isObj(data.contexts)) return false;
61
+ if (data.current_context !== null && data.current_context !== undefined && typeof data.current_context !== "string") return false;
62
+ for (const ctx of Object.values(data.contexts)) {
63
+ if (!isObj(ctx) || !validPair(ctx.dev) || !validPair(ctx.api)) return false;
64
+ if (ctx.site !== null && ctx.site !== undefined && !(isObj(ctx.site) && (isStr(ctx.site.id) || Number.isFinite(ctx.site.id)))) return false;
65
+ }
66
+ return true;
31
67
  }
32
68
 
33
- function readExisting() {
69
+ /**
70
+ * Read the store (migrating a v1 file). `{ secretStore }` options pass through to the secret adapters (tests).
71
+ * A missing file is an empty store and is NOT created.
72
+ */
73
+ export function loadStore(options = {}) {
74
+ let raw;
75
+ try {
76
+ raw = readFileSync(credentialsPath(), "utf8");
77
+ } catch (error) {
78
+ if (error?.code === "ENOENT") return emptyStore();
79
+ throw new CredentialsError("CREDENTIALS_UNREADABLE", `Cannot read ${credentialsPath()} (${error?.code ?? "error"}).`, { path: credentialsPath() });
80
+ }
81
+ let data;
34
82
  try {
35
- const data = JSON.parse(readFileSync(credentialsPath(), "utf8"));
36
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
83
+ data = JSON.parse(raw);
37
84
  } catch {
38
- return {}; // missing/corrupt → start clean
85
+ throw corrupt("not valid JSON");
39
86
  }
87
+ if (!isObj(data)) throw corrupt("not a JSON object");
88
+ if ("schema_version" in data) {
89
+ if (!validV2(data)) throw corrupt("unexpected structure");
90
+ return { schema_version: SCHEMA_VERSION, current_context: data.current_context ?? null, contexts: data.contexts };
91
+ }
92
+ return migrateV1(raw, data, options);
40
93
  }
41
94
 
42
- /** Merge `patch` into the credentials file and write it with 0600 permissions. */
43
- export function saveCredentials(patch) {
95
+ /** v1 `{url, token, apiUrl, apiKey}` → v2 `contexts.default`. Validates EVERYTHING before the first write. */
96
+ function migrateV1(raw, data, options) {
97
+ const known = ["url", "token", "apiUrl", "apiKey"];
98
+ if (Object.keys(data).length === 0) return emptyStore();
99
+ for (const k of known) if (k in data && !isStr(data[k])) throw corrupt(`field "${k}" is not a string`);
100
+ const hasDev = isStr(data.url) || isStr(data.token);
101
+ const hasApi = isStr(data.apiUrl) || isStr(data.apiKey);
102
+ if (hasDev && !(isStr(data.url) && isStr(data.token))) throw corrupt("the dev URL/token pair is incomplete");
103
+ if (hasApi && !(isStr(data.apiUrl) && isStr(data.apiKey))) throw corrupt("the API URL/key pair is incomplete");
104
+ if (!hasDev && !hasApi) throw corrupt("no credential pair found");
105
+
106
+ const context = { platform_origin: null, site: null, verified_at: null };
107
+ if (hasDev) context.dev = { url: stripSlash(data.url), secret: { store: "file" } };
108
+ if (hasApi) context.api = { url: stripSlash(data.apiUrl), secret: { store: "file" } };
109
+ const store = { schema_version: SCHEMA_VERSION, current_context: "default", contexts: { default: context } };
110
+
111
+ // 1) exact-bytes backup (never overwrite an older, different backup)
44
112
  mkdirSync(dir(), { recursive: true, mode: 0o700 });
45
- const merged = { ...readExisting(), ...patch };
46
- writeFileSync(credentialsPath(), JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
47
- chmodSync(credentialsPath(), 0o600); // create-mode is ignored if the file already exists → enforce it
113
+ let backup = backupPath();
114
+ if (existsSync(backup) && readFileSync(backup, "utf8") !== raw) backup = join(dir(), `credentials.v1.bak.${Date.now()}.json`);
115
+ if (!existsSync(backup)) {
116
+ writeFileSync(backup, raw, { mode: 0o600 });
117
+ chmodSync(backup, 0o600);
118
+ }
119
+ // 2) secrets, 3) the v2 file (a crash between steps re-runs an idempotent migration from the untouched v1 file)
120
+ const secrets = secretStoreFor("file", { filePath: secretsPath(), ...options.secretStore });
121
+ if (hasDev) secrets.set("default", "dev", data.token);
122
+ if (hasApi) secrets.set("default", "api", data.apiKey);
123
+ saveStore(store);
124
+ return store;
48
125
  }
49
126
 
127
+ const LOCK_STALE_MS = 10000;
128
+ const LOCK_WAIT_MS = 15000;
129
+ export const lockPath = () => join(dir(), ".lock");
130
+
50
131
  /**
51
- * Resolve credentials: environment first (both vars), then the file. Returns
52
- * `{ url, token, source }` or null. Trailing slashes are stripped from the URL.
132
+ * Review M5 — run the synchronous load-modify-save `fn` under an exclusive `~/.blocofy/.lock` (O_EXCL create), so two
133
+ * concurrent logins/logouts cannot overwrite each other's context or secret. A lock older than 10 s is stale (a
134
+ * crashed process) and is taken over; waiting longer than 15 s is CREDENTIALS_LOCKED.
53
135
  */
54
- export function loadCredentials() {
55
- const envUrl = process.env.BLOCOFY_URL;
56
- const envToken = process.env.BLOCOFY_TOKEN;
57
- if (envUrl && envToken) {
58
- return { url: stripSlash(envUrl), token: envToken, source: "env" };
136
+ export function withStoreLock(fn) {
137
+ mkdirSync(dir(), { recursive: true, mode: 0o700 });
138
+ const deadline = Date.now() + LOCK_WAIT_MS;
139
+ const pause = new Int32Array(new SharedArrayBuffer(4));
140
+ for (;;) {
141
+ try {
142
+ const fd = openSync(lockPath(), "wx", 0o600);
143
+ writeSync(fd, String(process.pid));
144
+ closeSync(fd);
145
+ break;
146
+ } catch (error) {
147
+ if (error?.code !== "EEXIST") throw error;
148
+ }
149
+ try {
150
+ if (Date.now() - statSync(lockPath()).mtimeMs > LOCK_STALE_MS) {
151
+ rmSync(lockPath(), { force: true });
152
+ continue;
153
+ }
154
+ } catch {
155
+ continue; // released between the open and the stat
156
+ }
157
+ if (Date.now() > deadline) {
158
+ throw new CredentialsError("CREDENTIALS_LOCKED", `Another blocofy command is updating ${credentialsPath()}. Nothing was written; try again (or remove ${lockPath()} if no blocofy command is running).`, { path: lockPath() });
159
+ }
160
+ Atomics.wait(pause, 0, 0, 20);
59
161
  }
60
- const data = readExisting();
61
- if (typeof data.url === "string" && typeof data.token === "string") {
62
- return { url: stripSlash(data.url), token: data.token, source: "file" };
162
+ try {
163
+ return fn();
164
+ } finally {
165
+ rmSync(lockPath(), { force: true });
63
166
  }
64
- return null;
65
167
  }
66
168
 
67
- /**
68
- * Resolve the v1 API pair: BLOCOFY_API_KEY + BLOCOFY_API_URL (BOTH required — with only one
69
- * set this throws and does NOT fall through to the file, so a half-configured CI job fails
70
- * loudly instead of silently using a developer's file), then the file's `{ apiUrl, apiKey }`.
71
- * Returns `{ apiUrl, apiKey, source }` or null. The thrown message never contains the key.
72
- */
73
- export function loadApiCredentials() {
74
- const envKey = process.env.BLOCOFY_API_KEY;
75
- const envUrl = process.env.BLOCOFY_API_URL;
76
- if (envKey && envUrl) {
77
- return { apiUrl: stripSlash(envUrl), apiKey: envKey, source: "env" };
169
+ export function saveStore(store) {
170
+ writeJsonAtomic(credentialsPath(), { schema_version: SCHEMA_VERSION, current_context: store.current_context ?? null, contexts: store.contexts });
171
+ }
172
+
173
+ /** The secret-store name for NEW secrets: `--keychain` flag or BLOCOFY_SECRET_STORE, default "file". */
174
+ export function defaultSecretStoreName({ keychain = false, env = process.env } = {}) {
175
+ if (keychain) return "keychain";
176
+ const v = env.BLOCOFY_SECRET_STORE;
177
+ if (v === undefined || v === "" || v === "file") return "file";
178
+ if (v === "keychain") return "keychain";
179
+ throw new CredentialsError("SECRET_STORE_UNAVAILABLE", `BLOCOFY_SECRET_STORE must be "file" or "keychain".`);
180
+ }
181
+
182
+ export function openSecretStore(name, options = {}) {
183
+ try {
184
+ return secretStoreFor(name, { filePath: secretsPath(), ...options });
185
+ } catch (error) {
186
+ throw asCredentialsError(error);
187
+ }
188
+ }
189
+
190
+ function asCredentialsError(error) {
191
+ if (error instanceof SecretStoreError) return new CredentialsError(error.code, error.message);
192
+ return error;
193
+ }
194
+
195
+ /** `{ devToken, apiKey }` for a stored context (null where the pair or its secret is absent). */
196
+ export function readSecrets(name, ctx, options = {}) {
197
+ try {
198
+ const get = (kind) => (ctx[kind] ? openSecretStore(ctx[kind].secret.store, options).get(name, kind) : null);
199
+ return { devToken: get("dev"), apiKey: get("api") };
200
+ } catch (error) {
201
+ throw asCredentialsError(error);
78
202
  }
79
- if (envKey || envUrl) {
80
- const missing = envKey ? "BLOCOFY_API_URL" : "BLOCOFY_API_KEY";
81
- throw new Error(`${missing} is not set — BLOCOFY_API_KEY and BLOCOFY_API_URL must be set together (the credentials file is not consulted while either is set).`);
203
+ }
204
+
205
+ export function writeSecret(name, kind, storeName, value, options = {}) {
206
+ try {
207
+ openSecretStore(storeName, options).set(name, kind, value);
208
+ } catch (error) {
209
+ throw asCredentialsError(error);
82
210
  }
83
- const data = readExisting();
84
- if (typeof data.apiUrl === "string" && typeof data.apiKey === "string") {
85
- return { apiUrl: stripSlash(data.apiUrl), apiKey: data.apiKey, source: "file" };
211
+ }
212
+
213
+ export function removeSecrets(name, ctx, options = {}) {
214
+ for (const kind of ["dev", "api"]) {
215
+ if (!ctx?.[kind]) continue;
216
+ try {
217
+ openSecretStore(ctx[kind].secret.store, options).remove(name, kind);
218
+ } catch (error) {
219
+ throw asCredentialsError(error);
220
+ }
86
221
  }
87
- return null;
88
222
  }
89
223
 
90
- function stripSlash(url) {
91
- return url.replace(/\/+$/, "");
224
+ /**
225
+ * The ephemeral "env" context, or null when no variable is set. Half-set pairs throw (the message names the
226
+ * missing variable only).
227
+ */
228
+ export function envContext(env = process.env) {
229
+ const pair = (urlVar, secretVar) => {
230
+ const url = env[urlVar];
231
+ const secret = env[secretVar];
232
+ if (url && secret) return { url: stripSlash(url), secret };
233
+ if (url || secret) {
234
+ const missing = url ? secretVar : urlVar;
235
+ throw new CredentialsError("ENV_CREDENTIALS_INCOMPLETE", `${missing} is not set — ${urlVar} and ${secretVar} must be set together (the credentials file is not consulted while either is set).`);
236
+ }
237
+ return null;
238
+ };
239
+ const dev = pair("BLOCOFY_URL", "BLOCOFY_TOKEN");
240
+ const api = pair("BLOCOFY_API_URL", "BLOCOFY_API_KEY");
241
+ if (!dev && !api) return null;
242
+ return {
243
+ name: ENV_CONTEXT,
244
+ source: "env",
245
+ context: { platform_origin: null, site: null, ...(dev ? { dev: { url: dev.url } } : {}), ...(api ? { api: { url: api.url } } : {}) },
246
+ secrets: { devToken: dev?.secret ?? null, apiKey: api?.secret ?? null },
247
+ };
92
248
  }
package/lib/http.mjs ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * CF-T3 (contract C4, CLI half) — the ONE retry policy for every CLI request that is safe to resend.
3
+ *
4
+ * Retried: a thrown fetch (network error, connection reset, per-attempt timeout) and HTTP 429 / 502 / 503 / 504.
5
+ * NOT retried: 500 and every other status (a 500 may mean the server applied part of the work; a 4xx is a refusal).
6
+ * At most 3 retries (4 attempts). Wait before retry n: the response's `Retry-After` (delta-seconds or HTTP-date,
7
+ * capped at 30 s) when present, else 300 ms / 900 ms / 2000 ms. The SAME request is resent: the caller's `init`
8
+ * (method, headers incl. `x-idempotency-key`, body string) is reused unchanged, so a replay converges on the
9
+ * server's idempotency handling instead of applying a second, different mutation.
10
+ *
11
+ * Callers only route requests here that are safe to replay: reads, upserts, pointer sets, and mutations carrying
12
+ * an idempotency key. `onRetry({ attempt, retries, reason, waitMs })` fires before every wait (never silent).
13
+ */
14
+
15
+ export const RETRY_STATUSES = new Set([429, 502, 503, 504]);
16
+ export const DEFAULT_RETRIES = 3;
17
+ export const DEFAULT_BACKOFF_MS = [300, 900, 2000];
18
+ export const MAX_RETRY_WAIT_MS = 30000;
19
+
20
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
+
22
+ /** `Retry-After` → milliseconds (capped), or null when absent/unparseable. */
23
+ export function parseRetryAfter(value, nowMs = Date.now()) {
24
+ if (typeof value !== "string" || value.trim() === "") return null;
25
+ const v = value.trim();
26
+ let ms;
27
+ if (/^\d+$/.test(v)) ms = Number(v) * 1000;
28
+ else if (/[A-Za-z]/.test(v)) {
29
+ const at = Date.parse(v);
30
+ if (Number.isNaN(at)) return null;
31
+ ms = at - nowMs;
32
+ } else return null;
33
+ return Math.min(Math.max(ms, 0), MAX_RETRY_WAIT_MS);
34
+ }
35
+
36
+ /**
37
+ * `fetch` with the retry contract above. Options: `retries`, `backoff` (ms per retry index), `onRetry`,
38
+ * `fetchImpl` (default: the global fetch at call time), `sleep(ms)`, `now()`, `timeoutMs` (per attempt).
39
+ */
40
+ export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs } = {}) {
41
+ const doFetch = fetchImpl ?? globalThis.fetch;
42
+ for (let attempt = 0; ; attempt += 1) {
43
+ const attemptInit = timeoutMs ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init;
44
+ let res;
45
+ try {
46
+ res = await doFetch(input, attemptInit);
47
+ } catch (error) {
48
+ if (attempt >= retries) throw error;
49
+ const waitMs = backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
50
+ onRetry?.({ attempt: attempt + 1, retries, reason: error?.name === "TimeoutError" ? "timeout" : error?.message ?? String(error), waitMs });
51
+ await sleep(waitMs);
52
+ continue;
53
+ }
54
+ if (!RETRY_STATUSES.has(res.status) || attempt >= retries) return res;
55
+ const waitMs = parseRetryAfter(res.headers.get("retry-after"), now()) ?? backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
56
+ await res.arrayBuffer().catch(() => {}); // release the connection before resending
57
+ onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}`, waitMs });
58
+ await sleep(waitMs);
59
+ }
60
+ }
@@ -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
+ }