@blocofy/cli 0.13.0 → 0.14.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
@@ -61,7 +61,7 @@ Show which site a command in `[dir]` would hit (verified), without writing anyth
61
61
  ### Theme
62
62
 
63
63
  ```
64
- blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
64
+ blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
65
65
  ```
66
66
  Start a local dev server that renders your local theme files with the site's live content.
67
67
  Press `l` to open it, `q` to quit. Edit a file and save → the view reloads. Saves sync to a
@@ -73,6 +73,8 @@ changing credentials, context or the project binding).
73
73
  - `--port <n>` — local port (default 3030).
74
74
  - `--no-sync` — local preview only (skip draft sync).
75
75
  - `--name <name>` — name the draft when it is first created (ignored if it already exists).
76
+ - `--instance <handle>` — sync into this draft. Needed when the platform cannot tell which draft is the
77
+ CLI draft (see "Which draft a draft command writes to" below); the live theme's handle is refused.
76
78
 
77
79
  ```
78
80
  blocofy theme pull [dir] [--draft] [--instance <handle>]
@@ -94,11 +96,19 @@ the admin panel, never touching the live site. Publish it with `blocofy theme pu
94
96
  non-interactive shells must add `--yes`.
95
97
  - `--draft` — explicit draft (same as the default; safe).
96
98
  - `--yes` — confirm a `--live` push without prompting (CI/agents).
97
- - `--instance <handle>` — push to a specific theme by its handle (safe targeted write — no
98
- live-confirmation prompt).
99
- - `--name <name>` — name the new draft (draft mode only; ignored on `--live`/`--instance`). A draft
100
- push reuses the existing CLI draft whatever its name; then the push says `--name` was not applied
101
- and prints the `blocofy theme rename` command for it.
99
+ - `--instance <handle>` — push to a specific theme by its handle. The live theme's handle asks for
100
+ the same confirmation as `--live` (non-interactive shells add `--yes`); so does any handle the CLI
101
+ cannot tell apart from the live theme (the live theme is unknown, or a raw numeric id was given).
102
+ With `--draft --instance <handle>` the push is a draft write to that draft: it never writes the
103
+ live theme (refused locally and by the platform, `draft_target_is_live`).
104
+ On a site with **no live theme** (or when the live theme cannot be read) the CLI cannot tell any
105
+ handle apart from the live one, so it stays on the safe side by design: every `--instance` push
106
+ asks for confirmation (`--yes` in non-interactive shells), and `--draft --instance` and
107
+ `theme dev --instance` refuse.
108
+ - `--name <name>` — name the new draft: it is created as `CLI Draft — <name>` (draft mode only;
109
+ ignored on `--live`/`--instance`). When the push reuses the existing CLI draft instead, it says
110
+ `--name` was not applied. Do not rename the CLI draft by hand to a name that does not start with
111
+ `CLI Draft — `: the next draft push would refuse it and ask for `--instance`.
102
112
  - `--dry-run` / `--validate` — check the push on the server without writing; the two flags are
103
113
  aliases. The dry run covers exactly the files the push would send (remote-only files it keeps
104
114
  included; with `--prune`, without the ones it would remove). A current platform plans the whole
@@ -108,6 +118,18 @@ the admin panel, never touching the live site. Publish it with `blocofy theme pu
108
118
  compares with the CLI draft it writes to (named by handle); with no CLI draft yet, with the live
109
119
  theme (the push would create the draft). `--live` compares with live, `--instance` with that theme.
110
120
 
121
+ **Which draft a draft command writes to.** A draft push, `theme pull --draft` and the `theme dev`
122
+ sync write to (or read) the CLI draft only when the platform can tell which draft that is: exactly
123
+ one draft created by a CLI push, still carrying a name the platform gives it (`CLI Draft`,
124
+ `CLI Draft — YYYY-MM-DD` or `CLI Draft — <name>`), and not a draft a site-state restore built.
125
+ Otherwise — two such drafts, a site-state restore draft, or a CLI draft renamed by hand — the command
126
+ writes nothing, lists the candidate drafts and exits 2 (`draft_target_ambiguous`). The message
127
+ pre-fills `--instance <handle>` only for a CLI-named draft that is not a restore draft; naming any
128
+ other listed draft would overwrite it. On a site whose only candidate is a site-state restore draft,
129
+ create a new draft theme in the admin panel and pass its handle with `--instance`, or publish or
130
+ delete the restore draft first. `theme publish` without `--instance` uses the same rule and never
131
+ publishes a guessed draft. With no draft at all, the first push creates one as before.
132
+
111
133
  After a draft push the CLI prints the draft it wrote to (`Draft: <handle> "<name>" (new)` or
112
134
  `(existing CLI draft, updated)`) and the `blocofy theme publish --instance <handle>` command.
113
135
  - `--idempotency-key <k>` — attach a key so a retried push is not double-applied.
@@ -158,7 +180,9 @@ flag, publishes the draft that `theme dev` / `theme push --draft` writes into. T
158
180
  refuses to publish a theme that has no pages (it would 404); preview first. The site is the one
159
181
  `[dir]`'s project is bound to (`dir` defaults to cwd), so `blocofy theme push ./shop && blocofy
160
182
  theme publish ./shop` always publishes `./shop`'s site, whatever directory you run it from.
161
- - `--instance <handle>` — publish a specific theme.
183
+ - `--instance <handle>` — publish a specific theme. Without it the platform publishes only the
184
+ site's CLI draft; any other theme (a site-state restore draft, a renamed or panel-copied theme) is
185
+ refused with `publish_target_unconfirmed` (exit 2, nothing published) — name it with `--instance`.
162
186
 
163
187
  ### Status
164
188
 
@@ -461,6 +485,30 @@ Each retry prints a notice on stderr.
461
485
 
462
486
  ## Changelog
463
487
 
488
+ - **0.14.0** — Draft commands no longer guess their draft (platform #989).
489
+ - **Behaviour change:** `theme push` (draft), `theme push --diff`, `theme pull --draft` and the
490
+ `theme dev` sync refuse, writing nothing, when the platform answers `draft_target_ambiguous` (or the
491
+ site status shows the same case): the message lists the candidate drafts and the exact
492
+ `--instance <handle>` command; exit code 2; never retried. A CLI draft you renamed is refused too.
493
+ - The refusal pre-fills `--instance` only for a safe candidate (CLI-named, not a restore draft) and
494
+ otherwise warns that naming a listed draft would overwrite it.
495
+ - `theme dev --instance <handle>` and `theme push --draft --instance <handle>` write to a chosen draft
496
+ (sent with `draft: true`; never the live theme).
497
+ - `theme dev` reports a draft-sync failure in one line, also at startup (it was silent there):
498
+ `draft_target_unverifiable` (the next save tries again) and `draft_target_is_live` (stops).
499
+ - **Behaviour change:** `theme publish` without `--instance` uses the same rule; it never publishes
500
+ a guessed draft.
501
+ - **Behaviour change:** `--name X` creates the draft as `CLI Draft — X`; the rename advice after a
502
+ reused draft is gone (a draft renamed by hand to another name is refused).
503
+ - **Behaviour change (CI):** `theme push --instance <the live theme's handle>` asks for the same
504
+ confirmation as `--live` (`--yes` in non-interactive shells); so does a handle the CLI cannot tell
505
+ apart from the live theme (live theme unknown, or a raw numeric id).
506
+ - `theme publish --instance <handle>` tells the platform the theme was named explicitly
507
+ (`explicit: true`); the automatic pick sends no flag and the platform re-checks it. A theme that is
508
+ not the site's CLI draft (a site-state restore draft, a renamed or panel-copied theme) is published
509
+ through the CLI only with `--instance`; otherwise the platform refuses with
510
+ `publish_target_unconfirmed` (exit 2, nothing published). Older CLIs get that refusal as a generic
511
+ error and can never publish such a theme.
464
512
  - **0.13.0** — Draft push names its target (customer items PS-22/PS-23/PS-26).
465
513
  - `theme push` (draft) prints the draft it wrote to — handle, name, new or existing — and
466
514
  `blocofy theme publish --instance <handle>`. When it updated the existing CLI draft, it says
package/bin/blocofy.mjs CHANGED
@@ -71,7 +71,7 @@ import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } f
71
71
  import { SiteStateFsError, hashBuffer, readSiteStateTree, stagedWriteTree } from "../lib/site-state-fs.mjs";
72
72
  import { migrateSiteState } from "../lib/site-migrate.mjs";
73
73
  import { applySiteState, downloadAssetBytes, fetchSiteStateExport, planSiteState, publishSiteState, uploadMediaAsset } from "../lib/site-state-client.mjs";
74
- import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
74
+ import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
75
75
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
76
76
  import { hyperlink, openUrl } from "../lib/term.mjs";
77
77
  import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
@@ -90,7 +90,7 @@ const KNOWN = {
90
90
  pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
91
91
  themePull: ["draft", "instance"],
92
92
  themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
93
- themeDev: ["port", "dry", "no-sync", "name"],
93
+ themeDev: ["port", "dry", "no-sync", "name", "instance"],
94
94
  themePublish: ["instance"],
95
95
  themeRename: ["name", "dir"],
96
96
  content: [],
@@ -177,7 +177,7 @@ Usage
177
177
  blocofy target [dir] [--context <name>] [--json]
178
178
  Show which site a command in [dir] would hit (verified), without writing anything.
179
179
 
180
- blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
180
+ blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
181
181
  Start a local dev server that renders your local theme files with the site's
182
182
  live content. Press l to open it, q to quit. Edit a file and save → the view
183
183
  reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
@@ -188,6 +188,8 @@ Usage
188
188
  --port <n> local port (default 3030)
189
189
  --no-sync local preview only (skip draft sync)
190
190
  --name <name> name the draft when it is first created (ignored if it already exists)
191
+ --instance <handle> sync into this draft (when the platform cannot tell which draft is
192
+ the CLI draft it refuses and asks for this; never the live theme)
191
193
 
192
194
  blocofy theme pull [dir] [--draft] [--instance <handle>]
193
195
  Download the live theme to disk. (dir defaults to cwd)
@@ -204,9 +206,11 @@ Usage
204
206
  confirmation first; non-interactive shells must add --yes.
205
207
  --draft explicit draft (same as the default; safe)
206
208
  --yes confirm a --live push without prompting (for CI / agents)
207
- --instance <handle> push to a specific theme by its handle (safe targeted
208
- write — no live-confirmation prompt)
209
- --name <name> name the NEW draft (draft mode only; ignored on --live/--instance)
209
+ --instance <handle> push to a specific theme by its handle. If the platform cannot
210
+ tell which draft is the CLI draft, a draft push refuses and asks
211
+ for this. The LIVE theme's handle asks for confirmation like --live
212
+ --name <name> name the NEW draft "CLI Draft — <name>" (draft mode only; ignored on
213
+ --live/--instance). With --draft --instance, writes that draft (never live)
210
214
  --dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
211
215
  --validate alias for --dry-run (validate only, nothing written)
212
216
  --diff show what a push WOULD change vs the target (read-only), then stop
@@ -226,7 +230,9 @@ Usage
226
230
  'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
227
231
  With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
228
232
  The server refuses to publish a theme that has no pages (it would 404); preview first.
229
- --instance <handle> publish a specific theme (handle from the panel / status)
233
+ --instance <handle> publish a specific theme (handle from the panel / status). Without it
234
+ the platform publishes only the site's CLI draft; any other theme
235
+ is refused (publish_target_unconfirmed) — name it with --instance
230
236
 
231
237
  blocofy status [dir]
232
238
  Show the live theme, page distribution per instance, drafts, and a health flag
@@ -503,6 +509,36 @@ function failAndExit(error) {
503
509
  process.exit(code);
504
510
  }
505
511
 
512
+ /**
513
+ * #989 — the platform (or `findCliDraft`, which mirrors it) refused to pick the draft automatically. Says why, lists the
514
+ * candidate drafts and the exact command to repeat with `--instance`; exits 2 (409). Never retried.
515
+ */
516
+ function failDraftTargetAmbiguous(error, command, { action = "write" } = {}) {
517
+ // Review P3: a refusal of a RESENT apply says nothing about the earlier attempt — never "Nothing was written" then.
518
+ const earlierAttempt = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
519
+ failAndExit({
520
+ code: DRAFT_TARGET_AMBIGUOUS,
521
+ status: 409,
522
+ message: draftTargetAmbiguousMessage(error, { command, action, earlierAttempt }),
523
+ details: {
524
+ reason: error.reason ?? null,
525
+ candidates: error.candidates ?? [],
526
+ suggestedInstance: error.suggestedInstance ?? null,
527
+ ...(earlierAttempt ? { earlierAttempt } : {}),
528
+ },
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Could `instance` be the live theme? Fail-closed (#989 review): yes when it is the live handle, when the live theme is
534
+ * not known, and when it is a raw numeric id (the server accepts one, the live handle comparison cannot see it).
535
+ */
536
+ const instanceMaybeLive = (instance, liveThemeId) =>
537
+ liveThemeId == null || String(instance) === String(liveThemeId) || /^\d+$/.test(String(instance));
538
+
539
+ /** The command line to repeat with `--instance` (the directory argument kept, as the user typed it). */
540
+ const commandLine = (base, positionals) => [base, ...positionals.slice(0, 1)].join(" ");
541
+
506
542
  function contextNameOrExit(name) {
507
543
  if (name === ENV_CONTEXT || !CONTEXT_NAME_RE.test(name)) {
508
544
  throw new TargetError("TARGET_CONTEXT_INVALID", `"${name}" cannot be used as a context name (letters, digits, . _ -; "env" is reserved).`, { context: name }, 1);
@@ -927,7 +963,16 @@ async function themePull(rest) {
927
963
  const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
928
964
  // Review M1: a draft pull provisions the draft server-side (`?draft=1`), so it is a remote mutation: binding required.
929
965
  const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
930
- const { count } = await withNewBindingClaim(target, dir, () => pullTheme({ dir, url: target.dev.url, token: target.dev.token, draft, instance, onRetry }));
966
+ let count;
967
+ try {
968
+ ({ count } = await withNewBindingClaim(target, dir, () => pullTheme({ dir, url: target.dev.url, token: target.dev.token, draft, instance, onRetry })));
969
+ } catch (error) {
970
+ if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme pull", positionals));
971
+ if (error?.code === "draft_target_unverifiable") {
972
+ failAndExit({ code: "draft_target_unverifiable", status: 503, message: "The platform could not verify which draft to pull (a read failed on its side). Nothing was written. Try again in a moment.", details: {} });
973
+ }
974
+ throw error;
975
+ }
931
976
  console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
932
977
  bindAfterPull(target, dir);
933
978
  }
@@ -969,7 +1014,14 @@ async function themePush(rest) {
969
1014
  // server-side (a read-only command must not mutate). With no CLI draft yet the push would create one: vs live.
970
1015
  if (flags.diff) {
971
1016
  const diffTarget = ` of ${siteLabel(target.identity.site)}`;
972
- const cliDraft = mode === "draft" ? findCliDraft(await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry })) : null;
1017
+ let cliDraft = null;
1018
+ try {
1019
+ cliDraft = mode === "draft" ? findCliDraft(await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry })) : null;
1020
+ } catch (error) {
1021
+ // #989: the diff compares with the draft the push would write to — and refuses exactly when the push would.
1022
+ if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, `${commandLine("blocofy theme push", positionals)} --diff`);
1023
+ throw error;
1024
+ }
973
1025
  const diffInstance = instanceFlag ?? cliDraft?.id ?? null;
974
1026
  const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: diffInstance, onRetry });
975
1027
  if (instanceFlag) console.log(`Diff vs theme ${instanceFlag}${diffTarget}:`);
@@ -992,7 +1044,7 @@ async function themePush(rest) {
992
1044
  const whoami = target.identity;
993
1045
  const siteName = siteLabel(whoami.site) || String(whoami.site.id);
994
1046
  if (mode === "instance") {
995
- console.log(`→ Pushing to theme ${instance} of ${siteName}`);
1047
+ console.log(`→ Pushing to theme ${instance} of ${siteName}${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
996
1048
  } else if (mode === "live") {
997
1049
  console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
998
1050
  } else {
@@ -1003,13 +1055,34 @@ async function themePush(rest) {
1003
1055
  // kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
1004
1056
  // güvenli → otomatik onay (prompt yok).
1005
1057
  // 0.5.0: `--dry-run` hiçbir şey yazmaz → canlı onayı gereksiz (draft:true gibi davranır).
1058
+ // #989 review: `--instance <the live theme's handle>` writes the LIVE theme exactly like `--live`, so it needs the
1059
+ // same confirmation (an explicit --instance is the documented way past a refused draft pick — it must not become a
1060
+ // way to write live without asking).
1061
+ const instanceIsLive = mode === "instance" && instanceMaybeLive(instance, whoami.liveThemeId);
1062
+ // `--draft --instance <h>`: a draft write to a chosen draft. It never reaches the live theme — refused here when the
1063
+ // handle may be live (fail-closed), and by the server (422 draft_target_is_live) in any case.
1064
+ const draftInstance = mode === "instance" && Boolean(flags.draft);
1065
+ if (draftInstance && instanceIsLive && !dryRun) {
1066
+ console.error(`✗ --draft --instance ${instance}: ${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? "that is the LIVE theme" : "this may be the LIVE theme (it could not be told apart from it)"}; a draft push never writes live. Nothing was written.`);
1067
+ console.error(" Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.");
1068
+ process.exit(2);
1069
+ }
1006
1070
  const decision = livePushDecision({
1007
- draft: mode !== "live" || dryRun,
1071
+ draft: (mode !== "live" && (!instanceIsLive || draftInstance)) || dryRun,
1008
1072
  yes: Boolean(flags.yes),
1009
1073
  confirm: Boolean(flags.confirm),
1010
1074
  isTTY: Boolean(process.stdin.isTTY),
1011
1075
  });
1012
1076
  if (decision.mustAbort) {
1077
+ if (instanceIsLive) {
1078
+ console.error(
1079
+ whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId)
1080
+ ? `⚠ Theme ${instance} is the LIVE theme of ${siteName}: this push writes to it immediately (no preview).`
1081
+ : `⚠ Theme ${instance} may be the LIVE theme of ${siteName} (it could not be told apart from it): a push to it may write live immediately.`,
1082
+ );
1083
+ console.error(` Non-interactive shell: pass --yes to confirm, or push to a draft instead. Nothing was written.`);
1084
+ process.exit(1);
1085
+ }
1013
1086
  console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${siteName} immediately (no preview).`);
1014
1087
  console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
1015
1088
  process.exit(1);
@@ -1033,7 +1106,7 @@ async function themePush(rest) {
1033
1106
  // `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
1034
1107
  // TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
1035
1108
  const prune = Boolean(flags.prune);
1036
- const liveTarget = mode === "live" || (mode === "instance" && (whoami.liveThemeId == null || String(instance) === String(whoami.liveThemeId)));
1109
+ const liveTarget = mode === "live" || instanceIsLive;
1037
1110
  const pruneDecision = livePushDecision({
1038
1111
  draft: !prune || dryRun || !liveTarget,
1039
1112
  yes: Boolean(flags.yes),
@@ -1080,7 +1153,7 @@ async function themePush(rest) {
1080
1153
  dir,
1081
1154
  url: creds.url,
1082
1155
  token: creds.token,
1083
- draft: mode === "draft",
1156
+ draft: mode === "draft" || draftInstance,
1084
1157
  instance: mode === "instance" ? instance : null,
1085
1158
  name: mode === "draft" ? name : null,
1086
1159
  dryRun,
@@ -1098,6 +1171,10 @@ async function themePush(rest) {
1098
1171
  }
1099
1172
  failAndExit({ code: "cli_upgrade_required", status: error.status, message: "The server refused this CLI version; update it: npm i -g @blocofy/cli@latest", details: { missing } });
1100
1173
  }
1174
+ if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme push", positionals));
1175
+ if (error?.code === "draft_target_is_live") {
1176
+ failAndExit({ code: "draft_target_is_live", status: 422, message: "A draft push cannot write the LIVE theme; nothing was written. Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.", details: {} });
1177
+ }
1101
1178
  if (error?.code === "idempotency_conflict") {
1102
1179
  if (!JSON_MODE) {
1103
1180
  console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
@@ -1150,9 +1227,10 @@ async function themePush(rest) {
1150
1227
  const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
1151
1228
  const label = result.targetName ? ` "${result.targetName}"` : "";
1152
1229
  if (h) console.log(result.newDraft ? `Draft: ${h}${label} (new)` : `Draft: ${h}${label} (existing CLI draft, updated)`);
1153
- // PS-22: the server reuses the CLI draft whatever its name; --name only names a draft this push creates.
1230
+ // --name only names a draft this push creates. #989 review: no rename advice — a draft renamed by hand to a name
1231
+ // that does not start with "CLI Draft — " is no longer picked automatically.
1154
1232
  if (name && result.newDraft !== true) {
1155
- console.log(`Note: --name was not applied — this push updated the existing CLI draft${h ? ` ${h}` : ""}. Rename it with: blocofy theme rename ${h ?? "<handle>"} "${name}"`);
1233
+ console.log(`Note: --name was not applied — it names only a draft this push creates; this push updated the existing CLI draft${h ? ` ${h}` : ""}.`);
1156
1234
  }
1157
1235
  console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
1158
1236
  console.log(`Publish it live with: blocofy theme publish${h ? ` --instance ${h}` : ""}`);
@@ -1235,6 +1313,12 @@ function themePushRefusal(error, { draft = false, idempotencyKey = null } = {})
1235
1313
  details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
1236
1314
  });
1237
1315
  switch (error?.code) {
1316
+ case "draft_target_unverifiable":
1317
+ // #989 review P3: the platform could not read which draft to write to; it wrote nothing for this attempt.
1318
+ return {
1319
+ message: `The platform could not verify which draft this push would write to (a read failed on its side). ${nothing("Run the push again in a moment.")}`,
1320
+ details: withEarlier({}),
1321
+ };
1238
1322
  case "pointer_version_conflict":
1239
1323
  return {
1240
1324
  message: `The target theme was deployed again after this push checked it (now at pointer ${body.currentVersion == null ? "none" : `v${body.currentVersion}`}). ${nothing("Run the push again.")}`,
@@ -1989,6 +2073,13 @@ async function themeDev(rest) {
1989
2073
  const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
1990
2074
  const creds = target.dev;
1991
2075
  const port = Number(flags.port) || 3030;
2076
+ // #989: `--instance <handle>` names the draft to sync into, for a site where the platform refuses to pick one. Draft
2077
+ // sync never writes the live theme, so the live theme's handle is refused here.
2078
+ const devInstance = typeof flags.instance === "string" ? flags.instance : null;
2079
+ if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
2080
+ console.error(`✗ ${devInstance} is (or could not be told apart from) the LIVE theme. 'theme dev' syncs to a draft only; pick a draft handle (\`blocofy status\`) or use --no-sync.`);
2081
+ process.exit(2);
2082
+ }
1992
2083
 
1993
2084
  // Dev session: live-domain preview + theme editor URLs (+ a draft to sync into).
1994
2085
  // Graceful: if the platform can't provide one, fall back to local-only preview.
@@ -2066,6 +2157,19 @@ async function themeDev(rest) {
2066
2157
  // session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
2067
2158
  // da sessizce kapanıyordu — ölü bir uç, çalışan bir özelliği götürüyordu. Tek kapatma yolu --no-sync.
2068
2159
  syncDraft: !flags["no-sync"],
2160
+ instance: devInstance,
2161
+ // #989: the platform refused to pick the draft — say which drafts and how to choose, then stop (no retry loop).
2162
+ onSyncRefused: (error) => {
2163
+ try {
2164
+ handle.close();
2165
+ } catch {
2166
+ /* yoksay */
2167
+ }
2168
+ if (error?.code === "draft_target_is_live") {
2169
+ failAndExit({ code: "draft_target_is_live", status: 422, message: draftSyncErrorLine(error), details: {} });
2170
+ }
2171
+ failDraftTargetAmbiguous(error, commandLine("blocofy theme dev", positionals));
2172
+ },
2069
2173
  onRetry: (info) => console.error(` ${retryNotice(info)}`),
2070
2174
  onWarn: (msg) => console.warn(` ⚠ ${msg}`),
2071
2175
  // Her kaydetmede ne olduğunu bas — "reloaded" = watch tetiklendi; "0 views"
@@ -2074,7 +2178,7 @@ async function themeDev(rest) {
2074
2178
  onReload: ({ file, synced, clients, error }) => {
2075
2179
  const what = file || "change";
2076
2180
  if (error) {
2077
- console.error(` ↻ ${what} — draft sync failed: ${error} (local view still reloaded)`);
2181
+ console.error(` ↻ ${what} — ${error} (local view still reloaded)`);
2078
2182
  return;
2079
2183
  }
2080
2184
  const views = clients ? `${clients} view${clients === 1 ? "" : "s"}` : "no views connected";
@@ -2130,28 +2234,45 @@ async function themePublish(rest) {
2130
2234
  const creds = target.dev;
2131
2235
  let instance = typeof flags.instance === "string" ? flags.instance : null;
2132
2236
  if (!instance) {
2133
- // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
2134
- // `GET /api/dev/site` — eski `fetchDevSession` yolu sunucuda 410'a döndü ve bu komutu
2135
- // try/catch'siz, boş mesajlı bir Error ile tamamen çalışmaz hâle getirmişti.
2136
- //
2137
- // `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
2138
- // `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
2139
- // burada tekrarla — yoksa bir kez yayın yapmış her sitede iki taslak görünür ve komut takılır.
2237
+ // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak `GET /api/dev/site`.
2238
+ // #989 review P2: the SAME rule as a draft push (`findCliDraft`, mirroring the server) and the same refusal — a
2239
+ // guessed draft (a site-state restore draft, a renamed one, one of several) is never published live.
2140
2240
  const status = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
2141
2241
  const drafts = status?.drafts ?? [];
2142
- const cliDrafts = drafts.filter((d) => d.source === "import");
2143
- if (cliDrafts.length === 1) {
2144
- instance = cliDrafts[0].id;
2242
+ let cliDraft;
2243
+ try {
2244
+ cliDraft = findCliDraft(status);
2245
+ } catch (error) {
2246
+ if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme publish", positionals), { action: "publish" });
2247
+ throw error;
2248
+ }
2249
+ if (cliDraft) {
2250
+ instance = cliDraft.id;
2145
2251
  } else if (drafts.length === 0) {
2146
2252
  console.error("No draft theme to publish. Create one first: blocofy theme push --draft");
2147
2253
  process.exit(1);
2148
2254
  } else {
2149
- console.error("Could not tell which draft to publish — pick one with --instance <handle>:");
2255
+ // Drafts exist, but none is the CLI draft (panel copies, starter themes…): publishing one is a choice to make.
2256
+ console.error("No CLI draft to publish. Publish one of the site's drafts explicitly with --instance <handle>:");
2150
2257
  for (const d of drafts) console.error(` ${d.id} ${d.name ?? "(unnamed)"}`);
2151
2258
  process.exit(1);
2152
2259
  }
2153
2260
  }
2154
- const result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance, onRetry });
2261
+ let result;
2262
+ try {
2263
+ // K1 (#989): `explicit` only when the user named the theme; the automatic pick is re-checked by the platform.
2264
+ result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance, explicit: typeof flags.instance === "string", onRetry });
2265
+ } catch (error) {
2266
+ if (error?.code === PUBLISH_TARGET_UNCONFIRMED) {
2267
+ failAndExit({
2268
+ code: PUBLISH_TARGET_UNCONFIRMED,
2269
+ status: 409,
2270
+ message: publishTargetUnconfirmedMessage(error, { command: commandLine("blocofy theme publish", positionals) }),
2271
+ details: { reason: error.reason ?? null, instance: error.instance ?? null },
2272
+ });
2273
+ }
2274
+ throw error;
2275
+ }
2155
2276
  console.log(
2156
2277
  `✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
2157
2278
  );
@@ -9,7 +9,7 @@ import {
9
9
  injectLivereload,
10
10
  readLocalTemplates,
11
11
  } from "./local-theme.mjs";
12
- import { pushTheme } from "./theme-sync.mjs";
12
+ import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-sync.mjs";
13
13
 
14
14
  /**
15
15
  * Standalone dev server. For each page request it reads the local theme files and
@@ -20,7 +20,7 @@ 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, onWarn }) {
23
+ export function startDevServer({ dir, url, token, port, syncDraft = false, instance = null, onError, onReload, onRetry, onWarn, onSyncRefused }) {
24
24
  const base = url.replace(/\/+$/, "");
25
25
  const clients = new Set();
26
26
 
@@ -46,10 +46,27 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
46
46
 
47
47
  // Açılıştaki push'u BİR promise olarak tut: ilk render isteği bunu bekler, yoksa ilk sayfa
48
48
  // yüklemesi canlı içerikle gelir ve geliştirici "taslağım görünmüyor" sanır.
49
+ // #989: `instance` (theme dev --instance) syncs into that draft; otherwise the platform picks the CLI draft and
50
+ // refuses (409 draft_target_ambiguous) when it cannot prove which one that is. A refusal is final for the session:
51
+ // `onSyncRefused` is told once (the caller exits) — the sync is never retried in a loop.
52
+ const syncTarget = instance ? { instance, draft: true } : { draft: true };
53
+ let refused = false;
54
+ // Final for the session (the caller exits): the platform cannot pick the draft, or the named instance is live.
55
+ const refusedBy = (err) => {
56
+ if (err?.code !== DRAFT_TARGET_AMBIGUOUS && err?.code !== "draft_target_is_live") return false;
57
+ if (!refused) {
58
+ refused = true;
59
+ onSyncRefused?.(err);
60
+ }
61
+ return true;
62
+ };
49
63
  const draftReady = syncDraft
50
- ? pushTheme({ dir, url, token, draft: true, onRetry })
64
+ ? pushTheme({ dir, url, token, ...syncTarget, onRetry })
51
65
  .then(noteHandle)
52
- .catch(() => {})
66
+ .catch((err) => {
67
+ // #989 re-review P3: a startup sync failure is said, in one line — never swallowed.
68
+ if (!refusedBy(err)) onWarn?.(draftSyncErrorLine(err));
69
+ })
53
70
  : Promise.resolve();
54
71
 
55
72
  async function renderPath(pathname) {
@@ -131,14 +148,15 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, onErr
131
148
  // hangi dosya, senkron oldu mu, kaç görünüm bağlı (clients.size = açık EventSource'lar).
132
149
  const watcher = startWatch(dir, async (file) => {
133
150
  let error = null;
134
- if (syncDraft) {
151
+ if (syncDraft && !refused) {
135
152
  try {
136
153
  // 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
154
  // başına revizyon+deployment üretirdi (retention yok) ve taslak önizlemeye katkısı sıfır.
138
155
  // 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 }));
156
+ noteHandle(await pushTheme({ dir, url, token, ...syncTarget, onRetry }));
140
157
  } catch (err) {
141
- error = err?.message ?? String(err); // senkron hatası reload'u engellemesin
158
+ if (refusedBy(err)) return;
159
+ error = draftSyncErrorLine(err); // senkron hatası reload'u engellemesin
142
160
  }
143
161
  }
144
162
  broadcast();
@@ -50,6 +50,20 @@ async function errorText(res) {
50
50
 
51
51
  /** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
52
52
  async function responseError(res) {
53
+ if (res.status === 409) {
54
+ // #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
55
+ const body = await res.clone().json().catch(() => null);
56
+ if (body?.error === DRAFT_TARGET_AMBIGUOUS) return withDraftTargetDetails(Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { status: 409, code: DRAFT_TARGET_AMBIGUOUS }), body);
57
+ if (body?.error === PUBLISH_TARGET_UNCONFIRMED) {
58
+ return Object.assign(new Error(PUBLISH_TARGET_UNCONFIRMED), {
59
+ status: 409,
60
+ code: PUBLISH_TARGET_UNCONFIRMED,
61
+ reason: typeof body.reason === "string" ? body.reason : null,
62
+ instance: typeof body.instance === "string" ? body.instance : null,
63
+ name: typeof body.name === "string" ? body.name : null,
64
+ });
65
+ }
66
+ }
53
67
  const detail = await errorText(res);
54
68
  const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
55
69
  err.status = res.status;
@@ -75,9 +89,153 @@ async function httpError(res) {
75
89
  err.status = res.status;
76
90
  err.code = typeof body?.error === "string" ? body.error : null;
77
91
  err.body = body;
92
+ return withDraftTargetDetails(err, body);
93
+ }
94
+
95
+ /**
96
+ * #989 — the platform refuses to GUESS which draft a draft push / `pull --draft` / `theme dev` sync writes to when it
97
+ * cannot prove the draft is the CLI's: more than one candidate draft, a site-state restore draft, or a single draft
98
+ * without the platform-generated "CLI Draft" name (a renamed CLI draft included). 409 `draft_target_ambiguous` with
99
+ * `reason` and `candidates` [{instance, name}]; nothing was written. The way through is an explicit `--instance`.
100
+ */
101
+ export const DRAFT_TARGET_AMBIGUOUS = "draft_target_ambiguous";
102
+ /** K1 (#989): the platform refused to publish a theme the user did not name that is not the site's CLI draft. */
103
+ export const PUBLISH_TARGET_UNCONFIRMED = "publish_target_unconfirmed";
104
+
105
+ /** The human message for a K1 refusal; `command` is the publish command line to repeat with `--instance`. */
106
+ export function publishTargetUnconfirmedMessage(error, { command = "blocofy theme publish" } = {}) {
107
+ const why =
108
+ error?.reason === "site_state_restore"
109
+ ? "it is a site-state restore draft"
110
+ : error?.reason === "unrecognized_name"
111
+ ? 'it is not named "CLI Draft — …" (it may not be the CLI\'s draft)'
112
+ : error?.reason === "multiple_candidates"
113
+ ? "the site has more than one draft that could be the CLI draft"
114
+ : "it is not the site's CLI draft";
115
+ const h = error?.instance ?? "<handle>";
116
+ const head = `✗ Refused to publish theme ${h}${error?.name ? ` "${error.name}"` : ""} automatically: ${why}. Nothing was published.`;
117
+ // K1 review: a restore draft is never handed out as a ready-to-run command (the push rule) — naming it would make the
118
+ // restore's state live. The user-named themes keep the pre-filled command.
119
+ if (error?.reason === "site_state_restore") {
120
+ return [
121
+ head,
122
+ ` Warning: publishing ${h} with --instance would make the site-state restore draft LIVE.`,
123
+ " Safe paths: publish the restore through its own site-state publish flow, or from the admin panel.",
124
+ ` If you meant another theme: ${command} --instance <handle>`,
125
+ " (`blocofy status` lists the site's drafts.)",
126
+ ].join("\n");
127
+ }
128
+ return [head, ` To publish exactly this theme, name it: ${command} --instance ${h}`, " (`blocofy status` lists the site's drafts.)"].join("\n");
129
+ }
130
+ /**
131
+ * The platform-generated CLI draft name — the server's TEMPORARY compatibility filter (never proof of ownership):
132
+ * "CLI Draft", "CLI Draft — <date>" or "CLI Draft — <name>" (what a push with `--name <name>` creates).
133
+ */
134
+ const CLI_DRAFT_NAME = /^CLI Draft( — .+)?$/;
135
+ /** The name the platform gives a draft a push creates with `--name` (mirror of the server's `cliDraftName`). */
136
+ export function cliDraftName(name) {
137
+ const clean = String(name ?? "").trim().slice(0, 100);
138
+ if (!clean) return null;
139
+ return CLI_DRAFT_NAME.test(clean) ? clean : `CLI Draft — ${clean}`;
140
+ }
141
+ /** The name a site-state restore gives the draft it builds (`Site State · <12 hex>`). */
142
+ const SITE_STATE_DRAFT_NAME = /^Site State · [0-9a-f]{12}$/;
143
+
144
+ function withDraftTargetDetails(err, body) {
145
+ if (err.code !== DRAFT_TARGET_AMBIGUOUS) return err;
146
+ err.reason = typeof body?.reason === "string" ? body.reason : null;
147
+ err.candidates = Array.isArray(body?.candidates)
148
+ ? body.candidates
149
+ .filter((c) => c && typeof c.instance === "string")
150
+ .map((c) => ({ instance: c.instance, name: typeof c.name === "string" ? c.name : null, ...(typeof c.restore === "boolean" ? { restore: c.restore } : {}) }))
151
+ : [];
152
+ // The server names the one candidate safe to suggest (CLI-named, not a restore draft), or null for none.
153
+ if (body && "suggestedInstance" in body) err.suggestedInstance = typeof body.suggestedInstance === "string" ? body.suggestedInstance : null;
78
154
  return err;
79
155
  }
80
156
 
157
+ /** A candidate that is a site-state restore draft — the server's flag, else its `Site State · …` name. */
158
+ const isRestore = (c) => c.restore === true || SITE_STATE_DRAFT_NAME.test(c.name ?? "");
159
+
160
+ /**
161
+ * #989 re-review P3 — one line for a `theme dev` draft-sync failure, in the push/pull wording: 503
162
+ * `draft_target_unverifiable` (transient; the next save tries again) and 422 `draft_target_is_live` get their own
163
+ * sentence, anything else its message. Never swallowed.
164
+ */
165
+ export function draftSyncErrorLine(err) {
166
+ if (err?.code === "draft_target_unverifiable") {
167
+ return "draft sync: the platform could not verify which draft to write to (a read failed on its side). Nothing was written; the next save tries again.";
168
+ }
169
+ if (err?.code === "draft_target_is_live") {
170
+ return "draft sync refused: that is the LIVE theme, and a draft sync never writes live. Nothing was written; pass a draft's handle with --instance (`blocofy status`).";
171
+ }
172
+ return `draft sync failed: ${err?.message ?? String(err)}`;
173
+ }
174
+
175
+ /** A local #989 refusal with the server's shape (used where the CLI selects the draft itself, e.g. `push --diff`). */
176
+ function draftTargetAmbiguous(reason, candidates) {
177
+ for (const c of candidates) c.restore = SITE_STATE_DRAFT_NAME.test(c.name ?? "");
178
+ return Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { code: DRAFT_TARGET_AMBIGUOUS, status: 409, reason, candidates });
179
+ }
180
+
181
+ /**
182
+ * The human message for a #989 refusal: why, which drafts, and how to go on. `command` is the command line to repeat
183
+ * with `--instance <handle>` (e.g. "blocofy theme push ./shop"); `action` is what that command would do to the draft
184
+ * ("write" or "publish").
185
+ *
186
+ * #989 review P1 — `--instance` is pre-filled ONLY with a candidate that is CLI-named and not a site-state restore
187
+ * draft (the server's `suggestedInstance` when it sends one). Otherwise the command keeps the `<handle>` placeholder,
188
+ * the message warns that naming a listed draft would OVERWRITE (or publish) it, and names the safe ways forward.
189
+ *
190
+ * `earlierAttempt` (review P3, a keyed apply resent after an attempt without a certain answer): this refusal wrote
191
+ * nothing, but the earlier attempt may have — the message says so instead of "Nothing was written".
192
+ */
193
+ export function draftTargetAmbiguousMessage(error, { command = "blocofy theme push", action = "write", earlierAttempt = null } = {}) {
194
+ const candidates = Array.isArray(error?.candidates) ? error.candidates : [];
195
+ const why =
196
+ error?.reason === "multiple_candidates"
197
+ ? "this site has more than one draft theme that could be the CLI draft"
198
+ : error?.reason === "site_state_restore"
199
+ ? "a candidate draft belongs to a site-state restore"
200
+ : error?.reason === "unrecognized_name"
201
+ ? 'the only draft it could pick is not named "CLI Draft — …" (it may not be the CLI\'s draft; a draft renamed by hand is refused too)'
202
+ : "it cannot tell which draft is the CLI draft";
203
+ const safe =
204
+ error?.suggestedInstance !== undefined
205
+ ? candidates.find((c) => c.instance === error.suggestedInstance) ?? null
206
+ : candidates.find((c) => !isRestore(c) && CLI_DRAFT_NAME.test(c.name ?? "")) ?? null;
207
+ const unsafe = candidates.filter((c) => c !== safe);
208
+ const done =
209
+ earlierAttempt === "committed"
210
+ ? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
211
+ : earlierAttempt === "unknown"
212
+ ? "This attempt wrote nothing, but an earlier attempt of this push got no answer, so whether it wrote is unknown."
213
+ : action === "publish"
214
+ ? "Nothing was published."
215
+ : "Nothing was written.";
216
+ const lines = [`✗ Refused to pick a draft automatically: ${why}. ${done}`];
217
+ if (earlierAttempt) lines.push(" Check what the site holds now with `blocofy status` (and `blocofy theme push --diff --instance <handle>`) before running the push again.");
218
+ if (candidates.length) {
219
+ lines.push(" Candidate drafts:");
220
+ for (const c of candidates) lines.push(` ${c.instance}${c.name ? ` "${c.name}"` : ""}${isRestore(c) ? " (site-state restore draft)" : ""}`);
221
+ }
222
+ const verb = action === "publish" ? "PUBLISH it live" : "OVERWRITE it";
223
+ if (unsafe.length) {
224
+ lines.push(` Warning: naming ${unsafe.map((c) => c.instance).join(", ")} with --instance would ${verb}${unsafe.some(isRestore) ? " (including the restore's work)" : ""}.`);
225
+ }
226
+ if (safe) {
227
+ lines.push(` To use the CLI draft: ${command} --instance ${safe.instance}`);
228
+ } else if (action === "publish") {
229
+ lines.push(` Publish the draft you mean explicitly: ${command} --instance <handle>`);
230
+ } else {
231
+ lines.push(` Choose the draft explicitly: ${command} --instance <handle>`);
232
+ lines.push(" Safe ways forward: create a new draft theme in the admin panel and pass its handle with --instance,");
233
+ lines.push(" or publish or delete the site-state restore draft first.");
234
+ }
235
+ lines.push(" (`blocofy status` lists the site's drafts.)");
236
+ return lines.join("\n");
237
+ }
238
+
81
239
  /**
82
240
  * Sunucu kanonik protokolü konuşuyor mu? (0.5.0 `--dry-run` ön kontrolü.) SORGUSUZ canlı GET — mutasyonsuz
83
241
  * (`?draft=1` GET'i sunucuda taslak provizyonlar, o yüzden burada KULLANILMAZ). Eski bir sunucu `protocol`
@@ -289,7 +447,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
289
447
  const files = readLocalTemplates(dir);
290
448
  const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
291
449
  const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
292
- const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
450
+ // #989 review: a draft push naming an instance sends both — the server then refuses the live instance (422).
451
+ const target = instance ? { files, instance, ...(draft ? { draft: true } : {}) } : name ? { files, draft, name } : { files, draft };
293
452
  const post = (body, key, retry = {}) =>
294
453
  fetchWithRetry(
295
454
  `${base}/api/dev/theme`,
@@ -398,16 +557,29 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
398
557
  // A server whose plan carries no `newDraft` created one exactly when a draft push found no CLI draft to reuse.
399
558
  const newDraft = typeof plan.newDraft === "boolean" ? plan.newDraft : Boolean(draft && !instance && cliDraft == null);
400
559
  const targetInstance = applied.targetInstance ?? plan.targetInstance ?? (newDraft ? null : probeInstance) ?? null;
401
- const targetName = newDraft ? name : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
560
+ // #989 review: a draft this push created is named "CLI Draft — <name>" by the platform (date when no --name).
561
+ const targetName = newDraft ? cliDraftName(name) : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
402
562
  return { ...applied, ...extras, targetInstance, newDraft, targetName };
403
563
  }
404
564
 
405
565
  /**
406
- * The CLI draft a draft push writes into: the first `source: "import"` draft — `/api/dev/site` lists drafts by id,
407
- * the server's `ensureDraftInstance` reuses the first non-live import instance by id. `null` = a push would create one.
566
+ * The CLI draft a draft push writes into, or `null` when a push would create one. #989: mirrors the server's rule —
567
+ * exactly one `source: "import"` draft, carrying the platform-generated "CLI Draft" name and not a site-state restore
568
+ * draft — and otherwise throws the server's 409 `draft_target_ambiguous` shape, so `push --diff` and the push's merge
569
+ * probe never read a draft the push itself would refuse. The server stays authoritative (it also refuses a CLI-named
570
+ * draft a site-state theme deploy wrote to, which this list cannot show).
408
571
  */
409
572
  export function findCliDraft(status) {
410
- return (status?.drafts ?? []).find((d) => d.source === "import") ?? null;
573
+ const candidates = (status?.drafts ?? []).filter((d) => d.source === "import");
574
+ if (candidates.length === 0) return null;
575
+ const listed = candidates.map((d) => ({ instance: d.id, name: d.name ?? null }));
576
+ if (candidates.length > 1) {
577
+ throw draftTargetAmbiguous(candidates.some((d) => SITE_STATE_DRAFT_NAME.test(d.name ?? "")) ? "site_state_restore" : "multiple_candidates", listed);
578
+ }
579
+ const [only] = candidates;
580
+ if (SITE_STATE_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("site_state_restore", listed);
581
+ if (!CLI_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("unrecognized_name", listed);
582
+ return only;
411
583
  }
412
584
 
413
585
  /**
@@ -440,12 +612,14 @@ export async function diffTheme({ dir, url, token, instance = null, onRetry = nu
440
612
  * içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
441
613
  * yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
442
614
  */
443
- export async function publishInstance({ url, token, instanceId, onRetry = null }) {
615
+ export async function publishInstance({ url, token, instanceId, explicit = false, onRetry = null }) {
444
616
  const base = url.replace(/\/+$/, "");
445
617
  const res = await fetchWithRetry(`${base}/api/dev/publish`, {
446
618
  method: "POST",
447
619
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
448
- body: JSON.stringify({ instanceId }),
620
+ // K1 (#989): `explicit: true` ONLY when the user named the theme (`--instance`). Without it the platform publishes
621
+ // nothing but the site's provable CLI draft (409 publish_target_unconfirmed otherwise).
622
+ body: JSON.stringify(explicit ? { instanceId, explicit: true } : { instanceId }),
449
623
  }, { onRetry });
450
624
  if (!res.ok) throw await responseError(res);
451
625
  return res.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",