@blocofy/cli 0.4.0 → 0.6.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,35 @@ blocofy --help
33
33
 
34
34
  ## Changelog
35
35
 
36
+ - **0.6.0** — `theme dev`'s local view finally shows what you are working on. It used to render your
37
+ DRAFT theme against the site's LIVE page content, so draft page documents were invisible; each render
38
+ now carries the draft theme instance and the platform resolves that instance's pages. Three failures
39
+ caused by a retired server endpoint (`/api/dev/session`, gone since the platform's preview-security
40
+ work) are fixed at the root: **draft sync** was switched off whenever that endpoint failed even though
41
+ sync never used it (it posts to `/api/dev/theme`); **`theme publish`** without `--instance` called it
42
+ with no error handling and died with an empty message — it now resolves the CLI draft from
43
+ `blocofy status` (`source: "import"`); and an empty response body produced the undiagnosable
44
+ `dev session unavailable ()` — errors now fall back to `HTTP <status>` and a 410 explains itself.
45
+ If the server ever stops returning a draft handle, the CLI now says so instead of silently rendering
46
+ live content. Remote (shared-link and editor) preview is NOT restored — that surface was retired on
47
+ the platform side; `theme dev` prints the local view only.
48
+
49
+ - **0.5.0** — M4 canonical deploy protocol. `theme push` now deploys through the platform's atomic
50
+ source pipeline: the CLI declares the protocol handshake and auto-generates a per-push idempotency
51
+ key (transport retries converge; `--idempotency-key <k>` overrides it for scripting). A `--live`
52
+ push is finally visible on the pinned production render again. "Push does not delete" still holds —
53
+ remote-only files are carried along, and server-retained rows survive. New flags: `--dry-run` /
54
+ `--validate` (server-side validation, nothing written — refused against a server that predates the
55
+ protocol, which would otherwise silently write), `--diff` (read-only compare vs the LIVE theme).
56
+ `--help` on ANY subcommand now prints help and never runs the command (0.4.0 executed a real push);
57
+ unknown flags exit write-free instead of silently swallowing the next argument. `layer/` joined the
58
+ synced theme directories. Server errors `cli_upgrade_required` (426) and `idempotency_conflict`
59
+ (409) get human messages. Token format check aligned to the server minimum (total length ≥24 incl. the
60
+ `bcf_` prefix; real tokens are 47 chars). Two honest costs of the canonical pipeline: re-pushing
61
+ identical bytes still records a NEW deployment (the revision itself is deduplicated server-side; on
62
+ a repo-linked live target it also produces a reconcile bot commit), and the no-delete guarantee is
63
+ bought by carrying remote-only files in the payload — a very large theme (local+remote > 256 files
64
+ or > 4 MiB) can now hit the server's payload limits where 0.4.0's upsert did not.
36
65
  - **0.4.0** — `theme push` / `theme dev` accept `--name <name>` to name a new draft (applied
37
66
  only when the draft is first created; reuse ignores it). New `theme rename <handle> <new name>`
38
67
  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)) {
@@ -350,8 +460,8 @@ async function themeDev(rest) {
350
460
  session = await fetchDevSession({ url: creds.url, token: creds.token, name });
351
461
  } catch (error) {
352
462
  console.warn(
353
- `Warning: dev session unavailable (${error?.message ?? error}). ` +
354
- `Local preview only — live-domain/editor views + draft sync disabled.`,
463
+ `Warning: dev session unavailable (${error?.message || error}). ` +
464
+ `Live-domain and editor views are disabled; draft sync and local preview keep working.`,
355
465
  );
356
466
  }
357
467
  }
@@ -413,8 +523,12 @@ async function themeDev(rest) {
413
523
  url: creds.url,
414
524
  token: creds.token,
415
525
  port,
416
- syncDraft: Boolean(session),
526
+ // Taslak senkronu dev session'a BAĞLI DEĞİL: `pushTheme({draft:true})` /api/dev/theme'e gider ve
527
+ // session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
528
+ // da sessizce kapanıyordu — ölü bir uç, çalışan bir özelliği götürüyordu. Tek kapatma yolu --no-sync.
529
+ syncDraft: !flags["no-sync"],
417
530
  onRetry: (info) => console.error(` ${retryNotice(info)}`),
531
+ onWarn: (msg) => console.warn(` ⚠ ${msg}`),
418
532
  // Her kaydetmede ne olduğunu bas — "reloaded" = watch tetiklendi; "0 views"
419
533
  // = hiçbir tarayıcı sekmesi bağlı değil (yanlış görünüme bakıyorsun); sync
420
534
  // hatası = draft güncellenemedi (preview/editör eski kalır, local yine yenilenir).
@@ -471,13 +585,30 @@ async function themeDev(rest) {
471
585
  }
472
586
 
473
587
  async function themePublish(rest) {
474
- const { flags } = parseArgs(rest);
588
+ const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
475
589
  const creds = requireCreds();
476
590
  let instance = typeof flags.instance === "string" ? flags.instance : null;
477
591
  if (!instance) {
478
- // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla.
479
- const session = await fetchDevSession({ url: creds.url, token: creds.token });
480
- instance = session.draftInstanceId;
592
+ // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
593
+ // `GET /api/dev/site` — eski `fetchDevSession` yolu sunucuda 410'a döndü ve bu komutu
594
+ // try/catch'siz, boş mesajlı bir Error ile tamamen çalışmaz hâle getirmişti.
595
+ //
596
+ // `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
597
+ // `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
598
+ // burada tekrarla — yoksa bir kez yayın yapmış her sitede iki taslak görünür ve komut takılır.
599
+ const status = await fetchSiteStatus({ url: creds.url, token: creds.token });
600
+ const drafts = status?.drafts ?? [];
601
+ const cliDrafts = drafts.filter((d) => d.source === "import");
602
+ if (cliDrafts.length === 1) {
603
+ instance = cliDrafts[0].id;
604
+ } else if (drafts.length === 0) {
605
+ console.error("No draft theme to publish. Create one first: blocofy theme push --draft");
606
+ process.exit(1);
607
+ } else {
608
+ console.error("Could not tell which draft to publish — pick one with --instance <handle>:");
609
+ for (const d of drafts) console.error(` ${d.id} ${d.name ?? "(unnamed)"}`);
610
+ process.exit(1);
611
+ }
481
612
  }
482
613
  const result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance });
483
614
  console.log(
@@ -486,7 +617,7 @@ async function themePublish(rest) {
486
617
  }
487
618
 
488
619
  async function themeRename(rest) {
489
- const { flags, positionals } = parseArgs(rest);
620
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
490
621
  const handle = positionals[0];
491
622
  const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
492
623
  if (!handle || !name) {
@@ -540,6 +671,13 @@ if (first === "--version" || first === "-v") {
540
671
  console.log(VERSION);
541
672
  } else if (!first || first === "--help" || first === "-h" || first === "help") {
542
673
  printHelp();
674
+ } else if (args.includes("--help") || args.includes("-h")) {
675
+ // 0.5.0: `--help` HERHANGİ bir konumda YARDIMDIR — alt-komut handler'ı asla koşmaz.
676
+ // (0.4.0'da `blocofy theme push --help` GERÇEK bir push koşuyordu.)
677
+ printHelp();
678
+ } else if (args.includes("--version") || args.includes("-v")) {
679
+ // Aynı simetri: `theme push <dir> --version` da yalnız sürüm basar, asla yazmaz.
680
+ console.log(VERSION);
543
681
  } else if (first === "login") {
544
682
  login(rest).catch((error) => {
545
683
  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;
@@ -20,16 +20,52 @@ import { pushTheme } from "./theme-sync.mjs";
20
20
  * separate request); direct `/asset/*` references are served from disk. Images/JS
21
21
  * are remote (Directus/CDN), so no local serving is needed for them.
22
22
  */
23
- export function startDevServer({ dir, url, token, port, syncDraft = false, onError, onReload, onRetry }) {
23
+ export function startDevServer({ dir, url, token, port, syncDraft = false, onError, onReload, onRetry, onWarn }) {
24
24
  const base = url.replace(/\/+$/, "");
25
25
  const clients = new Set();
26
26
 
27
+ // Taslak instance handle'ı: `pushTheme({draft:true})` yanıtından gelir (ek istek YOK). Render
28
+ // istekleri bunu taşıyınca platform CANLI sayfa belgeleri yerine TASLAĞIN sayfa belgelerini
29
+ // çözer — `theme dev`'in yerel görünümü nihayet üzerinde çalışılan şeyi gösterir.
30
+ let draftInstance = null;
31
+ // Kanonik yazma yolu ham control-plane JSON'u döndürür ve `instanceId` içermeyebilir. O durumda
32
+ // sessizce canlıya dönmek, bu onarımın kapattığı arıza sınıfının aynısı olurdu — bir kez uyar.
33
+ let warnedNoHandle = false;
34
+ const noteHandle = (pushed) => {
35
+ if (pushed?.instanceId) {
36
+ draftInstance = pushed.instanceId;
37
+ return;
38
+ }
39
+ if (!warnedNoHandle) {
40
+ warnedNoHandle = true;
41
+ onWarn?.(
42
+ "the server did not return a draft theme handle — local preview will render LIVE page content, not your draft.",
43
+ );
44
+ }
45
+ };
46
+
47
+ // Açılıştaki push'u BİR promise olarak tut: ilk render isteği bunu bekler, yoksa ilk sayfa
48
+ // yüklemesi canlı içerikle gelir ve geliştirici "taslağım görünmüyor" sanır.
49
+ const draftReady = syncDraft
50
+ ? pushTheme({ dir, url, token, draft: true, onRetry })
51
+ .then(noteHandle)
52
+ .catch(() => {})
53
+ : Promise.resolve();
54
+
27
55
  async function renderPath(pathname) {
56
+ // Açılış push'u bitene kadar bekle — yalnız ilk istekte gerçekten bekler, sonra çözülmüş promise.
57
+ if (!draftInstance) await draftReady;
28
58
  const templates = readLocalTemplates(dir);
29
59
  const res = await fetch(`${base}/api/dev/render`, {
30
60
  method: "POST",
31
61
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
32
- body: JSON.stringify({ path: pathname, templates }),
62
+ // `preview` BİLİNÇLE gönderilmez: sunucuda preview yalnız tazelik değil — önizleme bandı,
63
+ // editör bridge'i ve blok işaretleyicileri de ona bağlı. `theme dev`'in vaadi prod paritesi.
64
+ body: JSON.stringify({
65
+ path: pathname,
66
+ templates,
67
+ ...(draftInstance ? { instance: draftInstance } : {}),
68
+ }),
33
69
  });
34
70
  const text = await res.text();
35
71
  if (!res.ok) {
@@ -97,7 +133,10 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
97
133
  let error = null;
98
134
  if (syncDraft) {
99
135
  try {
100
- await pushTheme({ dir, url, token, draft: true, onRetry });
136
+ // 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
137
+ // başına revizyon+deployment üretirdi (retention yok) ve taslak önizlemeye katkısı sıfır.
138
+ // Key'siz istek sunucuda legacy draft yazımıdır; kanonik mühür bilinçli `theme push` işidir.
139
+ noteHandle(await pushTheme({ dir, url, token, draft: true, onRetry }));
101
140
  } catch (err) {
102
141
  error = err?.message ?? String(err); // senkron hatası reload'u engellemesin
103
142
  }
@@ -106,11 +145,6 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
106
145
  onReload?.({ file, synced: syncDraft && !error, clients: clients.size, error });
107
146
  });
108
147
 
109
- // Açılışta taslağı bir kez doldur → uzak görünümler hemen güncel local'i gösterir.
110
- if (syncDraft) {
111
- void pushTheme({ dir, url, token, draft: true, onRetry }).catch(() => {});
112
- }
113
-
114
148
  server.listen(port);
115
149
 
116
150
  return {
@@ -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,13 +9,66 @@ 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 {
15
- return JSON.parse(text).error ?? text;
28
+ const parsed = JSON.parse(text).error;
29
+ if (parsed) return parsed;
30
+ } catch {
31
+ /* JSON değil — ham metne düş */
32
+ }
33
+ // İçeriksiz yanıt (ör. 410 tombstone) boş dize döndürüp `Error("")` üretiyordu; kullanıcı
34
+ // `unavailable ()` görüyordu. Gövde yoksa statü tek teşhis kaynağıdır — onu taşı.
35
+ return text || `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
36
+ }
37
+
38
+ /**
39
+ * Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
40
+ * `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
41
+ * için insan-dili mesajı ÇAĞIRAN kurar; buradaki throw yalnız veriyi kaybetmeden taşır.
42
+ */
43
+ async function httpError(res) {
44
+ const text = await res.text();
45
+ let body = null;
46
+ try {
47
+ body = JSON.parse(text);
16
48
  } catch {
17
- return text;
49
+ /* düz metin gövde */
18
50
  }
51
+ const err = new Error(typeof body?.error === "string" ? body.error : text || `HTTP ${res.status}`);
52
+ err.status = res.status;
53
+ err.code = typeof body?.error === "string" ? body.error : null;
54
+ err.body = body;
55
+ return err;
56
+ }
57
+
58
+ /**
59
+ * Sunucu kanonik protokolü konuşuyor mu? (0.5.0 `--dry-run` ön kontrolü.) SORGUSUZ canlı GET — mutasyonsuz
60
+ * (`?draft=1` GET'i sunucuda taslak provizyonlar, o yüzden burada KULLANILMAZ). Eski bir sunucu `protocol`
61
+ * alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
62
+ * yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
63
+ */
64
+ export async function fetchCanonicalSupport({ url, token }) {
65
+ const base = url.replace(/\/+$/, "");
66
+ const res = await fetch(`${base}/api/dev/theme`, {
67
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
68
+ });
69
+ if (!res.ok) throw await httpError(res);
70
+ const body = await res.json();
71
+ return { supported: body?.protocol === 1 };
19
72
  }
20
73
 
21
74
  /** Backoff delays (ms) between attempts — index 0 = after the first failure. */
@@ -70,7 +123,7 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
70
123
  const base = url.replace(/\/+$/, "");
71
124
  const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
72
125
  const res = await fetch(`${base}/api/dev/theme${query}`, {
73
- headers: { authorization: `Bearer ${token}` },
126
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
74
127
  });
75
128
  if (!res.ok) throw new Error(await errorText(res));
76
129
  const { files } = await res.json();
@@ -109,7 +162,19 @@ export async function fetchDevSession({ url, token, name = null }) {
109
162
  const res = await fetch(`${base}/api/dev/session${query}`, {
110
163
  headers: { authorization: `Bearer ${token}` },
111
164
  });
112
- if (!res.ok) throw new Error(await errorText(res));
165
+ if (!res.ok) {
166
+ const detail = await errorText(res);
167
+ // Yeni sunucular 410'da açıklayıcı bir gövde döndürür — onu olduğu gibi göster, o otoritedir.
168
+ // Gövdesiz 410 (eski sunucu) `errorText`'ten "HTTP 410 …" olarak gelir; o durumda teşhisi
169
+ // biz veririz, yoksa kullanıcı yalnız bir statü kodu görür.
170
+ if (res.status === 410 && /^HTTP 410\b/.test(detail)) {
171
+ throw new Error(
172
+ "CLI remote preview (the signed preview URL and the editor view) is retired on this server " +
173
+ "(HTTP 410). Draft sync and `theme publish` are unaffected — they use different endpoints.",
174
+ );
175
+ }
176
+ throw new Error(detail);
177
+ }
113
178
  return res.json();
114
179
  }
115
180
 
@@ -118,21 +183,86 @@ export async function fetchDevSession({ url, token, name = null }) {
118
183
  * writes to a draft theme instance instead of the live theme — preview & publish
119
184
  * it from the admin panel without affecting the live site.
120
185
  */
121
- export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, onRetry = null }) {
186
+ export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null }) {
122
187
  const base = url.replace(/\/+$/, "");
123
188
  const files = readLocalTemplates(dir);
124
- const payload = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
189
+ const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
190
+ const remoteOnlyKept = [];
191
+ if (idempotencyKey && !dryRun) {
192
+ // 0.5.0 kanonik push üç aşamadır (denetim düzeltmeleri):
193
+ // 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
194
+ // provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
195
+ // arkada instance + sayfa klonu bırakıyordu.
196
+ // 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
197
+ // dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
198
+ // retained-rows kuralı korur. Taslak hedefte `?name=` taşınır: adsız probe taslağı
199
+ // `CLI Draft — <tarih>` olarak yaratır ve POST'un --name'i reuse yolunda yok sayılırdı.
200
+ // 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
201
+ const pre = await fetchWithRetry(
202
+ `${base}/api/dev/theme`,
203
+ { method: "POST", headers: headersFor({ "content-type": "application/json", "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
204
+ { onRetry },
205
+ );
206
+ if (!pre.ok) throw await httpError(pre);
207
+
208
+ const query = instance
209
+ ? `?instance=${encodeURIComponent(instance)}`
210
+ : draft
211
+ ? `?draft=1${name ? `&name=${encodeURIComponent(name)}` : ""}`
212
+ : "";
213
+ const probe = await fetch(`${base}/api/dev/theme${query}`, { headers: headersFor() });
214
+ if (!probe.ok) throw await httpError(probe);
215
+ const { files: remote = {} } = await probe.json();
216
+ for (const [key, content] of Object.entries(remote)) {
217
+ if (key in files) continue;
218
+ const top = key.split("/")[0] ?? "";
219
+ // Sunucu kapı-kabul kümesinin birebir aynası: THEME_DIRS altı ÇOK-SEGMENTLİ path'ler + tek
220
+ // config istisnası (çıplak `layout` gibi slash'sız bir uzak anahtar sunucuda 422 olurdu).
221
+ if ((key.includes("/") && THEME_DIRS.has(top)) || key === "config/settings_schema.json") {
222
+ files[key] = typeof content === "string" ? content : "";
223
+ remoteOnlyKept.push(key);
224
+ }
225
+ }
226
+ remoteOnlyKept.sort();
227
+ }
228
+ const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
229
+ // dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
230
+ const payload = dryRun ? { ...target, dryRun: true } : target;
231
+ const headers = canonicalHeaders({ "content-type": "application/json", authorization: `Bearer ${token}` });
232
+ if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
125
233
  const res = await fetchWithRetry(
126
234
  `${base}/api/dev/theme`,
127
- {
128
- method: "POST",
129
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
130
- body: JSON.stringify(payload),
131
- },
235
+ { method: "POST", headers, body: JSON.stringify(payload) },
132
236
  { onRetry },
133
237
  );
238
+ if (!res.ok) throw await httpError(res);
239
+ const json = await res.json();
240
+ return remoteOnlyKept.length ? { ...json, remoteOnlyKept } : json;
241
+ }
242
+
243
+ /**
244
+ * `blocofy theme push --diff` — compare the LOCAL theme with the LIVE theme (or an explicit `--instance`).
245
+ * Read-only: the draft target is deliberately NOT diffable — `?draft=1` GET'i sunucuda taslak PROVİZYONLAR
246
+ * (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
247
+ * çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
248
+ */
249
+ export async function diffTheme({ dir, url, token, instance = null }) {
250
+ const base = url.replace(/\/+$/, "");
251
+ const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
252
+ const res = await fetch(`${base}/api/dev/theme${query}`, {
253
+ headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
254
+ });
134
255
  if (!res.ok) throw new Error(await errorText(res));
135
- return res.json();
256
+ const { files: remote = {} } = await res.json();
257
+ const local = readLocalTemplates(dir);
258
+ const added = [];
259
+ const changed = [];
260
+ for (const [key, content] of Object.entries(local)) {
261
+ if (!(key in remote)) added.push(key);
262
+ else if (remote[key] !== content) changed.push(key);
263
+ }
264
+ const removed = Object.keys(remote).filter((k) => !(k in local));
265
+ return { added: added.sort(), changed: changed.sort(), removed: removed.sort() };
136
266
  }
137
267
 
138
268
  /**
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.6.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",