@blocofy/cli 0.9.0 → 0.11.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,12 +1,18 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
3
3
 
4
4
  import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
5
+ import { fetchWithRetry } from "./http.mjs";
6
+ import { PagesCliError, stagedWrite } from "./page-files.mjs";
5
7
 
6
8
  /**
7
9
  * `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
8
10
  * (Bearer token). pull = GET → write to disk (re-adding `.liquid`); push =
9
11
  * readLocalTemplates → POST (create/update; no delete).
12
+ *
13
+ * CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs — 429/502/503/504 + network errors, Retry-After).
14
+ * All are safe to resend: reads, a keyed (or naturally idempotent upsert) theme POST, publish (sets a pointer),
15
+ * rename (sets a label). `onRetry` surfaces each retry on stderr.
10
16
  */
11
17
 
12
18
  /**
@@ -18,6 +24,13 @@ import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs"
18
24
  const CANONICAL_PROTOCOL = "1";
19
25
  const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
20
26
 
27
+ /**
28
+ * CF-T5 review I1 — OPTIONAL capabilities (never part of the required fence above). `theme-locales`: this CLI sends the
29
+ * workspace's `locales/*` files, so a canonical deploy may replace `locales/` like any other theme folder (a server
30
+ * that sees no declaration keeps stored locale rows). Sent on every theme POST; GETs don't need it.
31
+ */
32
+ const OPTIONAL_CAPABILITIES = "theme-locales";
33
+
21
34
  function canonicalHeaders(extra = {}) {
22
35
  return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
23
36
  }
@@ -35,6 +48,16 @@ async function errorText(res) {
35
48
  return text || `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
36
49
  }
37
50
 
51
+ /** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
52
+ async function responseError(res) {
53
+ const detail = await errorText(res);
54
+ const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
55
+ err.status = res.status;
56
+ if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
57
+ else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
58
+ return err;
59
+ }
60
+
38
61
  /**
39
62
  * Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
40
63
  * `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
@@ -61,57 +84,57 @@ async function httpError(res) {
61
84
  * alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
62
85
  * yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
63
86
  */
64
- export async function fetchCanonicalSupport({ url, token }) {
87
+ export async function fetchCanonicalSupport({ url, token, onRetry = null }) {
65
88
  const base = url.replace(/\/+$/, "");
66
- const res = await fetch(`${base}/api/dev/theme`, {
89
+ const res = await fetchWithRetry(`${base}/api/dev/theme`, {
67
90
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
68
- });
91
+ }, { onRetry });
69
92
  if (!res.ok) throw await httpError(res);
70
93
  const body = await res.json();
71
94
  return { supported: body?.protocol === 1 };
72
95
  }
73
96
 
74
- /** Backoff delays (ms) between attempts — index 0 = after the first failure. */
75
- const RETRY_BACKOFF_MS = [300, 900];
76
-
77
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
78
-
79
97
  /**
80
- * fetch wrapper that retries ONLY on transient failures: a thrown fetch (network
81
- * error / connection reset) or a 5xx / 429 response. Permanent 4xx (401/403/422 …)
82
- * fail immediately — no retry. Max 2 retries with a short backoff (300ms, 900ms).
83
- *
84
- * Safe because every endpoint here is idempotent: theme push is a file upsert
85
- * (create/update, no delete), pull/whoami/session/site are reads, publish sets a
86
- * pointer. Re-sending the same request cannot double-apply anything.
98
+ * CF-T1/T2 review I1 — a pulled path is accepted only if `readLocalTemplates` would read it back: a top-level theme dir
99
+ * (exact case) with a file below it, or `config/settings_schema.json`. Any `.blocofy`/staging segment (compared
100
+ * case-insensitively: APFS/NTFS fold case, so `.BLOCOFY/project.json` IS the binding) and any dot-directory (`.git`…)
101
+ * is refused. Returns a reason string, or null when the path is acceptable.
102
+ */
103
+ /**
104
+ * The flat root files a theme actually HAS. The platform's starter themes ship exactly these two, so exactly
105
+ * these two come down.
87
106
  *
88
- * `onRetry({ attempt, retries, reason })` is called before each wait so callers can
89
- * surface a "Network error, retrying…" line (not silent).
107
+ * This is an allowlist on purpose. It was briefly a denylist of tooling filenames, and that inverted the rule
108
+ * the rest of this function is built on: anything the server named that was not a lockfile - `CLAUDE.md`,
109
+ * `AGENTS.md`, `next.config.js` - was written straight into the developer's project root by an ordinary pull.
110
+ * A theme download must never be able to place a file the developer's own tooling then trusts. If a theme
111
+ * legitimately grows another root file, this set is where it is added, deliberately.
90
112
  */
91
- export async function fetchWithRetry(input, init, { retries = 2, onRetry, backoff = RETRY_BACKOFF_MS } = {}) {
92
- let lastErr;
93
- for (let attempt = 0; attempt <= retries; attempt += 1) {
94
- let res;
95
- try {
96
- res = await fetch(input, init);
97
- } catch (err) {
98
- // Thrown fetch = network-level failure (DNS, reset, "fetch failed"). Transient.
99
- lastErr = err;
100
- if (attempt === retries) throw err;
101
- onRetry?.({ attempt: attempt + 1, retries, reason: err?.message ?? String(err) });
102
- await sleep(backoff[attempt] ?? 0);
103
- continue;
104
- }
105
- // 5xx / 429 = transient server-side; retry. Other statuses (incl. 4xx) return as-is.
106
- if ((res.status >= 500 || res.status === 429) && attempt < retries) {
107
- onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}` });
108
- await sleep(backoff[attempt] ?? 0);
109
- continue;
110
- }
111
- return res;
113
+ const ROOT_FILES_ALLOWED = new Set(["readme.md", "blueprint.json"]);
114
+
115
+ function pullPathProblem(rel) {
116
+ const segs = rel.split("/");
117
+ if (segs.some((seg) => { const l = seg.toLowerCase(); return l === ".blocofy" || l.startsWith(".blocofy-staging-"); })) return "reserved CLI path";
118
+ if (segs.slice(0, -1).some((seg) => seg.startsWith("."))) return "hidden directory";
119
+ // `config/` — the theme's own configuration rows. `settings_schema.json` is the one a push sends back; the
120
+ // platform serves the others (a starter theme ships `config/theme.json`) and a pull that refused them
121
+ // refused the WHOLE download, so a freshly provisioned site could not be pulled at all. They are written
122
+ // read-only: the push gate accepts only `settings_schema.json` under `config/`, and `pages`/`settings`
123
+ // have their own commands. One flat level, no nesting.
124
+ if (segs[0] === "config") {
125
+ if (segs.length !== 2) return "config files are one level deep";
126
+ // `settings pull` owns this name; a theme row must not shadow the file that command writes.
127
+ return segs[1] === "settings.json" ? "config/settings.json belongs to `settings pull`" : null;
128
+ }
129
+ if (segs.length === 1) {
130
+ // A FLAT FILE AT THE THEME ROOT. The platform stores rows a push does not send (the starter themes ship
131
+ // `README.md` and `blueprint.json`) and serves them on pull; refusing them refused the whole download.
132
+ // Case-folded, because APFS and NTFS fold case: `Readme.MD` and `README.md` are one file on disk.
133
+ if (segs[0].startsWith(".")) return "hidden file";
134
+ return ROOT_FILES_ALLOWED.has(segs[0].toLowerCase()) ? null : "not a theme file at the theme root";
112
135
  }
113
- // Unreachable (loop returns or throws), but keeps the type honest.
114
- throw lastErr;
136
+ if (!THEME_DIRS.has(segs[0])) return "not a theme file (would not be pushed back)";
137
+ return null;
115
138
  }
116
139
 
117
140
  /**
@@ -119,22 +142,32 @@ export async function fetchWithRetry(input, init, { retries = 2, onRetry, backof
119
142
  * With `draft`, pulls the "CLI Draft" instance (what `theme dev` syncs into)
120
143
  * instead of the live theme — symmetric with `push --draft`.
121
144
  */
122
- export async function pullTheme({ dir, url, token, draft = false, instance = null }) {
145
+ export async function pullTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
123
146
  const base = url.replace(/\/+$/, "");
124
147
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
125
- const res = await fetch(`${base}/api/dev/theme${query}`, {
148
+ const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
126
149
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
127
- });
128
- if (!res.ok) throw new Error(await errorText(res));
150
+ }, { onRetry });
151
+ if (!res.ok) throw await responseError(res);
129
152
  const { files } = await res.json();
130
- let count = 0;
131
- for (const [key, content] of Object.entries(files ?? {})) {
132
- const full = join(dir, localPathFor(key));
133
- mkdirSync(dirname(full), { recursive: true });
134
- writeFileSync(full, typeof content === "string" ? content : "");
135
- count += 1;
153
+ // CF-T2: staged + contained (all-or-nothing). Every path is validated before the first byte is written; a
154
+ // server path that escapes the directory, targets the CLI's own `.blocofy/` binding, or would not be read back by
155
+ // the next push refuses the whole pull.
156
+ const entries = Object.entries(files ?? {}).map(([key, content]) => [localPathFor(key), content]);
157
+ const rejected = entries.map(([rel]) => [rel, pullPathProblem(rel)]).filter(([, problem]) => problem);
158
+ if (rejected.length) {
159
+ throw new PagesCliError("PAGES_PATH_ESCAPE", "The server sent a path this CLI does not write; nothing was written.", {
160
+ diagnostics: rejected.map(([rel, problem]) => ({ level: "error", code: "PAGES_PATH_ESCAPE", message: problem, path: rel })),
161
+ });
162
+ }
163
+ const created = !existsSync(dir);
164
+ mkdirSync(dir, { recursive: true });
165
+ try {
166
+ return { count: stagedWrite(realpathSync(dir), entries) };
167
+ } catch (error) {
168
+ if (created) rmSync(dir, { recursive: true, force: true }); // no empty directory left behind
169
+ throw error;
136
170
  }
137
- return { count };
138
171
  }
139
172
 
140
173
  /**
@@ -143,12 +176,12 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
143
176
  * öncesi (hedef tenant'ı yaz) çağırır; yanlış-tenant'a yazımı görünür kılar.
144
177
  * `{ site: { id, slug, name }, liveThemeId }`.
145
178
  */
146
- export async function fetchWhoami({ url, token }) {
179
+ export async function fetchWhoami({ url, token, onRetry = null }) {
147
180
  const base = url.replace(/\/+$/, "");
148
- const res = await fetch(`${base}/api/dev/whoami`, {
181
+ const res = await fetchWithRetry(`${base}/api/dev/whoami`, {
149
182
  headers: { authorization: `Bearer ${token}` },
150
- });
151
- if (!res.ok) throw new Error(await errorText(res));
183
+ }, { onRetry });
184
+ if (!res.ok) throw await responseError(res);
152
185
  return res.json();
153
186
  }
154
187
 
@@ -156,12 +189,12 @@ export async function fetchWhoami({ url, token }) {
156
189
  * Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
157
190
  * 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
158
191
  */
159
- export async function fetchDevSession({ url, token, name = null }) {
192
+ export async function fetchDevSession({ url, token, name = null, onRetry = null }) {
160
193
  const base = url.replace(/\/+$/, "");
161
194
  const query = name ? `?name=${encodeURIComponent(name)}` : "";
162
- const res = await fetch(`${base}/api/dev/session${query}`, {
195
+ const res = await fetchWithRetry(`${base}/api/dev/session${query}`, {
163
196
  headers: { authorization: `Bearer ${token}` },
164
- });
197
+ }, { onRetry });
165
198
  if (!res.ok) {
166
199
  const detail = await errorText(res);
167
200
  // Yeni sunucular 410'da açıklayıcı bir gövde döndürür — onu olduğu gibi göster, o otoritedir.
@@ -173,11 +206,79 @@ export async function fetchDevSession({ url, token, name = null }) {
173
206
  "(HTTP 410). Draft sync and `theme publish` are unaffected — they use different endpoints.",
174
207
  );
175
208
  }
176
- throw new Error(detail);
209
+ throw Object.assign(new Error(detail), { status: res.status });
177
210
  }
178
211
  return res.json();
179
212
  }
180
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
+
181
282
  /**
182
283
  * Write the local theme to the site (create/update; no delete). With `draft`,
183
284
  * writes to a draft theme instance instead of the live theme — preview & publish
@@ -187,78 +288,110 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
187
288
  const base = url.replace(/\/+$/, "");
188
289
  const files = readLocalTemplates(dir);
189
290
  const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
190
- const remoteOnlyKept = [];
191
- const remoteOnlyRemoved = [];
192
- if (idempotencyKey && !dryRun) {
193
- // 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
194
- // 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
195
- // provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
196
- // arkada instance + sayfa klonu bırakıyordu.
197
- // 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
198
- // dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
199
- // retained-rows kuralı korur. `--prune` bu dosyaları EKLEMEZ (kanonik deploy klasörleri
200
- // atomik değiştirir → silinirler) ve yazımdan ÖNCE `confirmPrune(liste)`'ye bildirir.
201
- // PS-09: taslak hedefte taslak-bayraklı GET KULLANILMAZ — sunucuda taslak provizyonlar (yan
202
- // etki; yarıda kalırsa boş taslak bırakırdı). Mevcut CLI taslağı `/api/dev/site`'tan
203
- // (`source === "import"`, sunucunun ensureDraftInstance seçimiyle aynı: id sıralı ilk) bulunup
204
- // `?instance=` ile yoklanır; yoksa yoklanacak uzak dosya da yoktur → probe atlanır.
205
- // 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
206
- const pre = await fetchWithRetry(
291
+ const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
292
+ const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
293
+ const post = (body, key, retry = {}) =>
294
+ fetchWithRetry(
207
295
  `${base}/api/dev/theme`,
208
- { method: "POST", headers: headersFor({ "content-type": "application/json", "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
209
- { onRetry },
296
+ { method: "POST", headers: postHeaders(key ? { "x-idempotency-key": key } : {}), body: JSON.stringify(body) },
297
+ { onRetry, ...retry },
210
298
  );
211
- if (!pre.ok) throw await httpError(pre);
212
299
 
213
- let probeInstance = instance;
214
- if (!instance && draft) {
215
- const status = await fetchSiteStatus({ url, token });
216
- probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
217
- }
218
- let remote = {};
219
- if (probeInstance || !draft) {
220
- const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
221
- const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
222
- if (!probe.ok) throw await httpError(probe);
223
- remote = (await probe.json()).files ?? {};
224
- }
225
- for (const [key, content] of Object.entries(remote)) {
226
- if (key in files) continue;
227
- const top = key.split("/")[0] ?? "";
228
- // Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
229
- // config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
230
- if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
231
- if (prune) {
232
- remoteOnlyRemoved.push(key);
233
- } else {
234
- files[key] = typeof content === "string" ? content : "";
235
- remoteOnlyKept.push(key);
236
- }
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);
237
344
  }
238
345
  }
239
- remoteOnlyKept.sort();
240
- remoteOnlyRemoved.sort();
241
- if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
242
- return { aborted: true, remoteOnlyRemoved };
243
- }
244
346
  }
245
- const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
246
- // dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
247
- const payload = dryRun ? { ...target, dryRun: true } : target;
248
- const headers = canonicalHeaders({ "content-type": "application/json", authorization: `Bearer ${token}` });
249
- if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
250
- const res = await fetchWithRetry(
251
- `${base}/api/dev/theme`,
252
- { method: "POST", headers, body: JSON.stringify(payload) },
253
- { onRetry },
254
- );
255
- if (!res.ok) throw await httpError(res);
256
- const json = await res.json();
257
- return {
258
- ...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 = {
259
358
  ...(remoteOnlyKept.length ? { remoteOnlyKept } : {}),
260
359
  ...(remoteOnlyRemoved.length ? { remoteOnlyRemoved } : {}),
261
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 };
262
395
  }
263
396
 
264
397
  /**
@@ -267,13 +400,13 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
267
400
  * (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
268
401
  * çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
269
402
  */
270
- export async function diffTheme({ dir, url, token, instance = null }) {
403
+ export async function diffTheme({ dir, url, token, instance = null, onRetry = null }) {
271
404
  const base = url.replace(/\/+$/, "");
272
405
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
273
- const res = await fetch(`${base}/api/dev/theme${query}`, {
406
+ const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
274
407
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
275
- });
276
- if (!res.ok) throw new Error(await errorText(res));
408
+ }, { onRetry });
409
+ if (!res.ok) throw await responseError(res);
277
410
  const { files: remote = {} } = await res.json();
278
411
  const local = readLocalTemplates(dir);
279
412
  const added = [];
@@ -291,14 +424,14 @@ export async function diffTheme({ dir, url, token, instance = null }) {
291
424
  * içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
292
425
  * yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
293
426
  */
294
- export async function publishInstance({ url, token, instanceId }) {
427
+ export async function publishInstance({ url, token, instanceId, onRetry = null }) {
295
428
  const base = url.replace(/\/+$/, "");
296
- const res = await fetch(`${base}/api/dev/publish`, {
429
+ const res = await fetchWithRetry(`${base}/api/dev/publish`, {
297
430
  method: "POST",
298
431
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
299
432
  body: JSON.stringify({ instanceId }),
300
- });
301
- if (!res.ok) throw new Error(await errorText(res));
433
+ }, { onRetry });
434
+ if (!res.ok) throw await responseError(res);
302
435
  return res.json();
303
436
  }
304
437
 
@@ -307,14 +440,14 @@ export async function publishInstance({ url, token, instanceId }) {
307
440
  * bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
308
441
  * `{ ok, id, name }` döner.
309
442
  */
310
- export async function renameInstance({ url, token, instance, name }) {
443
+ export async function renameInstance({ url, token, instance, name, onRetry = null }) {
311
444
  const base = url.replace(/\/+$/, "");
312
- const res = await fetch(`${base}/api/dev/theme/rename`, {
445
+ const res = await fetchWithRetry(`${base}/api/dev/theme/rename`, {
313
446
  method: "POST",
314
447
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
315
448
  body: JSON.stringify({ instance, name }),
316
- });
317
- if (!res.ok) throw new Error(await errorText(res));
449
+ }, { onRetry });
450
+ if (!res.ok) throw await responseError(res);
318
451
  return res.json();
319
452
  }
320
453
 
@@ -322,9 +455,9 @@ export async function renameInstance({ url, token, instance, name }) {
322
455
  * Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
323
456
  * instance-başına sayfa dağılımı, taslaklar ve health döner.
324
457
  */
325
- export async function fetchSiteStatus({ url, token }) {
458
+ export async function fetchSiteStatus({ url, token, onRetry = null }) {
326
459
  const base = url.replace(/\/+$/, "");
327
- const res = await fetch(`${base}/api/dev/site`, { headers: { authorization: `Bearer ${token}` } });
328
- if (!res.ok) throw new Error(await errorText(res));
460
+ const res = await fetchWithRetry(`${base}/api/dev/site`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
461
+ if (!res.ok) throw await responseError(res);
329
462
  return res.json();
330
463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Develop your Blocofy theme locally against live data with instant preview — login, theme dev, pull, push.",
5
5
  "type": "module",
6
6
  "license": "MIT",