@blocofy/cli 0.4.0 → 0.5.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/README.md CHANGED
@@ -33,6 +33,22 @@ blocofy --help
33
33
 
34
34
  ## Changelog
35
35
 
36
+ - **0.5.0** — M4 canonical deploy protocol. `theme push` now deploys through the platform's atomic
37
+ source pipeline: the CLI declares the protocol handshake and auto-generates a per-push idempotency
38
+ key (transport retries converge; `--idempotency-key <k>` overrides it for scripting). A `--live`
39
+ push is finally visible on the pinned production render again. "Push does not delete" still holds —
40
+ remote-only files are carried along, and server-retained rows survive. New flags: `--dry-run` /
41
+ `--validate` (server-side validation, nothing written — refused against a server that predates the
42
+ protocol, which would otherwise silently write), `--diff` (read-only compare vs the LIVE theme).
43
+ `--help` on ANY subcommand now prints help and never runs the command (0.4.0 executed a real push);
44
+ unknown flags exit write-free instead of silently swallowing the next argument. `layer/` joined the
45
+ synced theme directories. Server errors `cli_upgrade_required` (426) and `idempotency_conflict`
46
+ (409) get human messages. Token format check aligned to the server minimum (total length ≥24 incl. the
47
+ `bcf_` prefix; real tokens are 47 chars). Two honest costs of the canonical pipeline: re-pushing
48
+ identical bytes still records a NEW deployment (the revision itself is deduplicated server-side; on
49
+ a repo-linked live target it also produces a reconcile bot commit), and the no-delete guarantee is
50
+ bought by carrying remote-only files in the payload — a very large theme (local+remote > 256 files
51
+ or > 4 MiB) can now hit the server's payload limits where 0.4.0's upsert did not.
36
52
  - **0.4.0** — `theme push` / `theme dev` accept `--name <name>` to name a new draft (applied
37
53
  only when the draft is first created; reuse ignores it). New `theme rename <handle> <new name>`
38
54
  renames a theme (the name is a label; works on any theme, including the live one).
package/bin/blocofy.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  * `/api/dev/render` endpoint (local theme files + live data) with file-watch
9
9
  * livereload. No monorepo required.
10
10
  */
11
+ import { randomUUID } from "node:crypto";
11
12
  import { existsSync } from "node:fs";
12
13
  import { createRequire } from "node:module";
13
14
  import { resolve } from "node:path";
@@ -19,7 +20,7 @@ import { credentialsPath, loadCredentials, saveCredentials } from "../lib/creden
19
20
  import { startDevServer } from "../lib/dev-server.mjs";
20
21
  import { readLocalTemplates } from "../lib/local-theme.mjs";
21
22
  import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
22
- import { fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
23
+ import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
23
24
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
24
25
  import { hyperlink, openUrl } from "../lib/term.mjs";
25
26
  import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
@@ -27,6 +28,28 @@ import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
27
28
  const VERSION = createRequire(import.meta.url)("../package.json").version;
28
29
  const args = process.argv.slice(2);
29
30
 
31
+ // 0.5.0 güvenlik sıkılaştırması: her komutun kabul ettiği bayraklar açık bir listedir. Bilinmeyen bir
32
+ // bayrak YAZIMSIZ çıkışla reddedilir (eskiden sonraki token'ı değer olarak yutup hedef dizini sessizce
33
+ // kaydırıyordu). `--confirm` ve `--dry` belgelenmemiş ama gerçek bayraklardır; `--dry` (theme dev'in
34
+ // sunucu-başlatmama kancası) `--dry-run`'dan (sunucu-tarafı doğrulama) FARKLIDIR, alias değildir.
35
+ const KNOWN = {
36
+ login: ["url", "token"],
37
+ themePull: ["draft", "instance"],
38
+ themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key"],
39
+ themeDev: ["port", "dry", "no-sync", "name"],
40
+ themePublish: ["instance"],
41
+ themeRename: ["name"],
42
+ content: [],
43
+ };
44
+ function parseArgsOrExit(rest, known) {
45
+ const parsed = parseArgs(rest, new Set([...known, "help", "version"]));
46
+ if (parsed.unknownFlag) {
47
+ console.error(`Unknown flag ${parsed.unknownFlag}. Nothing was written. See \`blocofy --help\`.`);
48
+ process.exit(1);
49
+ }
50
+ return parsed;
51
+ }
52
+
30
53
  /** Human label for a resolved site: "Name (slug)" or just the slug. */
31
54
  function siteLabel(site) {
32
55
  if (!site) return "";
@@ -69,6 +92,10 @@ Usage
69
92
  --instance <handle> push to a specific theme by its handle (safe targeted
70
93
  write — no live-confirmation prompt)
71
94
  --name <name> name the NEW draft (draft mode only; ignored on --live/--instance)
95
+ --dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
96
+ --validate alias for --dry-run (validate only, nothing written)
97
+ --diff show what a push WOULD change vs the target (read-only), then stop
98
+ --idempotency-key <k> attach an idempotency key so a retried push is not double-applied
72
99
 
73
100
  blocofy theme rename <handle> <new name>
74
101
  Rename a theme (the name is just a label). Works on any of your themes,
@@ -118,7 +145,7 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
118
145
  }
119
146
 
120
147
  async function login(rest) {
121
- const { flags } = parseArgs(rest);
148
+ const { flags } = parseArgsOrExit(rest, KNOWN.login);
122
149
  let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
123
150
  let token = typeof flags.token === "string" ? flags.token.trim() : "";
124
151
 
@@ -186,7 +213,7 @@ function requireCreds() {
186
213
  }
187
214
 
188
215
  async function themePull(rest) {
189
- const { flags, positionals } = parseArgs(rest);
216
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePull);
190
217
  const dir = resolve(positionals[0] ?? process.cwd());
191
218
  const creds = requireCreds();
192
219
  const draft = Boolean(flags.draft);
@@ -197,7 +224,7 @@ async function themePull(rest) {
197
224
  }
198
225
 
199
226
  async function themePush(rest) {
200
- const { flags, positionals } = parseArgs(rest);
227
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePush);
201
228
  const dir = resolve(positionals[0] ?? process.cwd());
202
229
  if (!existsSync(dir)) {
203
230
  console.error(`Theme directory not found: ${dir}`);
@@ -206,6 +233,38 @@ async function themePush(rest) {
206
233
  const creds = requireCreds();
207
234
  const instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
208
235
  const name = typeof flags.name === "string" ? flags.name : null;
236
+ const dryRun = Boolean(flags["dry-run"] || flags.validate);
237
+ // 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
238
+ // OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
239
+ // read cutover'ının ana CLI şikâyeti). Push-OPERASYONU-başına üretilir: fetchWithRetry'nin 5xx/429
240
+ // denemeleri aynı key'le yakınsar, ardışık push'lar farklı key alır (sabit key, settings/hedef
241
+ // değişiminde kalıcı 409 idempotency_conflict üretirdi). Ham id gönderilir — sunucu `idem:` ile
242
+ // ad-alanlar, önek EKLENMEZ.
243
+ const idempotencyKey = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : `cli-${randomUUID()}`;
244
+
245
+ // `--diff`: read-only preview vs the LIVE theme (or --instance). No write; the draft target is not
246
+ // diffable (a draft GET would PROVISION the draft server-side — a read-only command must not mutate).
247
+ if (flags.diff) {
248
+ let diffSite = null;
249
+ try {
250
+ diffSite = (await fetchWhoami({ url: creds.url, token: creds.token })).site;
251
+ } catch {
252
+ /* best-effort — etiket kozmetik, diff yine koşar */
253
+ }
254
+ const diffTarget = diffSite ? ` of ${siteLabel(diffSite)}` : "";
255
+ const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag });
256
+ console.log(instanceFlag ? `Diff vs theme ${instanceFlag}${diffTarget}:` : `Diff vs the LIVE theme${diffTarget} (push default writes to a DRAFT):`);
257
+ const total = d.added.length + d.changed.length + d.removed.length;
258
+ if (total === 0) {
259
+ console.log("No differences — local theme matches the target.");
260
+ return;
261
+ }
262
+ for (const k of d.added) console.log(` + ${k}`);
263
+ for (const k of d.changed) console.log(` ~ ${k}`);
264
+ for (const k of d.removed) console.log(` - ${k} (present on target, absent locally — push does not delete)`);
265
+ console.log(`\n${d.added.length} added, ${d.changed.length} changed, ${d.removed.length} remote-only.`);
266
+ return;
267
+ }
209
268
 
210
269
  // Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
211
270
  // açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
@@ -229,7 +288,7 @@ async function themePush(rest) {
229
288
  if (mode === "instance") {
230
289
  console.log(`→ Pushing to theme ${instance}${site ? ` of ${target}` : ""}`);
231
290
  } else if (mode === "live") {
232
- console.log(`→ Pushing to the LIVE theme of ${target}`);
291
+ console.log(dryRun ? `→ Validating against the LIVE theme of ${target} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${target}`);
233
292
  } else {
234
293
  console.log(`→ Pushing to a draft${site ? ` of ${target}` : ""}`);
235
294
  }
@@ -237,8 +296,9 @@ async function themePush(rest) {
237
296
  // Canlı push (`--live`) ANINDA canlı temayı değiştirir (önizleme yok). Agent/CI
238
297
  // kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
239
298
  // güvenli → otomatik onay (prompt yok).
299
+ // 0.5.0: `--dry-run` hiçbir şey yazmaz → canlı onayı gereksiz (draft:true gibi davranır).
240
300
  const decision = livePushDecision({
241
- draft: mode !== "live",
301
+ draft: mode !== "live" || dryRun,
242
302
  yes: Boolean(flags.yes),
243
303
  confirm: Boolean(flags.confirm),
244
304
  isTTY: Boolean(process.stdin.isTTY),
@@ -268,15 +328,65 @@ async function themePush(rest) {
268
328
  console.error("Note: --name yalnız yeni taslak yaratırken (varsayılan push) geçerli, yok sayıldı.");
269
329
  }
270
330
 
271
- const result = await pushTheme({
272
- dir,
273
- url: creds.url,
274
- token: creds.token,
275
- draft: mode === "draft",
276
- instance: mode === "instance" ? instance : null,
277
- name: mode === "draft" ? name : null,
278
- onRetry: (info) => console.error(retryNotice(info)),
279
- });
331
+ // 0.5.0: eski (protokolsüz) bir sunucu `dryRun` alanını bilmez ve SESSİZCE GERÇEK YAZIM yapardı —
332
+ // dry-run yalnız sunucu kanonik protokolü beyan ediyorsa koşar (sorgusuz, mutasyonsuz GET ön kontrolü).
333
+ if (dryRun) {
334
+ const { supported } = await fetchCanonicalSupport({ url: creds.url, token: creds.token });
335
+ if (!supported) {
336
+ console.error("✗ --dry-run needs a server that speaks the canonical protocol; this one does not.");
337
+ console.error(" An old server would IGNORE dryRun and write for real. Nothing was sent.");
338
+ process.exit(1);
339
+ }
340
+ }
341
+
342
+ let result;
343
+ try {
344
+ result = await pushTheme({
345
+ dir,
346
+ url: creds.url,
347
+ token: creds.token,
348
+ draft: mode === "draft",
349
+ instance: mode === "instance" ? instance : null,
350
+ name: mode === "draft" ? name : null,
351
+ dryRun,
352
+ idempotencyKey,
353
+ onRetry: (info) => console.error(retryNotice(info)),
354
+ });
355
+ } catch (error) {
356
+ if (error?.code === "cli_upgrade_required") {
357
+ const missing = Array.isArray(error?.body?.fence?.missing) ? error.body.fence.missing.join(", ") : "";
358
+ console.error("✗ Sunucu bu CLI sürümünü reddetti (cli_upgrade_required).");
359
+ console.error(` Güncelle: npm i -g @blocofy/cli@latest${missing ? `\n Sunucunun istediği eksik yetenekler: ${missing}` : ""}`);
360
+ process.exit(1);
361
+ }
362
+ if (error?.code === "idempotency_conflict") {
363
+ console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
364
+ console.error(" `--idempotency-key` verdiysen yeni bir anahtarla dene; vermediysen tekrar `blocofy theme push` yeterli (her koşu taze anahtar üretir).");
365
+ process.exit(1);
366
+ }
367
+ throw error;
368
+ }
369
+
370
+ // --dry-run / --validate: the server validated without writing. Report and stop.
371
+ if (result.dryRun) {
372
+ const warnings = Array.isArray(result.warnings) ? result.warnings : [];
373
+ console.log(`✓ Validation passed (dry run — nothing written).${warnings.length ? ` ${warnings.length} warning(s).` : ""}`);
374
+ for (const w of warnings) console.log(` ⚠ ${w}`);
375
+ return;
376
+ }
377
+
378
+ // 0.5.0: kanonik boru hattı yanıtı (CP üzerinden atomik deploy) — legacy created/updated alanları yok.
379
+ if (result.committed === true) {
380
+ if (Array.isArray(result.remoteOnlyKept) && result.remoteOnlyKept.length) {
381
+ console.log(` (kept ${result.remoteOnlyKept.length} remote-only file(s) — push does not delete)`);
382
+ }
383
+ console.log(`✓ Deployed atomically: deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}.`);
384
+ if (mode === "draft") {
385
+ console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
386
+ console.log("Publish it live with: blocofy theme publish");
387
+ }
388
+ return;
389
+ }
280
390
 
281
391
  // Sunucu canlı-yazımı bildirdiyse (yeni alan; eski sunucuda yok) belirgin uyar.
282
392
  if (result.warning === "live_write" && result.message) {
@@ -300,7 +410,7 @@ async function themePush(rest) {
300
410
  }
301
411
 
302
412
  async function contentPush(scope, rest) {
303
- const { positionals } = parseArgs(rest);
413
+ const { positionals } = parseArgsOrExit(rest, KNOWN.content);
304
414
  const dir = resolve(positionals[0] ?? process.cwd());
305
415
  if (!existsSync(dir)) {
306
416
  console.error(`Directory not found: ${dir}`);
@@ -322,7 +432,7 @@ async function contentPush(scope, rest) {
322
432
  }
323
433
 
324
434
  async function contentPull(scope, rest) {
325
- const { positionals } = parseArgs(rest);
435
+ const { positionals } = parseArgsOrExit(rest, KNOWN.content);
326
436
  const dir = resolve(positionals[0] ?? process.cwd());
327
437
  const creds = requireCreds();
328
438
  const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
@@ -330,7 +440,7 @@ async function contentPull(scope, rest) {
330
440
  }
331
441
 
332
442
  async function themeDev(rest) {
333
- const { flags, positionals } = parseArgs(rest);
443
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeDev);
334
444
  const themeDir = resolve(positionals[0] ?? process.cwd());
335
445
 
336
446
  if (!existsSync(themeDir)) {
@@ -471,7 +581,7 @@ async function themeDev(rest) {
471
581
  }
472
582
 
473
583
  async function themePublish(rest) {
474
- const { flags } = parseArgs(rest);
584
+ const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
475
585
  const creds = requireCreds();
476
586
  let instance = typeof flags.instance === "string" ? flags.instance : null;
477
587
  if (!instance) {
@@ -486,7 +596,7 @@ async function themePublish(rest) {
486
596
  }
487
597
 
488
598
  async function themeRename(rest) {
489
- const { flags, positionals } = parseArgs(rest);
599
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
490
600
  const handle = positionals[0];
491
601
  const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
492
602
  if (!handle || !name) {
@@ -540,6 +650,13 @@ if (first === "--version" || first === "-v") {
540
650
  console.log(VERSION);
541
651
  } else if (!first || first === "--help" || first === "-h" || first === "help") {
542
652
  printHelp();
653
+ } else if (args.includes("--help") || args.includes("-h")) {
654
+ // 0.5.0: `--help` HERHANGİ bir konumda YARDIMDIR — alt-komut handler'ı asla koşmaz.
655
+ // (0.4.0'da `blocofy theme push --help` GERÇEK bir push koşuyordu.)
656
+ printHelp();
657
+ } else if (args.includes("--version") || args.includes("-v")) {
658
+ // Aynı simetri: `theme push <dir> --version` da yalnız sürüm basar, asla yazmaz.
659
+ console.log(VERSION);
543
660
  } else if (first === "login") {
544
661
  login(rest).catch((error) => {
545
662
  console.error(error?.message ?? error);
package/lib/args.mjs CHANGED
@@ -5,9 +5,14 @@
5
5
  */
6
6
 
7
7
  /** Sonraki token'ı değer olarak almayan flag'ler. */
8
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "help", "version"]);
8
+ const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version"]);
9
9
 
10
- export function parseArgs(rest) {
10
+ /**
11
+ * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
12
+ * `unknownFlag` olarak döner ve DEĞER YUTMAZ — eskiden `--halp mydir` `mydir`'i değer sanıp
13
+ * hedef dizini sessizce cwd'ye kaydırıyordu (0.5.0: bilinmeyen bayrak = yazımsız çıkış).
14
+ */
15
+ export function parseArgs(rest, known = null) {
11
16
  const flags = {};
12
17
  const positionals = [];
13
18
  for (let i = 0; i < rest.length; i++) {
@@ -17,6 +22,9 @@ export function parseArgs(rest) {
17
22
  continue;
18
23
  }
19
24
  const key = arg.slice(2);
25
+ if (known && !known.has(key)) {
26
+ return { flags, positionals, unknownFlag: `--${key}` };
27
+ }
20
28
  if (BOOLEAN_FLAGS.has(key)) {
21
29
  flags[key] = true;
22
30
  continue;
@@ -97,6 +97,9 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
97
97
  let error = null;
98
98
  if (syncDraft) {
99
99
  try {
100
+ // BİLİNÇLE key'siz (0.5.0): dev sync her kayıtta koşar — kanonik mühür (key'li dal) kayıt
101
+ // başına revizyon+deployment üretirdi (retention yok) ve taslak önizlemeye katkısı sıfır.
102
+ // Key'siz istek sunucuda legacy draft yazımıdır; kanonik mühür bilinçli `theme push` işidir.
100
103
  await pushTheme({ dir, url, token, draft: true, onRetry });
101
104
  } catch (err) {
102
105
  error = err?.message ?? String(err); // senkron hatası reload'u engellemesin
@@ -7,7 +7,9 @@ import { extname, join, relative, sep } from "node:path";
7
7
  * THEME_DIRS, keyed by relative path with the `.liquid` extension stripped.
8
8
  */
9
9
 
10
- export const THEME_DIRS = new Set(["layout", "section", "block", "partial", "asset", "template"]);
10
+ // Mirror of the platform's theme-paths.ts THEME_DIRS (incl. `layer`, added M5 B1 — 0.5.0 closes the
11
+ // drift where a pulled layer/ file was silently dropped from the next push).
12
+ export const THEME_DIRS = new Set(["layout", "section", "block", "layer", "partial", "asset", "template"]);
11
13
  const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template"]);
12
14
 
13
15
  /**
@@ -16,7 +18,11 @@ const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template
16
18
  */
17
19
  export function localPathFor(key) {
18
20
  const top = key.split("/")[0] ?? "";
19
- return LIQUID_KINDS.has(top) && !key.endsWith(".liquid") ? `${key}.liquid` : key;
21
+ // Platform repoPathFor aynası (0.5.0): `.liquid` yalnız UZANTISIZ basename'lere — `template/index.json`
22
+ // diske AYNEN yazılır; eski kural onu `.json.liquid` yapıp git-checkout'lu bir çalışma dizininde
23
+ // repo'daki gerçek dosyanın yanına churn-ikizi düşürüyordu.
24
+ const base = key.split("/").pop() ?? "";
25
+ return LIQUID_KINDS.has(top) && !base.includes(".") ? `${key}.liquid` : key;
20
26
  }
21
27
 
22
28
  /** Turn a theme directory into a `{ path: content }` map (sent to dev-render). */
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
- import { localPathFor, readLocalTemplates } from "./local-theme.mjs";
4
+ import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
5
5
 
6
6
  /**
7
7
  * `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
@@ -9,6 +9,19 @@ import { localPathFor, readLocalTemplates } from "./local-theme.mjs";
9
9
  * readLocalTemplates → POST (create/update; no delete).
10
10
  */
11
11
 
12
+ /**
13
+ * M4 canonical source-write handshake. We declare the protocol version + the full canonical-write
14
+ * capability set; the server is authoritative and fences an under-declaring client (a NEW CLI against an
15
+ * OLD server just sees the headers ignored — forward compatible). Capabilities must match the server's
16
+ * CANONICAL_WRITE_CAPABILITIES exactly, in order.
17
+ */
18
+ const CANONICAL_PROTOCOL = "1";
19
+ const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
20
+
21
+ function canonicalHeaders(extra = {}) {
22
+ return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
23
+ }
24
+
12
25
  async function errorText(res) {
13
26
  const text = await res.text();
14
27
  try {
@@ -18,6 +31,42 @@ async function errorText(res) {
18
31
  }
19
32
  }
20
33
 
34
+ /**
35
+ * Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
36
+ * `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
37
+ * için insan-dili mesajı ÇAĞIRAN kurar; buradaki throw yalnız veriyi kaybetmeden taşır.
38
+ */
39
+ async function httpError(res) {
40
+ const text = await res.text();
41
+ let body = null;
42
+ try {
43
+ body = JSON.parse(text);
44
+ } catch {
45
+ /* düz metin gövde */
46
+ }
47
+ const err = new Error(typeof body?.error === "string" ? body.error : text || `HTTP ${res.status}`);
48
+ err.status = res.status;
49
+ err.code = typeof body?.error === "string" ? body.error : null;
50
+ err.body = body;
51
+ return err;
52
+ }
53
+
54
+ /**
55
+ * Sunucu kanonik protokolü konuşuyor mu? (0.5.0 `--dry-run` ön kontrolü.) SORGUSUZ canlı GET — mutasyonsuz
56
+ * (`?draft=1` GET'i sunucuda taslak provizyonlar, o yüzden burada KULLANILMAZ). Eski bir sunucu `protocol`
57
+ * alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
58
+ * yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
59
+ */
60
+ export async function fetchCanonicalSupport({ url, token }) {
61
+ const base = url.replace(/\/+$/, "");
62
+ const res = await fetch(`${base}/api/dev/theme`, {
63
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
64
+ });
65
+ if (!res.ok) throw await httpError(res);
66
+ const body = await res.json();
67
+ return { supported: body?.protocol === 1 };
68
+ }
69
+
21
70
  /** Backoff delays (ms) between attempts — index 0 = after the first failure. */
22
71
  const RETRY_BACKOFF_MS = [300, 900];
23
72
 
@@ -70,7 +119,7 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
70
119
  const base = url.replace(/\/+$/, "");
71
120
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
72
121
  const res = await fetch(`${base}/api/dev/theme${query}`, {
73
- headers: { authorization: `Bearer ${token}` },
122
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
74
123
  });
75
124
  if (!res.ok) throw new Error(await errorText(res));
76
125
  const { files } = await res.json();
@@ -118,21 +167,86 @@ export async function fetchDevSession({ url, token, name = null }) {
118
167
  * writes to a draft theme instance instead of the live theme — preview & publish
119
168
  * it from the admin panel without affecting the live site.
120
169
  */
121
- export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, onRetry = null }) {
170
+ export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null }) {
122
171
  const base = url.replace(/\/+$/, "");
123
172
  const files = readLocalTemplates(dir);
124
- const payload = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
173
+ const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
174
+ const remoteOnlyKept = [];
175
+ if (idempotencyKey && !dryRun) {
176
+ // 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
177
+ // 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
178
+ // provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
179
+ // arkada instance + sayfa klonu bırakıyordu.
180
+ // 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
181
+ // dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
182
+ // retained-rows kuralı korur. Taslak hedefte `?name=` taşınır: adsız probe taslağı
183
+ // `CLI Draft — <tarih>` olarak yaratır ve POST'un --name'i reuse yolunda yok sayılırdı.
184
+ // 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
185
+ const pre = await fetchWithRetry(
186
+ `${base}/api/dev/theme`,
187
+ { method: "POST", headers: headersFor({ "content-type": "application/json", "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
188
+ { onRetry },
189
+ );
190
+ if (!pre.ok) throw await httpError(pre);
191
+
192
+ const query = instance
193
+ ? `?instance=${encodeURIComponent(instance)}`
194
+ : draft
195
+ ? `?draft=1${name ? `&name=${encodeURIComponent(name)}` : ""}`
196
+ : "";
197
+ const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
198
+ if (!probe.ok) throw await httpError(probe);
199
+ const { files: remote = {} } = await probe.json();
200
+ for (const [key, content] of Object.entries(remote)) {
201
+ if (key in files) continue;
202
+ const top = key.split("/")[0] ?? "";
203
+ // Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
204
+ // config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
205
+ if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
206
+ files[key] = typeof content === "string" ? content : "";
207
+ remoteOnlyKept.push(key);
208
+ }
209
+ }
210
+ remoteOnlyKept.sort();
211
+ }
212
+ const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
213
+ // dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
214
+ const payload = dryRun ? { ...target, dryRun: true } : target;
215
+ const headers = canonicalHeaders({ "content-type": "application/json", authorization: `Bearer ${token}` });
216
+ if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
125
217
  const res = await fetchWithRetry(
126
218
  `${base}/api/dev/theme`,
127
- {
128
- method: "POST",
129
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
130
- body: JSON.stringify(payload),
131
- },
219
+ { method: "POST", headers, body: JSON.stringify(payload) },
132
220
  { onRetry },
133
221
  );
222
+ if (!res.ok) throw await httpError(res);
223
+ const json = await res.json();
224
+ return remoteOnlyKept.length ? { ...json, remoteOnlyKept } : json;
225
+ }
226
+
227
+ /**
228
+ * `blocofy theme push --diff` — compare the LOCAL theme with the LIVE theme (or an explicit `--instance`).
229
+ * Read-only: the draft target is deliberately NOT diffable — `?draft=1` GET'i sunucuda taslak PROVİZYONLAR
230
+ * (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
231
+ * çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
232
+ */
233
+ export async function diffTheme({ dir, url, token, instance = null }) {
234
+ const base = url.replace(/\/+$/, "");
235
+ const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
236
+ const res = await fetch(`${base}/api/dev/theme${query}`, {
237
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
238
+ });
134
239
  if (!res.ok) throw new Error(await errorText(res));
135
- return res.json();
240
+ const { files: remote = {} } = await res.json();
241
+ const local = readLocalTemplates(dir);
242
+ const added = [];
243
+ const changed = [];
244
+ for (const [key, content] of Object.entries(local)) {
245
+ if (!(key in remote)) added.push(key);
246
+ else if (remote[key] !== content) changed.push(key);
247
+ }
248
+ const removed = Object.keys(remote).filter((k) => !(k in local));
249
+ return { added: added.sort(), changed: changed.sort(), removed: removed.sort() };
136
250
  }
137
251
 
138
252
  /**
package/lib/validate.mjs CHANGED
@@ -14,5 +14,5 @@ export function isValidUrl(u) {
14
14
  }
15
15
 
16
16
  export function isValidToken(t) {
17
- return typeof t === "string" && t.startsWith("bcf_") && t.length >= 12;
17
+ return typeof t === "string" && t.startsWith("bcf_") && t.length >= 24;
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",