@blocofy/cli 0.12.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,16 +61,20 @@ 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
- Start a dev server and print 3 auto-reloading views — Local, live-domain Preview, and the theme
67
- Editor. Press `l` / `p` / `e` to open each, `q` to quit. Edit a file and save → every open view
68
- reloads. Saves sync to a **draft** theme only (never the live site). `dir` defaults to cwd.
66
+ Start a local dev server that renders your local theme files with the site's live content.
67
+ Press `l` to open it, `q` to quit. Edit a file and save → the view reloads. Saves sync to a
68
+ **draft** theme only (never the live site). `dir` defaults to cwd. The platform's remote preview
69
+ and editor views for this command are retired; to share a draft page, create a preview link for
70
+ it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
69
71
  The target site is verified once at start; a long session keeps that target (restart it after
70
72
  changing credentials, context or the project binding).
71
73
  - `--port <n>` — local port (default 3030).
72
- - `--no-sync` — local preview only (skip draft sync + remote views).
74
+ - `--no-sync` — local preview only (skip draft sync).
73
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.
74
78
 
75
79
  ```
76
80
  blocofy theme pull [dir] [--draft] [--instance <handle>]
@@ -92,15 +96,42 @@ the admin panel, never touching the live site. Publish it with `blocofy theme pu
92
96
  non-interactive shells must add `--yes`.
93
97
  - `--draft` — explicit draft (same as the default; safe).
94
98
  - `--yes` — confirm a `--live` push without prompting (CI/agents).
95
- - `--instance <handle>` — push to a specific theme by its handle (safe targeted write — no
96
- live-confirmation prompt).
97
- - `--name <name>` — name the new draft (draft mode only; ignored on `--live`/`--instance`).
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`.
98
112
  - `--dry-run` / `--validate` — check the push on the server without writing; the two flags are
99
113
  aliases. The dry run covers exactly the files the push would send (remote-only files it keeps
100
114
  included; with `--prune`, without the ones it would remove). A current platform plans the whole
101
115
  deploy and prints the target, its pointer version and what happens to each file
102
116
  (`+` created, `~` updated, `-` removed; unchanged files are counted).
103
- - `--diff` — show what a push would change vs the target (read-only), then stop.
117
+ - `--diff` — show what a push would change vs the target (read-only), then stop. A draft push
118
+ compares with the CLI draft it writes to (named by handle); with no CLI draft yet, with the live
119
+ theme (the push would create the draft). `--live` compares with live, `--instance` with that theme.
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
+
133
+ After a draft push the CLI prints the draft it wrote to (`Draft: <handle> "<name>" (new)` or
134
+ `(existing CLI draft, updated)`) and the `blocofy theme publish --instance <handle>` command.
104
135
  - `--idempotency-key <k>` — attach a key so a retried push is not double-applied.
105
136
  - `--prune` — also remove target files that no longer exist locally (`locales/` included);
106
137
  lists them first, and on the live theme asks to confirm (non-interactive shells add `--yes`).
@@ -149,7 +180,9 @@ flag, publishes the draft that `theme dev` / `theme push --draft` writes into. T
149
180
  refuses to publish a theme that has no pages (it would 404); preview first. The site is the one
150
181
  `[dir]`'s project is bound to (`dir` defaults to cwd), so `blocofy theme push ./shop && blocofy
151
182
  theme publish ./shop` always publishes `./shop`'s site, whatever directory you run it from.
152
- - `--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`.
153
186
 
154
187
  ### Status
155
188
 
@@ -452,6 +485,41 @@ Each retry prints a notice on stderr.
452
485
 
453
486
  ## Changelog
454
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.
512
+ - **0.13.0** — Draft push names its target (customer items PS-22/PS-23/PS-26).
513
+ - `theme push` (draft) prints the draft it wrote to — handle, name, new or existing — and
514
+ `blocofy theme publish --instance <handle>`. When it updated the existing CLI draft, it says
515
+ `--name` was not applied and prints the rename command.
516
+ - **Behaviour change:** `theme push --diff` without `--live`/`--instance` now compares with the CLI
517
+ draft the push writes to (found read-only via the site status, never provisioned), not with the
518
+ live theme. With no CLI draft yet it says so and compares with live. Release it after the
519
+ platform's PS-22 fix (uncached theme reads): before that fix a draft read could return its
520
+ pre-push files for up to five minutes.
521
+ - `theme dev` help no longer lists the retired live-domain and editor views.
522
+ - `TARGET_BINDING_REQUIRED` also names `blocofy link <dir>` for env credentials.
455
523
  - **0.12.0** — Translation packages (#925).
456
524
  - `blocofy translations export --locale <tag> --out <file> [--format json|xliff] [--only …]`
457
525
  writes every text of one language that needs translating to one JSON or XLIFF 1.2 file. It
@@ -619,11 +687,9 @@ files and sends them to the platform's dev-render endpoint (`/api/dev/render`).
619
687
  renders them with the site's **live data** and returns HTML — so the CLI ships no rendering
620
688
  engine and you see exactly the production output.
621
689
 
622
- It also continuously syncs your local files to a **draft theme** so you can view the same work
623
- three ways — the local preview, a shareable live-domain preview link, and the admin theme
624
- editor — without affecting your published theme. Save a file and **every open view reloads**
625
- (the platform-rendered pages connect back to the local dev server's reload channel). Publish
626
- the draft from the theme editor when you're ready.
690
+ It also continuously syncs your local files to a **draft theme**, so the same work can be opened
691
+ in the admin theme editor without affecting your published theme. Save a file and the local view
692
+ reloads. Publish the draft from the theme editor, or with `blocofy theme publish`, when you're ready.
627
693
 
628
694
  Credentials live in `~/.blocofy/credentials.json` (contexts, no secrets) + either
629
695
  `~/.blocofy/secrets.json` (0600) or the macOS keychain — written by `blocofy login`, or the
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, 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,16 +177,19 @@ 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>]
181
- Start a dev server and print 3 auto-reloading views — Local, live-domain
182
- Preview, and the theme Editor. Press l / p / e to open each, q to quit.
183
- Edit a file and save → every open view reloads. Saves sync to a DRAFT theme
184
- only (never the live site). (dir defaults to cwd)
180
+ blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
181
+ Start a local dev server that renders your local theme files with the site's
182
+ live content. Press l to open it, q to quit. Edit a file and save → the view
183
+ reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
184
+ The platform's remote preview and editor views for this command are retired; to
185
+ share a draft page, create a preview link for it (v1 API / MCP create_preview_link).
185
186
  The target site is verified ONCE at start; a long session keeps that target (restart
186
187
  it after changing credentials, context or the project binding).
187
188
  --port <n> local port (default 3030)
188
- --no-sync local preview only (skip draft sync + remote views)
189
+ --no-sync local preview only (skip draft sync)
189
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)
190
193
 
191
194
  blocofy theme pull [dir] [--draft] [--instance <handle>]
192
195
  Download the live theme to disk. (dir defaults to cwd)
@@ -203,9 +206,11 @@ Usage
203
206
  confirmation first; non-interactive shells must add --yes.
204
207
  --draft explicit draft (same as the default; safe)
205
208
  --yes confirm a --live push without prompting (for CI / agents)
206
- --instance <handle> push to a specific theme by its handle (safe targeted
207
- write — no live-confirmation prompt)
208
- --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)
209
214
  --dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
210
215
  --validate alias for --dry-run (validate only, nothing written)
211
216
  --diff show what a push WOULD change vs the target (read-only), then stop
@@ -225,7 +230,9 @@ Usage
225
230
  'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
226
231
  With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
227
232
  The server refuses to publish a theme that has no pages (it would 404); preview first.
228
- --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
229
236
 
230
237
  blocofy status [dir]
231
238
  Show the live theme, page distribution per instance, drafts, and a health flag
@@ -502,6 +509,36 @@ function failAndExit(error) {
502
509
  process.exit(code);
503
510
  }
504
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
+
505
542
  function contextNameOrExit(name) {
506
543
  if (name === ENV_CONTEXT || !CONTEXT_NAME_RE.test(name)) {
507
544
  throw new TargetError("TARGET_CONTEXT_INVALID", `"${name}" cannot be used as a context name (letters, digits, . _ -; "env" is reserved).`, { context: name }, 1);
@@ -926,7 +963,16 @@ async function themePull(rest) {
926
963
  const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
927
964
  // Review M1: a draft pull provisions the draft server-side (`?draft=1`), so it is a remote mutation: binding required.
928
965
  const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
929
- 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
+ }
930
976
  console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
931
977
  bindAfterPull(target, dir);
932
978
  }
@@ -952,7 +998,7 @@ async function themePush(rest) {
952
998
  // CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
953
999
  // target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
954
1000
  const readOnly = Boolean(flags.diff) || dryRun;
955
- const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
1001
+ const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : mode === "draft" ? "CLI draft" : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
956
1002
  const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
957
1003
  const creds = target.dev;
958
1004
  // 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
@@ -963,12 +1009,25 @@ async function themePush(rest) {
963
1009
  // ad-alanlar, önek EKLENMEZ.
964
1010
  const idempotencyKey = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : `cli-${randomUUID()}`;
965
1011
 
966
- // `--diff`: read-only preview vs the LIVE theme (or --instance). No write; the draft target is not
967
- // diffable (a draft GET would PROVISION the draft server-side — a read-only command must not mutate).
1012
+ // `--diff`: read-only preview vs what the push would write to. PS-26: a draft push writes to the CLI draft, so
1013
+ // that draft is found via `/api/dev/site` and diffed by handle — never via `?draft=1`, which PROVISIONS a draft
1014
+ // server-side (a read-only command must not mutate). With no CLI draft yet the push would create one: vs live.
968
1015
  if (flags.diff) {
969
1016
  const diffTarget = ` of ${siteLabel(target.identity.site)}`;
970
- const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag, onRetry });
971
- console.log(instanceFlag ? `Diff vs theme ${instanceFlag}${diffTarget}:` : `Diff vs the LIVE theme${diffTarget} (push default writes to a DRAFT):`);
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
+ }
1025
+ const diffInstance = instanceFlag ?? cliDraft?.id ?? null;
1026
+ const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: diffInstance, onRetry });
1027
+ if (instanceFlag) console.log(`Diff vs theme ${instanceFlag}${diffTarget}:`);
1028
+ else if (cliDraft) console.log(`Diff vs draft ${cliDraft.id}${cliDraft.name ? ` "${cliDraft.name}"` : ""}${diffTarget} (the CLI draft this push writes to):`);
1029
+ else if (mode === "draft") console.log(`No CLI draft yet — this push would create one. Diff vs the LIVE theme${diffTarget}:`);
1030
+ else console.log(`Diff vs the LIVE theme${diffTarget}:`);
972
1031
  const total = d.added.length + d.changed.length + d.removed.length;
973
1032
  if (total === 0) {
974
1033
  console.log("No differences — local theme matches the target.");
@@ -985,7 +1044,7 @@ async function themePush(rest) {
985
1044
  const whoami = target.identity;
986
1045
  const siteName = siteLabel(whoami.site) || String(whoami.site.id);
987
1046
  if (mode === "instance") {
988
- 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)" : ""}`);
989
1048
  } else if (mode === "live") {
990
1049
  console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
991
1050
  } else {
@@ -996,13 +1055,34 @@ async function themePush(rest) {
996
1055
  // kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
997
1056
  // güvenli → otomatik onay (prompt yok).
998
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
+ }
999
1070
  const decision = livePushDecision({
1000
- draft: mode !== "live" || dryRun,
1071
+ draft: (mode !== "live" && (!instanceIsLive || draftInstance)) || dryRun,
1001
1072
  yes: Boolean(flags.yes),
1002
1073
  confirm: Boolean(flags.confirm),
1003
1074
  isTTY: Boolean(process.stdin.isTTY),
1004
1075
  });
1005
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
+ }
1006
1086
  console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${siteName} immediately (no preview).`);
1007
1087
  console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
1008
1088
  process.exit(1);
@@ -1026,7 +1106,7 @@ async function themePush(rest) {
1026
1106
  // `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
1027
1107
  // TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
1028
1108
  const prune = Boolean(flags.prune);
1029
- const liveTarget = mode === "live" || (mode === "instance" && (whoami.liveThemeId == null || String(instance) === String(whoami.liveThemeId)));
1109
+ const liveTarget = mode === "live" || instanceIsLive;
1030
1110
  const pruneDecision = livePushDecision({
1031
1111
  draft: !prune || dryRun || !liveTarget,
1032
1112
  yes: Boolean(flags.yes),
@@ -1073,7 +1153,7 @@ async function themePush(rest) {
1073
1153
  dir,
1074
1154
  url: creds.url,
1075
1155
  token: creds.token,
1076
- draft: mode === "draft",
1156
+ draft: mode === "draft" || draftInstance,
1077
1157
  instance: mode === "instance" ? instance : null,
1078
1158
  name: mode === "draft" ? name : null,
1079
1159
  dryRun,
@@ -1091,6 +1171,10 @@ async function themePush(rest) {
1091
1171
  }
1092
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 } });
1093
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
+ }
1094
1178
  if (error?.code === "idempotency_conflict") {
1095
1179
  if (!JSON_MODE) {
1096
1180
  console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
@@ -1140,8 +1224,16 @@ async function themePush(rest) {
1140
1224
  }
1141
1225
  if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
1142
1226
  if (mode === "draft") {
1227
+ const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
1228
+ const label = result.targetName ? ` "${result.targetName}"` : "";
1229
+ if (h) console.log(result.newDraft ? `Draft: ${h}${label} (new)` : `Draft: ${h}${label} (existing CLI draft, updated)`);
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.
1232
+ if (name && result.newDraft !== true) {
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}` : ""}.`);
1234
+ }
1143
1235
  console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
1144
- console.log("Publish it live with: blocofy theme publish");
1236
+ console.log(`Publish it live with: blocofy theme publish${h ? ` --instance ${h}` : ""}`);
1145
1237
  }
1146
1238
  return;
1147
1239
  }
@@ -1221,6 +1313,12 @@ function themePushRefusal(error, { draft = false, idempotencyKey = null } = {})
1221
1313
  details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
1222
1314
  });
1223
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
+ };
1224
1322
  case "pointer_version_conflict":
1225
1323
  return {
1226
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.")}`,
@@ -1975,6 +2073,13 @@ async function themeDev(rest) {
1975
2073
  const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
1976
2074
  const creds = target.dev;
1977
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
+ }
1978
2083
 
1979
2084
  // Dev session: live-domain preview + theme editor URLs (+ a draft to sync into).
1980
2085
  // Graceful: if the platform can't provide one, fall back to local-only preview.
@@ -2052,6 +2157,19 @@ async function themeDev(rest) {
2052
2157
  // session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
2053
2158
  // da sessizce kapanıyordu — ölü bir uç, çalışan bir özelliği götürüyordu. Tek kapatma yolu --no-sync.
2054
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
+ },
2055
2173
  onRetry: (info) => console.error(` ${retryNotice(info)}`),
2056
2174
  onWarn: (msg) => console.warn(` ⚠ ${msg}`),
2057
2175
  // Her kaydetmede ne olduğunu bas — "reloaded" = watch tetiklendi; "0 views"
@@ -2060,7 +2178,7 @@ async function themeDev(rest) {
2060
2178
  onReload: ({ file, synced, clients, error }) => {
2061
2179
  const what = file || "change";
2062
2180
  if (error) {
2063
- console.error(` ↻ ${what} — draft sync failed: ${error} (local view still reloaded)`);
2181
+ console.error(` ↻ ${what} — ${error} (local view still reloaded)`);
2064
2182
  return;
2065
2183
  }
2066
2184
  const views = clients ? `${clients} view${clients === 1 ? "" : "s"}` : "no views connected";
@@ -2116,28 +2234,45 @@ async function themePublish(rest) {
2116
2234
  const creds = target.dev;
2117
2235
  let instance = typeof flags.instance === "string" ? flags.instance : null;
2118
2236
  if (!instance) {
2119
- // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
2120
- // `GET /api/dev/site` — eski `fetchDevSession` yolu sunucuda 410'a döndü ve bu komutu
2121
- // try/catch'siz, boş mesajlı bir Error ile tamamen çalışmaz hâle getirmişti.
2122
- //
2123
- // `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
2124
- // `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
2125
- // 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.
2126
2240
  const status = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
2127
2241
  const drafts = status?.drafts ?? [];
2128
- const cliDrafts = drafts.filter((d) => d.source === "import");
2129
- if (cliDrafts.length === 1) {
2130
- 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;
2131
2251
  } else if (drafts.length === 0) {
2132
2252
  console.error("No draft theme to publish. Create one first: blocofy theme push --draft");
2133
2253
  process.exit(1);
2134
2254
  } else {
2135
- 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>:");
2136
2257
  for (const d of drafts) console.error(` ${d.id} ${d.name ?? "(unnamed)"}`);
2137
2258
  process.exit(1);
2138
2259
  }
2139
2260
  }
2140
- 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
+ }
2141
2276
  console.log(
2142
2277
  `✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
2143
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();
package/lib/target.mjs CHANGED
@@ -217,13 +217,16 @@ function isEmptyOrMissing(dir) {
217
217
  * Command-class policy (before any credential or network use). Returns `{ newBinding }`: true when a local-write
218
218
  * into a missing/empty unbound dir will record provenance after it succeeds.
219
219
  */
220
+ /** PS-22 note: with env credentials there is no context to name — `link` binds to the site those credentials reach. */
221
+ const ENV_LINK_HINT = (dir) => `; with BLOCOFY_URL + BLOCOFY_TOKEN set: blocofy link ${dir}`;
222
+
220
223
  export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
221
224
  if (!COMMAND_CLASSES.has(commandClass)) throw new Error(`internal: unknown command class ${commandClass}`);
222
225
  if (binding) return { newBinding: false };
223
226
  if (commandClass === "remote-mutation") {
224
227
  throw new TargetError(
225
228
  "TARGET_BINDING_REQUIRED",
226
- `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>`,
229
+ `\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}`,
227
230
  { dir },
228
231
  );
229
232
  }
@@ -231,7 +234,7 @@ export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
231
234
  if (isEmptyOrMissing(dir)) return { newBinding: true };
232
235
  throw new TargetError(
233
236
  "TARGET_BINDING_REQUIRED",
234
- `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>) or pull into an empty directory.`,
237
+ `${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}) or pull into an empty directory.`,
235
238
  { dir },
236
239
  );
237
240
  }
@@ -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`,
@@ -322,9 +481,10 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
322
481
  const remoteOnlyKept = [];
323
482
  const remoteOnlyRemoved = [];
324
483
  let probeInstance = instance;
484
+ let cliDraft = null;
325
485
  if (!instance && draft) {
326
- const status = await fetchSiteStatus({ url, token, onRetry });
327
- probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
486
+ cliDraft = findCliDraft(await fetchSiteStatus({ url, token, onRetry }));
487
+ probeInstance = cliDraft?.id ?? null;
328
488
  }
329
489
  let remote = {};
330
490
  if (probeInstance || !draft) {
@@ -391,7 +551,35 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
391
551
  throw applyError(error);
392
552
  }
393
553
  if (!res.ok) throw applyError(await httpError(res));
394
- return { ...(await res.json()), ...extras };
554
+ const applied = await res.json();
555
+ // PS-22: which theme received the files. A current server names it on the apply; an older one only in the plan
556
+ // (null for "a new draft"); oldest of all, the CLI draft looked up before the push is the one reused.
557
+ // A server whose plan carries no `newDraft` created one exactly when a draft push found no CLI draft to reuse.
558
+ const newDraft = typeof plan.newDraft === "boolean" ? plan.newDraft : Boolean(draft && !instance && cliDraft == null);
559
+ const targetInstance = applied.targetInstance ?? plan.targetInstance ?? (newDraft ? null : probeInstance) ?? 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;
562
+ return { ...applied, ...extras, targetInstance, newDraft, targetName };
563
+ }
564
+
565
+ /**
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).
571
+ */
572
+ export function findCliDraft(status) {
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;
395
583
  }
396
584
 
397
585
  /**
@@ -424,12 +612,14 @@ export async function diffTheme({ dir, url, token, instance = null, onRetry = nu
424
612
  * içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
425
613
  * yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
426
614
  */
427
- export async function publishInstance({ url, token, instanceId, onRetry = null }) {
615
+ export async function publishInstance({ url, token, instanceId, explicit = false, onRetry = null }) {
428
616
  const base = url.replace(/\/+$/, "");
429
617
  const res = await fetchWithRetry(`${base}/api/dev/publish`, {
430
618
  method: "POST",
431
619
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
432
- 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 }),
433
623
  }, { onRetry });
434
624
  if (!res.ok) throw await responseError(res);
435
625
  return res.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.12.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",