@blocofy/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/args.mjs CHANGED
@@ -8,8 +8,9 @@
8
8
  * Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
9
9
  * gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
10
10
  * CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
11
+ * #925: `--publish` (translations import) da değersizdir.
11
12
  */
12
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force"]);
13
+ const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force", "publish"]);
13
14
 
14
15
  /**
15
16
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
package/lib/http.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  *
11
11
  * Callers only route requests here that are safe to replay: reads, upserts, pointer sets, and mutations carrying
12
12
  * an idempotency key. `onRetry({ attempt, retries, reason, waitMs })` fires before every wait (never silent).
13
+ * `isFinal(response)` (optional, given a clone) marks a retry-status answer that is a verdict, not a transient
14
+ * failure: it is returned at once instead of being resent.
13
15
  */
14
16
 
15
17
  export const RETRY_STATUSES = new Set([429, 502, 503, 504]);
@@ -37,7 +39,7 @@ export function parseRetryAfter(value, nowMs = Date.now()) {
37
39
  * `fetch` with the retry contract above. Options: `retries`, `backoff` (ms per retry index), `onRetry`,
38
40
  * `fetchImpl` (default: the global fetch at call time), `sleep(ms)`, `now()`, `timeoutMs` (per attempt).
39
41
  */
40
- export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs } = {}) {
42
+ export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs, isFinal } = {}) {
41
43
  const doFetch = fetchImpl ?? globalThis.fetch;
42
44
  for (let attempt = 0; ; attempt += 1) {
43
45
  const attemptInit = timeoutMs ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init;
@@ -52,6 +54,7 @@ export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRI
52
54
  continue;
53
55
  }
54
56
  if (!RETRY_STATUSES.has(res.status) || attempt >= retries) return res;
57
+ if (isFinal && (await isFinal(res.clone()))) return res;
55
58
  const waitMs = parseRetryAfter(res.headers.get("retry-after"), now()) ?? backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
56
59
  await res.arrayBuffer().catch(() => {}); // release the connection before resending
57
60
  onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}`, waitMs });
package/lib/output.mjs CHANGED
@@ -16,22 +16,41 @@ export function redact(text) {
16
16
  return out;
17
17
  }
18
18
 
19
- /** The target block as data (used for `--json` and for `blocofy target`). */
20
- export function targetData({ site, url, contextName, bindingLabel, operation }) {
19
+ const SOURCE_LABELS = {
20
+ env: "env credentials",
21
+ binding: "the one saved context for this project's site",
22
+ prompt: "picked at the prompt",
23
+ current_context: "default context (blocofy use)",
24
+ };
25
+
26
+ /**
27
+ * The target block as data (used for `--json` and for `blocofy target`). 1.8: it names the platform the site was
28
+ * verified on (`platform_origin`, the environment), where the context choice came from (`context_source`) and every
29
+ * stated choice an explicit `--context` overrode (`context_overrides`), plus the command and its mode.
30
+ */
31
+ export function targetData({ site, url, platformOrigin = null, contextName, contextSource = null, contextOverrides = [], bindingLabel, command, mode }) {
21
32
  return {
22
33
  site: { id: site.id, slug: site.slug ?? null, name: site.name ?? null, domain: site.domain ?? null },
23
34
  url: url ?? null,
35
+ platform_origin: platformOrigin ?? null,
24
36
  context: contextName,
37
+ context_source: contextSource,
38
+ context_overrides: contextOverrides.map((a) => ({ source: a.source, context: a.name })),
25
39
  binding: bindingLabel,
26
- operation,
40
+ command,
41
+ mode,
42
+ operation: `${command} · ${mode}`,
27
43
  };
28
44
  }
29
45
 
30
46
  export function formatTargetBlock(t) {
31
47
  const name = t.site.name ?? t.site.slug ?? String(t.site.id);
48
+ const overrides = (t.context_overrides ?? []).map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.context}`));
49
+ const source = t.context_source ? ` (from ${SOURCE_LABELS[t.context_source] ?? t.context_source}${overrides.length ? `; overrides ${overrides.join(", ")}` : ""})` : "";
32
50
  return [
33
51
  `Target: ${name} · ${t.site.id} · ${t.site.domain ?? t.url ?? "?"}`,
34
- `Context: ${t.context}`,
52
+ `Platform: ${t.platform_origin ?? "(not reported by the server)"}`,
53
+ `Context: ${t.context}${source}`,
35
54
  `Binding: ${t.binding}`,
36
55
  `Operation: ${t.operation}`,
37
56
  ].join("\n");
@@ -196,7 +196,17 @@ export function checkSiteStateTreePaths(paths) {
196
196
 
197
197
  export const ASSET_REF_PREFIX = "asset:sha256:";
198
198
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
199
- const PLATFORM_ASSET_RE = /\/(?:cdn|assets)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i;
199
+ /** Mirror of the platform's `SVG_ASSET_ID_RE` (media/media-ref.ts): a platform SVG asset id, stored lowercase only. */
200
+ const SVG_ASSET_ID_RE = /^bsvg_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
201
+ // Mirror of the platform's asset-ref.ts grammar (W1 C1 + a8e8666c), piece for piece: FULL-VALUE anchored — only a
202
+ // value that IS one platform asset URL matches; HTML, CSS url(...) or a sentence containing one is carried as is.
203
+ // Whitespace, ", <, > and \ appear nowhere; the host also excludes ' ( ); the path, query and fragment admit ( ) '
204
+ // (encodeURIComponent leaves them unescaped in a media-picker URL such as ".../logo%20(1).png").
205
+ const ASSET_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
206
+ const ASSET_HOST = String.raw`(?:(?:https?:)?\/\/[^\s\/?#"'<>()\\]+)?`;
207
+ const ASSET_TAIL = String.raw`(?:\/[^\s?#"<>\\]*)?(?:\?[^\s#"<>\\]*)?(?:#[^\s"<>\\]*)?`;
208
+ export const PLATFORM_ASSET_RE = new RegExp(String.raw`^${ASSET_HOST}\/(?:cdn|assets)\/(${ASSET_UUID})${ASSET_TAIL}$`, "i");
209
+ export const PLATFORM_SVG_RE = new RegExp(String.raw`^${ASSET_HOST}\/cdn\/svg\/(bsvg_[0-9abcdefghjkmnpqrstvwxyz]{26})${ASSET_TAIL}$`);
200
210
  export const PLATFORM_METADATA_KEY = "platform";
201
211
  export const MEDIA_DECISION_AUDIT_FIELDS = ["decided_by", "decided_at", "policy_version"];
202
212
 
@@ -208,14 +218,26 @@ export function parseAssetRef(value) {
208
218
  return SHA256_HEX.test(sha) ? sha : null;
209
219
  }
210
220
 
211
- /** Mirror of the platform's `normalizeAssetRef` (asset-ref.ts): a platform asset value → its raw uuid. */
221
+ /**
222
+ * Mirror of the platform's `parseMediaRef` (media/media-ref.ts): a bare stored reference → its canonical id (a
223
+ * Directus uuid, lowercased; a platform SVG id exactly as stored), or null.
224
+ */
225
+ function mediaRefId(value) {
226
+ if (typeof value !== "string") return null;
227
+ if (SVG_ASSET_ID_RE.test(value)) return value;
228
+ if (UUID_RE.test(value)) return value.toLowerCase();
229
+ return null;
230
+ }
231
+
232
+ /** Mirror of the platform's `normalizeAssetRef` (asset-ref.ts): a value that IS one platform asset reference → its id. */
212
233
  export function normalizeAssetRef(value) {
213
234
  if (typeof value !== "string") return value;
214
235
  const v = value.trim();
215
236
  if (!v) return value;
216
- if (UUID_RE.test(v)) return v.toLowerCase();
217
- const m = v.match(PLATFORM_ASSET_RE);
218
- if (m) return m[1].toLowerCase();
237
+ const bare = mediaRefId(v);
238
+ if (bare) return bare;
239
+ const m = v.match(PLATFORM_ASSET_RE) ?? v.match(PLATFORM_SVG_RE);
240
+ if (m) return mediaRefId(m[1]) ?? value;
219
241
  return value;
220
242
  }
221
243
 
@@ -236,12 +258,13 @@ function mapStrings(value, fn) {
236
258
 
237
259
  export function encodeAssetRefs(value, shaOf, report) {
238
260
  return mapStrings(value, (s) => {
239
- const ref = normalizeAssetRef(s);
240
- if (typeof ref !== "string" || !UUID_RE.test(ref)) return s;
241
- const uuid = ref.toLowerCase();
242
- const sha = shaOf(uuid);
261
+ // Full-value anchored (W1 C1): only a string that IS one reference is rewritten. A platform SVG id goes through
262
+ // the same lookup as a Directus uuid.
263
+ const ref = mediaRefId(normalizeAssetRef(s));
264
+ if (ref === null) return s;
265
+ const sha = shaOf(ref);
243
266
  if (sha === null) {
244
- report.foreign.add(uuid);
267
+ report.foreign.add(ref);
245
268
  return s;
246
269
  }
247
270
  report.used.add(sha);
package/lib/target.mjs CHANGED
@@ -3,6 +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
7
 
7
8
  /**
8
9
  * CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
@@ -244,18 +245,32 @@ function contextMatchesBinding(ctx, project) {
244
245
  return isObj(ctx?.site) && sameId(ctx.site.id, project.site_id) && compareOrigin(ctx.platform_origin, project.platform_origin) !== "mismatch";
245
246
  }
246
247
 
248
+ const authorityLabel = (a) => (a.source === "env" ? "env credentials (BLOCOFY_URL/BLOCOFY_TOKEN, BLOCOFY_API_URL/BLOCOFY_API_KEY)" : `${a.source}=${a.name}`);
249
+
247
250
  /**
248
251
  * Contract C2 order: --context → BLOCOFY_CONTEXT → full env pair → .blocofy/local.json → the unique context
249
252
  * matching the binding (site.id + platform_origin) → TTY pick among matching candidates → TARGET_CONTEXT_REQUIRED.
250
- * With a binding present `current_context` is ignored; without one it is used only by `read` commands.
253
+ *
254
+ * 1.8 closure (zero silent fallback): BLOCOFY_CONTEXT, the env pair and `.blocofy/local.json` are each a stated
255
+ * choice. When two of them name DIFFERENT contexts the command is refused before anything is read
256
+ * (TARGET_CONTEXT_CONFLICT, exit 3) — only an explicit `--context` settles it, and the result still lists what it
257
+ * overrode (`ignored`). With a binding present `current_context` is ignored; without one it is used only by the
258
+ * commands that opt in (`allowCurrentContext`: status, target, pages check — contract C2).
251
259
  *
252
260
  * `getStore()` is called only when a named context is needed (a corrupt file does not block the env context).
253
- * `prompt(candidates)` → name (TTY only). Returns `{ name, source, context, env? }`.
261
+ * `prompt(candidates)` → name (TTY only). Returns `{ name, source, context, ignored, env? }`; `ignored` lists the
262
+ * stated authorities (`{ source, name }`) that named another context.
254
263
  */
255
- export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null, commandClass, isTTY = false, prompt = null }) {
264
+ export async function resolveContext({ flagContext = null, envContextName = null, envCtx = null, getStore, binding = null, allowCurrentContext = false, isTTY = false, prompt = null }) {
265
+ const stated = [];
266
+ if (envContextName) stated.push({ source: "BLOCOFY_CONTEXT", name: envContextName });
267
+ if (envCtx) stated.push({ source: "env", name: ENV_CONTEXT });
268
+ if (binding?.localContext) stated.push({ source: ".blocofy/local.json", name: binding.localContext });
269
+
256
270
  const named = (name, source) => {
271
+ const ignored = stated.filter((a) => a.name !== name);
257
272
  if (name === ENV_CONTEXT) {
258
- if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx };
273
+ if (envCtx) return { name: ENV_CONTEXT, source, context: envCtx.context, env: envCtx, ignored };
259
274
  throw new TargetError("TARGET_CONTEXT_UNKNOWN", `Context "env" needs BLOCOFY_URL+BLOCOFY_TOKEN or BLOCOFY_API_URL+BLOCOFY_API_KEY.`, { context: name });
260
275
  }
261
276
  const store = getStore();
@@ -263,12 +278,18 @@ export async function resolveContext({ flagContext = null, envContextName = null
263
278
  if (!ctx) {
264
279
  throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}" (from ${source}). List them with \`blocofy contexts\`.`, { context: name, source });
265
280
  }
266
- return { name, source, context: ctx };
281
+ return { name, source, context: ctx, ignored };
267
282
  };
268
283
 
269
284
  if (flagContext) return named(flagContext, "--context");
270
- if (envContextName) return named(envContextName, "BLOCOFY_CONTEXT");
271
- if (envCtx) return { name: ENV_CONTEXT, source: "env", context: envCtx.context, env: envCtx };
285
+ if (new Set(stated.map((a) => a.name)).size > 1) {
286
+ throw new TargetError(
287
+ "TARGET_CONTEXT_CONFLICT",
288
+ `Conflicting context choices: ${stated.map(authorityLabel).join(" vs ")}. Nothing was read or written. Choose one explicitly with --context <name>, or unset the others.`,
289
+ { authorities: stated },
290
+ );
291
+ }
292
+ if (stated.length) return named(stated[0].name, stated[0].source);
272
293
 
273
294
  const store = getStore();
274
295
  const names = Object.keys(store.contexts);
@@ -277,12 +298,11 @@ export async function resolveContext({ flagContext = null, envContextName = null
277
298
  }
278
299
 
279
300
  if (binding) {
280
- if (binding.localContext) return named(binding.localContext, ".blocofy/local.json");
281
301
  const candidates = names.filter((n) => contextMatchesBinding(store.contexts[n], binding.project));
282
- if (candidates.length === 1) return { name: candidates[0], source: "binding", context: store.contexts[candidates[0]] };
302
+ if (candidates.length === 1) return { name: candidates[0], source: "binding", context: store.contexts[candidates[0]], ignored: [] };
283
303
  if (candidates.length > 1 && isTTY && prompt) {
284
304
  const picked = await prompt(candidates);
285
- if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked] };
305
+ if (candidates.includes(picked)) return { name: picked, source: "prompt", context: store.contexts[picked], ignored: [] };
286
306
  }
287
307
  throw new TargetError(
288
308
  "TARGET_CONTEXT_REQUIRED",
@@ -293,10 +313,14 @@ export async function resolveContext({ flagContext = null, envContextName = null
293
313
  );
294
314
  }
295
315
 
296
- if (commandClass === "read" && store.current_context && store.contexts[store.current_context]) {
297
- return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context] };
316
+ if (allowCurrentContext && store.current_context && store.contexts[store.current_context]) {
317
+ return { name: store.current_context, source: "current_context", context: store.contexts[store.current_context], ignored: [] };
298
318
  }
299
- throw new TargetError("TARGET_CONTEXT_REQUIRED", "Choose the site: pass --context <name> (see `blocofy contexts`) or set BLOCOFY_CONTEXT.", { contexts: names });
319
+ throw new TargetError(
320
+ "TARGET_CONTEXT_REQUIRED",
321
+ "This directory is not bound to a site, so the site must be chosen explicitly: pass --context <name> (see `blocofy contexts`), set BLOCOFY_CONTEXT, or bind the directory (blocofy link <dir> --context <name>).",
322
+ { contexts: names },
323
+ );
300
324
  }
301
325
 
302
326
  /** Offline check before any network: a context already recorded for another site cannot serve this binding. */
@@ -312,6 +336,40 @@ export function precheckContext({ binding, resolved }) {
312
336
  }
313
337
  }
314
338
 
339
+ /** The theme dev token's prefix (the v1 API key's is API_KEY_PREFIX, `blcf_live_`). */
340
+ const DEV_TOKEN_PREFIX = "bcf_";
341
+
342
+ /**
343
+ * 1.8: the dev token and the v1 API key are two separate credentials and stay two. A pair that holds the other
344
+ * type's secret is refused before any request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1) — it is never sent to the
345
+ * other endpoint, and never silently used as the other type. The message names where the secret came from, never
346
+ * the secret.
347
+ */
348
+ export function assertCredentialTypes({ resolved, secrets }) {
349
+ const refuse = (kind, secret) => {
350
+ const env = Boolean(resolved.env);
351
+ 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}"`;
352
+ const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
353
+ 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";
354
+ const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
355
+ const fix = env
356
+ ? kind === "dev"
357
+ ? "put the dev token in BLOCOFY_TOKEN and the API key in BLOCOFY_API_KEY (+ BLOCOFY_API_URL)"
358
+ : "put the API key in BLOCOFY_API_KEY and the dev token in BLOCOFY_TOKEN (+ BLOCOFY_URL)"
359
+ : kind === "dev"
360
+ ? `run \`blocofy login --context ${resolved.name}\``
361
+ : `run \`blocofy login --api-key --context ${resolved.name}\``;
362
+ throw new TargetError(
363
+ "TARGET_CREDENTIAL_WRONG_TYPE",
364
+ `${where} ${holds}; ${expected}. The dev token and the v1 API key are separate credentials: ${fix}. Nothing was sent.`,
365
+ { context: resolved.name, pair: kind },
366
+ 1,
367
+ );
368
+ };
369
+ if (resolved.context.dev && typeof secrets.devToken === "string" && !secrets.devToken.startsWith(DEV_TOKEN_PREFIX)) refuse("dev", secrets.devToken);
370
+ 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);
371
+ }
372
+
315
373
  // ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
316
374
 
317
375
  const IDENTITY_TIMEOUT_MS = 15000;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
2
3
 
3
4
  import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
@@ -210,6 +211,74 @@ export async function fetchDevSession({ url, token, name = null, onRetry = null
210
211
  return res.json();
211
212
  }
212
213
 
214
+ /**
215
+ * TPUSH-5 (6.5) — what a dry run binds the apply to. A server with the 6.5 contract answers a keyed dry run with the
216
+ * `manifestHash` of the files it checked and the `pointerVersion` its plan ran from (null: no deployment yet); the
217
+ * apply sends both back (`manifestHash`, `expectedPointerVersion`) so it is refused unless it writes exactly those
218
+ * bytes over exactly that state. An older server answers neither and gets the 0.10 body unchanged.
219
+ * The pointer version is per instance, so the apply also names the instance the plan ran against: a server that
220
+ * answers `targetInstance` (an opaque theme handle, or null for "a new draft") gets it back as
221
+ * `expectedTargetInstance` and refuses 409 `target_changed` when the apply would write anywhere else.
222
+ */
223
+ function pushBindings(plan) {
224
+ if (typeof plan?.manifestHash !== "string" || !/^[0-9a-f]{64}$/.test(plan.manifestHash)) return {};
225
+ const v = plan.pointerVersion;
226
+ const t = plan.targetInstance;
227
+ return {
228
+ manifestHash: plan.manifestHash,
229
+ ...(v === null || (Number.isInteger(v) && v >= 0) ? { expectedPointerVersion: v } : {}),
230
+ ...(t === null || (typeof t === "string" && t !== "") ? { expectedTargetInstance: t } : {}),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The paths a dry-run plan says the push would remove. `config/settings.json` is left out: a stored row there is the
236
+ * anomaly every canonical deploy cleans up (the settings themselves live in site_themes), not a theme file.
237
+ */
238
+ function plannedRemovals(plan) {
239
+ if (!Array.isArray(plan?.files)) return [];
240
+ return plan.files.filter((f) => f?.outcome === "removed" && typeof f.path === "string" && f.path !== "config/settings.json").map((f) => f.path);
241
+ }
242
+
243
+ async function isReadbackUnverified(res) {
244
+ try {
245
+ return (await res.json())?.error === "readback_unverified";
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The paths a push can send: the mirror of the server's gate — multi-segment paths under THEME_DIRS plus the one
253
+ * config exception (a slash-less remote key such as a bare `layout` would be refused 422 by the server).
254
+ */
255
+ function sendable(key) {
256
+ return (key.includes("/") && THEME_DIRS.has(key.split("/")[0] ?? "")) || key === "config/settings_schema.json";
257
+ }
258
+
259
+ /**
260
+ * THEME_PUSH_TARGET_CHANGED — the plan would remove files this push did not carry, and says only what is true of them.
261
+ * A path the push cannot send (outside the merge mirror, e.g. a bare `layout`) is never kept by a re-run, nor by a
262
+ * local file at that path: only --prune gets past it. That includes every path the merge probe returned, since a
263
+ * sendable one would have been merged. Any other path was either added while the push ran (a re-run reads and keeps
264
+ * it) or is a row the probe cannot list (it lists published rows only), which no re-run keeps either.
265
+ */
266
+ function targetChangedError(paths, remote) {
267
+ const notCarryable = paths.filter((p) => Object.hasOwn(remote, p) || !sendable(p));
268
+ const unseen = paths.filter((p) => !notCarryable.includes(p));
269
+ const parts = [`The dry run would remove ${paths.length} file(s) this push does not have locally (${paths.join(", ")}). Nothing was written.`];
270
+ if (notCarryable.length) {
271
+ parts.push(`${notCarryable.join(", ")}: the push cannot carry these paths, so running it again will not keep them; it can go ahead only with --prune, which removes them.`);
272
+ }
273
+ if (unseen.length) {
274
+ parts.push(
275
+ `${unseen.join(", ")}: not on the target when this push read it. If they were added while this push was running, run the push again and it keeps them. ` +
276
+ "If the same files stop the push again, the push cannot read them (for example a theme file that is not published) and cannot keep them: pass --prune to remove them, or add a local file at that path to replace it.",
277
+ );
278
+ }
279
+ return Object.assign(new Error(parts.join(" ")), { code: "THEME_PUSH_TARGET_CHANGED", details: { paths, notCarryable } });
280
+ }
281
+
213
282
  /**
214
283
  * Write the local theme to the site (create/update; no delete). With `draft`,
215
284
  * writes to a draft theme instance instead of the live theme — preview & publish
@@ -220,78 +289,109 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
220
289
  const files = readLocalTemplates(dir);
221
290
  const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
222
291
  const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
223
- const remoteOnlyKept = [];
224
- const remoteOnlyRemoved = [];
225
- if (idempotencyKey && !dryRun) {
226
- // 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
227
- // 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
228
- // provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
229
- // arkada instance + sayfa klonu bırakıyordu.
230
- // 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
231
- // dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
232
- // retained-rows kuralı korur. `--prune` bu dosyaları EKLEMEZ (kanonik deploy klasörleri
233
- // atomik değiştirir → silinirler) ve yazımdan ÖNCE `confirmPrune(liste)`'ye bildirir.
234
- // PS-09: taslak hedefte taslak-bayraklı GET KULLANILMAZ — sunucuda taslak provizyonlar (yan
235
- // etki; yarıda kalırsa boş taslak bırakırdı). Mevcut CLI taslağı `/api/dev/site`'tan
236
- // (`source === "import"`, sunucunun ensureDraftInstance seçimiyle aynı: id sıralı ilk) bulunup
237
- // `?instance=` ile yoklanır; yoksa yoklanacak uzak dosya da yoktur → probe atlanır.
238
- // 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
239
- const pre = await fetchWithRetry(
292
+ const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
293
+ const post = (body, key, retry = {}) =>
294
+ fetchWithRetry(
240
295
  `${base}/api/dev/theme`,
241
- { method: "POST", headers: postHeaders({ "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
242
- { onRetry },
296
+ { method: "POST", headers: postHeaders(key ? { "x-idempotency-key": key } : {}), body: JSON.stringify(body) },
297
+ { onRetry, ...retry },
243
298
  );
244
- if (!pre.ok) throw await httpError(pre);
245
299
 
246
- let probeInstance = instance;
247
- if (!instance && draft) {
248
- const status = await fetchSiteStatus({ url, token, onRetry });
249
- probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
250
- }
251
- let remote = {};
252
- if (probeInstance || !draft) {
253
- const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
254
- const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
255
- if (!probe.ok) throw await httpError(probe);
256
- remote = (await probe.json()).files ?? {};
257
- }
258
- for (const [key, content] of Object.entries(remote)) {
259
- if (key in files) continue;
260
- const top = key.split("/")[0] ?? "";
261
- // Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
262
- // config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
263
- if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
264
- if (prune) {
265
- remoteOnlyRemoved.push(key);
266
- } else {
267
- files[key] = typeof content === "string" ? content : "";
268
- remoteOnlyKept.push(key);
269
- }
300
+ // Keyless (theme dev's draft sync): one POST to the per-file writer — no probe, no plan (deliberate legacy path).
301
+ if (!idempotencyKey) {
302
+ const res = await post(dryRun ? { ...target, dryRun: true } : target, null);
303
+ if (!res.ok) throw await httpError(res);
304
+ return res.json();
305
+ }
306
+
307
+ // Keyed canonical push (and its --dry-run), TPUSH-5: ONE manifest for the dry run and the apply.
308
+ // 1) MERGE PROBE GET — "push does not delete": remote files missing locally that the server's gate accepts are
309
+ // added to the payload verbatim; rows outside that set (README.md…) are kept by the server's retained-rows
310
+ // rule. `--prune` leaves them out instead (the canonical deploy replaces the folders, so they are removed).
311
+ // PS-09: a draft target is never probed with a draft-flagged GET (that provisions a draft server-side). The
312
+ // existing CLI draft is found via `/api/dev/site` (`source === "import"`, the server's own pick: the first by
313
+ // id) and probed by handle; with no draft there is nothing remote to merge, so there is no probe.
314
+ // 2) DRY RUN of that MERGED payload. On a 6.5 server this is the control plane's rolled-back plan of the very
315
+ // operation the apply runs: refused by what the apply is refused by, with per-file outcomes, the manifest hash
316
+ // and the pointer version it ran from. It carries its OWN throwaway key: the control plane fingerprints the
317
+ // action, so a plan under a key an earlier deploy already spent is refused 409 — a same-key retry of a
318
+ // committed push could never converge. A plan removing a file the probe did not see means the target changed
319
+ // under this push; without --prune that is refused (push does not delete), with --prune it is confirmed.
320
+ // 3) APPLY of the same payload under the push key, bound by `manifestHash` + `expectedPointerVersion`.
321
+ // theme dev's sync sends no key and pays none of this.
322
+ const remoteOnlyKept = [];
323
+ const remoteOnlyRemoved = [];
324
+ let probeInstance = instance;
325
+ if (!instance && draft) {
326
+ const status = await fetchSiteStatus({ url, token, onRetry });
327
+ probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
328
+ }
329
+ let remote = {};
330
+ if (probeInstance || !draft) {
331
+ const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
332
+ const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
333
+ if (!probe.ok) throw await httpError(probe);
334
+ remote = (await probe.json()).files ?? {};
335
+ }
336
+ for (const [key, content] of Object.entries(remote)) {
337
+ if (key in files) continue;
338
+ if (sendable(key)) {
339
+ if (prune) {
340
+ remoteOnlyRemoved.push(key);
341
+ } else {
342
+ files[key] = typeof content === "string" ? content : "";
343
+ remoteOnlyKept.push(key);
270
344
  }
271
345
  }
272
- remoteOnlyKept.sort();
273
- remoteOnlyRemoved.sort();
274
- if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
275
- return { aborted: true, remoteOnlyRemoved };
276
- }
277
346
  }
278
- const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
279
- // dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
280
- const payload = dryRun ? { ...target, dryRun: true } : target;
281
- const headers = postHeaders();
282
- if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
283
- const res = await fetchWithRetry(
284
- `${base}/api/dev/theme`,
285
- { method: "POST", headers, body: JSON.stringify(payload) },
286
- { onRetry },
287
- );
288
- if (!res.ok) throw await httpError(res);
289
- const json = await res.json();
290
- return {
291
- ...json,
347
+
348
+ // A plan's 502 readback_unverified is a verdict on a rolled-back plan, not a transient failure: not resent.
349
+ const pre = await post({ ...target, dryRun: true }, `cli-plan-${randomUUID()}`, { isFinal: isReadbackUnverified });
350
+ if (!pre.ok) throw Object.assign(await httpError(pre), { phase: "plan" });
351
+ const plan = await pre.json();
352
+ const unexpected = plannedRemovals(plan).filter((p) => !remoteOnlyRemoved.includes(p)).sort();
353
+ if (unexpected.length && !prune) throw targetChangedError(unexpected, remote);
354
+ remoteOnlyRemoved.push(...unexpected);
355
+ remoteOnlyKept.sort();
356
+ remoteOnlyRemoved.sort();
357
+ const extras = {
292
358
  ...(remoteOnlyKept.length ? { remoteOnlyKept } : {}),
293
359
  ...(remoteOnlyRemoved.length ? { remoteOnlyRemoved } : {}),
294
360
  };
361
+ if (dryRun) return { ...plan, ...extras };
362
+ if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
363
+ return { aborted: true, remoteOnlyRemoved };
364
+ }
365
+
366
+ const bindings = pushBindings(plan);
367
+ // An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
368
+ // refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
369
+ let earlierAttempt = null;
370
+ const applyError = (err) => {
371
+ Object.assign(err, { phase: "apply" });
372
+ // target_changed names the current target; the one this push planned against is known only here.
373
+ if ("expectedTargetInstance" in bindings) err.expectedTargetInstance = bindings.expectedTargetInstance;
374
+ if (earlierAttempt) err.earlierAttempt = earlierAttempt;
375
+ return err;
376
+ };
377
+ let res;
378
+ try {
379
+ res = await post({ ...target, ...bindings }, idempotencyKey, {
380
+ onRetry: (info) => {
381
+ earlierAttempt ??= "unknown";
382
+ onRetry?.(info);
383
+ },
384
+ isFinal: async (attempt) => {
385
+ if ((await attempt.json().catch(() => null))?.committed === true) earlierAttempt = "committed";
386
+ return false;
387
+ },
388
+ });
389
+ } catch (error) {
390
+ // No answer to the last attempt either (network error, timeout): the write's outcome is unknown.
391
+ throw applyError(error);
392
+ }
393
+ if (!res.ok) throw applyError(await httpError(res));
394
+ return { ...(await res.json()), ...extras };
295
395
  }
296
396
 
297
397
  /**