@blocofy/cli 0.13.0 → 0.15.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/lib/args.mjs CHANGED
@@ -8,9 +8,9 @@
8
8
  * Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
9
9
  * gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
10
10
  * CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
11
- * #925: `--publish` (translations import) da değersizdir.
11
+ * #925: `--publish` (translations import) da değersizdir. `--require-fresh` (theme work resume) da; `--open` ve `--wait` (theme work request-approval) da.
12
12
  */
13
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force", "publish"]);
13
+ const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force", "publish", "require-fresh", "open", "wait"]);
14
14
 
15
15
  /**
16
16
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
@@ -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,10 +50,28 @@ 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
+ // #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
54
+ const body = await res.clone().json().catch(() => null);
55
+ if (res.status === 409) {
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;
70
+ const retryAfter = res.headers.get("retry-after");
71
+ if (retryAfter) err.retryAfter = retryAfter;
56
72
  if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
73
+ // `{ error: "<sentence>", code }` (e.g. dev/publish `work_not_publishable`): the code sits beside the sentence.
74
+ else if (typeof body?.code === "string" && /^[a-z][a-z0-9_]*$/.test(body.code)) err.code = body.code;
57
75
  else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
58
76
  return err;
59
77
  }
@@ -75,9 +93,297 @@ async function httpError(res) {
75
93
  err.status = res.status;
76
94
  err.code = typeof body?.error === "string" ? body.error : null;
77
95
  err.body = body;
96
+ const retryAfter = res.headers.get("retry-after");
97
+ if (retryAfter) err.retryAfter = retryAfter;
98
+ return withDraftTargetDetails(err, body);
99
+ }
100
+
101
+ /**
102
+ * #989 — the platform refuses to GUESS which draft a draft push / `pull --draft` / `theme dev` sync writes to when it
103
+ * cannot prove the draft is the CLI's: more than one candidate draft, a site-state restore draft, or a single draft
104
+ * without the platform-generated "CLI Draft" name (a renamed CLI draft included). 409 `draft_target_ambiguous` with
105
+ * `reason` and `candidates` [{instance, name}]; nothing was written. The way through is an explicit `--instance`.
106
+ */
107
+ export const DRAFT_TARGET_AMBIGUOUS = "draft_target_ambiguous";
108
+ /** K1 (#989): the platform refused to publish a theme the user did not name that is not the site's CLI draft. */
109
+ export const PUBLISH_TARGET_UNCONFIRMED = "publish_target_unconfirmed";
110
+
111
+ /** The human message for a K1 refusal; `command` is the publish command line to repeat with `--instance`. */
112
+ export function publishTargetUnconfirmedMessage(error, { command = "blocofy theme publish" } = {}) {
113
+ const why =
114
+ error?.reason === "site_state_restore"
115
+ ? "it is a site-state restore draft"
116
+ : error?.reason === "unrecognized_name"
117
+ ? 'it is not named "CLI Draft — …" (it may not be the CLI\'s draft)'
118
+ : error?.reason === "multiple_candidates"
119
+ ? "the site has more than one draft that could be the CLI draft"
120
+ : "it is not the site's CLI draft";
121
+ const h = error?.instance ?? "<handle>";
122
+ const head = `✗ Refused to publish theme ${h}${error?.name ? ` "${error.name}"` : ""} automatically: ${why}. Nothing was published.`;
123
+ // K1 review: a restore draft is never handed out as a ready-to-run command (the push rule) — naming it would make the
124
+ // restore's state live. The user-named themes keep the pre-filled command.
125
+ if (error?.reason === "site_state_restore") {
126
+ return [
127
+ head,
128
+ ` Warning: publishing ${h} with --instance would make the site-state restore draft LIVE.`,
129
+ " Safe paths: publish the restore through its own site-state publish flow, or from the admin panel.",
130
+ ` If you meant another theme: ${command} --instance <handle>`,
131
+ " (`blocofy status` lists the site's drafts.)",
132
+ ].join("\n");
133
+ }
134
+ return [head, ` To publish exactly this theme, name it: ${command} --instance ${h}`, " (`blocofy status` lists the site's drafts.)"].join("\n");
135
+ }
136
+ /**
137
+ * The platform-generated CLI draft name — the server's TEMPORARY compatibility filter (never proof of ownership):
138
+ * "CLI Draft", "CLI Draft — <date>" or "CLI Draft — <name>" (what a push with `--name <name>` creates).
139
+ */
140
+ const CLI_DRAFT_NAME = /^CLI Draft( — .+)?$/;
141
+ /** The name the platform gives a draft a push creates with `--name` (mirror of the server's `cliDraftName`). */
142
+ export function cliDraftName(name) {
143
+ const clean = String(name ?? "").trim().slice(0, 100);
144
+ if (!clean) return null;
145
+ return CLI_DRAFT_NAME.test(clean) ? clean : `CLI Draft — ${clean}`;
146
+ }
147
+ /** The name a site-state restore gives the draft it builds (`Site State · <12 hex>`). */
148
+ const SITE_STATE_DRAFT_NAME = /^Site State · [0-9a-f]{12}$/;
149
+
150
+ function withDraftTargetDetails(err, body) {
151
+ if (err.code !== DRAFT_TARGET_AMBIGUOUS) return err;
152
+ err.reason = typeof body?.reason === "string" ? body.reason : null;
153
+ err.candidates = Array.isArray(body?.candidates)
154
+ ? body.candidates
155
+ .filter((c) => c && typeof c.instance === "string")
156
+ .map((c) => ({ instance: c.instance, name: typeof c.name === "string" ? c.name : null, ...(typeof c.restore === "boolean" ? { restore: c.restore } : {}) }))
157
+ : [];
158
+ // The server names the one candidate safe to suggest (CLI-named, not a restore draft), or null for none.
159
+ if (body && "suggestedInstance" in body) err.suggestedInstance = typeof body.suggestedInstance === "string" ? body.suggestedInstance : null;
78
160
  return err;
79
161
  }
80
162
 
163
+ /** A candidate that is a site-state restore draft — the server's flag, else its `Site State · …` name. */
164
+ const isRestore = (c) => c.restore === true || SITE_STATE_DRAFT_NAME.test(c.name ?? "");
165
+
166
+ /**
167
+ * #989 re-review P3 — one line for a `theme dev` draft-sync failure, in the push/pull wording: 503
168
+ * `draft_target_unverifiable` (transient; the next save tries again) and 422 `draft_target_is_live` get their own
169
+ * sentence, the theme-capacity refusals (`themeCapacityRefusal`) theirs, anything else its message. Never swallowed.
170
+ */
171
+ export function draftSyncErrorLine(err) {
172
+ if (err?.code === "draft_target_unverifiable") {
173
+ 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.";
174
+ }
175
+ if (err?.code === "draft_target_is_live") {
176
+ 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`).";
177
+ }
178
+ const capacity = themeCapacityRefusal(err);
179
+ if (capacity) return `draft sync: ${capacity.short}`;
180
+ return `draft sync failed: ${err?.message ?? String(err)}`;
181
+ }
182
+
183
+ const trNumber = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
184
+ const isAmount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
185
+ const bytesLabel = (b) => (b >= 1024 ** 3 ? `${trNumber.format(b / 1024 ** 3)} GB` : `${trNumber.format(b / 1024 ** 2)} MB`);
186
+
187
+ /** The admission's typed meter in words (V2 storage bytes; V1 / other `used / limit`), or null. */
188
+ function quotaMeter(d) {
189
+ if (!d || typeof d !== "object") return null;
190
+ if (isAmount(d.usedBytes) && isAmount(d.limitBytes)) {
191
+ const need = isAmount(d.estimateBytes) ? `; yeni taslak yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : "";
192
+ return `Kullanılan depolama: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.limitBytes)}${need}.`;
193
+ }
194
+ if (isAmount(d.used) && isAmount(d.limit)) {
195
+ const what = d.resource === "theme_drafts" ? "Taslak tema sayısı" : typeof d.resource === "string" ? d.resource : "Kullanım";
196
+ return `${what}: ${d.used} / ${d.limit}.`;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ const CAPACITY_CODES = new Set(["target_missing", "quota_exceeded", "capacity_unavailable", "resource_busy", "source_stale"]);
202
+ /** Transient admission refusals: every attempt answering one of these wrote nothing (a definite answer). */
203
+ export const CAPACITY_RETRYABLE = new Set(["capacity_unavailable", "resource_busy"]);
204
+
205
+ /**
206
+ * Theme-capacity program (ADR-0013) — the dev theme endpoint's refusals around the CLI draft, in words:
207
+ * - 404 `target_missing`: `pull --draft` found no CLI draft. A read never creates a theme; `push --draft` does.
208
+ * - 422 `quota_exceeded`, by `details.resource`:
209
+ * `theme_bytes` — the site's theme space (a technical allowance, not the plan) is full. Any theme write can meet
210
+ * it, a push to an existing draft included: no "new draft" wording, no plan upgrade; free space by deleting
211
+ * unused draft themes (cancelled works' themes included; cancel releases only a work's reserved publish space,
212
+ * archive frees no theme bytes), or contact support.
213
+ * `theme_drafts` (the legacy draft count of a site not on theme space) — not tied to the plan: remove a draft
214
+ * you no longer need; never a plan upgrade.
215
+ * `storage_mb` (the plan's storage) — the NEW draft was refused for the plan: the plan message.
216
+ * - 503 `capacity_unavailable` / `resource_busy`: transient (Retry-After), never the plan. lib/http.mjs has already
217
+ * resent the request within its retry policy; this is the last answer.
218
+ * - 409 `source_stale`: the live theme the new draft copies changed meanwhile; running the command again copies it anew.
219
+ * The platform wrote nothing for that answer. Returns `{ lines, short, message, details }`: `lines` the plain Turkish
220
+ * explanation (stderr, human mode; first line non-technical), `short` one line for `theme dev`, `message`/`details` the
221
+ * technical `error [code]` line and `--json` envelope. `null` for any other code (draft_target_ambiguous keeps its own).
222
+ * `pushCommand` is the user's push command line (its directory argument kept). A refusal of a RESENT push apply
223
+ * (`earlierAttempt`) says nothing about that earlier attempt, so it never claims "nothing was written" then.
224
+ */
225
+ export function themeCapacityRefusal(error, { pushCommand = "blocofy theme push" } = {}) {
226
+ const code = error?.code;
227
+ if (!CAPACITY_CODES.has(code)) return null;
228
+ const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
229
+ const done =
230
+ earlier === "committed"
231
+ ? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi işlendi (yanıtı öyle söyledi)."
232
+ : earlier === "unknown"
233
+ ? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi kesin yanıt alamadı, yazıp yazmadığı bilinmiyor."
234
+ : "Hiçbir şey yazılmadı.";
235
+ const doneEn =
236
+ earlier === "committed"
237
+ ? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
238
+ : earlier === "unknown"
239
+ ? "This attempt wrote nothing, but an earlier attempt of this push got no definite answer, so whether it wrote is unknown."
240
+ : "Nothing was written.";
241
+ const check = earlier ? [" Tekrar çalıştırmadan önce sitenin taslaklarını `blocofy status` ile kontrol et."] : [];
242
+ const checkEn = earlier ? " Check the site's drafts with `blocofy status` before running the push again." : "";
243
+ const extra = earlier ? { earlierAttempt: earlier } : {};
244
+
245
+ if (code === "target_missing") {
246
+ return {
247
+ lines: [
248
+ "Bu sitede henüz bir CLI taslağı yok; indirilecek taslak bulunamadı. Hiçbir dosya yazılmadı.",
249
+ ` Önce taslağı oluştur: ${pushCommand} --draft`,
250
+ " (Canlı temayı indirmek istiyorsan --draft olmadan çalıştır.)",
251
+ ],
252
+ short: "Bu sitede henüz bir CLI taslağı yok.",
253
+ message: `No CLI draft on this site yet (a read never creates one). Nothing was written. Create it first: ${pushCommand} --draft`,
254
+ details: {},
255
+ };
256
+ }
257
+ if (code === "quota_exceeded") {
258
+ const d = error?.body?.details && typeof error.body.details === "object" ? error.body.details : null;
259
+ if (d?.resource === "theme_bytes") {
260
+ // The site's theme space (a technical allowance, not the plan): any theme write can meet it — a push to an
261
+ // existing draft included — so no "new draft" wording and no plan upgrade; space is freed by removing drafts.
262
+ const head = "Sitenin tema alanı dolu; bu değişiklik için yer kalmadı.";
263
+ const meter = isAmount(d.usedBytes) && isAmount(d.allowanceBytes)
264
+ ? `Kullanılan tema alanı: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.allowanceBytes)}${isAmount(d.estimateBytes) ? `; bu değişiklik yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : ""}.`
265
+ : null;
266
+ return {
267
+ lines: [
268
+ `${head} ${done}`,
269
+ ...(meter ? [` ${meter}`] : []),
270
+ " Yer açmak için kullanmadığın taslak temaları (iptal edilmiş çalışmaların temaları dahil) tema kütüphanesinden sil (admin paneli → Tasarım); açık bir çalışmayı iptal etmek ayrıca onun için ayrılan yayın alanını bırakır.",
271
+ " Sorun sürerse destek ekibine yaz.",
272
+ ...check,
273
+ ],
274
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; yer açmak için kullanılmayan bir taslağı sil.`,
275
+ message: `The site's theme space is full; this change does not fit (quota_exceeded, theme_bytes). ${doneEn} Free space by deleting unused draft themes from the theme library (cancelled works' themes included); cancelling an open work also releases its reserved publish space. Or contact support.${checkEn}`,
276
+ details: { ...d, ...extra },
277
+ };
278
+ }
279
+ const meter = quotaMeter(d);
280
+ if (d?.resource === "theme_drafts") {
281
+ // The legacy draft count of a site not (yet) on theme space: not tied to the plan, so no upgrade advice.
282
+ const head = "Sitenin taslak tema sınırına ulaşıldı; yeni taslak oluşturulamadı.";
283
+ return {
284
+ lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Artık kullanmadığın bir taslak temayı sil (admin paneli → Tasarım).", ...check],
285
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; kullanmadığın bir taslağı sil.`,
286
+ message: `The site's draft limit refused the new draft this push needs (quota_exceeded, theme_drafts). ${doneEn} Remove a draft you no longer need.${checkEn}`,
287
+ details: { ...d, ...extra },
288
+ };
289
+ }
290
+ const head = `${d?.resource === "storage_mb" || isAmount(d?.usedBytes) ? "Depolama alanın dolu" : "Planının sınırına ulaşıldı"}; yeni taslak oluşturulamadı.`;
291
+ return {
292
+ lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Yeni taslak için planını yükseltebilirsin: admin paneli → Ayarlar → Plan & faturalandırma.", ...check],
293
+ short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı.`,
294
+ message: `The plan's capacity refused the new draft this push needs (quota_exceeded). ${doneEn}${checkEn}`,
295
+ details: { ...(d ?? {}), ...extra },
296
+ };
297
+ }
298
+ if (code === "source_stale") {
299
+ const head = "Canlı tema bu sırada değişti; yeni taslak onun güncel hâlinden oluşturulamadı.";
300
+ return {
301
+ lines: [`${head} ${done}`, ` Komutu yeniden çalıştır; taslak canlının güncel hâlinden oluşturulur: ${pushCommand}`, ...check],
302
+ short: `${head} Hiçbir şey yazılmadı; bir sonraki kayıt yeniden dener.`,
303
+ message: `The live theme a new draft copies changed while it was prepared (source_stale). ${doneEn} Run the command again.${checkEn}`,
304
+ details: extra,
305
+ };
306
+ }
307
+ // capacity_unavailable / resource_busy: transient, already resent within the CLI's retry policy.
308
+ // Neither is about the plan or a full space, and either can answer a push to an existing draft: no "new draft" wording.
309
+ const head =
310
+ code === "resource_busy"
311
+ ? "Site şu anda meşgul (başka bir yazım sürüyor); işlem şimdilik yapılamadı."
312
+ : "Platform tema alanını şu an doğrulayamadı; bu geçici bir durum, işlem şimdilik yapılamadı.";
313
+ const raw = String(error?.retryAfter ?? "").trim();
314
+ const seconds = /^\d+$/.test(raw) ? Number(raw) : null;
315
+ return {
316
+ lines: [`${head} ${done}`, ` Biraz sonra tekrar dene${seconds ? ` (en az ${seconds} sn sonra)` : ""}: ${pushCommand}`, ...check],
317
+ short: `${head} Hiçbir şey yazılmadı. Biraz sonra tekrar dene; bir sonraki kayıt yeniden dener.`,
318
+ message: `${code === "resource_busy" ? "The site is busy with another write" : "The platform could not check the theme space right now (temporary)"}; the change was not applied (${code}). ${doneEn} Try again later.${checkEn}`,
319
+ details: { ...(seconds != null ? { retryAfterSeconds: seconds } : {}), ...extra },
320
+ };
321
+ }
322
+
323
+ /** A local #989 refusal with the server's shape (used where the CLI selects the draft itself, e.g. `push --diff`). */
324
+ function draftTargetAmbiguous(reason, candidates) {
325
+ for (const c of candidates) c.restore = SITE_STATE_DRAFT_NAME.test(c.name ?? "");
326
+ return Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { code: DRAFT_TARGET_AMBIGUOUS, status: 409, reason, candidates });
327
+ }
328
+
329
+ /**
330
+ * The human message for a #989 refusal: why, which drafts, and how to go on. `command` is the command line to repeat
331
+ * with `--instance <handle>` (e.g. "blocofy theme push ./shop"); `action` is what that command would do to the draft
332
+ * ("write" or "publish").
333
+ *
334
+ * #989 review P1 — `--instance` is pre-filled ONLY with a candidate that is CLI-named and not a site-state restore
335
+ * draft (the server's `suggestedInstance` when it sends one). Otherwise the command keeps the `<handle>` placeholder,
336
+ * the message warns that naming a listed draft would OVERWRITE (or publish) it, and names the safe ways forward.
337
+ *
338
+ * `earlierAttempt` (review P3, a keyed apply resent after an attempt without a certain answer): this refusal wrote
339
+ * nothing, but the earlier attempt may have — the message says so instead of "Nothing was written".
340
+ */
341
+ export function draftTargetAmbiguousMessage(error, { command = "blocofy theme push", action = "write", earlierAttempt = null } = {}) {
342
+ const candidates = Array.isArray(error?.candidates) ? error.candidates : [];
343
+ const why =
344
+ error?.reason === "multiple_candidates"
345
+ ? "this site has more than one draft theme that could be the CLI draft"
346
+ : error?.reason === "site_state_restore"
347
+ ? "a candidate draft belongs to a site-state restore"
348
+ : error?.reason === "unrecognized_name"
349
+ ? '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)'
350
+ : "it cannot tell which draft is the CLI draft";
351
+ const safe =
352
+ error?.suggestedInstance !== undefined
353
+ ? candidates.find((c) => c.instance === error.suggestedInstance) ?? null
354
+ : candidates.find((c) => !isRestore(c) && CLI_DRAFT_NAME.test(c.name ?? "")) ?? null;
355
+ const unsafe = candidates.filter((c) => c !== safe);
356
+ const done =
357
+ earlierAttempt === "committed"
358
+ ? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
359
+ : earlierAttempt === "unknown"
360
+ ? "This attempt wrote nothing, but an earlier attempt of this push got no answer, so whether it wrote is unknown."
361
+ : action === "publish"
362
+ ? "Nothing was published."
363
+ : "Nothing was written.";
364
+ const lines = [`✗ Refused to pick a draft automatically: ${why}. ${done}`];
365
+ 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.");
366
+ if (candidates.length) {
367
+ lines.push(" Candidate drafts:");
368
+ for (const c of candidates) lines.push(` ${c.instance}${c.name ? ` "${c.name}"` : ""}${isRestore(c) ? " (site-state restore draft)" : ""}`);
369
+ }
370
+ const verb = action === "publish" ? "PUBLISH it live" : "OVERWRITE it";
371
+ if (unsafe.length) {
372
+ lines.push(` Warning: naming ${unsafe.map((c) => c.instance).join(", ")} with --instance would ${verb}${unsafe.some(isRestore) ? " (including the restore's work)" : ""}.`);
373
+ }
374
+ if (safe) {
375
+ lines.push(` To use the CLI draft: ${command} --instance ${safe.instance}`);
376
+ } else if (action === "publish") {
377
+ lines.push(` Publish the draft you mean explicitly: ${command} --instance <handle>`);
378
+ } else {
379
+ lines.push(` Choose the draft explicitly: ${command} --instance <handle>`);
380
+ lines.push(" Safe ways forward: create a new draft theme in the admin panel and pass its handle with --instance,");
381
+ lines.push(" or publish or delete the site-state restore draft first.");
382
+ }
383
+ lines.push(" (`blocofy status` lists the site's drafts.)");
384
+ return lines.join("\n");
385
+ }
386
+
81
387
  /**
82
388
  * Sunucu kanonik protokolü konuşuyor mu? (0.5.0 `--dry-run` ön kontrolü.) SORGUSUZ canlı GET — mutasyonsuz
83
389
  * (`?draft=1` GET'i sunucuda taslak provizyonlar, o yüzden burada KULLANILMAZ). Eski bir sunucu `protocol`
@@ -289,7 +595,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
289
595
  const files = readLocalTemplates(dir);
290
596
  const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
291
597
  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 };
598
+ // #989 review: a draft push naming an instance sends both — the server then refuses the live instance (422).
599
+ const target = instance ? { files, instance, ...(draft ? { draft: true } : {}) } : name ? { files, draft, name } : { files, draft };
293
600
  const post = (body, key, retry = {}) =>
294
601
  fetchWithRetry(
295
602
  `${base}/api/dev/theme`,
@@ -368,6 +675,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
368
675
  // An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
369
676
  // refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
370
677
  let earlierAttempt = null;
678
+ // ADR-0013: a 503 capacity_unavailable / resource_busy is a definite "nothing was written" answer, not an unknown one.
679
+ let definiteNothing = false;
371
680
  const applyError = (err) => {
372
681
  Object.assign(err, { phase: "apply" });
373
682
  // target_changed names the current target; the one this push planned against is known only here.
@@ -379,11 +688,14 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
379
688
  try {
380
689
  res = await post({ ...target, ...bindings }, idempotencyKey, {
381
690
  onRetry: (info) => {
382
- earlierAttempt ??= "unknown";
691
+ if (!definiteNothing) earlierAttempt ??= "unknown";
692
+ definiteNothing = false;
383
693
  onRetry?.(info);
384
694
  },
385
695
  isFinal: async (attempt) => {
386
- if ((await attempt.json().catch(() => null))?.committed === true) earlierAttempt = "committed";
696
+ const answer = await attempt.json().catch(() => null);
697
+ if (answer?.committed === true) earlierAttempt = "committed";
698
+ definiteNothing = CAPACITY_RETRYABLE.has(answer?.error);
387
699
  return false;
388
700
  },
389
701
  });
@@ -398,16 +710,29 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
398
710
  // A server whose plan carries no `newDraft` created one exactly when a draft push found no CLI draft to reuse.
399
711
  const newDraft = typeof plan.newDraft === "boolean" ? plan.newDraft : Boolean(draft && !instance && cliDraft == null);
400
712
  const targetInstance = applied.targetInstance ?? plan.targetInstance ?? (newDraft ? null : probeInstance) ?? null;
401
- const targetName = newDraft ? name : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
713
+ // #989 review: a draft this push created is named "CLI Draft — <name>" by the platform (date when no --name).
714
+ const targetName = newDraft ? cliDraftName(name) : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
402
715
  return { ...applied, ...extras, targetInstance, newDraft, targetName };
403
716
  }
404
717
 
405
718
  /**
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.
719
+ * The CLI draft a draft push writes into, or `null` when a push would create one. #989: mirrors the server's rule —
720
+ * exactly one `source: "import"` draft, carrying the platform-generated "CLI Draft" name and not a site-state restore
721
+ * draft — and otherwise throws the server's 409 `draft_target_ambiguous` shape, so `push --diff` and the push's merge
722
+ * probe never read a draft the push itself would refuse. The server stays authoritative (it also refuses a CLI-named
723
+ * draft a site-state theme deploy wrote to, which this list cannot show).
408
724
  */
409
725
  export function findCliDraft(status) {
410
- return (status?.drafts ?? []).find((d) => d.source === "import") ?? null;
726
+ const candidates = (status?.drafts ?? []).filter((d) => d.source === "import");
727
+ if (candidates.length === 0) return null;
728
+ const listed = candidates.map((d) => ({ instance: d.id, name: d.name ?? null }));
729
+ if (candidates.length > 1) {
730
+ throw draftTargetAmbiguous(candidates.some((d) => SITE_STATE_DRAFT_NAME.test(d.name ?? "")) ? "site_state_restore" : "multiple_candidates", listed);
731
+ }
732
+ const [only] = candidates;
733
+ if (SITE_STATE_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("site_state_restore", listed);
734
+ if (!CLI_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("unrecognized_name", listed);
735
+ return only;
411
736
  }
412
737
 
413
738
  /**
@@ -440,12 +765,14 @@ export async function diffTheme({ dir, url, token, instance = null, onRetry = nu
440
765
  * içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
441
766
  * yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
442
767
  */
443
- export async function publishInstance({ url, token, instanceId, onRetry = null }) {
768
+ export async function publishInstance({ url, token, instanceId, explicit = false, onRetry = null }) {
444
769
  const base = url.replace(/\/+$/, "");
445
770
  const res = await fetchWithRetry(`${base}/api/dev/publish`, {
446
771
  method: "POST",
447
772
  headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
448
- body: JSON.stringify({ instanceId }),
773
+ // K1 (#989): `explicit: true` ONLY when the user named the theme (`--instance`). Without it the platform publishes
774
+ // nothing but the site's provable CLI draft (409 publish_target_unconfirmed otherwise).
775
+ body: JSON.stringify(explicit ? { instanceId, explicit: true } : { instanceId }),
449
776
  }, { onRetry });
450
777
  if (!res.ok) throw await responseError(res);
451
778
  return res.json();