@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/README.md +173 -9
- package/bin/blocofy.mjs +545 -39
- package/lib/args.mjs +2 -2
- package/lib/dev-server.mjs +25 -7
- package/lib/theme-sync.mjs +336 -9
- package/lib/theme-work.mjs +369 -0
- package/package.json +1 -1
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 { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
74
|
+
import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance, 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
|
-
themeDev: ["port", "dry", "no-sync", "name"],
|
|
93
|
+
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
|
|
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"],
|
|
@@ -177,7 +184,7 @@ Usage
|
|
|
177
184
|
blocofy target [dir] [--context <name>] [--json]
|
|
178
185
|
Show which site a command in [dir] would hit (verified), without writing anything.
|
|
179
186
|
|
|
180
|
-
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
187
|
+
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
|
|
181
188
|
Start a local dev server that renders your local theme files with the site's
|
|
182
189
|
live content. Press l to open it, q to quit. Edit a file and save → the view
|
|
183
190
|
reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
|
|
@@ -188,11 +195,15 @@ Usage
|
|
|
188
195
|
--port <n> local port (default 3030)
|
|
189
196
|
--no-sync local preview only (skip draft sync)
|
|
190
197
|
--name <name> name the draft when it is first created (ignored if it already exists)
|
|
198
|
+
--instance <handle> sync into this draft (when the platform cannot tell which draft is
|
|
199
|
+
the CLI draft it refuses and asks for this; never the live theme)
|
|
191
200
|
|
|
192
201
|
blocofy theme pull [dir] [--draft] [--instance <handle>]
|
|
193
202
|
Download the live theme to disk. (dir defaults to cwd)
|
|
194
|
-
--draft pull the draft
|
|
195
|
-
|
|
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
|
|
196
207
|
--instance <handle> pull a specific theme by its handle (from the admin
|
|
197
208
|
panel theme card, or \`blocofy status\`)
|
|
198
209
|
|
|
@@ -204,9 +215,11 @@ Usage
|
|
|
204
215
|
confirmation first; non-interactive shells must add --yes.
|
|
205
216
|
--draft explicit draft (same as the default; safe)
|
|
206
217
|
--yes confirm a --live push without prompting (for CI / agents)
|
|
207
|
-
--instance <handle> push to a specific theme by its handle
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
--instance <handle> push to a specific theme by its handle. If the platform cannot
|
|
219
|
+
tell which draft is the CLI draft, a draft push refuses and asks
|
|
220
|
+
for this. The LIVE theme's handle asks for confirmation like --live
|
|
221
|
+
--name <name> name the NEW draft "CLI Draft — <name>" (draft mode only; ignored on
|
|
222
|
+
--live/--instance). With --draft --instance, writes that draft (never live)
|
|
210
223
|
--dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
|
|
211
224
|
--validate alias for --dry-run (validate only, nothing written)
|
|
212
225
|
--diff show what a push WOULD change vs the target (read-only), then stop
|
|
@@ -214,6 +227,40 @@ Usage
|
|
|
214
227
|
--prune also REMOVE target files that no longer exist locally, locales/ included
|
|
215
228
|
(lists them first; on the live theme asks to confirm — non-interactive
|
|
216
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.
|
|
217
264
|
|
|
218
265
|
blocofy theme rename <handle> <new name> [--dir <dir>]
|
|
219
266
|
Rename a theme (the name is just a label). Works on any of your themes,
|
|
@@ -226,7 +273,9 @@ Usage
|
|
|
226
273
|
'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
|
|
227
274
|
With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
|
|
228
275
|
The server refuses to publish a theme that has no pages (it would 404); preview first.
|
|
229
|
-
--instance <handle> publish a specific theme (handle from the panel / status)
|
|
276
|
+
--instance <handle> publish a specific theme (handle from the panel / status). Without it
|
|
277
|
+
the platform publishes only the site's CLI draft; any other theme
|
|
278
|
+
is refused (publish_target_unconfirmed) — name it with --instance
|
|
230
279
|
|
|
231
280
|
blocofy status [dir]
|
|
232
281
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
@@ -503,6 +552,49 @@ function failAndExit(error) {
|
|
|
503
552
|
process.exit(code);
|
|
504
553
|
}
|
|
505
554
|
|
|
555
|
+
/**
|
|
556
|
+
* #989 — the platform (or `findCliDraft`, which mirrors it) refused to pick the draft automatically. Says why, lists the
|
|
557
|
+
* candidate drafts and the exact command to repeat with `--instance`; exits 2 (409). Never retried.
|
|
558
|
+
*/
|
|
559
|
+
function failDraftTargetAmbiguous(error, command, { action = "write" } = {}) {
|
|
560
|
+
// Review P3: a refusal of a RESENT apply says nothing about the earlier attempt — never "Nothing was written" then.
|
|
561
|
+
const earlierAttempt = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
562
|
+
failAndExit({
|
|
563
|
+
code: DRAFT_TARGET_AMBIGUOUS,
|
|
564
|
+
status: 409,
|
|
565
|
+
message: draftTargetAmbiguousMessage(error, { command, action, earlierAttempt }),
|
|
566
|
+
details: {
|
|
567
|
+
reason: error.reason ?? null,
|
|
568
|
+
candidates: error.candidates ?? [],
|
|
569
|
+
suggestedInstance: error.suggestedInstance ?? null,
|
|
570
|
+
...(earlierAttempt ? { earlierAttempt } : {}),
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
}
|
|
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
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Could `instance` be the live theme? Fail-closed (#989 review): yes when it is the live handle, when the live theme is
|
|
590
|
+
* not known, and when it is a raw numeric id (the server accepts one, the live handle comparison cannot see it).
|
|
591
|
+
*/
|
|
592
|
+
const instanceMaybeLive = (instance, liveThemeId) =>
|
|
593
|
+
liveThemeId == null || String(instance) === String(liveThemeId) || /^\d+$/.test(String(instance));
|
|
594
|
+
|
|
595
|
+
/** The command line to repeat with `--instance` (the directory argument kept, as the user typed it). */
|
|
596
|
+
const commandLine = (base, positionals) => [base, ...positionals.slice(0, 1)].join(" ");
|
|
597
|
+
|
|
506
598
|
function contextNameOrExit(name) {
|
|
507
599
|
if (name === ENV_CONTEXT || !CONTEXT_NAME_RE.test(name)) {
|
|
508
600
|
throw new TargetError("TARGET_CONTEXT_INVALID", `"${name}" cannot be used as a context name (letters, digits, . _ -; "env" is reserved).`, { context: name }, 1);
|
|
@@ -871,7 +963,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
871
963
|
if (needs === "both" && !hasDev) {
|
|
872
964
|
throw new TargetError(
|
|
873
965
|
"LOGIN_REQUIRED",
|
|
874
|
-
`Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN).
|
|
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).`,
|
|
875
967
|
{ context: resolved.name },
|
|
876
968
|
1,
|
|
877
969
|
);
|
|
@@ -925,9 +1017,21 @@ async function themePull(rest) {
|
|
|
925
1017
|
const draft = Boolean(flags.draft);
|
|
926
1018
|
const instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
927
1019
|
const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
|
|
928
|
-
// Review M1: a draft pull
|
|
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.
|
|
929
1023
|
const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
|
|
930
|
-
|
|
1024
|
+
let count;
|
|
1025
|
+
try {
|
|
1026
|
+
({ count } = await withNewBindingClaim(target, dir, () => pullTheme({ dir, url: target.dev.url, token: target.dev.token, draft, instance, onRetry })));
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme pull", positionals));
|
|
1029
|
+
if (error?.code === "draft_target_unverifiable") {
|
|
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: {} });
|
|
1031
|
+
}
|
|
1032
|
+
failOnThemeCapacityRefusal(error, commandLine("blocofy theme push", positionals));
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
931
1035
|
console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
|
|
932
1036
|
bindAfterPull(target, dir);
|
|
933
1037
|
}
|
|
@@ -939,22 +1043,34 @@ async function themePush(rest) {
|
|
|
939
1043
|
console.error(`Theme directory not found: ${dir}`);
|
|
940
1044
|
process.exit(1);
|
|
941
1045
|
}
|
|
942
|
-
|
|
1046
|
+
let instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
|
|
943
1047
|
const name = typeof flags.name === "string" ? flags.name : null;
|
|
944
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;
|
|
945
1064
|
// Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
|
|
946
1065
|
// açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
|
|
947
1066
|
// modu canlıya yazar ve onay gerektirir.
|
|
948
1067
|
const { mode, instance } = resolvePushMode({
|
|
949
1068
|
live: Boolean(flags.live),
|
|
950
|
-
draft:
|
|
1069
|
+
draft: draftFlag,
|
|
951
1070
|
instance: instanceFlag,
|
|
952
1071
|
});
|
|
953
|
-
// CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
|
|
954
|
-
// target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
|
|
955
|
-
const readOnly = Boolean(flags.diff) || dryRun;
|
|
956
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;
|
|
957
|
-
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" : ""}` }));
|
|
958
1074
|
const creds = target.dev;
|
|
959
1075
|
// 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
|
|
960
1076
|
// OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
|
|
@@ -969,7 +1085,14 @@ async function themePush(rest) {
|
|
|
969
1085
|
// server-side (a read-only command must not mutate). With no CLI draft yet the push would create one: vs live.
|
|
970
1086
|
if (flags.diff) {
|
|
971
1087
|
const diffTarget = ` of ${siteLabel(target.identity.site)}`;
|
|
972
|
-
|
|
1088
|
+
let cliDraft = null;
|
|
1089
|
+
try {
|
|
1090
|
+
cliDraft = mode === "draft" ? findCliDraft(await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry })) : null;
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
// #989: the diff compares with the draft the push would write to — and refuses exactly when the push would.
|
|
1093
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, `${commandLine("blocofy theme push", positionals)} --diff`);
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
973
1096
|
const diffInstance = instanceFlag ?? cliDraft?.id ?? null;
|
|
974
1097
|
const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: diffInstance, onRetry });
|
|
975
1098
|
if (instanceFlag) console.log(`Diff vs theme ${instanceFlag}${diffTarget}:`);
|
|
@@ -992,7 +1115,7 @@ async function themePush(rest) {
|
|
|
992
1115
|
const whoami = target.identity;
|
|
993
1116
|
const siteName = siteLabel(whoami.site) || String(whoami.site.id);
|
|
994
1117
|
if (mode === "instance") {
|
|
995
|
-
console.log(`→ Pushing to theme ${instance} of ${siteName}`);
|
|
1118
|
+
console.log(`→ Pushing to theme ${instance} of ${siteName}${workHandle ? ` (work ${workHandle})` : whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
|
|
996
1119
|
} else if (mode === "live") {
|
|
997
1120
|
console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
|
|
998
1121
|
} else {
|
|
@@ -1003,13 +1126,34 @@ async function themePush(rest) {
|
|
|
1003
1126
|
// kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
|
|
1004
1127
|
// güvenli → otomatik onay (prompt yok).
|
|
1005
1128
|
// 0.5.0: `--dry-run` hiçbir şey yazmaz → canlı onayı gereksiz (draft:true gibi davranır).
|
|
1129
|
+
// #989 review: `--instance <the live theme's handle>` writes the LIVE theme exactly like `--live`, so it needs the
|
|
1130
|
+
// same confirmation (an explicit --instance is the documented way past a refused draft pick — it must not become a
|
|
1131
|
+
// way to write live without asking).
|
|
1132
|
+
const instanceIsLive = mode === "instance" && instanceMaybeLive(instance, whoami.liveThemeId);
|
|
1133
|
+
// `--draft --instance <h>`: a draft write to a chosen draft. It never reaches the live theme — refused here when the
|
|
1134
|
+
// handle may be live (fail-closed), and by the server (422 draft_target_is_live) in any case.
|
|
1135
|
+
const draftInstance = mode === "instance" && draftFlag;
|
|
1136
|
+
if (draftInstance && instanceIsLive && !dryRun) {
|
|
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.`);
|
|
1138
|
+
console.error(" Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.");
|
|
1139
|
+
process.exit(2);
|
|
1140
|
+
}
|
|
1006
1141
|
const decision = livePushDecision({
|
|
1007
|
-
draft: mode !== "live" || dryRun,
|
|
1142
|
+
draft: (mode !== "live" && (!instanceIsLive || draftInstance)) || dryRun,
|
|
1008
1143
|
yes: Boolean(flags.yes),
|
|
1009
1144
|
confirm: Boolean(flags.confirm),
|
|
1010
1145
|
isTTY: Boolean(process.stdin.isTTY),
|
|
1011
1146
|
});
|
|
1012
1147
|
if (decision.mustAbort) {
|
|
1148
|
+
if (instanceIsLive) {
|
|
1149
|
+
console.error(
|
|
1150
|
+
whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId)
|
|
1151
|
+
? `⚠ Theme ${instance} is the LIVE theme of ${siteName}: this push writes to it immediately (no preview).`
|
|
1152
|
+
: `⚠ 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.`,
|
|
1153
|
+
);
|
|
1154
|
+
console.error(` Non-interactive shell: pass --yes to confirm, or push to a draft instead. Nothing was written.`);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1013
1157
|
console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${siteName} immediately (no preview).`);
|
|
1014
1158
|
console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
|
|
1015
1159
|
process.exit(1);
|
|
@@ -1033,7 +1177,7 @@ async function themePush(rest) {
|
|
|
1033
1177
|
// `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
|
|
1034
1178
|
// TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
|
|
1035
1179
|
const prune = Boolean(flags.prune);
|
|
1036
|
-
const liveTarget = mode === "live" ||
|
|
1180
|
+
const liveTarget = mode === "live" || instanceIsLive;
|
|
1037
1181
|
const pruneDecision = livePushDecision({
|
|
1038
1182
|
draft: !prune || dryRun || !liveTarget,
|
|
1039
1183
|
yes: Boolean(flags.yes),
|
|
@@ -1080,7 +1224,7 @@ async function themePush(rest) {
|
|
|
1080
1224
|
dir,
|
|
1081
1225
|
url: creds.url,
|
|
1082
1226
|
token: creds.token,
|
|
1083
|
-
draft: mode === "draft",
|
|
1227
|
+
draft: mode === "draft" || draftInstance,
|
|
1084
1228
|
instance: mode === "instance" ? instance : null,
|
|
1085
1229
|
name: mode === "draft" ? name : null,
|
|
1086
1230
|
dryRun,
|
|
@@ -1098,6 +1242,10 @@ async function themePush(rest) {
|
|
|
1098
1242
|
}
|
|
1099
1243
|
failAndExit({ code: "cli_upgrade_required", status: error.status, message: "The server refused this CLI version; update it: npm i -g @blocofy/cli@latest", details: { missing } });
|
|
1100
1244
|
}
|
|
1245
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme push", positionals));
|
|
1246
|
+
if (error?.code === "draft_target_is_live") {
|
|
1247
|
+
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: {} });
|
|
1248
|
+
}
|
|
1101
1249
|
if (error?.code === "idempotency_conflict") {
|
|
1102
1250
|
if (!JSON_MODE) {
|
|
1103
1251
|
console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
|
|
@@ -1105,6 +1253,8 @@ async function themePush(rest) {
|
|
|
1105
1253
|
}
|
|
1106
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: {} });
|
|
1107
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));
|
|
1108
1258
|
const refusal = themePushRefusal(error, { draft: mode === "draft", idempotencyKey });
|
|
1109
1259
|
if (refusal) failAndExit({ code: error.code, status: error.status, ...refusal });
|
|
1110
1260
|
throw error;
|
|
@@ -1146,13 +1296,18 @@ async function themePush(rest) {
|
|
|
1146
1296
|
console.log(`✓ Deployed: ${ids}. The written files were not verified (this server does not read them back).`);
|
|
1147
1297
|
}
|
|
1148
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
|
+
}
|
|
1149
1303
|
if (mode === "draft") {
|
|
1150
1304
|
const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
|
|
1151
1305
|
const label = result.targetName ? ` "${result.targetName}"` : "";
|
|
1152
1306
|
if (h) console.log(result.newDraft ? `Draft: ${h}${label} (new)` : `Draft: ${h}${label} (existing CLI draft, updated)`);
|
|
1153
|
-
//
|
|
1307
|
+
// --name only names a draft this push creates. #989 review: no rename advice — a draft renamed by hand to a name
|
|
1308
|
+
// that does not start with "CLI Draft — " is no longer picked automatically.
|
|
1154
1309
|
if (name && result.newDraft !== true) {
|
|
1155
|
-
console.log(`Note: --name was not applied — this push updated the existing CLI draft${h ? ` ${h}` : ""}
|
|
1310
|
+
console.log(`Note: --name was not applied — it names only a draft this push creates; this push updated the existing CLI draft${h ? ` ${h}` : ""}.`);
|
|
1156
1311
|
}
|
|
1157
1312
|
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
1158
1313
|
console.log(`Publish it live with: blocofy theme publish${h ? ` --instance ${h}` : ""}`);
|
|
@@ -1235,6 +1390,12 @@ function themePushRefusal(error, { draft = false, idempotencyKey = null } = {})
|
|
|
1235
1390
|
details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
|
|
1236
1391
|
});
|
|
1237
1392
|
switch (error?.code) {
|
|
1393
|
+
case "draft_target_unverifiable":
|
|
1394
|
+
// #989 review P3: the platform could not read which draft to write to; it wrote nothing for this attempt.
|
|
1395
|
+
return {
|
|
1396
|
+
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.")}`,
|
|
1397
|
+
details: withEarlier({}),
|
|
1398
|
+
};
|
|
1238
1399
|
case "pointer_version_conflict":
|
|
1239
1400
|
return {
|
|
1240
1401
|
message: `The target theme was deployed again after this push checked it (now at pointer ${body.currentVersion == null ? "none" : `v${body.currentVersion}`}). ${nothing("Run the push again.")}`,
|
|
@@ -1989,6 +2150,13 @@ async function themeDev(rest) {
|
|
|
1989
2150
|
const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
|
|
1990
2151
|
const creds = target.dev;
|
|
1991
2152
|
const port = Number(flags.port) || 3030;
|
|
2153
|
+
// #989: `--instance <handle>` names the draft to sync into, for a site where the platform refuses to pick one. Draft
|
|
2154
|
+
// sync never writes the live theme, so the live theme's handle is refused here.
|
|
2155
|
+
const devInstance = typeof flags.instance === "string" ? flags.instance : null;
|
|
2156
|
+
if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
|
|
2157
|
+
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.`);
|
|
2158
|
+
process.exit(2);
|
|
2159
|
+
}
|
|
1992
2160
|
|
|
1993
2161
|
// Dev session: live-domain preview + theme editor URLs (+ a draft to sync into).
|
|
1994
2162
|
// Graceful: if the platform can't provide one, fall back to local-only preview.
|
|
@@ -2066,6 +2234,19 @@ async function themeDev(rest) {
|
|
|
2066
2234
|
// session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
|
|
2067
2235
|
// da sessizce kapanıyordu — ölü bir uç, çalışan bir özelliği götürüyordu. Tek kapatma yolu --no-sync.
|
|
2068
2236
|
syncDraft: !flags["no-sync"],
|
|
2237
|
+
instance: devInstance,
|
|
2238
|
+
// #989: the platform refused to pick the draft — say which drafts and how to choose, then stop (no retry loop).
|
|
2239
|
+
onSyncRefused: (error) => {
|
|
2240
|
+
try {
|
|
2241
|
+
handle.close();
|
|
2242
|
+
} catch {
|
|
2243
|
+
/* yoksay */
|
|
2244
|
+
}
|
|
2245
|
+
if (error?.code === "draft_target_is_live") {
|
|
2246
|
+
failAndExit({ code: "draft_target_is_live", status: 422, message: draftSyncErrorLine(error), details: {} });
|
|
2247
|
+
}
|
|
2248
|
+
failDraftTargetAmbiguous(error, commandLine("blocofy theme dev", positionals));
|
|
2249
|
+
},
|
|
2069
2250
|
onRetry: (info) => console.error(` ${retryNotice(info)}`),
|
|
2070
2251
|
onWarn: (msg) => console.warn(` ⚠ ${msg}`),
|
|
2071
2252
|
// Her kaydetmede ne olduğunu bas — "reloaded" = watch tetiklendi; "0 views"
|
|
@@ -2074,7 +2255,7 @@ async function themeDev(rest) {
|
|
|
2074
2255
|
onReload: ({ file, synced, clients, error }) => {
|
|
2075
2256
|
const what = file || "change";
|
|
2076
2257
|
if (error) {
|
|
2077
|
-
console.error(` ↻ ${what} —
|
|
2258
|
+
console.error(` ↻ ${what} — ${error} (local view still reloaded)`);
|
|
2078
2259
|
return;
|
|
2079
2260
|
}
|
|
2080
2261
|
const views = clients ? `${clients} view${clients === 1 ? "" : "s"}` : "no views connected";
|
|
@@ -2130,33 +2311,357 @@ async function themePublish(rest) {
|
|
|
2130
2311
|
const creds = target.dev;
|
|
2131
2312
|
let instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
2132
2313
|
if (!instance) {
|
|
2133
|
-
// Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
// `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
|
|
2138
|
-
// `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
|
|
2139
|
-
// burada tekrarla — yoksa bir kez yayın yapmış her sitede iki taslak görünür ve komut takılır.
|
|
2314
|
+
// Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak `GET /api/dev/site`.
|
|
2315
|
+
// #989 review P2: the SAME rule as a draft push (`findCliDraft`, mirroring the server) and the same refusal — a
|
|
2316
|
+
// guessed draft (a site-state restore draft, a renamed one, one of several) is never published live.
|
|
2140
2317
|
const status = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
|
|
2141
2318
|
const drafts = status?.drafts ?? [];
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2319
|
+
let cliDraft;
|
|
2320
|
+
try {
|
|
2321
|
+
cliDraft = findCliDraft(status);
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme publish", positionals), { action: "publish" });
|
|
2324
|
+
throw error;
|
|
2325
|
+
}
|
|
2326
|
+
if (cliDraft) {
|
|
2327
|
+
instance = cliDraft.id;
|
|
2145
2328
|
} else if (drafts.length === 0) {
|
|
2146
2329
|
console.error("No draft theme to publish. Create one first: blocofy theme push --draft");
|
|
2147
2330
|
process.exit(1);
|
|
2148
2331
|
} else {
|
|
2149
|
-
|
|
2332
|
+
// Drafts exist, but none is the CLI draft (panel copies, starter themes…): publishing one is a choice to make.
|
|
2333
|
+
console.error("No CLI draft to publish. Publish one of the site's drafts explicitly with --instance <handle>:");
|
|
2150
2334
|
for (const d of drafts) console.error(` ${d.id} ${d.name ?? "(unnamed)"}`);
|
|
2151
2335
|
process.exit(1);
|
|
2152
2336
|
}
|
|
2153
2337
|
}
|
|
2154
|
-
|
|
2338
|
+
let result;
|
|
2339
|
+
try {
|
|
2340
|
+
// K1 (#989): `explicit` only when the user named the theme; the automatic pick is re-checked by the platform.
|
|
2341
|
+
result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance, explicit: typeof flags.instance === "string", onRetry });
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
if (error?.code === PUBLISH_TARGET_UNCONFIRMED) {
|
|
2344
|
+
failAndExit({
|
|
2345
|
+
code: PUBLISH_TARGET_UNCONFIRMED,
|
|
2346
|
+
status: 409,
|
|
2347
|
+
message: publishTargetUnconfirmedMessage(error, { command: commandLine("blocofy theme publish", positionals) }),
|
|
2348
|
+
details: { reason: error.reason ?? null, instance: error.instance ?? null },
|
|
2349
|
+
});
|
|
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" });
|
|
2353
|
+
throw error;
|
|
2354
|
+
}
|
|
2155
2355
|
console.log(
|
|
2156
2356
|
`✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
|
|
2157
2357
|
);
|
|
2158
2358
|
}
|
|
2159
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
|
+
|
|
2160
2665
|
async function themeRename(rest) {
|
|
2161
2666
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeRename);
|
|
2162
2667
|
const handle = positionals[0];
|
|
@@ -2374,6 +2879,7 @@ const COMMANDS = {
|
|
|
2374
2879
|
"theme push": themePush,
|
|
2375
2880
|
"theme publish": themePublish,
|
|
2376
2881
|
"theme rename": themeRename,
|
|
2882
|
+
"theme work": themeWork,
|
|
2377
2883
|
"pages media-uses": pagesMediaUses,
|
|
2378
2884
|
"pages media-decide": pagesMediaDecide,
|
|
2379
2885
|
"pages pull": pagesPull,
|