@blocofy/cli 0.2.4 → 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
@@ -17,13 +17,45 @@ blocofy login [--url <url>] [--token <bcf_…>]
17
17
  # Save your platform URL + dev token (~/.blocofy/credentials.json, 0600).
18
18
  blocofy theme dev [dir] # Local dev server. Prints 3 views — local preview, a live-domain
19
19
  # preview link, and the theme editor — all auto-reloading on save.
20
- # --port <n> (default 3030), --no-sync (local preview only)
20
+ # --port <n> (default 3030), --no-sync (local preview only),
21
+ # --name <name> (name the draft when first created)
21
22
  blocofy theme pull [dir] # Download the live theme to disk.
22
- blocofy theme push [dir] # Write the local theme to the live site (create/update; no delete).
23
+ blocofy theme push [dir] # Write the local theme to a DRAFT by default (create/update; no
24
+ # delete) — preview & publish from the admin panel. --live writes
25
+ # to the live site immediately (asks to confirm; add --yes for CI).
26
+ # --name <name> names the new draft (draft mode only).
27
+ blocofy theme rename <handle> <new name>
28
+ # Rename a theme (label only). Works on any theme, live included.
29
+ # Handle from the panel theme card or `blocofy status`.
23
30
  blocofy --version
24
31
  blocofy --help
25
32
  ```
26
33
 
34
+ ## Changelog
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.
52
+ - **0.4.0** — `theme push` / `theme dev` accept `--name <name>` to name a new draft (applied
53
+ only when the draft is first created; reuse ignores it). New `theme rename <handle> <new name>`
54
+ renames a theme (the name is a label; works on any theme, including the live one).
55
+ - **0.3.0** — `theme push` now writes to a **draft** by default; use `--live` for the old
56
+ immediate-live behavior (with a confirmation prompt; `--yes` to skip it in CI). `blocofy
57
+ status` now names the exact pages at 404 risk when a theme's pages are split off the live theme.
58
+
27
59
  ## How it works
28
60
 
29
61
  `theme dev` starts a local HTTP server. For each page request it reads your local theme
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,14 +20,36 @@ 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 } from "../lib/theme-sync.mjs";
23
- import { isAffirmative, livePushDecision } from "../lib/confirm.mjs";
23
+ import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
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";
26
27
 
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 "";
@@ -43,13 +66,14 @@ Usage
43
66
  Save your platform URL + dev token to ~/.blocofy/credentials.json.
44
67
  Get a token from the admin panel → Settings → Theme CLI tokens.
45
68
 
46
- blocofy theme dev [dir] [--port <n>] [--no-sync]
69
+ blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
47
70
  Start a dev server and print 3 auto-reloading views — Local, live-domain
48
71
  Preview, and the theme Editor. Press l / p / e to open each, q to quit.
49
72
  Edit a file and save → every open view reloads. Saves sync to a DRAFT theme
50
73
  only (never the live site). (dir defaults to cwd)
51
74
  --port <n> local port (default 3030)
52
75
  --no-sync local preview only (skip draft sync + remote views)
76
+ --name <name> name the draft when it is first created (ignored if it already exists)
53
77
 
54
78
  blocofy theme pull [dir] [--draft] [--instance <handle>]
55
79
  Download the live theme to disk. (dir defaults to cwd)
@@ -57,14 +81,25 @@ Usage
57
81
  --instance <handle> pull a specific theme by its handle (from the admin
58
82
  panel theme card, or \`blocofy status\`)
59
83
 
60
- blocofy theme push [dir] [--draft] [--yes] [--instance <handle>]
61
- Write the local theme to the LIVE site IMMEDIATELY (create/update; no delete,
62
- no preview). Asks for confirmation first; non-interactive shells must pass --yes.
63
- --draft write to a draft theme instead — preview & publish it from the
64
- admin panel (recommended; never touches the live site)
65
- --yes confirm the live push without prompting (for CI / agents)
84
+ blocofy theme push [dir] [--live] [--yes] [--instance <handle>]
85
+ By DEFAULT writes to a DRAFT theme (create/update; no delete) — preview & publish
86
+ it from the admin panel, never touching the live site. Publish it with
87
+ 'blocofy theme publish'.
88
+ --live write to the LIVE site IMMEDIATELY (no preview). Asks for
89
+ confirmation first; non-interactive shells must add --yes.
90
+ --draft explicit draft (same as the default; safe)
91
+ --yes confirm a --live push without prompting (for CI / agents)
66
92
  --instance <handle> push to a specific theme by its handle (safe targeted
67
93
  write — no live-confirmation prompt)
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
99
+
100
+ blocofy theme rename <handle> <new name>
101
+ Rename a theme (the name is just a label). Works on any of your themes,
102
+ including the live one. Handle comes from the panel theme card or 'blocofy status'.
68
103
 
69
104
  blocofy theme publish [--instance <handle>]
70
105
  Publish a draft theme to the LIVE site. With no flag, publishes the draft that
@@ -90,7 +125,7 @@ Usage
90
125
  Examples
91
126
  blocofy login --url https://store.myblocofy.com --token bcf_xxxxxxxx
92
127
  blocofy theme pull && blocofy theme dev
93
- blocofy theme push --draft && blocofy theme publish
128
+ blocofy theme push && blocofy theme publish
94
129
  blocofy status
95
130
 
96
131
  Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
@@ -110,7 +145,7 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
110
145
  }
111
146
 
112
147
  async function login(rest) {
113
- const { flags } = parseArgs(rest);
148
+ const { flags } = parseArgsOrExit(rest, KNOWN.login);
114
149
  let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
115
150
  let token = typeof flags.token === "string" ? flags.token.trim() : "";
116
151
 
@@ -178,7 +213,7 @@ function requireCreds() {
178
213
  }
179
214
 
180
215
  async function themePull(rest) {
181
- const { flags, positionals } = parseArgs(rest);
216
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePull);
182
217
  const dir = resolve(positionals[0] ?? process.cwd());
183
218
  const creds = requireCreds();
184
219
  const draft = Boolean(flags.draft);
@@ -189,15 +224,56 @@ async function themePull(rest) {
189
224
  }
190
225
 
191
226
  async function themePush(rest) {
192
- const { flags, positionals } = parseArgs(rest);
227
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePush);
193
228
  const dir = resolve(positionals[0] ?? process.cwd());
194
229
  if (!existsSync(dir)) {
195
230
  console.error(`Theme directory not found: ${dir}`);
196
231
  process.exit(1);
197
232
  }
198
233
  const creds = requireCreds();
199
- const draft = Boolean(flags.draft);
200
- const instance = typeof flags.instance === "string" ? flags.instance : null;
234
+ const instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
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
+ }
268
+
269
+ // Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
270
+ // açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
271
+ // modu canlıya yazar ve onay gerektirir.
272
+ const { mode, instance } = resolvePushMode({
273
+ live: Boolean(flags.live),
274
+ draft: Boolean(flags.draft),
275
+ instance: instanceFlag,
276
+ });
201
277
 
202
278
  // Hedef tenant'ı çöz ve GÖSTER — site sunucuda TOKEN'dan çözülür (URL kozmetik),
203
279
  // yanlış-tenant'a yazımı görünür kılar ("klarosa sandım, ksc'ye yazdım"). whoami
@@ -209,21 +285,27 @@ async function themePush(rest) {
209
285
  /* best-effort */
210
286
  }
211
287
  const target = site ? siteLabel(site) : creds.url;
212
- if (site) {
213
- console.log(`→ ${draft ? "Pushing to a draft" : "Pushing to the LIVE theme"} of ${target}`);
288
+ if (mode === "instance") {
289
+ console.log(`→ Pushing to theme ${instance}${site ? ` of ${target}` : ""}`);
290
+ } else if (mode === "live") {
291
+ console.log(dryRun ? `→ Validating against the LIVE theme of ${target} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${target}`);
292
+ } else {
293
+ console.log(`→ Pushing to a draft${site ? ` of ${target}` : ""}`);
214
294
  }
215
295
 
216
- // Canlı push (flag'siz) ANINDA canlı temayı değiştirir (önizleme yok). Agent/CI
217
- // kazara canlıya basmasın diye açık onay şart (#431 L2).
296
+ // Canlı push (`--live`) ANINDA canlı temayı değiştirir (önizleme yok). Agent/CI
297
+ // kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
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).
218
300
  const decision = livePushDecision({
219
- draft: draft || Boolean(instance),
301
+ draft: mode !== "live" || dryRun,
220
302
  yes: Boolean(flags.yes),
221
303
  confirm: Boolean(flags.confirm),
222
304
  isTTY: Boolean(process.stdin.isTTY),
223
305
  });
224
306
  if (decision.mustAbort) {
225
- console.error(`⚠ 'theme push' writes to the LIVE theme of ${target} immediately (no preview).`);
226
- console.error(` Non-interactive shell: pass --yes to confirm, or use --draft for a safe draft.`);
307
+ console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${target} immediately (no preview).`);
308
+ console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
227
309
  process.exit(1);
228
310
  }
229
311
  if (decision.needsPrompt) {
@@ -235,22 +317,90 @@ async function themePush(rest) {
235
317
  rl.close();
236
318
  }
237
319
  if (!isAffirmative(answer)) {
238
- console.error("Aborted. Use `--draft` to push to a draft, or `--yes` to confirm the live push.");
320
+ console.error("Aborted. Omit `--live` to push to a safe draft, or pass `--live --yes` to confirm.");
239
321
  process.exit(1);
240
322
  }
241
323
  }
242
324
 
243
- const result = await pushTheme({
244
- dir,
245
- url: creds.url,
246
- token: creds.token,
247
- draft,
248
- instance,
249
- onRetry: (info) => console.error(retryNotice(info)),
250
- });
325
+ // `--name` yalnızca YENİ taslak yaratırken (draft modu) anlamlı — canlıya/mevcut
326
+ // instance'a yazarken ad kaydedilmez, sessizce kaybolmasın diye açıkça uyar.
327
+ if (name && mode !== "draft") {
328
+ console.error("Note: --name yalnız yeni taslak yaratırken (varsayılan push) geçerli, yok sayıldı.");
329
+ }
330
+
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
+ }
390
+
391
+ // Sunucu canlı-yazımı bildirdiyse (yeni alan; eski sunucuda yok) belirgin uyar.
392
+ if (result.warning === "live_write" && result.message) {
393
+ console.error(`\n⚠ ${result.message}\n`);
394
+ }
395
+
251
396
  if (result.instanceId) {
252
397
  console.log(`Pushed to theme ${result.instanceId} (${result.created} created, ${result.updated} updated).`);
253
- console.log(`Preview & publish it in the admin panel: Theme → Theme library → "Open in editor".`);
398
+ if (mode === "draft") {
399
+ console.log(`Preview & publish it in the admin panel: Theme → Theme library → "Open in editor".`);
400
+ console.log(`Publish it live with: blocofy theme publish`);
401
+ } else {
402
+ console.log(`Preview & publish it in the admin panel: Theme → Theme library → "Open in editor".`);
403
+ }
254
404
  } else {
255
405
  const extra = result.skippedDeletes
256
406
  ? `, ${result.skippedDeletes} remote file(s) absent locally (not deleted)`
@@ -260,7 +410,7 @@ async function themePush(rest) {
260
410
  }
261
411
 
262
412
  async function contentPush(scope, rest) {
263
- const { positionals } = parseArgs(rest);
413
+ const { positionals } = parseArgsOrExit(rest, KNOWN.content);
264
414
  const dir = resolve(positionals[0] ?? process.cwd());
265
415
  if (!existsSync(dir)) {
266
416
  console.error(`Directory not found: ${dir}`);
@@ -282,7 +432,7 @@ async function contentPush(scope, rest) {
282
432
  }
283
433
 
284
434
  async function contentPull(scope, rest) {
285
- const { positionals } = parseArgs(rest);
435
+ const { positionals } = parseArgsOrExit(rest, KNOWN.content);
286
436
  const dir = resolve(positionals[0] ?? process.cwd());
287
437
  const creds = requireCreds();
288
438
  const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
@@ -290,7 +440,7 @@ async function contentPull(scope, rest) {
290
440
  }
291
441
 
292
442
  async function themeDev(rest) {
293
- const { flags, positionals } = parseArgs(rest);
443
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeDev);
294
444
  const themeDir = resolve(positionals[0] ?? process.cwd());
295
445
 
296
446
  if (!existsSync(themeDir)) {
@@ -306,7 +456,8 @@ async function themeDev(rest) {
306
456
  let session = null;
307
457
  if (!flags["no-sync"]) {
308
458
  try {
309
- session = await fetchDevSession({ url: creds.url, token: creds.token });
459
+ const name = typeof flags.name === "string" ? flags.name : null;
460
+ session = await fetchDevSession({ url: creds.url, token: creds.token, name });
310
461
  } catch (error) {
311
462
  console.warn(
312
463
  `Warning: dev session unavailable (${error?.message ?? error}). ` +
@@ -430,7 +581,7 @@ async function themeDev(rest) {
430
581
  }
431
582
 
432
583
  async function themePublish(rest) {
433
- const { flags } = parseArgs(rest);
584
+ const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
434
585
  const creds = requireCreds();
435
586
  let instance = typeof flags.instance === "string" ? flags.instance : null;
436
587
  if (!instance) {
@@ -444,6 +595,20 @@ async function themePublish(rest) {
444
595
  );
445
596
  }
446
597
 
598
+ async function themeRename(rest) {
599
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
600
+ const handle = positionals[0];
601
+ const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
602
+ if (!handle || !name) {
603
+ console.error("Usage: blocofy theme rename <handle> <new name>");
604
+ console.error(" Rename a theme (handle from the panel theme card or `blocofy status`).");
605
+ process.exit(1);
606
+ }
607
+ const creds = requireCreds();
608
+ const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name });
609
+ console.log(`✓ Renamed to "${result.name}" (${result.id}).`);
610
+ }
611
+
447
612
  async function status() {
448
613
  const creds = requireCreds();
449
614
  const s = await fetchSiteStatus({ url: creds.url, token: creds.token });
@@ -455,12 +620,23 @@ async function status() {
455
620
  : `Live theme: none`,
456
621
  );
457
622
  console.log(`Health: ${s.health}`);
623
+ // orphan_missing_slugs yeni sunucu alanı (PR #661). Eski/deploy-edilmemiş sunucuda
624
+ // yok → null; o durumda eski (yumuşatılmış) sayaç satırına düş.
625
+ const missing = Array.isArray(s.orphan_missing_slugs) ? s.orphan_missing_slugs : null;
458
626
  if (s.health === "live_instance_empty") {
459
627
  console.error(
460
628
  ` ⚠ The live theme has no pages — the site will 404. Publish a theme with content: blocofy theme publish`,
461
629
  );
462
630
  } else if (s.health === "pages_split") {
463
- console.warn(` ⚠ ${s.orphaned_pages} page(s) live on a non-live instance (orphan).`);
631
+ if (missing && missing.length) {
632
+ console.warn(
633
+ ` ⚠ These pages are published only on a non-live theme, so visitors can't reach them (404 risk): ` +
634
+ `${missing.join(", ")}\n` +
635
+ ` Fix: publish the theme that has them — blocofy theme publish`,
636
+ );
637
+ } else {
638
+ console.warn(` ⚠ ${s.orphaned_pages} page(s) published on a non-live theme.`);
639
+ }
464
640
  }
465
641
  if (Array.isArray(s.drafts) && s.drafts.length > 0) {
466
642
  console.log(`Drafts: ${s.drafts.map((d) => `${d.id}${d.name ? ` ${d.name}` : ""}`).join(", ")}`);
@@ -474,6 +650,13 @@ if (first === "--version" || first === "-v") {
474
650
  console.log(VERSION);
475
651
  } else if (!first || first === "--help" || first === "-h" || first === "help") {
476
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);
477
660
  } else if (first === "login") {
478
661
  login(rest).catch((error) => {
479
662
  console.error(error?.message ?? error);
@@ -499,6 +682,11 @@ if (first === "--version" || first === "-v") {
499
682
  console.error(error?.message ?? error);
500
683
  process.exit(1);
501
684
  });
685
+ } else if (first === "theme" && rest[0] === "rename") {
686
+ themeRename(rest.slice(1)).catch((error) => {
687
+ console.error(error?.message ?? error);
688
+ process.exit(1);
689
+ });
502
690
  } else if (first === "status") {
503
691
  status().catch((error) => {
504
692
  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", "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;
package/lib/confirm.mjs CHANGED
@@ -15,6 +15,22 @@ export function livePushDecision({ draft, yes, confirm, isTTY }) {
15
15
  return { autoApproved: false, needsPrompt: true, mustAbort: false };
16
16
  }
17
17
 
18
+ /**
19
+ * `theme push` hedefini seç (saf, yan etkisiz). Yeni varsayılan: DRAFT (güvenli).
20
+ * `--live` eski "anında canlı" davranışını açıkça geri getirir.
21
+ * - `instance` verilmişse → { mode: "instance", instance } (açık-hedef; `--live`
22
+ * olsa bile instance KAZANIR — kullanıcı belirli bir temayı adresledi).
23
+ * - `--live` (instance yok) → { mode: "live" } (onay gerektiren tek yol).
24
+ * - aksi halde → { mode: "draft" } (varsayılan; açık `--draft` dahil).
25
+ * - `--live` + `--draft` birlikte → draft (güvenli olan kazanır).
26
+ */
27
+ export function resolvePushMode({ live, draft, instance } = {}) {
28
+ if (instance) return { mode: "instance", instance };
29
+ if (draft) return { mode: "draft" };
30
+ if (live) return { mode: "live" };
31
+ return { mode: "draft" };
32
+ }
33
+
18
34
  /** y/N yanıtını yorumla (yes/y → onay). */
19
35
  export function isAffirmative(answer) {
20
36
  const a = String(answer ?? "").trim().toLowerCase();
@@ -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();
@@ -103,9 +152,10 @@ export async function fetchWhoami({ url, token }) {
103
152
  * Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
104
153
  * 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
105
154
  */
106
- export async function fetchDevSession({ url, token }) {
155
+ export async function fetchDevSession({ url, token, name = null }) {
107
156
  const base = url.replace(/\/+$/, "");
108
- const res = await fetch(`${base}/api/dev/session`, {
157
+ const query = name ? `?name=${encodeURIComponent(name)}` : "";
158
+ const res = await fetch(`${base}/api/dev/session${query}`, {
109
159
  headers: { authorization: `Bearer ${token}` },
110
160
  });
111
161
  if (!res.ok) throw new Error(await errorText(res));
@@ -117,21 +167,86 @@ export async function fetchDevSession({ url, token }) {
117
167
  * writes to a draft theme instance instead of the live theme — preview & publish
118
168
  * it from the admin panel without affecting the live site.
119
169
  */
120
- export async function pushTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
170
+ export async function pushTheme({ dir, url, token, draft = false, instance = null, name = null, dryRun = false, idempotencyKey = null, onRetry = null }) {
121
171
  const base = url.replace(/\/+$/, "");
122
172
  const files = readLocalTemplates(dir);
123
- const payload = instance ? { files, instance } : { 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;
124
217
  const res = await fetchWithRetry(
125
218
  `${base}/api/dev/theme`,
126
- {
127
- method: "POST",
128
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
129
- body: JSON.stringify(payload),
130
- },
219
+ { method: "POST", headers, body: JSON.stringify(payload) },
131
220
  { onRetry },
132
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
+ });
133
239
  if (!res.ok) throw new Error(await errorText(res));
134
- 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() };
135
250
  }
136
251
 
137
252
  /**
@@ -150,6 +265,22 @@ export async function publishInstance({ url, token, instanceId }) {
150
265
  return res.json();
151
266
  }
152
267
 
268
+ /**
269
+ * Bir tema instance'ının adını değiştir (`POST /api/dev/theme/rename`). Ad yalnızca
270
+ * bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
271
+ * `{ ok, id, name }` döner.
272
+ */
273
+ export async function renameInstance({ url, token, instance, name }) {
274
+ const base = url.replace(/\/+$/, "");
275
+ const res = await fetch(`${base}/api/dev/theme/rename`, {
276
+ method: "POST",
277
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
278
+ body: JSON.stringify({ instance, name }),
279
+ });
280
+ if (!res.ok) throw new Error(await errorText(res));
281
+ return res.json();
282
+ }
283
+
153
284
  /**
154
285
  * Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
155
286
  * instance-başına sayfa dağılımı, taslaklar ve health döner.
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.2.4",
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",