@blocofy/cli 0.14.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/bin/blocofy.mjs CHANGED
@@ -71,8 +71,9 @@ 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 { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
74
+ import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance, themeCapacityRefusal } from "../lib/theme-sync.mjs";
75
75
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
76
+ import { DEFAULT_WORK_INTENT, WORK_INTENT_MAX, WORK_KEY_RE, approvalOutcome, approvalWaitState, cancelWork, getPublishStatus, getWork, isWorkHandle, newWorkKey, publishStatusLines, readSavedWork, requestApproval, resumeWork, safeApprovalUrl, saveWork, sealWork, staleLines, startWork, stateLabel, themeWorkRefusal, workLines } from "../lib/theme-work.mjs";
76
77
  import { hyperlink, openUrl } from "../lib/term.mjs";
77
78
  import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
78
79
 
@@ -89,10 +90,16 @@ const KNOWN = {
89
90
  login: ["url", "token", "api-key", "api-url", "keychain"],
90
91
  pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
91
92
  themePull: ["draft", "instance"],
92
- themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
93
+ themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
93
94
  themeDev: ["port", "dry", "no-sync", "name", "instance"],
94
95
  themePublish: ["instance"],
95
96
  themeRename: ["name", "dir"],
97
+ themeWorkStart: ["intent", "idempotency-key"],
98
+ themeWorkStatus: ["dir"],
99
+ themeWorkResume: ["dir", "require-fresh"],
100
+ themeWorkCancel: ["dir"],
101
+ themeWorkSeal: ["dir"],
102
+ themeWorkRequestApproval: ["dir", "open", "wait", "interval"],
96
103
  content: [],
97
104
  settingsPush: ["instance", "live", "yes", "confirm"],
98
105
  pagesPull: ["strict"],
@@ -193,8 +200,10 @@ Usage
193
200
 
194
201
  blocofy theme pull [dir] [--draft] [--instance <handle>]
195
202
  Download the live theme to disk. (dir defaults to cwd)
196
- --draft pull the draft theme (what 'theme dev' syncs into) instead of live;
197
- creates the draft if missing, so it needs a bound project
203
+ --draft pull the CLI draft (what 'theme dev' / 'theme push --draft' write into)
204
+ instead of live. Read-only: it never creates the draft — with none yet
205
+ it stops (target_missing); run 'blocofy theme push --draft' first.
206
+ Needs a bound project
198
207
  --instance <handle> pull a specific theme by its handle (from the admin
199
208
  panel theme card, or \`blocofy status\`)
200
209
 
@@ -218,6 +227,40 @@ Usage
218
227
  --prune also REMOVE target files that no longer exist locally, locales/ included
219
228
  (lists them first; on the live theme asks to confirm — non-interactive
220
229
  shells must add --yes)
230
+ --work <wk_…> write into that WORK's own draft theme (see 'theme work'); never live.
231
+ Needs both the dev token and the v1 API key of the same site. Refused
232
+ unless the work is open; with --live/--instance it is a usage error
233
+
234
+ blocofy theme work start [dir] [--intent "<text>"] [--idempotency-key <k>] (new)
235
+ Start a WORK: a private, safe copy of the site's CURRENT live theme to change and
236
+ later hand over for publication. Visitors never see it. Prints its handle (wk_…) and
237
+ remembers it in .blocofy/local.json (git-ignored). Needs the v1 API key
238
+ (blocofy login --api-key); the work belongs to that key.
239
+ --intent "<text>" a short label of the job (1-120 characters)
240
+ --idempotency-key <k> retry key: the same key returns the SAME work, never a second one.
241
+ Without it a fresh key is generated and printed
242
+ blocofy theme work status <wk_…> [--dir <dir>]
243
+ Show the work: its state, its theme handle, whether the site changed since it started,
244
+ and where its publication stands (review, waiting for approval, published).
245
+ blocofy theme work seal <wk_…> [--dir <dir>]
246
+ Prepare the work for review: its content is frozen and can no longer change. Asks for
247
+ no approval and publishes nothing.
248
+ blocofy theme work request-approval <wk_…> [--dir <dir>] [--open] [--wait [--interval <s>]]
249
+ Ask a person to publish the work (an open work is prepared for review first). Prints the
250
+ approval URL: a signed-in site owner or team member with the theme permission approves it
251
+ in Blocofy. The URL carries no token; having it is not a permission to publish.
252
+ --open also open the approval page in your browser
253
+ --wait wait until it is published, or the request expires, is declined, or the
254
+ live site changes (read-only polling; nothing is published from here)
255
+ --interval <s> seconds between status checks while waiting (1-60, default 3)
256
+ --wait exit codes: 0 published · 2 ended, nothing published (approval_stale, approval_expired,
257
+ approval_declined, approval_superseded, work_cancelled) · 1 wait_timeout or publish_failed
258
+ blocofy theme work resume <wk_…> [--dir <dir>] [--require-fresh]
259
+ Continue that exact work here (it becomes this project's saved work). Writes nothing.
260
+ --require-fresh refuse (work_stale) when the site changed since the work started
261
+ blocofy theme work cancel <wk_…> [--dir <dir>]
262
+ Give the work up. The live site is not touched; its theme stays in the theme library.
263
+ Publication of a work is approved by a person in the admin panel; the CLI never publishes it.
221
264
 
222
265
  blocofy theme rename <handle> <new name> [--dir <dir>]
223
266
  Rename a theme (the name is just a label). Works on any of your themes,
@@ -529,6 +572,19 @@ function failDraftTargetAmbiguous(error, command, { action = "write" } = {}) {
529
572
  });
530
573
  }
531
574
 
575
+ /**
576
+ * Theme-capacity (ADR-0013) refusals of the dev theme endpoint: `target_missing`, `quota_exceeded`,
577
+ * `capacity_unavailable`, `resource_busy`, `source_stale`. The plain Turkish explanation first (human mode), then the
578
+ * shared `error [code]` line / `--json` envelope; exit 2 for the 4xx ones, 1 for the 503 ones. Returns only when
579
+ * `error` is not one of them.
580
+ */
581
+ function failOnThemeCapacityRefusal(error, pushCommand) {
582
+ const refusal = themeCapacityRefusal(error, { pushCommand });
583
+ if (!refusal) return;
584
+ if (!JSON_MODE) for (const line of refusal.lines) console.error(line);
585
+ failAndExit({ code: error.code, status: error.status, message: refusal.message, details: refusal.details });
586
+ }
587
+
532
588
  /**
533
589
  * Could `instance` be the live theme? Fail-closed (#989 review): yes when it is the live handle, when the live theme is
534
590
  * not known, and when it is a raw numeric id (the server accepts one, the live handle comparison cannot see it).
@@ -907,7 +963,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
907
963
  if (needs === "both" && !hasDev) {
908
964
  throw new TargetError(
909
965
  "LOGIN_REQUIRED",
910
- `Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN). Site plan/apply need both a dev token and a v1 API key.`,
966
+ `Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN). This command needs both a dev token and a v1 API key (site plan/apply, theme push --work).`,
911
967
  { context: resolved.name },
912
968
  1,
913
969
  );
@@ -961,7 +1017,9 @@ async function themePull(rest) {
961
1017
  const draft = Boolean(flags.draft);
962
1018
  const instance = typeof flags.instance === "string" ? flags.instance : null;
963
1019
  const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
964
- // Review M1: a draft pull provisions the draft server-side (`?draft=1`), so it is a remote mutation: binding required.
1020
+ // Review M1: a draft pull was classed a remote mutation (binding required) because `?draft=1` used to provision the
1021
+ // draft server-side. A current platform never creates a theme on a read (ADR-0013 D6: 404 `target_missing`); the
1022
+ // class is kept fail-closed for platforms from before that change.
965
1023
  const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
966
1024
  let count;
967
1025
  try {
@@ -971,6 +1029,7 @@ async function themePull(rest) {
971
1029
  if (error?.code === "draft_target_unverifiable") {
972
1030
  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
1031
  }
1032
+ failOnThemeCapacityRefusal(error, commandLine("blocofy theme push", positionals));
974
1033
  throw error;
975
1034
  }
976
1035
  console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
@@ -984,22 +1043,34 @@ async function themePush(rest) {
984
1043
  console.error(`Theme directory not found: ${dir}`);
985
1044
  process.exit(1);
986
1045
  }
987
- const instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
1046
+ let instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
988
1047
  const name = typeof flags.name === "string" ? flags.name : null;
989
1048
  const dryRun = Boolean(flags["dry-run"] || flags.validate);
1049
+ // CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
1050
+ // target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
1051
+ const readOnly = Boolean(flags.diff) || dryRun;
1052
+ // Theme work: `--work <wk_…>` names the target itself — that work's own draft theme, read from the v1 API (the
1053
+ // work belongs to the API key) and written through the dev token as `--draft --instance <work theme>`. Both pairs
1054
+ // must resolve to the same site (prepareTarget "both"); the work must be open. Never live.
1055
+ const workHandle = flags.work === undefined ? null : flags.work;
1056
+ let workTarget = null;
1057
+ if (workHandle !== null) {
1058
+ if (!isWorkHandle(workHandle)) throw new TargetError("USAGE", "--work needs a work handle (wk_…, from `blocofy theme work start`). Nothing was sent.", {}, 1);
1059
+ if (flags.live || instanceFlag) throw new TargetError("USAGE", "--work names the target itself; it cannot be combined with --live or --instance. Nothing was sent.", {}, 1);
1060
+ workTarget = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "both", mode: `${readOnly ? (flags.diff ? "read · diff vs " : "read · dry run · ") : "draft · "}work ${workHandle}` });
1061
+ instanceFlag = (await openWorkTheme(workTarget.api, workHandle)).theme;
1062
+ }
1063
+ const draftFlag = Boolean(flags.draft) || workHandle !== null;
990
1064
  // Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
991
1065
  // açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
992
1066
  // modu canlıya yazar ve onay gerektirir.
993
1067
  const { mode, instance } = resolvePushMode({
994
1068
  live: Boolean(flags.live),
995
- draft: Boolean(flags.draft),
1069
+ draft: draftFlag,
996
1070
  instance: instanceFlag,
997
1071
  });
998
- // CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
999
- // target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
1000
- const readOnly = Boolean(flags.diff) || dryRun;
1001
1072
  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;
1002
- const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
1073
+ const target = workTarget ?? (await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` }));
1003
1074
  const creds = target.dev;
1004
1075
  // 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
1005
1076
  // OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
@@ -1044,7 +1115,7 @@ async function themePush(rest) {
1044
1115
  const whoami = target.identity;
1045
1116
  const siteName = siteLabel(whoami.site) || String(whoami.site.id);
1046
1117
  if (mode === "instance") {
1047
- console.log(`→ Pushing to theme ${instance} of ${siteName}${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
1118
+ console.log(`→ Pushing to theme ${instance} of ${siteName}${workHandle ? ` (work ${workHandle})` : whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
1048
1119
  } else if (mode === "live") {
1049
1120
  console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
1050
1121
  } else {
@@ -1061,7 +1132,7 @@ async function themePush(rest) {
1061
1132
  const instanceIsLive = mode === "instance" && instanceMaybeLive(instance, whoami.liveThemeId);
1062
1133
  // `--draft --instance <h>`: a draft write to a chosen draft. It never reaches the live theme — refused here when the
1063
1134
  // 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);
1135
+ const draftInstance = mode === "instance" && draftFlag;
1065
1136
  if (draftInstance && instanceIsLive && !dryRun) {
1066
1137
  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
1138
  console.error(" Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.");
@@ -1182,6 +1253,8 @@ async function themePush(rest) {
1182
1253
  }
1183
1254
  failAndExit({ code: "idempotency_conflict", status: error.status, message: "The idempotency key was already used with different content. Retry with a new key (or omit --idempotency-key).", details: {} });
1184
1255
  }
1256
+ // ADR-0013: the new draft this push needs was refused (definite answer, before the unknown-outcome handling below).
1257
+ failOnThemeCapacityRefusal(error, commandLine("blocofy theme push", positionals));
1185
1258
  const refusal = themePushRefusal(error, { draft: mode === "draft", idempotencyKey });
1186
1259
  if (refusal) failAndExit({ code: error.code, status: error.status, ...refusal });
1187
1260
  throw error;
@@ -1223,6 +1296,10 @@ async function themePush(rest) {
1223
1296
  console.log(`✓ Deployed: ${ids}. The written files were not verified (this server does not read them back).`);
1224
1297
  }
1225
1298
  if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
1299
+ if (workHandle) {
1300
+ console.log(`Çalışma ${workHandle} güncellendi (tema ${instance}). Canlı site değişmedi.`);
1301
+ console.log(`Durumu: blocofy theme work status ${workHandle}`);
1302
+ }
1226
1303
  if (mode === "draft") {
1227
1304
  const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
1228
1305
  const label = result.targetName ? ` "${result.targetName}"` : "";
@@ -2271,6 +2348,8 @@ async function themePublish(rest) {
2271
2348
  details: { reason: error.reason ?? null, instance: error.instance ?? null },
2272
2349
  });
2273
2350
  }
2351
+ // A theme work's copy is never published directly; its way live is the approval (`theme work request-approval`).
2352
+ if (error?.code === "work_not_publishable") failOnThemeWorkRefusal(error, { op: "publish" });
2274
2353
  throw error;
2275
2354
  }
2276
2355
  console.log(
@@ -2278,6 +2357,311 @@ async function themePublish(rest) {
2278
2357
  );
2279
2358
  }
2280
2359
 
2360
+ // ── theme work ("çalışma"): a safe, private copy of the live theme, continued by its exact handle ──────────────
2361
+
2362
+ /**
2363
+ * A theme-work refusal: the plain Turkish explanation first (human mode), then the shared `error [code]` line /
2364
+ * `--json` envelope; exit 2 for a 4xx refusal, 1 for a 5xx one. Returns only when `error` is not a known refusal.
2365
+ */
2366
+ function failOnThemeWorkRefusal(error, opts) {
2367
+ const refusal = themeWorkRefusal(error, opts);
2368
+ if (!refusal) return;
2369
+ if (!JSON_MODE) for (const line of refusal.lines) console.error(line);
2370
+ const code = error instanceof CliRefusal ? error.error?.code : error?.code;
2371
+ failAndExit({ code, status: error.status, message: refusal.message, details: refusal.details });
2372
+ }
2373
+
2374
+ /** The handle positional of status/resume/cancel — explicit, never "the latest"; the saved one is only suggested. */
2375
+ function workHandleArg(positionals, flags, usage) {
2376
+ const handle = positionals[0];
2377
+ if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
2378
+ if (handle === undefined) {
2379
+ let saved = null;
2380
+ try {
2381
+ saved = readSavedWork(findBinding(commandDir(flags.dir)));
2382
+ } catch {
2383
+ saved = null;
2384
+ }
2385
+ const hint = saved ? ` This project's saved work is ${saved.handle}${saved.intent ? ` ("${saved.intent}")` : ""}.` : "";
2386
+ throw new TargetError("USAGE", `Usage: ${usage} — name the work by its handle (wk_…).${hint} Nothing was sent.`, saved ? { saved_work: saved.handle } : {}, 1);
2387
+ }
2388
+ if (!isWorkHandle(handle)) throw new TargetError("USAGE", `"${handle}" is not a work handle (wk_ + 26 characters, from \`blocofy theme work start\`). Nothing was sent.`, {}, 1);
2389
+ return handle;
2390
+ }
2391
+
2392
+ /** `theme push --work`: the work's theme, only while the work is open. Refusals exit here. */
2393
+ async function openWorkTheme(api, handle) {
2394
+ let work;
2395
+ try {
2396
+ ({ work } = await getWork({ ...api, handle, onRetry }));
2397
+ } catch (error) {
2398
+ failOnThemeWorkRefusal(error, { op: "push", handle });
2399
+ throw error;
2400
+ }
2401
+ if (work.state !== "open" || !work.theme) {
2402
+ if (!JSON_MODE) {
2403
+ console.error(`Çalışma ${handle} şu an değiştirilemez (durum: ${stateLabel(work.state)}). Hiçbir şey yazılmadı.`);
2404
+ console.error(work.state === "sealed" ? " İnceleme bitince ya da panelde düzenlemeye geri alınınca tekrar dene." : " Yeni bir çalışma başlat: blocofy theme work start");
2405
+ }
2406
+ failAndExit({ code: "work_state_conflict", status: 409, message: `The work is ${work.state}; a push needs an open work. Nothing was written.`, details: { state: work.state, stateVersion: work.state_version } });
2407
+ }
2408
+ if (!JSON_MODE) for (const line of staleLines(work) ?? []) console.error(line);
2409
+ return work;
2410
+ }
2411
+
2412
+ async function themeWorkStart(rest) {
2413
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkStart);
2414
+ const dir = singleDirArg(positionals, 'blocofy theme work start [dir] [--intent "<text>"] [--idempotency-key <k>]');
2415
+ if (flags.intent !== undefined && (typeof flags.intent !== "string" || !flags.intent.trim() || flags.intent.trim().length > WORK_INTENT_MAX)) {
2416
+ throw new TargetError("USAGE", `--intent needs a short label (1-${WORK_INTENT_MAX} characters). Nothing was sent.`, {}, 1);
2417
+ }
2418
+ if (flags["idempotency-key"] !== undefined && (typeof flags["idempotency-key"] !== "string" || !WORK_KEY_RE.test(flags["idempotency-key"]))) {
2419
+ throw new TargetError("USAGE", "--idempotency-key must be 8-120 characters: letters, digits, . _ : - Nothing was sent.", {}, 1);
2420
+ }
2421
+ const intent = typeof flags.intent === "string" ? flags.intent.trim() : DEFAULT_WORK_INTENT;
2422
+ const key = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : newWorkKey();
2423
+ const target = await prepareTarget({ command: "theme work start", commandClass: "remote-mutation", dir, flags, needs: "api", mode: "draft · new work" });
2424
+ const retryLine = `blocofy theme work start${positionals[0] ? ` ${positionals[0]}` : ""} --intent ${JSON.stringify(intent)} --idempotency-key ${key}`;
2425
+ let answer;
2426
+ try {
2427
+ answer = await startWork({ ...target.api, intent, idempotencyKey: key, onRetry });
2428
+ } catch (error) {
2429
+ // A 4xx is a definite refusal (nothing started). Anything else (network, a 5xx after the retries) may hide a work
2430
+ // that WAS started: the SAME key returns it, a new key would start a second one — so the retry line names the key.
2431
+ const definite = error instanceof CliRefusal;
2432
+ const refusal = themeWorkRefusal(error, { op: "start" });
2433
+ if (definite && !refusal) throw error;
2434
+ if (!JSON_MODE) {
2435
+ const lines = refusal?.lines ?? ["Platformdan kesin yanıt alınamadı; çalışmanın başlayıp başlamadığı bilinmiyor."];
2436
+ for (const line of lines) console.error(line);
2437
+ if (!definite) console.error(` Aynı anahtarla tekrar çalıştır (başladıysa aynı çalışma döner, ikincisi açılmaz): ${retryLine}`);
2438
+ }
2439
+ const code = definite ? error.error?.code : error?.code ?? (error instanceof TypeError ? "NETWORK_ERROR" : "ERROR");
2440
+ const message = refusal?.message ?? `No definite answer from the platform; whether the work started is unknown. Run again with the same key: ${retryLine}`;
2441
+ failAndExit({ code, status: error.status, message, details: { ...(refusal?.details ?? {}), ...(definite ? {} : { idempotencyKey: key }) } });
2442
+ }
2443
+ const { work } = answer;
2444
+ const saved = saveWork(target.binding, work);
2445
+ if (JSON_MODE) {
2446
+ console.log(JSON.stringify({ work, ...(answer.replayed ? { replayed: true } : {}), idempotency_key: key, saved }, null, 2));
2447
+ return;
2448
+ }
2449
+ console.log(answer.replayed ? `✓ Bu anahtarla başlatılmış çalışma bulundu (yeni bir çalışma açılmadı): ${work.id}` : `✓ Çalışma başlatıldı: ${work.id}`);
2450
+ for (const line of workLines(work)) console.log(line);
2451
+ for (const line of staleLines(work) ?? []) console.log(line);
2452
+ console.log("Canlı site değişmedi; bu çalışma ziyaretçilere görünmez.");
2453
+ console.log(`Dosyalarını gönder: blocofy theme push${positionals[0] ? ` ${positionals[0]}` : ""} --draft --work ${work.id}`);
2454
+ console.log(`Durumu: blocofy theme work status ${work.id}`);
2455
+ console.log(saved ? " (Bu projede kayıtlı çalışma olarak .blocofy/local.json'a yazıldı.)" : " (Proje bağlı olmadığı için tanıtıcı kaydedilmedi; bir yere not et.)");
2456
+ console.log(`technical: work ${work.id}, theme ${work.theme}, idempotency key ${key}`);
2457
+ }
2458
+
2459
+ async function themeWorkStatus(rest) {
2460
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkStatus);
2461
+ const handle = workHandleArg(positionals, flags, "blocofy theme work status <wk_…> [--dir <dir>]");
2462
+ const target = await prepareTarget({ command: "theme work status", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · work ${handle}` });
2463
+ let answer;
2464
+ try {
2465
+ answer = await getWork({ ...target.api, handle, onRetry });
2466
+ } catch (error) {
2467
+ failOnThemeWorkRefusal(error, { op: "status", handle });
2468
+ throw error;
2469
+ }
2470
+ // Where its publication stands (read-only). A platform without the status endpoint just shows the work.
2471
+ let publish = null;
2472
+ try {
2473
+ publish = await getPublishStatus({ ...target.api, handle, onRetry });
2474
+ } catch {
2475
+ publish = null;
2476
+ }
2477
+ if (JSON_MODE) {
2478
+ console.log(JSON.stringify({ work: answer.work, ...(publish ? { publish } : {}) }, null, 2));
2479
+ return;
2480
+ }
2481
+ for (const line of workLines(answer.work)) console.log(line);
2482
+ for (const line of publishStatusLines(publish)) console.log(line);
2483
+ for (const line of staleLines(answer.work) ?? []) console.log(line);
2484
+ }
2485
+
2486
+ /** A 404/405 from a review endpoint right after the work was read means the platform has no such endpoint yet. */
2487
+ const endpointMissing = (error) =>
2488
+ error instanceof CliRefusal && ["not_found", "http_404", "http_405", "method_not_allowed"].includes(error.error?.code) && error.error?.details?.reason !== "target_deleted";
2489
+
2490
+ async function themeWorkSeal(rest) {
2491
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkSeal);
2492
+ const handle = workHandleArg(positionals, flags, "blocofy theme work seal <wk_…> [--dir <dir>]");
2493
+ const target = await prepareTarget({ command: "theme work seal", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · prepare work ${handle} for review` });
2494
+ try {
2495
+ await getWork({ ...target.api, handle, onRetry });
2496
+ } catch (error) {
2497
+ failOnThemeWorkRefusal(error, { op: "seal", handle });
2498
+ throw error;
2499
+ }
2500
+ let answer;
2501
+ try {
2502
+ answer = await sealWork({ ...target.api, handle, onRetry });
2503
+ } catch (error) {
2504
+ if (endpointMissing(error)) {
2505
+ if (!JSON_MODE) {
2506
+ console.error("Bu platform ayrı bir 'incelemeye hazırla' adımını henüz desteklemiyor. Hiçbir şey yazılmadı.");
2507
+ console.error(` Onay isteği çalışmayı kendisi hazırlar: blocofy theme work request-approval ${handle}`);
2508
+ }
2509
+ failAndExit({ code: "seal_unsupported", status: error.status, message: "This platform has no seal endpoint yet; request-approval prepares the work itself (seal_unsupported)." });
2510
+ }
2511
+ failOnThemeWorkRefusal(error, { op: "seal", handle });
2512
+ throw error;
2513
+ }
2514
+ if (JSON_MODE) {
2515
+ console.log(JSON.stringify(answer, null, 2));
2516
+ return;
2517
+ }
2518
+ console.log(answer.already_sealed ? `✓ Çalışma zaten incelemeye hazır: ${handle}` : `✓ Çalışma incelemeye hazır: ${handle}`);
2519
+ console.log(" İçeriği artık değişmiyor. Canlı site değişmedi; onay istenmedi.");
2520
+ if (answer.package?.digest_short) console.log(` İçerik özeti: ${answer.package.digest_short}`);
2521
+ console.log(`Yayın için onay iste: blocofy theme work request-approval ${handle}`);
2522
+ }
2523
+
2524
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2525
+
2526
+ /**
2527
+ * `--wait`: poll the read-only status until the request this command made ends (published, stale, expired, declined,
2528
+ * superseded, cancelled, failed) or a deadline passes (the request's expiry + 1 minute, at most 20 minutes). A
2529
+ * transient error keeps polling; a refusal ends the wait. Never publishes and never decides.
2530
+ */
2531
+ async function waitForApproval(api, handle, approval, intervalMs) {
2532
+ const expiry = Date.parse(approval.expires_at ?? "");
2533
+ const cap = Date.now() + 20 * 60_000;
2534
+ const deadline = Number.isFinite(expiry) ? Math.min(cap, expiry + 60_000) : cap;
2535
+ let last = null;
2536
+ for (;;) {
2537
+ try {
2538
+ last = await getPublishStatus({ ...api, handle, onRetry });
2539
+ const state = approvalWaitState(last, approval.id);
2540
+ if (state !== "pending") return { state, status: last };
2541
+ } catch (error) {
2542
+ if (error instanceof CliRefusal) {
2543
+ failOnThemeWorkRefusal(error, { op: "wait", handle });
2544
+ throw error;
2545
+ }
2546
+ if (!JSON_MODE) console.error("Durum okunamadı; tekrar denenecek.");
2547
+ }
2548
+ if (Date.now() + intervalMs > deadline) return { state: "timeout", status: last };
2549
+ await sleep(intervalMs);
2550
+ }
2551
+ }
2552
+
2553
+ async function themeWorkRequestApproval(rest) {
2554
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkRequestApproval);
2555
+ const handle = workHandleArg(positionals, flags, "blocofy theme work request-approval <wk_…> [--dir <dir>] [--open] [--wait [--interval <s>]]");
2556
+ let intervalMs = 3000;
2557
+ if (flags.interval !== undefined) {
2558
+ const n = Number(flags.interval);
2559
+ if (!flags.wait || typeof flags.interval !== "string" || !Number.isInteger(n) || n < 1 || n > 60) {
2560
+ throw new TargetError("USAGE", "--interval needs --wait and a whole number of seconds (1-60). Nothing was sent.", {}, 1);
2561
+ }
2562
+ intervalMs = n * 1000;
2563
+ }
2564
+ const target = await prepareTarget({ command: "theme work request-approval", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · request approval for work ${handle}` });
2565
+ let answer;
2566
+ try {
2567
+ answer = await requestApproval({ ...target.api, handle, onRetry });
2568
+ } catch (error) {
2569
+ failOnThemeWorkRefusal(error, { op: "request-approval", handle });
2570
+ throw error;
2571
+ }
2572
+ const { approval } = answer;
2573
+ const url = safeApprovalUrl(approval?.approval_url, target.api.apiUrl);
2574
+ if (!JSON_MODE) {
2575
+ console.log(`✓ Çalışma incelemeye hazır ve onay istendi: ${handle}`);
2576
+ console.log(`Onay URL'i: ${url ? (process.stdout.isTTY ? hyperlink(url) : url) : approval?.approval_url ?? "(yok)"}`);
2577
+ console.log(" Site sahibi ya da tema yetkisi olan bir ekip üyesi Blocofy'de oturum açıp bu sayfadan onaylar.");
2578
+ console.log(" URL'de gizli bir anahtar yok; URL'e sahip olmak yayın yetkisi vermez.");
2579
+ if (approval?.digest_short) console.log(` Onaylanacak içerik özeti: ${approval.digest_short}${approval.expires_at ? ` · son geçerlilik: ${approval.expires_at}` : ""}`);
2580
+ console.log(" Onaylanana kadar canlı site değişmez. Bu komut yayınlamaz.");
2581
+ }
2582
+ if (flags.open) {
2583
+ if (url) {
2584
+ openUrl(url);
2585
+ if (!JSON_MODE) console.log("Onay sayfası tarayıcıda açılıyor.");
2586
+ } else if (!JSON_MODE) {
2587
+ console.error("Onay URL'i bu platformun onay sayfası gibi görünmüyor; tarayıcıda açılmadı.");
2588
+ }
2589
+ }
2590
+ if (!flags.wait) {
2591
+ if (JSON_MODE) console.log(JSON.stringify(answer, null, 2));
2592
+ else console.log(`Durumu izle: blocofy theme work status ${handle} (ya da --wait ile bekle)`);
2593
+ return;
2594
+ }
2595
+ if (!JSON_MODE) console.log("Karar bekleniyor (Ctrl+C ile çıkabilirsin; onay isteği geçerli kalır)…");
2596
+ const { state, status } = await waitForApproval(target.api, handle, approval, intervalMs);
2597
+ const outcome = approvalOutcome(state, { handle, status });
2598
+ if (outcome.ok) {
2599
+ if (JSON_MODE) console.log(JSON.stringify({ ...answer, outcome: state, publish: status }, null, 2));
2600
+ else for (const line of outcome.lines) console.log(line);
2601
+ return;
2602
+ }
2603
+ if (!JSON_MODE) for (const line of outcome.lines) console.error(line);
2604
+ // A definite "not published" end is a refusal (exit 2); a timeout or a failure is not (exit 1).
2605
+ const definite = !["timeout", "failed"].includes(state);
2606
+ failAndExit({ code: outcome.code, ...(definite ? { status: 409 } : {}), message: outcome.message, details: { approval: approval?.id ?? null, phase: status?.phase ?? null } });
2607
+ }
2608
+
2609
+ async function themeWorkResume(rest) {
2610
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkResume);
2611
+ const handle = workHandleArg(positionals, flags, "blocofy theme work resume <wk_…> [--dir <dir>] [--require-fresh]");
2612
+ const target = await prepareTarget({ command: "theme work resume", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · work ${handle}` });
2613
+ let answer;
2614
+ try {
2615
+ answer = await resumeWork({ ...target.api, handle, requireFresh: Boolean(flags["require-fresh"]), onRetry });
2616
+ } catch (error) {
2617
+ failOnThemeWorkRefusal(error, { op: "resume", handle });
2618
+ throw error;
2619
+ }
2620
+ const saved = saveWork(target.binding, answer.work);
2621
+ if (JSON_MODE) {
2622
+ console.log(JSON.stringify({ work: answer.work, saved }, null, 2));
2623
+ return;
2624
+ }
2625
+ console.log(`✓ Çalışmaya devam ediliyor: ${handle}`);
2626
+ for (const line of workLines(answer.work)) console.log(line);
2627
+ for (const line of staleLines(answer.work) ?? []) console.log(line);
2628
+ console.log(`Dosyalarını gönder: blocofy theme push --draft --work ${handle}`);
2629
+ }
2630
+
2631
+ async function themeWorkCancel(rest) {
2632
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeWorkCancel);
2633
+ const handle = workHandleArg(positionals, flags, "blocofy theme work cancel <wk_…> [--dir <dir>]");
2634
+ const target = await prepareTarget({ command: "theme work cancel", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · cancel work ${handle}` });
2635
+ let work;
2636
+ try {
2637
+ ({ work } = await getWork({ ...target.api, handle, onRetry }));
2638
+ if (work.state !== "cancelled") ({ work } = await cancelWork({ ...target.api, handle, expectedStateVersion: work.state_version, onRetry }));
2639
+ } catch (error) {
2640
+ // A resend after a lost answer meets its own earlier cancel: the work IS cancelled — report it, not a conflict.
2641
+ if (!(error instanceof CliRefusal && error.error?.code === "work_state_conflict" && error.error?.details?.state === "cancelled")) {
2642
+ failOnThemeWorkRefusal(error, { op: "cancel", handle });
2643
+ throw error;
2644
+ }
2645
+ work = { id: handle, state: "cancelled", state_version: error.error.details.stateVersion ?? null };
2646
+ }
2647
+ const saved = readSavedWork(target.binding);
2648
+ if (saved?.handle === handle) saveWork(target.binding, null);
2649
+ if (JSON_MODE) {
2650
+ console.log(JSON.stringify({ work }, null, 2));
2651
+ return;
2652
+ }
2653
+ console.log(`✓ Çalışma iptal edildi: ${handle}. Canlı site değişmedi; çalışmanın teması tema kitaplığında duruyor.`);
2654
+ }
2655
+
2656
+ const THEME_WORK_USAGE = "blocofy theme work start|status|resume|seal|request-approval|cancel … (see `blocofy --help`)";
2657
+
2658
+ async function themeWork(rest) {
2659
+ const [sub, ...subRest] = rest;
2660
+ const handler = { start: themeWorkStart, status: themeWorkStatus, resume: themeWorkResume, seal: themeWorkSeal, "request-approval": themeWorkRequestApproval, cancel: themeWorkCancel }[sub];
2661
+ if (!handler) throw new TargetError("USAGE", `Usage: ${THEME_WORK_USAGE}`, {}, 1);
2662
+ return handler(subRest);
2663
+ }
2664
+
2281
2665
  async function themeRename(rest) {
2282
2666
  const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
2283
2667
  const handle = positionals[0];
@@ -2495,6 +2879,7 @@ const COMMANDS = {
2495
2879
  "theme push": themePush,
2496
2880
  "theme publish": themePublish,
2497
2881
  "theme rename": themeRename,
2882
+ "theme work": themeWork,
2498
2883
  "pages media-uses": pagesMediaUses,
2499
2884
  "pages media-decide": pagesMediaDecide,
2500
2885
  "pages pull": pagesPull,
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`