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