@blocofy/cli 0.14.0 → 0.16.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,22 +1,35 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import { dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
5
6
 
6
7
  /**
7
8
  * 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
+ * context's secret (`secret: { store: "file" | "keychain" | "dpapi" | "secret-service" }`); the value itself is in:
9
10
  *
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.
11
+ * file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key, oauth_tokens } }`.
12
+ * keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api|oauth`.
13
+ * Opt-in for a pasted token (`BLOCOFY_SECRET_STORE=keychain` or `login --keychain`); other platforms → error.
13
14
  *
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.
15
+ * ADR-0014 §5.4 (wave P4) — the browser login's token set (kind `oauth`: one JSON string `{access_token,
16
+ * refresh_token, expires_at}`) lives in the OS secure store by default (`oauthSecretStore`):
17
+ *
18
+ * macOS keychain (above)
19
+ * Windows dpapi `%APPDATA%\blocofy\secrets.dpapi`: each value protected with DPAPI (user scope) by
20
+ * PowerShell `ConvertFrom-SecureString`; the secret goes in on stdin, never argv
21
+ * Linux secret-service libsecret's `secret-tool` (stdin), when a DBus session is present
22
+ * any file only when chosen explicitly (`--insecure-storage` / `BLOCOFY_SECRET_STORE=file`) and
23
+ * announced; an unavailable OS store is an error, never a silent plaintext downgrade
24
+ *
25
+ * Every store exposes `get(context, kind)`, `set(context, kind, value)`, `remove(context, kind?)` with kind
26
+ * `dev|api|oauth`. No method ever puts a secret into an error message.
16
27
  */
17
28
 
18
29
  export const KEYCHAIN_SERVICE = "blocofy-cli";
19
- const FIELD = { dev: "dev_token", api: "api_key" };
30
+ const FIELD = { dev: "dev_token", api: "api_key", oauth: "oauth_tokens" };
31
+ /** Every store name a context may record (`secret: { store }`). */
32
+ export const STORE_NAMES = ["file", "keychain", "dpapi", "secret-service"];
20
33
 
21
34
  export class SecretStoreError extends Error {
22
35
  constructor(code, message) {
@@ -125,9 +138,163 @@ export function createKeychainSecretStore({ exec = defaultExec, platform = proce
125
138
  };
126
139
  }
127
140
 
141
+ const storeAccount = (context, kind) => `${context}:${kind}`;
142
+
143
+ /** Windows: the protect/unprotect scripts. The value travels on stdin both ways; argv carries only the script. */
144
+ const PS_PROTECT =
145
+ "$s=[Console]::In.ReadToEnd(); $ss=ConvertTo-SecureString -String $s -AsPlainText -Force; [Console]::Out.Write((ConvertFrom-SecureString -SecureString $ss))";
146
+ const PS_UNPROTECT =
147
+ "$e=[Console]::In.ReadToEnd().Trim(); $ss=ConvertTo-SecureString -String $e; $b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss); " +
148
+ "try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($b)) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }";
149
+ const powershell = (exec, script, input) => exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input });
150
+
151
+ /** The DPAPI file's default place: `%APPDATA%\blocofy\secrets.dpapi` (user scope). */
152
+ export function dpapiPath(env = process.env) {
153
+ return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "blocofy", "secrets.dpapi");
154
+ }
155
+
156
+ /**
157
+ * Windows DPAPI adapter: the file maps `<context>:<kind>` to a DPAPI-protected blob (useless to another user or
158
+ * machine). An unreadable blob is an error (SECRET_STORE_FAILED), never "not logged in".
159
+ */
160
+ export function createDpapiSecretStore({ exec = defaultExec, platform = process.platform, filePath = dpapiPath() } = {}) {
161
+ const ensure = () => {
162
+ if (platform !== "win32") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The DPAPI secret store is only available on Windows.");
163
+ };
164
+ const readAll = () => {
165
+ let raw;
166
+ try {
167
+ raw = readFileSync(filePath, "utf8");
168
+ } catch (error) {
169
+ if (error?.code === "ENOENT") return {};
170
+ throw error;
171
+ }
172
+ try {
173
+ const data = JSON.parse(raw);
174
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
175
+ } catch {
176
+ /* fall through */
177
+ }
178
+ throw new SecretStoreError("CREDENTIALS_CORRUPT", `The protected secrets file is not valid JSON: ${filePath}. Nothing was written.`);
179
+ };
180
+ return {
181
+ name: "dpapi",
182
+ get(context, kind) {
183
+ ensure();
184
+ const blob = readAll()[storeAccount(context, kind)];
185
+ if (typeof blob !== "string" || !blob) return null;
186
+ const r = powershell(exec, PS_UNPROTECT, blob);
187
+ if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not read the secret from the Windows protected store (${filePath}); it was not used.`);
188
+ return r.stdout || null;
189
+ },
190
+ set(context, kind, value) {
191
+ ensure();
192
+ const r = powershell(exec, PS_PROTECT, String(value));
193
+ const blob = String(r.stdout ?? "").trim();
194
+ if (r.status !== 0 || r.error || !blob) throw new SecretStoreError("SECRET_STORE_FAILED", "Could not protect the secret with Windows DPAPI. Nothing was saved.");
195
+ const data = readAll();
196
+ data[storeAccount(context, kind)] = blob;
197
+ writeJsonAtomic(filePath, data);
198
+ },
199
+ remove(context, kind = null) {
200
+ ensure();
201
+ const data = readAll();
202
+ let changed = false;
203
+ for (const k of kind ? [kind] : Object.keys(FIELD)) {
204
+ if (storeAccount(context, k) in data) {
205
+ delete data[storeAccount(context, k)];
206
+ changed = true;
207
+ }
208
+ }
209
+ if (changed) writeJsonAtomic(filePath, data);
210
+ },
211
+ };
212
+ }
213
+
214
+ /** Linux Secret Service (libsecret) adapter through `secret-tool`; the secret goes in on stdin. */
215
+ export function createSecretServiceStore({ exec = defaultExec, platform = process.platform } = {}) {
216
+ const ensure = () => {
217
+ if (platform !== "linux") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The Secret Service store is only used on Linux.");
218
+ };
219
+ const attrs = (context, kind) => ["service", KEYCHAIN_SERVICE, "account", storeAccount(context, kind)];
220
+ return {
221
+ name: "secret-service",
222
+ get(context, kind) {
223
+ ensure();
224
+ const r = exec("secret-tool", ["lookup", ...attrs(context, kind)]);
225
+ if (r.error && r.error.code === "ENOENT") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "`secret-tool` (libsecret) is not installed; the saved login cannot be read.");
226
+ if (r.status !== 0) return null;
227
+ const v = String(r.stdout ?? "").replace(/\r?\n$/, "");
228
+ return v || null;
229
+ },
230
+ set(context, kind, value) {
231
+ ensure();
232
+ const r = exec("secret-tool", ["store", `--label=Blocofy CLI (${context})`, ...attrs(context, kind)], { input: String(value) });
233
+ if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not save the secret to the Secret Service (secret-tool exit ${r.status}). Nothing was saved.`);
234
+ },
235
+ remove(context, kind = null) {
236
+ ensure();
237
+ for (const k of kind ? [kind] : Object.keys(FIELD)) exec("secret-tool", ["clear", ...attrs(context, k)]);
238
+ },
239
+ };
240
+ }
241
+
128
242
  /** `store` name → adapter. Unknown names are a usage error. */
129
- export function secretStoreFor(name, { filePath, exec, platform } = {}) {
243
+ export function secretStoreFor(name, { filePath, exec, platform, dpapiPath: dpapiFile } = {}) {
130
244
  if (name === "file") return createFileSecretStore(filePath);
131
245
  if (name === "keychain") return createKeychainSecretStore({ exec, platform });
132
- throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use "file" or "keychain").`);
246
+ if (name === "dpapi") return createDpapiSecretStore({ exec, platform, ...(dpapiFile ? { filePath: dpapiFile } : {}) });
247
+ if (name === "secret-service") return createSecretServiceStore({ exec, platform });
248
+ throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use ${STORE_NAMES.map((n) => `"${n}"`).join(", ")}).`);
249
+ }
250
+
251
+ /** Where a store keeps secrets, in words (login output names it). */
252
+ export function storeLabel(name) {
253
+ switch (name) {
254
+ case "keychain":
255
+ return "macOS Keychain";
256
+ case "dpapi":
257
+ return "Windows DPAPI-protected file (OS protected file)";
258
+ case "secret-service":
259
+ return "Linux Secret Service (libsecret)";
260
+ case "file":
261
+ return "plain file ~/.blocofy/secrets.json (0600) — chosen explicitly";
262
+ default:
263
+ return String(name);
264
+ }
265
+ }
266
+
267
+ const OS_STORE = { darwin: "keychain", win32: "dpapi", linux: "secret-service" };
268
+
269
+ /** Is the Secret Service usable here? A DBus session and an installed `secret-tool`. */
270
+ function secretServiceAvailable(env, exec) {
271
+ if (!env.DBUS_SESSION_BUS_ADDRESS) return false;
272
+ const r = exec("secret-tool", ["--help"]);
273
+ return !(r.error && r.error.code === "ENOENT");
274
+ }
275
+
276
+ /**
277
+ * ADR-0014 §5.4 — the store for the browser login's tokens. `insecure` (`--insecure-storage`) or
278
+ * `BLOCOFY_SECRET_STORE=file` choose the 0600 file (`explicit: true`; the caller announces it); another
279
+ * `BLOCOFY_SECRET_STORE` value names an OS store, which must be this OS's. Otherwise this platform's OS store; none
280
+ * available → SECRET_STORE_UNAVAILABLE (never a silent plaintext downgrade).
281
+ */
282
+ export function oauthSecretStore({ platform = process.platform, env = process.env, insecure = false, exec = defaultExec } = {}) {
283
+ const unavailable = (why) =>
284
+ new SecretStoreError(
285
+ "SECRET_STORE_UNAVAILABLE",
286
+ `${why} The CLI login is never saved in a plain file automatically. To keep it in ~/.blocofy/secrets.json (0600) instead, choose that explicitly: blocofy login --insecure-storage (or BLOCOFY_SECRET_STORE=file). Nothing was saved.`,
287
+ );
288
+ const requested = env.BLOCOFY_SECRET_STORE;
289
+ if (insecure || requested === "file") return { name: "file", explicit: true };
290
+ if (requested !== undefined && requested !== "") {
291
+ if (!STORE_NAMES.includes(requested)) throw unavailable(`BLOCOFY_SECRET_STORE="${requested}" is not a known store (${STORE_NAMES.join(", ")}).`);
292
+ if (OS_STORE[platform] !== requested) throw unavailable(`The "${requested}" store is not available on this operating system (${platform}).`);
293
+ }
294
+ const name = OS_STORE[platform];
295
+ if (!name) throw unavailable(`No OS secure store is supported on this operating system (${platform}).`);
296
+ if (name === "secret-service" && !secretServiceAvailable(env, exec)) {
297
+ throw unavailable("No Secret Service is available (it needs a desktop DBus session and `secret-tool` from libsecret).");
298
+ }
299
+ return { name, explicit: false };
133
300
  }
package/lib/target.mjs CHANGED
@@ -3,7 +3,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync,
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  import { fetchWithRetry } from "./http.mjs";
6
- import { API_KEY_PREFIX } from "./media-uses.mjs";
6
+ import { API_KEY_PREFIX, isValidApiKey } from "./media-uses.mjs";
7
7
 
8
8
  /**
9
9
  * CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
@@ -108,7 +108,7 @@ function lstatOrNull(p) {
108
108
  * before the first write); each file is written to a fresh temp file in the same directory (`wx`, 0644) and
109
109
  * renamed into place. `staleLocal: true` (link) removes a `local.json` that an env-context binding does not own.
110
110
  */
111
- export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false }) {
111
+ export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false, profile = null, ignore = ["local.json"] }) {
112
112
  const d = join(root, PROJECT_DIR);
113
113
  const refuse = (path, what) => {
114
114
  throw new TargetError("TARGET_BINDING_INVALID", `${path} ${what}; the project binding was not written.`, { binding: path });
@@ -122,10 +122,12 @@ export function writeBinding(root, { site, platformOrigin, contextName, staleLoc
122
122
  if (st.isSymbolicLink() || !st.isDirectory()) refuse(d, "is not a real directory");
123
123
  }
124
124
  const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
125
+ // ADR-0014 §5.6 step 6 — additive: the profile the project was set up with (an older CLI ignores it; the server decides).
126
+ if (profile && typeof profile.id === "string") project.profile = { id: profile.id, version: profile.version ?? null };
125
127
  const writes = [["project.json", JSON.stringify(project, null, 2) + "\n"]];
126
128
  const ownsLocal = contextName && contextName !== ENV_CONTEXT;
127
129
  if (ownsLocal) writes.push(["local.json", JSON.stringify({ context: contextName }, null, 2) + "\n"]);
128
- writes.push([".gitignore", "local.json\n"]);
130
+ writes.push([".gitignore", ignore.join("\n") + "\n"]);
129
131
  for (const name of ["project.json", "local.json", ".gitignore"]) {
130
132
  const f = lstatOrNull(join(d, name));
131
133
  if (f && (f.isSymbolicLink() || !f.isFile())) refuse(join(d, name), "is a symlink or not a regular file");
@@ -352,7 +354,7 @@ export function assertCredentialTypes({ resolved, secrets }) {
352
354
  const refuse = (kind, secret) => {
353
355
  const env = Boolean(resolved.env);
354
356
  const where = kind === "dev" ? (env ? "BLOCOFY_TOKEN" : `The dev token of context "${resolved.name}"`) : env ? "BLOCOFY_API_KEY" : `The API key of context "${resolved.name}"`;
355
- const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
357
+ const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX) || secret.startsWith("bcf2_");
356
358
  const holds = kind === "dev" ? (other ? "holds a v1 API key (blcf_…)" : "is not a dev token") : other ? "holds a theme dev token (bcf_…)" : "is not a v1 API key";
357
359
  const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
358
360
  const fix = env
@@ -369,8 +371,10 @@ export function assertCredentialTypes({ resolved, secrets }) {
369
371
  1,
370
372
  );
371
373
  };
372
- if (resolved.context.dev && typeof secrets.devToken === "string" && !secrets.devToken.startsWith(DEV_TOKEN_PREFIX)) refuse("dev", secrets.devToken);
373
- if (resolved.context.api && typeof secrets.apiKey === "string" && !(secrets.apiKey.startsWith(API_KEY_PREFIX) && secrets.apiKey.length > API_KEY_PREFIX.length)) refuse("api", secrets.apiKey);
374
+ // A CLI login's context carries its own short-lived token in both places; the refresh layer vouches for it.
375
+ if (resolved.oauth) return;
376
+ if (resolved.context.dev && typeof secrets.devToken === "string" && !(secrets.devToken.startsWith(DEV_TOKEN_PREFIX) || secrets.devToken.startsWith("bcf2_"))) refuse("dev", secrets.devToken);
377
+ if (resolved.context.api && typeof secrets.apiKey === "string" && !isValidApiKey(secrets.apiKey)) refuse("api", secrets.apiKey);
374
378
  }
375
379
 
376
380
  // ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
@@ -386,7 +390,17 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
386
390
  return { error: `unreachable (${error?.name === "TimeoutError" ? "timeout" : "network error"})` };
387
391
  }
388
392
  const text = await res.text().catch(() => "");
389
- if (!res.ok) return { error: `HTTP ${res.status}` };
393
+ if (!res.ok) {
394
+ // ADR-0014: keep the server's refusal code (v1 `{error:{code}}`, renderer `{error, code}`) — e.g. audience_mismatch.
395
+ let code = null;
396
+ try {
397
+ const j = JSON.parse(text);
398
+ code = typeof j?.error?.code === "string" ? j.error.code : typeof j?.code === "string" ? j.code : null;
399
+ } catch {
400
+ code = null;
401
+ }
402
+ return { error: `HTTP ${res.status}${code ? ` ${code}` : ""}`, serverCode: code };
403
+ }
390
404
  try {
391
405
  return { body: JSON.parse(text) };
392
406
  } catch {
@@ -394,15 +408,15 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
394
408
  }
395
409
  }
396
410
 
397
- function unverified(what, url, reason) {
398
- return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason });
411
+ function unverified(what, url, reason, serverCode = null) {
412
+ return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason, ...(serverCode ? { server_code: serverCode } : {}) });
399
413
  }
400
414
 
401
415
  /** `GET {url}/api/dev/whoami` → `{ site, platformOrigin, liveThemeId }` or TARGET_UNVERIFIED. */
402
416
  export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retry = {} }) {
403
417
  const endpoint = `${url.replace(/\/+$/, "")}/api/dev/whoami`;
404
418
  const r = await getJson(fetchImpl, endpoint, token, retry);
405
- if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error);
419
+ if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error, r.serverCode);
406
420
  const site = r.body?.site;
407
421
  if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id)) || typeof site.slug !== "string") {
408
422
  throw unverified("/api/dev/whoami", endpoint, "malformed response (no site id/slug)");
@@ -414,7 +428,7 @@ export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retr
414
428
  export async function verifyApi({ url, apiKey, fetchImpl = globalThis.fetch, retry = {} }) {
415
429
  const endpoint = `${url.replace(/\/+$/, "")}/api/v1/ping`;
416
430
  const r = await getJson(fetchImpl, endpoint, apiKey, retry);
417
- if (r.error) throw unverified("/api/v1/ping", endpoint, r.error);
431
+ if (r.error) throw unverified("/api/v1/ping", endpoint, r.error, r.serverCode);
418
432
  const site = r.body?.site;
419
433
  if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id))) {
420
434
  throw unverified("/api/v1/ping", endpoint, "malformed response (no site id)");
@@ -50,9 +50,9 @@ async function errorText(res) {
50
50
 
51
51
  /** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
52
52
  async function responseError(res) {
53
+ // #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
54
+ const body = await res.clone().json().catch(() => null);
53
55
  if (res.status === 409) {
54
- // #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
55
- const body = await res.clone().json().catch(() => null);
56
56
  if (body?.error === DRAFT_TARGET_AMBIGUOUS) return withDraftTargetDetails(Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { status: 409, code: DRAFT_TARGET_AMBIGUOUS }), body);
57
57
  if (body?.error === PUBLISH_TARGET_UNCONFIRMED) {
58
58
  return Object.assign(new Error(PUBLISH_TARGET_UNCONFIRMED), {
@@ -67,8 +67,14 @@ async function responseError(res) {
67
67
  const detail = await errorText(res);
68
68
  const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
69
69
  err.status = res.status;
70
+ const retryAfter = res.headers.get("retry-after");
71
+ if (retryAfter) err.retryAfter = retryAfter;
70
72
  if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
73
+ // `{ error: "<sentence>", code }` (e.g. dev/publish `work_not_publishable`): the code sits beside the sentence.
74
+ else if (typeof body?.code === "string" && /^[a-z][a-z0-9_]*$/.test(body.code)) err.code = body.code;
71
75
  else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
76
+ // ADR-0014: a credential refusal's details (e.g. credential_reapproval_required's due date) for the CLI's words.
77
+ if (body && typeof body.details === "object" && body.details !== null) err.serverDetails = body.details;
72
78
  return err;
73
79
  }
74
80
 
@@ -89,6 +95,8 @@ async function httpError(res) {
89
95
  err.status = res.status;
90
96
  err.code = typeof body?.error === "string" ? body.error : null;
91
97
  err.body = body;
98
+ const retryAfter = res.headers.get("retry-after");
99
+ if (retryAfter) err.retryAfter = retryAfter;
92
100
  return withDraftTargetDetails(err, body);
93
101
  }
94
102
 
@@ -160,7 +168,7 @@ const isRestore = (c) => c.restore === true || SITE_STATE_DRAFT_NAME.test(c.name
160
168
  /**
161
169
  * #989 re-review P3 — one line for a `theme dev` draft-sync failure, in the push/pull wording: 503
162
170
  * `draft_target_unverifiable` (transient; the next save tries again) and 422 `draft_target_is_live` get their own
163
- * sentence, anything else its message. Never swallowed.
171
+ * sentence, the theme-capacity refusals (`themeCapacityRefusal`) theirs, anything else its message. Never swallowed.
164
172
  */
165
173
  export function draftSyncErrorLine(err) {
166
174
  if (err?.code === "draft_target_unverifiable") {
@@ -169,9 +177,151 @@ export function draftSyncErrorLine(err) {
169
177
  if (err?.code === "draft_target_is_live") {
170
178
  return "draft sync refused: that is the LIVE theme, and a draft sync never writes live. Nothing was written; pass a draft's handle with --instance (`blocofy status`).";
171
179
  }
180
+ const capacity = themeCapacityRefusal(err);
181
+ if (capacity) return `draft sync: ${capacity.short}`;
172
182
  return `draft sync failed: ${err?.message ?? String(err)}`;
173
183
  }
174
184
 
185
+ const trNumber = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
186
+ const isAmount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
187
+ const bytesLabel = (b) => (b >= 1024 ** 3 ? `${trNumber.format(b / 1024 ** 3)} GB` : `${trNumber.format(b / 1024 ** 2)} MB`);
188
+
189
+ /** The admission's typed meter in words (V2 storage bytes; V1 / other `used / limit`), or null. */
190
+ function quotaMeter(d) {
191
+ if (!d || typeof d !== "object") return null;
192
+ if (isAmount(d.usedBytes) && isAmount(d.limitBytes)) {
193
+ const need = isAmount(d.estimateBytes) ? `; yeni taslak yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : "";
194
+ return `Kullanılan depolama: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.limitBytes)}${need}.`;
195
+ }
196
+ if (isAmount(d.used) && isAmount(d.limit)) {
197
+ const what = d.resource === "theme_drafts" ? "Taslak tema sayısı" : typeof d.resource === "string" ? d.resource : "Kullanım";
198
+ return `${what}: ${d.used} / ${d.limit}.`;
199
+ }
200
+ return null;
201
+ }
202
+
203
+ const CAPACITY_CODES = new Set(["target_missing", "quota_exceeded", "capacity_unavailable", "resource_busy", "source_stale"]);
204
+ /** Transient admission refusals: every attempt answering one of these wrote nothing (a definite answer). */
205
+ export const CAPACITY_RETRYABLE = new Set(["capacity_unavailable", "resource_busy"]);
206
+
207
+ /**
208
+ * Theme-capacity program (ADR-0013) — the dev theme endpoint's refusals around the CLI draft, in words:
209
+ * - 404 `target_missing`: `pull --draft` found no CLI draft. A read never creates a theme; `push --draft` does.
210
+ * - 422 `quota_exceeded`, by `details.resource`:
211
+ * `theme_bytes` — the site's theme space (a technical allowance, not the plan) is full. Any theme write can meet
212
+ * it, a push to an existing draft included: no "new draft" wording, no plan upgrade; free space by deleting
213
+ * unused draft themes (cancelled works' themes included; cancel releases only a work's reserved publish space,
214
+ * archive frees no theme bytes), or contact support.
215
+ * `theme_drafts` (the legacy draft count of a site not on theme space) — not tied to the plan: remove a draft
216
+ * you no longer need; never a plan upgrade.
217
+ * `storage_mb` (the plan's storage) — the NEW draft was refused for the plan: the plan message.
218
+ * - 503 `capacity_unavailable` / `resource_busy`: transient (Retry-After), never the plan. lib/http.mjs has already
219
+ * resent the request within its retry policy; this is the last answer.
220
+ * - 409 `source_stale`: the live theme the new draft copies changed meanwhile; running the command again copies it anew.
221
+ * The platform wrote nothing for that answer. Returns `{ lines, short, message, details }`: `lines` the plain Turkish
222
+ * explanation (stderr, human mode; first line non-technical), `short` one line for `theme dev`, `message`/`details` the
223
+ * technical `error [code]` line and `--json` envelope. `null` for any other code (draft_target_ambiguous keeps its own).
224
+ * `pushCommand` is the user's push command line (its directory argument kept). A refusal of a RESENT push apply
225
+ * (`earlierAttempt`) says nothing about that earlier attempt, so it never claims "nothing was written" then.
226
+ */
227
+ export function themeCapacityRefusal(error, { pushCommand = "blocofy theme push" } = {}) {
228
+ const code = error?.code;
229
+ if (!CAPACITY_CODES.has(code)) return null;
230
+ const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
231
+ const done =
232
+ earlier === "committed"
233
+ ? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi işlendi (yanıtı öyle söyledi)."
234
+ : earlier === "unknown"
235
+ ? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi kesin yanıt alamadı, yazıp yazmadığı bilinmiyor."
236
+ : "Hiçbir şey yazılmadı.";
237
+ const doneEn =
238
+ earlier === "committed"
239
+ ? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
240
+ : earlier === "unknown"
241
+ ? "This attempt wrote nothing, but an earlier attempt of this push got no definite answer, so whether it wrote is unknown."
242
+ : "Nothing was written.";
243
+ const check = earlier ? [" Tekrar çalıştırmadan önce sitenin taslaklarını `blocofy status` ile kontrol et."] : [];
244
+ const checkEn = earlier ? " Check the site's drafts with `blocofy status` before running the push again." : "";
245
+ const extra = earlier ? { earlierAttempt: earlier } : {};
246
+
247
+ if (code === "target_missing") {
248
+ return {
249
+ lines: [
250
+ "Bu sitede henüz bir CLI taslağı yok; indirilecek taslak bulunamadı. Hiçbir dosya yazılmadı.",
251
+ ` Önce taslağı oluştur: ${pushCommand} --draft`,
252
+ " (Canlı temayı indirmek istiyorsan --draft olmadan çalıştır.)",
253
+ ],
254
+ short: "Bu sitede henüz bir CLI taslağı yok.",
255
+ message: `No CLI draft on this site yet (a read never creates one). Nothing was written. Create it first: ${pushCommand} --draft`,
256
+ details: {},
257
+ };
258
+ }
259
+ if (code === "quota_exceeded") {
260
+ const d = error?.body?.details && typeof error.body.details === "object" ? error.body.details : null;
261
+ if (d?.resource === "theme_bytes") {
262
+ // The site's theme space (a technical allowance, not the plan): any theme write can meet it — a push to an
263
+ // existing draft included — so no "new draft" wording and no plan upgrade; space is freed by removing drafts.
264
+ const head = "Sitenin tema alanı dolu; bu değişiklik için yer kalmadı.";
265
+ const meter = isAmount(d.usedBytes) && isAmount(d.allowanceBytes)
266
+ ? `Kullanılan tema alanı: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.allowanceBytes)}${isAmount(d.estimateBytes) ? `; bu değişiklik yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : ""}.`
267
+ : null;
268
+ return {
269
+ lines: [
270
+ `${head} ${done}`,
271
+ ...(meter ? [` ${meter}`] : []),
272
+ " Yer açmak için kullanmadığın taslak temaları (iptal edilmiş çalışmaların temaları dahil) tema kütüphanesinden sil (admin paneli → Tasarım); açık bir çalışmayı iptal etmek ayrıca onun için ayrılan yayın alanını bırakır.",
273
+ " Sorun sürerse destek ekibine yaz.",
274
+ ...check,
275
+ ],
276
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; yer açmak için kullanılmayan bir taslağı sil.`,
277
+ message: `The site's theme space is full; this change does not fit (quota_exceeded, theme_bytes). ${doneEn} Free space by deleting unused draft themes from the theme library (cancelled works' themes included); cancelling an open work also releases its reserved publish space. Or contact support.${checkEn}`,
278
+ details: { ...d, ...extra },
279
+ };
280
+ }
281
+ const meter = quotaMeter(d);
282
+ if (d?.resource === "theme_drafts") {
283
+ // The legacy draft count of a site not (yet) on theme space: not tied to the plan, so no upgrade advice.
284
+ const head = "Sitenin taslak tema sınırına ulaşıldı; yeni taslak oluşturulamadı.";
285
+ return {
286
+ lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Artık kullanmadığın bir taslak temayı sil (admin paneli → Tasarım).", ...check],
287
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; kullanmadığın bir taslağı sil.`,
288
+ message: `The site's draft limit refused the new draft this push needs (quota_exceeded, theme_drafts). ${doneEn} Remove a draft you no longer need.${checkEn}`,
289
+ details: { ...d, ...extra },
290
+ };
291
+ }
292
+ const head = `${d?.resource === "storage_mb" || isAmount(d?.usedBytes) ? "Depolama alanın dolu" : "Planının sınırına ulaşıldı"}; yeni taslak oluşturulamadı.`;
293
+ return {
294
+ lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Yeni taslak için planını yükseltebilirsin: admin paneli → Ayarlar → Plan & faturalandırma.", ...check],
295
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı.`,
296
+ message: `The plan's capacity refused the new draft this push needs (quota_exceeded). ${doneEn}${checkEn}`,
297
+ details: { ...(d ?? {}), ...extra },
298
+ };
299
+ }
300
+ if (code === "source_stale") {
301
+ const head = "Canlı tema bu sırada değişti; yeni taslak onun güncel hâlinden oluşturulamadı.";
302
+ return {
303
+ lines: [`${head} ${done}`, ` Komutu yeniden çalıştır; taslak canlının güncel hâlinden oluşturulur: ${pushCommand}`, ...check],
304
+ short: `${head} Hiçbir şey yazılmadı; bir sonraki kayıt yeniden dener.`,
305
+ message: `The live theme a new draft copies changed while it was prepared (source_stale). ${doneEn} Run the command again.${checkEn}`,
306
+ details: extra,
307
+ };
308
+ }
309
+ // capacity_unavailable / resource_busy: transient, already resent within the CLI's retry policy.
310
+ // Neither is about the plan or a full space, and either can answer a push to an existing draft: no "new draft" wording.
311
+ const head =
312
+ code === "resource_busy"
313
+ ? "Site şu anda meşgul (başka bir yazım sürüyor); işlem şimdilik yapılamadı."
314
+ : "Platform tema alanını şu an doğrulayamadı; bu geçici bir durum, işlem şimdilik yapılamadı.";
315
+ const raw = String(error?.retryAfter ?? "").trim();
316
+ const seconds = /^\d+$/.test(raw) ? Number(raw) : null;
317
+ return {
318
+ lines: [`${head} ${done}`, ` Biraz sonra tekrar dene${seconds ? ` (en az ${seconds} sn sonra)` : ""}: ${pushCommand}`, ...check],
319
+ short: `${head} Hiçbir şey yazılmadı. Biraz sonra tekrar dene; bir sonraki kayıt yeniden dener.`,
320
+ message: `${code === "resource_busy" ? "The site is busy with another write" : "The platform could not check the theme space right now (temporary)"}; the change was not applied (${code}). ${doneEn} Try again later.${checkEn}`,
321
+ details: { ...(seconds != null ? { retryAfterSeconds: seconds } : {}), ...extra },
322
+ };
323
+ }
324
+
175
325
  /** A local #989 refusal with the server's shape (used where the CLI selects the draft itself, e.g. `push --diff`). */
176
326
  function draftTargetAmbiguous(reason, candidates) {
177
327
  for (const c of candidates) c.restore = SITE_STATE_DRAFT_NAME.test(c.name ?? "");
@@ -527,6 +677,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
527
677
  // An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
528
678
  // refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
529
679
  let earlierAttempt = null;
680
+ // ADR-0013: a 503 capacity_unavailable / resource_busy is a definite "nothing was written" answer, not an unknown one.
681
+ let definiteNothing = false;
530
682
  const applyError = (err) => {
531
683
  Object.assign(err, { phase: "apply" });
532
684
  // target_changed names the current target; the one this push planned against is known only here.
@@ -538,11 +690,14 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
538
690
  try {
539
691
  res = await post({ ...target, ...bindings }, idempotencyKey, {
540
692
  onRetry: (info) => {
541
- earlierAttempt ??= "unknown";
693
+ if (!definiteNothing) earlierAttempt ??= "unknown";
694
+ definiteNothing = false;
542
695
  onRetry?.(info);
543
696
  },
544
697
  isFinal: async (attempt) => {
545
- if ((await attempt.json().catch(() => null))?.committed === true) earlierAttempt = "committed";
698
+ const answer = await attempt.json().catch(() => null);
699
+ if (answer?.committed === true) earlierAttempt = "committed";
700
+ definiteNothing = CAPACITY_RETRYABLE.has(answer?.error);
546
701
  return false;
547
702
  },
548
703
  });