@blocofy/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,17 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
3
2
 
4
3
  import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
4
+ import { fetchWithRetry } from "./http.mjs";
5
+ import { PagesCliError, stagedWrite } from "./page-files.mjs";
5
6
 
6
7
  /**
7
8
  * `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
8
9
  * (Bearer token). pull = GET → write to disk (re-adding `.liquid`); push =
9
10
  * readLocalTemplates → POST (create/update; no delete).
11
+ *
12
+ * CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs — 429/502/503/504 + network errors, Retry-After).
13
+ * All are safe to resend: reads, a keyed (or naturally idempotent upsert) theme POST, publish (sets a pointer),
14
+ * rename (sets a label). `onRetry` surfaces each retry on stderr.
10
15
  */
11
16
 
12
17
  /**
@@ -18,6 +23,13 @@ import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs"
18
23
  const CANONICAL_PROTOCOL = "1";
19
24
  const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
20
25
 
26
+ /**
27
+ * CF-T5 review I1 — OPTIONAL capabilities (never part of the required fence above). `theme-locales`: this CLI sends the
28
+ * workspace's `locales/*` files, so a canonical deploy may replace `locales/` like any other theme folder (a server
29
+ * that sees no declaration keeps stored locale rows). Sent on every theme POST; GETs don't need it.
30
+ */
31
+ const OPTIONAL_CAPABILITIES = "theme-locales";
32
+
21
33
  function canonicalHeaders(extra = {}) {
22
34
  return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
23
35
  }
@@ -35,6 +47,16 @@ async function errorText(res) {
35
47
  return text || `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
36
48
  }
37
49
 
50
+ /** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
51
+ async function responseError(res) {
52
+ const detail = await errorText(res);
53
+ const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
54
+ err.status = res.status;
55
+ if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
56
+ else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
57
+ return err;
58
+ }
59
+
38
60
  /**
39
61
  * Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
40
62
  * `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
@@ -61,57 +83,57 @@ async function httpError(res) {
61
83
  * alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
62
84
  * yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
63
85
  */
64
- export async function fetchCanonicalSupport({ url, token }) {
86
+ export async function fetchCanonicalSupport({ url, token, onRetry = null }) {
65
87
  const base = url.replace(/\/+$/, "");
66
- const res = await fetch(`${base}/api/dev/theme`, {
88
+ const res = await fetchWithRetry(`${base}/api/dev/theme`, {
67
89
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
68
- });
90
+ }, { onRetry });
69
91
  if (!res.ok) throw await httpError(res);
70
92
  const body = await res.json();
71
93
  return { supported: body?.protocol === 1 };
72
94
  }
73
95
 
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
96
  /**
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.
97
+ * CF-T1/T2 review I1 — a pulled path is accepted only if `readLocalTemplates` would read it back: a top-level theme dir
98
+ * (exact case) with a file below it, or `config/settings_schema.json`. Any `.blocofy`/staging segment (compared
99
+ * case-insensitively: APFS/NTFS fold case, so `.BLOCOFY/project.json` IS the binding) and any dot-directory (`.git`…)
100
+ * is refused. Returns a reason string, or null when the path is acceptable.
101
+ */
102
+ /**
103
+ * The flat root files a theme actually HAS. The platform's starter themes ship exactly these two, so exactly
104
+ * these two come down.
87
105
  *
88
- * `onRetry({ attempt, retries, reason })` is called before each wait so callers can
89
- * surface a "Network error, retrying…" line (not silent).
106
+ * This is an allowlist on purpose. It was briefly a denylist of tooling filenames, and that inverted the rule
107
+ * the rest of this function is built on: anything the server named that was not a lockfile - `CLAUDE.md`,
108
+ * `AGENTS.md`, `next.config.js` - was written straight into the developer's project root by an ordinary pull.
109
+ * A theme download must never be able to place a file the developer's own tooling then trusts. If a theme
110
+ * legitimately grows another root file, this set is where it is added, deliberately.
90
111
  */
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;
112
+ const ROOT_FILES_ALLOWED = new Set(["readme.md", "blueprint.json"]);
113
+
114
+ function pullPathProblem(rel) {
115
+ const segs = rel.split("/");
116
+ if (segs.some((seg) => { const l = seg.toLowerCase(); return l === ".blocofy" || l.startsWith(".blocofy-staging-"); })) return "reserved CLI path";
117
+ if (segs.slice(0, -1).some((seg) => seg.startsWith("."))) return "hidden directory";
118
+ // `config/` — the theme's own configuration rows. `settings_schema.json` is the one a push sends back; the
119
+ // platform serves the others (a starter theme ships `config/theme.json`) and a pull that refused them
120
+ // refused the WHOLE download, so a freshly provisioned site could not be pulled at all. They are written
121
+ // read-only: the push gate accepts only `settings_schema.json` under `config/`, and `pages`/`settings`
122
+ // have their own commands. One flat level, no nesting.
123
+ if (segs[0] === "config") {
124
+ if (segs.length !== 2) return "config files are one level deep";
125
+ // `settings pull` owns this name; a theme row must not shadow the file that command writes.
126
+ return segs[1] === "settings.json" ? "config/settings.json belongs to `settings pull`" : null;
127
+ }
128
+ if (segs.length === 1) {
129
+ // A FLAT FILE AT THE THEME ROOT. The platform stores rows a push does not send (the starter themes ship
130
+ // `README.md` and `blueprint.json`) and serves them on pull; refusing them refused the whole download.
131
+ // Case-folded, because APFS and NTFS fold case: `Readme.MD` and `README.md` are one file on disk.
132
+ if (segs[0].startsWith(".")) return "hidden file";
133
+ return ROOT_FILES_ALLOWED.has(segs[0].toLowerCase()) ? null : "not a theme file at the theme root";
112
134
  }
113
- // Unreachable (loop returns or throws), but keeps the type honest.
114
- throw lastErr;
135
+ if (!THEME_DIRS.has(segs[0])) return "not a theme file (would not be pushed back)";
136
+ return null;
115
137
  }
116
138
 
117
139
  /**
@@ -119,22 +141,32 @@ export async function fetchWithRetry(input, init, { retries = 2, onRetry, backof
119
141
  * With `draft`, pulls the "CLI Draft" instance (what `theme dev` syncs into)
120
142
  * instead of the live theme — symmetric with `push --draft`.
121
143
  */
122
- export async function pullTheme({ dir, url, token, draft = false, instance = null }) {
144
+ export async function pullTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
123
145
  const base = url.replace(/\/+$/, "");
124
146
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
125
- const res = await fetch(`${base}/api/dev/theme${query}`, {
147
+ const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
126
148
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
127
- });
128
- if (!res.ok) throw new Error(await errorText(res));
149
+ }, { onRetry });
150
+ if (!res.ok) throw await responseError(res);
129
151
  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;
152
+ // CF-T2: staged + contained (all-or-nothing). Every path is validated before the first byte is written; a
153
+ // server path that escapes the directory, targets the CLI's own `.blocofy/` binding, or would not be read back by
154
+ // the next push refuses the whole pull.
155
+ const entries = Object.entries(files ?? {}).map(([key, content]) => [localPathFor(key), content]);
156
+ const rejected = entries.map(([rel]) => [rel, pullPathProblem(rel)]).filter(([, problem]) => problem);
157
+ if (rejected.length) {
158
+ throw new PagesCliError("PAGES_PATH_ESCAPE", "The server sent a path this CLI does not write; nothing was written.", {
159
+ diagnostics: rejected.map(([rel, problem]) => ({ level: "error", code: "PAGES_PATH_ESCAPE", message: problem, path: rel })),
160
+ });
161
+ }
162
+ const created = !existsSync(dir);
163
+ mkdirSync(dir, { recursive: true });
164
+ try {
165
+ return { count: stagedWrite(realpathSync(dir), entries) };
166
+ } catch (error) {
167
+ if (created) rmSync(dir, { recursive: true, force: true }); // no empty directory left behind
168
+ throw error;
136
169
  }
137
- return { count };
138
170
  }
139
171
 
140
172
  /**
@@ -143,12 +175,12 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
143
175
  * öncesi (hedef tenant'ı yaz) çağırır; yanlış-tenant'a yazımı görünür kılar.
144
176
  * `{ site: { id, slug, name }, liveThemeId }`.
145
177
  */
146
- export async function fetchWhoami({ url, token }) {
178
+ export async function fetchWhoami({ url, token, onRetry = null }) {
147
179
  const base = url.replace(/\/+$/, "");
148
- const res = await fetch(`${base}/api/dev/whoami`, {
180
+ const res = await fetchWithRetry(`${base}/api/dev/whoami`, {
149
181
  headers: { authorization: `Bearer ${token}` },
150
- });
151
- if (!res.ok) throw new Error(await errorText(res));
182
+ }, { onRetry });
183
+ if (!res.ok) throw await responseError(res);
152
184
  return res.json();
153
185
  }
154
186
 
@@ -156,12 +188,12 @@ export async function fetchWhoami({ url, token }) {
156
188
  * Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
157
189
  * 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
158
190
  */
159
- export async function fetchDevSession({ url, token, name = null }) {
191
+ export async function fetchDevSession({ url, token, name = null, onRetry = null }) {
160
192
  const base = url.replace(/\/+$/, "");
161
193
  const query = name ? `?name=${encodeURIComponent(name)}` : "";
162
- const res = await fetch(`${base}/api/dev/session${query}`, {
194
+ const res = await fetchWithRetry(`${base}/api/dev/session${query}`, {
163
195
  headers: { authorization: `Bearer ${token}` },
164
- });
196
+ }, { onRetry });
165
197
  if (!res.ok) {
166
198
  const detail = await errorText(res);
167
199
  // Yeni sunucular 410'da açıklayıcı bir gövde döndürür — onu olduğu gibi göster, o otoritedir.
@@ -173,7 +205,7 @@ export async function fetchDevSession({ url, token, name = null }) {
173
205
  "(HTTP 410). Draft sync and `theme publish` are unaffected — they use different endpoints.",
174
206
  );
175
207
  }
176
- throw new Error(detail);
208
+ throw Object.assign(new Error(detail), { status: res.status });
177
209
  }
178
210
  return res.json();
179
211
  }
@@ -187,6 +219,7 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
187
219
  const base = url.replace(/\/+$/, "");
188
220
  const files = readLocalTemplates(dir);
189
221
  const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
222
+ const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
190
223
  const remoteOnlyKept = [];
191
224
  const remoteOnlyRemoved = [];
192
225
  if (idempotencyKey && !dryRun) {
@@ -205,20 +238,20 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
205
238
  // 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
206
239
  const pre = await fetchWithRetry(
207
240
  `${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 }) },
241
+ { method: "POST", headers: postHeaders({ "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
209
242
  { onRetry },
210
243
  );
211
244
  if (!pre.ok) throw await httpError(pre);
212
245
 
213
246
  let probeInstance = instance;
214
247
  if (!instance && draft) {
215
- const status = await fetchSiteStatus({ url, token });
248
+ const status = await fetchSiteStatus({ url, token, onRetry });
216
249
  probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
217
250
  }
218
251
  let remote = {};
219
252
  if (probeInstance || !draft) {
220
253
  const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
221
- const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
254
+ const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
222
255
  if (!probe.ok) throw await httpError(probe);
223
256
  remote = (await probe.json()).files ?? {};
224
257
  }
@@ -245,7 +278,7 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
245
278
  const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
246
279
  // dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
247
280
  const payload = dryRun ? { ...target, dryRun: true } : target;
248
- const headers = canonicalHeaders({ "content-type": "application/json", authorization: `Bearer ${token}` });
281
+ const headers = postHeaders();
249
282
  if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
250
283
  const res = await fetchWithRetry(
251
284
  `${base}/api/dev/theme`,
@@ -267,13 +300,13 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
267
300
  * (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
268
301
  * çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
269
302
  */
270
- export async function diffTheme({ dir, url, token, instance = null }) {
303
+ export async function diffTheme({ dir, url, token, instance = null, onRetry = null }) {
271
304
  const base = url.replace(/\/+$/, "");
272
305
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
273
- const res = await fetch(`${base}/api/dev/theme${query}`, {
306
+ const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
274
307
  headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
275
- });
276
- if (!res.ok) throw new Error(await errorText(res));
308
+ }, { onRetry });
309
+ if (!res.ok) throw await responseError(res);
277
310
  const { files: remote = {} } = await res.json();
278
311
  const local = readLocalTemplates(dir);
279
312
  const added = [];
@@ -291,14 +324,14 @@ export async function diffTheme({ dir, url, token, instance = null }) {
291
324
  * içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
292
325
  * yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
293
326
  */
294
- export async function publishInstance({ url, token, instanceId }) {
327
+ export async function publishInstance({ url, token, instanceId, onRetry = null }) {
295
328
  const base = url.replace(/\/+$/, "");
296
- const res = await fetch(`${base}/api/dev/publish`, {
329
+ const res = await fetchWithRetry(`${base}/api/dev/publish`, {
297
330
  method: "POST",
298
331
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
299
332
  body: JSON.stringify({ instanceId }),
300
- });
301
- if (!res.ok) throw new Error(await errorText(res));
333
+ }, { onRetry });
334
+ if (!res.ok) throw await responseError(res);
302
335
  return res.json();
303
336
  }
304
337
 
@@ -307,14 +340,14 @@ export async function publishInstance({ url, token, instanceId }) {
307
340
  * bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
308
341
  * `{ ok, id, name }` döner.
309
342
  */
310
- export async function renameInstance({ url, token, instance, name }) {
343
+ export async function renameInstance({ url, token, instance, name, onRetry = null }) {
311
344
  const base = url.replace(/\/+$/, "");
312
- const res = await fetch(`${base}/api/dev/theme/rename`, {
345
+ const res = await fetchWithRetry(`${base}/api/dev/theme/rename`, {
313
346
  method: "POST",
314
347
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
315
348
  body: JSON.stringify({ instance, name }),
316
- });
317
- if (!res.ok) throw new Error(await errorText(res));
349
+ }, { onRetry });
350
+ if (!res.ok) throw await responseError(res);
318
351
  return res.json();
319
352
  }
320
353
 
@@ -322,9 +355,9 @@ export async function renameInstance({ url, token, instance, name }) {
322
355
  * Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
323
356
  * instance-başına sayfa dağılımı, taslaklar ve health döner.
324
357
  */
325
- export async function fetchSiteStatus({ url, token }) {
358
+ export async function fetchSiteStatus({ url, token, onRetry = null }) {
326
359
  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));
360
+ const res = await fetchWithRetry(`${base}/api/dev/site`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
361
+ if (!res.ok) throw await responseError(res);
329
362
  return res.json();
330
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",