@blocofy/cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -15
- package/bin/blocofy.mjs +171 -36
- package/lib/dev-server.mjs +25 -7
- package/lib/target.mjs +5 -2
- package/lib/theme-sync.mjs +196 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -61,16 +61,20 @@ Show which site a command in `[dir]` would hit (verified), without writing anyth
|
|
|
61
61
|
### Theme
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
64
|
+
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
|
|
65
65
|
```
|
|
66
|
-
Start a dev server
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
Start a local dev server that renders your local theme files with the site's live content.
|
|
67
|
+
Press `l` to open it, `q` to quit. Edit a file and save → the view reloads. Saves sync to a
|
|
68
|
+
**draft** theme only (never the live site). `dir` defaults to cwd. The platform's remote preview
|
|
69
|
+
and editor views for this command are retired; to share a draft page, create a preview link for
|
|
70
|
+
it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
|
|
69
71
|
The target site is verified once at start; a long session keeps that target (restart it after
|
|
70
72
|
changing credentials, context or the project binding).
|
|
71
73
|
- `--port <n>` — local port (default 3030).
|
|
72
|
-
- `--no-sync` — local preview only (skip draft sync
|
|
74
|
+
- `--no-sync` — local preview only (skip draft sync).
|
|
73
75
|
- `--name <name>` — name the draft when it is first created (ignored if it already exists).
|
|
76
|
+
- `--instance <handle>` — sync into this draft. Needed when the platform cannot tell which draft is the
|
|
77
|
+
CLI draft (see "Which draft a draft command writes to" below); the live theme's handle is refused.
|
|
74
78
|
|
|
75
79
|
```
|
|
76
80
|
blocofy theme pull [dir] [--draft] [--instance <handle>]
|
|
@@ -92,15 +96,42 @@ the admin panel, never touching the live site. Publish it with `blocofy theme pu
|
|
|
92
96
|
non-interactive shells must add `--yes`.
|
|
93
97
|
- `--draft` — explicit draft (same as the default; safe).
|
|
94
98
|
- `--yes` — confirm a `--live` push without prompting (CI/agents).
|
|
95
|
-
- `--instance <handle>` — push to a specific theme by its handle
|
|
96
|
-
live-
|
|
97
|
-
|
|
99
|
+
- `--instance <handle>` — push to a specific theme by its handle. The live theme's handle asks for
|
|
100
|
+
the same confirmation as `--live` (non-interactive shells add `--yes`); so does any handle the CLI
|
|
101
|
+
cannot tell apart from the live theme (the live theme is unknown, or a raw numeric id was given).
|
|
102
|
+
With `--draft --instance <handle>` the push is a draft write to that draft: it never writes the
|
|
103
|
+
live theme (refused locally and by the platform, `draft_target_is_live`).
|
|
104
|
+
On a site with **no live theme** (or when the live theme cannot be read) the CLI cannot tell any
|
|
105
|
+
handle apart from the live one, so it stays on the safe side by design: every `--instance` push
|
|
106
|
+
asks for confirmation (`--yes` in non-interactive shells), and `--draft --instance` and
|
|
107
|
+
`theme dev --instance` refuse.
|
|
108
|
+
- `--name <name>` — name the new draft: it is created as `CLI Draft — <name>` (draft mode only;
|
|
109
|
+
ignored on `--live`/`--instance`). When the push reuses the existing CLI draft instead, it says
|
|
110
|
+
`--name` was not applied. Do not rename the CLI draft by hand to a name that does not start with
|
|
111
|
+
`CLI Draft — `: the next draft push would refuse it and ask for `--instance`.
|
|
98
112
|
- `--dry-run` / `--validate` — check the push on the server without writing; the two flags are
|
|
99
113
|
aliases. The dry run covers exactly the files the push would send (remote-only files it keeps
|
|
100
114
|
included; with `--prune`, without the ones it would remove). A current platform plans the whole
|
|
101
115
|
deploy and prints the target, its pointer version and what happens to each file
|
|
102
116
|
(`+` created, `~` updated, `-` removed; unchanged files are counted).
|
|
103
|
-
- `--diff` — show what a push would change vs the target (read-only), then stop.
|
|
117
|
+
- `--diff` — show what a push would change vs the target (read-only), then stop. A draft push
|
|
118
|
+
compares with the CLI draft it writes to (named by handle); with no CLI draft yet, with the live
|
|
119
|
+
theme (the push would create the draft). `--live` compares with live, `--instance` with that theme.
|
|
120
|
+
|
|
121
|
+
**Which draft a draft command writes to.** A draft push, `theme pull --draft` and the `theme dev`
|
|
122
|
+
sync write to (or read) the CLI draft only when the platform can tell which draft that is: exactly
|
|
123
|
+
one draft created by a CLI push, still carrying a name the platform gives it (`CLI Draft`,
|
|
124
|
+
`CLI Draft — YYYY-MM-DD` or `CLI Draft — <name>`), and not a draft a site-state restore built.
|
|
125
|
+
Otherwise — two such drafts, a site-state restore draft, or a CLI draft renamed by hand — the command
|
|
126
|
+
writes nothing, lists the candidate drafts and exits 2 (`draft_target_ambiguous`). The message
|
|
127
|
+
pre-fills `--instance <handle>` only for a CLI-named draft that is not a restore draft; naming any
|
|
128
|
+
other listed draft would overwrite it. On a site whose only candidate is a site-state restore draft,
|
|
129
|
+
create a new draft theme in the admin panel and pass its handle with `--instance`, or publish or
|
|
130
|
+
delete the restore draft first. `theme publish` without `--instance` uses the same rule and never
|
|
131
|
+
publishes a guessed draft. With no draft at all, the first push creates one as before.
|
|
132
|
+
|
|
133
|
+
After a draft push the CLI prints the draft it wrote to (`Draft: <handle> "<name>" (new)` or
|
|
134
|
+
`(existing CLI draft, updated)`) and the `blocofy theme publish --instance <handle>` command.
|
|
104
135
|
- `--idempotency-key <k>` — attach a key so a retried push is not double-applied.
|
|
105
136
|
- `--prune` — also remove target files that no longer exist locally (`locales/` included);
|
|
106
137
|
lists them first, and on the live theme asks to confirm (non-interactive shells add `--yes`).
|
|
@@ -149,7 +180,9 @@ flag, publishes the draft that `theme dev` / `theme push --draft` writes into. T
|
|
|
149
180
|
refuses to publish a theme that has no pages (it would 404); preview first. The site is the one
|
|
150
181
|
`[dir]`'s project is bound to (`dir` defaults to cwd), so `blocofy theme push ./shop && blocofy
|
|
151
182
|
theme publish ./shop` always publishes `./shop`'s site, whatever directory you run it from.
|
|
152
|
-
- `--instance <handle>` — publish a specific theme.
|
|
183
|
+
- `--instance <handle>` — publish a specific theme. Without it the platform publishes only the
|
|
184
|
+
site's CLI draft; any other theme (a site-state restore draft, a renamed or panel-copied theme) is
|
|
185
|
+
refused with `publish_target_unconfirmed` (exit 2, nothing published) — name it with `--instance`.
|
|
153
186
|
|
|
154
187
|
### Status
|
|
155
188
|
|
|
@@ -452,6 +485,41 @@ Each retry prints a notice on stderr.
|
|
|
452
485
|
|
|
453
486
|
## Changelog
|
|
454
487
|
|
|
488
|
+
- **0.14.0** — Draft commands no longer guess their draft (platform #989).
|
|
489
|
+
- **Behaviour change:** `theme push` (draft), `theme push --diff`, `theme pull --draft` and the
|
|
490
|
+
`theme dev` sync refuse, writing nothing, when the platform answers `draft_target_ambiguous` (or the
|
|
491
|
+
site status shows the same case): the message lists the candidate drafts and the exact
|
|
492
|
+
`--instance <handle>` command; exit code 2; never retried. A CLI draft you renamed is refused too.
|
|
493
|
+
- The refusal pre-fills `--instance` only for a safe candidate (CLI-named, not a restore draft) and
|
|
494
|
+
otherwise warns that naming a listed draft would overwrite it.
|
|
495
|
+
- `theme dev --instance <handle>` and `theme push --draft --instance <handle>` write to a chosen draft
|
|
496
|
+
(sent with `draft: true`; never the live theme).
|
|
497
|
+
- `theme dev` reports a draft-sync failure in one line, also at startup (it was silent there):
|
|
498
|
+
`draft_target_unverifiable` (the next save tries again) and `draft_target_is_live` (stops).
|
|
499
|
+
- **Behaviour change:** `theme publish` without `--instance` uses the same rule; it never publishes
|
|
500
|
+
a guessed draft.
|
|
501
|
+
- **Behaviour change:** `--name X` creates the draft as `CLI Draft — X`; the rename advice after a
|
|
502
|
+
reused draft is gone (a draft renamed by hand to another name is refused).
|
|
503
|
+
- **Behaviour change (CI):** `theme push --instance <the live theme's handle>` asks for the same
|
|
504
|
+
confirmation as `--live` (`--yes` in non-interactive shells); so does a handle the CLI cannot tell
|
|
505
|
+
apart from the live theme (live theme unknown, or a raw numeric id).
|
|
506
|
+
- `theme publish --instance <handle>` tells the platform the theme was named explicitly
|
|
507
|
+
(`explicit: true`); the automatic pick sends no flag and the platform re-checks it. A theme that is
|
|
508
|
+
not the site's CLI draft (a site-state restore draft, a renamed or panel-copied theme) is published
|
|
509
|
+
through the CLI only with `--instance`; otherwise the platform refuses with
|
|
510
|
+
`publish_target_unconfirmed` (exit 2, nothing published). Older CLIs get that refusal as a generic
|
|
511
|
+
error and can never publish such a theme.
|
|
512
|
+
- **0.13.0** — Draft push names its target (customer items PS-22/PS-23/PS-26).
|
|
513
|
+
- `theme push` (draft) prints the draft it wrote to — handle, name, new or existing — and
|
|
514
|
+
`blocofy theme publish --instance <handle>`. When it updated the existing CLI draft, it says
|
|
515
|
+
`--name` was not applied and prints the rename command.
|
|
516
|
+
- **Behaviour change:** `theme push --diff` without `--live`/`--instance` now compares with the CLI
|
|
517
|
+
draft the push writes to (found read-only via the site status, never provisioned), not with the
|
|
518
|
+
live theme. With no CLI draft yet it says so and compares with live. Release it after the
|
|
519
|
+
platform's PS-22 fix (uncached theme reads): before that fix a draft read could return its
|
|
520
|
+
pre-push files for up to five minutes.
|
|
521
|
+
- `theme dev` help no longer lists the retired live-domain and editor views.
|
|
522
|
+
- `TARGET_BINDING_REQUIRED` also names `blocofy link <dir>` for env credentials.
|
|
455
523
|
- **0.12.0** — Translation packages (#925).
|
|
456
524
|
- `blocofy translations export --locale <tag> --out <file> [--format json|xliff] [--only …]`
|
|
457
525
|
writes every text of one language that needs translating to one JSON or XLIFF 1.2 file. It
|
|
@@ -619,11 +687,9 @@ files and sends them to the platform's dev-render endpoint (`/api/dev/render`).
|
|
|
619
687
|
renders them with the site's **live data** and returns HTML — so the CLI ships no rendering
|
|
620
688
|
engine and you see exactly the production output.
|
|
621
689
|
|
|
622
|
-
It also continuously syncs your local files to a **draft theme
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
(the platform-rendered pages connect back to the local dev server's reload channel). Publish
|
|
626
|
-
the draft from the theme editor when you're ready.
|
|
690
|
+
It also continuously syncs your local files to a **draft theme**, so the same work can be opened
|
|
691
|
+
in the admin theme editor without affecting your published theme. Save a file and the local view
|
|
692
|
+
reloads. Publish the draft from the theme editor, or with `blocofy theme publish`, when you're ready.
|
|
627
693
|
|
|
628
694
|
Credentials live in `~/.blocofy/credentials.json` (contexts, no secrets) + either
|
|
629
695
|
`~/.blocofy/secrets.json` (0600) or the macOS keychain — written by `blocofy login`, or the
|
package/bin/blocofy.mjs
CHANGED
|
@@ -71,7 +71,7 @@ import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } f
|
|
|
71
71
|
import { SiteStateFsError, hashBuffer, readSiteStateTree, stagedWriteTree } from "../lib/site-state-fs.mjs";
|
|
72
72
|
import { migrateSiteState } from "../lib/site-migrate.mjs";
|
|
73
73
|
import { applySiteState, downloadAssetBytes, fetchSiteStateExport, planSiteState, publishSiteState, uploadMediaAsset } from "../lib/site-state-client.mjs";
|
|
74
|
-
import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
74
|
+
import { DRAFT_TARGET_AMBIGUOUS, PUBLISH_TARGET_UNCONFIRMED, publishTargetUnconfirmedMessage, diffTheme, draftSyncErrorLine, draftTargetAmbiguousMessage, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, findCliDraft, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
75
75
|
import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
|
|
76
76
|
import { hyperlink, openUrl } from "../lib/term.mjs";
|
|
77
77
|
import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
|
|
@@ -90,7 +90,7 @@ const KNOWN = {
|
|
|
90
90
|
pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
|
|
91
91
|
themePull: ["draft", "instance"],
|
|
92
92
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
93
|
-
themeDev: ["port", "dry", "no-sync", "name"],
|
|
93
|
+
themeDev: ["port", "dry", "no-sync", "name", "instance"],
|
|
94
94
|
themePublish: ["instance"],
|
|
95
95
|
themeRename: ["name", "dir"],
|
|
96
96
|
content: [],
|
|
@@ -177,16 +177,19 @@ Usage
|
|
|
177
177
|
blocofy target [dir] [--context <name>] [--json]
|
|
178
178
|
Show which site a command in [dir] would hit (verified), without writing anything.
|
|
179
179
|
|
|
180
|
-
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
181
|
-
Start a dev server
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
180
|
+
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]
|
|
181
|
+
Start a local dev server that renders your local theme files with the site's
|
|
182
|
+
live content. Press l to open it, q to quit. Edit a file and save → the view
|
|
183
|
+
reloads. Saves sync to a DRAFT theme only (never the live site). (dir defaults to cwd)
|
|
184
|
+
The platform's remote preview and editor views for this command are retired; to
|
|
185
|
+
share a draft page, create a preview link for it (v1 API / MCP create_preview_link).
|
|
185
186
|
The target site is verified ONCE at start; a long session keeps that target (restart
|
|
186
187
|
it after changing credentials, context or the project binding).
|
|
187
188
|
--port <n> local port (default 3030)
|
|
188
|
-
--no-sync local preview only (skip draft sync
|
|
189
|
+
--no-sync local preview only (skip draft sync)
|
|
189
190
|
--name <name> name the draft when it is first created (ignored if it already exists)
|
|
191
|
+
--instance <handle> sync into this draft (when the platform cannot tell which draft is
|
|
192
|
+
the CLI draft it refuses and asks for this; never the live theme)
|
|
190
193
|
|
|
191
194
|
blocofy theme pull [dir] [--draft] [--instance <handle>]
|
|
192
195
|
Download the live theme to disk. (dir defaults to cwd)
|
|
@@ -203,9 +206,11 @@ Usage
|
|
|
203
206
|
confirmation first; non-interactive shells must add --yes.
|
|
204
207
|
--draft explicit draft (same as the default; safe)
|
|
205
208
|
--yes confirm a --live push without prompting (for CI / agents)
|
|
206
|
-
--instance <handle> push to a specific theme by its handle
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
--instance <handle> push to a specific theme by its handle. If the platform cannot
|
|
210
|
+
tell which draft is the CLI draft, a draft push refuses and asks
|
|
211
|
+
for this. The LIVE theme's handle asks for confirmation like --live
|
|
212
|
+
--name <name> name the NEW draft "CLI Draft — <name>" (draft mode only; ignored on
|
|
213
|
+
--live/--instance). With --draft --instance, writes that draft (never live)
|
|
209
214
|
--dry-run validate on the server WITHOUT writing (auth + snapshot + Liquid check)
|
|
210
215
|
--validate alias for --dry-run (validate only, nothing written)
|
|
211
216
|
--diff show what a push WOULD change vs the target (read-only), then stop
|
|
@@ -225,7 +230,9 @@ Usage
|
|
|
225
230
|
'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
|
|
226
231
|
With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
|
|
227
232
|
The server refuses to publish a theme that has no pages (it would 404); preview first.
|
|
228
|
-
--instance <handle> publish a specific theme (handle from the panel / status)
|
|
233
|
+
--instance <handle> publish a specific theme (handle from the panel / status). Without it
|
|
234
|
+
the platform publishes only the site's CLI draft; any other theme
|
|
235
|
+
is refused (publish_target_unconfirmed) — name it with --instance
|
|
229
236
|
|
|
230
237
|
blocofy status [dir]
|
|
231
238
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
@@ -502,6 +509,36 @@ function failAndExit(error) {
|
|
|
502
509
|
process.exit(code);
|
|
503
510
|
}
|
|
504
511
|
|
|
512
|
+
/**
|
|
513
|
+
* #989 — the platform (or `findCliDraft`, which mirrors it) refused to pick the draft automatically. Says why, lists the
|
|
514
|
+
* candidate drafts and the exact command to repeat with `--instance`; exits 2 (409). Never retried.
|
|
515
|
+
*/
|
|
516
|
+
function failDraftTargetAmbiguous(error, command, { action = "write" } = {}) {
|
|
517
|
+
// Review P3: a refusal of a RESENT apply says nothing about the earlier attempt — never "Nothing was written" then.
|
|
518
|
+
const earlierAttempt = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
519
|
+
failAndExit({
|
|
520
|
+
code: DRAFT_TARGET_AMBIGUOUS,
|
|
521
|
+
status: 409,
|
|
522
|
+
message: draftTargetAmbiguousMessage(error, { command, action, earlierAttempt }),
|
|
523
|
+
details: {
|
|
524
|
+
reason: error.reason ?? null,
|
|
525
|
+
candidates: error.candidates ?? [],
|
|
526
|
+
suggestedInstance: error.suggestedInstance ?? null,
|
|
527
|
+
...(earlierAttempt ? { earlierAttempt } : {}),
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Could `instance` be the live theme? Fail-closed (#989 review): yes when it is the live handle, when the live theme is
|
|
534
|
+
* not known, and when it is a raw numeric id (the server accepts one, the live handle comparison cannot see it).
|
|
535
|
+
*/
|
|
536
|
+
const instanceMaybeLive = (instance, liveThemeId) =>
|
|
537
|
+
liveThemeId == null || String(instance) === String(liveThemeId) || /^\d+$/.test(String(instance));
|
|
538
|
+
|
|
539
|
+
/** The command line to repeat with `--instance` (the directory argument kept, as the user typed it). */
|
|
540
|
+
const commandLine = (base, positionals) => [base, ...positionals.slice(0, 1)].join(" ");
|
|
541
|
+
|
|
505
542
|
function contextNameOrExit(name) {
|
|
506
543
|
if (name === ENV_CONTEXT || !CONTEXT_NAME_RE.test(name)) {
|
|
507
544
|
throw new TargetError("TARGET_CONTEXT_INVALID", `"${name}" cannot be used as a context name (letters, digits, . _ -; "env" is reserved).`, { context: name }, 1);
|
|
@@ -926,7 +963,16 @@ async function themePull(rest) {
|
|
|
926
963
|
const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
|
|
927
964
|
// Review M1: a draft pull provisions the draft server-side (`?draft=1`), so it is a remote mutation: binding required.
|
|
928
965
|
const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
|
|
929
|
-
|
|
966
|
+
let count;
|
|
967
|
+
try {
|
|
968
|
+
({ count } = await withNewBindingClaim(target, dir, () => pullTheme({ dir, url: target.dev.url, token: target.dev.token, draft, instance, onRetry })));
|
|
969
|
+
} catch (error) {
|
|
970
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme pull", positionals));
|
|
971
|
+
if (error?.code === "draft_target_unverifiable") {
|
|
972
|
+
failAndExit({ code: "draft_target_unverifiable", status: 503, message: "The platform could not verify which draft to pull (a read failed on its side). Nothing was written. Try again in a moment.", details: {} });
|
|
973
|
+
}
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
930
976
|
console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
|
|
931
977
|
bindAfterPull(target, dir);
|
|
932
978
|
}
|
|
@@ -952,7 +998,7 @@ async function themePush(rest) {
|
|
|
952
998
|
// CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
|
|
953
999
|
// target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
|
|
954
1000
|
const readOnly = Boolean(flags.diff) || dryRun;
|
|
955
|
-
const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
|
|
1001
|
+
const opMode = flags.diff ? `read · diff vs ${instanceFlag ? `instance ${instanceFlag}` : mode === "draft" ? "CLI draft" : "live"}` : dryRun ? `read · dry run (${mode === "instance" ? `instance ${instance}` : mode})` : mode === "instance" ? `instance ${instance}` : mode;
|
|
956
1002
|
const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
|
|
957
1003
|
const creds = target.dev;
|
|
958
1004
|
// 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
|
|
@@ -963,12 +1009,25 @@ async function themePush(rest) {
|
|
|
963
1009
|
// ad-alanlar, önek EKLENMEZ.
|
|
964
1010
|
const idempotencyKey = typeof flags["idempotency-key"] === "string" ? flags["idempotency-key"] : `cli-${randomUUID()}`;
|
|
965
1011
|
|
|
966
|
-
// `--diff`: read-only preview vs the
|
|
967
|
-
//
|
|
1012
|
+
// `--diff`: read-only preview vs what the push would write to. PS-26: a draft push writes to the CLI draft, so
|
|
1013
|
+
// that draft is found via `/api/dev/site` and diffed by handle — never via `?draft=1`, which PROVISIONS a draft
|
|
1014
|
+
// server-side (a read-only command must not mutate). With no CLI draft yet the push would create one: vs live.
|
|
968
1015
|
if (flags.diff) {
|
|
969
1016
|
const diffTarget = ` of ${siteLabel(target.identity.site)}`;
|
|
970
|
-
|
|
971
|
-
|
|
1017
|
+
let cliDraft = null;
|
|
1018
|
+
try {
|
|
1019
|
+
cliDraft = mode === "draft" ? findCliDraft(await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry })) : null;
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
// #989: the diff compares with the draft the push would write to — and refuses exactly when the push would.
|
|
1022
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, `${commandLine("blocofy theme push", positionals)} --diff`);
|
|
1023
|
+
throw error;
|
|
1024
|
+
}
|
|
1025
|
+
const diffInstance = instanceFlag ?? cliDraft?.id ?? null;
|
|
1026
|
+
const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: diffInstance, onRetry });
|
|
1027
|
+
if (instanceFlag) console.log(`Diff vs theme ${instanceFlag}${diffTarget}:`);
|
|
1028
|
+
else if (cliDraft) console.log(`Diff vs draft ${cliDraft.id}${cliDraft.name ? ` "${cliDraft.name}"` : ""}${diffTarget} (the CLI draft this push writes to):`);
|
|
1029
|
+
else if (mode === "draft") console.log(`No CLI draft yet — this push would create one. Diff vs the LIVE theme${diffTarget}:`);
|
|
1030
|
+
else console.log(`Diff vs the LIVE theme${diffTarget}:`);
|
|
972
1031
|
const total = d.added.length + d.changed.length + d.removed.length;
|
|
973
1032
|
if (total === 0) {
|
|
974
1033
|
console.log("No differences — local theme matches the target.");
|
|
@@ -985,7 +1044,7 @@ async function themePush(rest) {
|
|
|
985
1044
|
const whoami = target.identity;
|
|
986
1045
|
const siteName = siteLabel(whoami.site) || String(whoami.site.id);
|
|
987
1046
|
if (mode === "instance") {
|
|
988
|
-
console.log(`→ Pushing to theme ${instance} of ${siteName}`);
|
|
1047
|
+
console.log(`→ Pushing to theme ${instance} of ${siteName}${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? " (the LIVE theme)" : ""}`);
|
|
989
1048
|
} else if (mode === "live") {
|
|
990
1049
|
console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
|
|
991
1050
|
} else {
|
|
@@ -996,13 +1055,34 @@ async function themePush(rest) {
|
|
|
996
1055
|
// kazara canlıya basmasın diye açık onay şart (#431 L2). Draft/instance modu
|
|
997
1056
|
// güvenli → otomatik onay (prompt yok).
|
|
998
1057
|
// 0.5.0: `--dry-run` hiçbir şey yazmaz → canlı onayı gereksiz (draft:true gibi davranır).
|
|
1058
|
+
// #989 review: `--instance <the live theme's handle>` writes the LIVE theme exactly like `--live`, so it needs the
|
|
1059
|
+
// same confirmation (an explicit --instance is the documented way past a refused draft pick — it must not become a
|
|
1060
|
+
// way to write live without asking).
|
|
1061
|
+
const instanceIsLive = mode === "instance" && instanceMaybeLive(instance, whoami.liveThemeId);
|
|
1062
|
+
// `--draft --instance <h>`: a draft write to a chosen draft. It never reaches the live theme — refused here when the
|
|
1063
|
+
// handle may be live (fail-closed), and by the server (422 draft_target_is_live) in any case.
|
|
1064
|
+
const draftInstance = mode === "instance" && Boolean(flags.draft);
|
|
1065
|
+
if (draftInstance && instanceIsLive && !dryRun) {
|
|
1066
|
+
console.error(`✗ --draft --instance ${instance}: ${whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId) ? "that is the LIVE theme" : "this may be the LIVE theme (it could not be told apart from it)"}; a draft push never writes live. Nothing was written.`);
|
|
1067
|
+
console.error(" Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.");
|
|
1068
|
+
process.exit(2);
|
|
1069
|
+
}
|
|
999
1070
|
const decision = livePushDecision({
|
|
1000
|
-
draft: mode !== "live" || dryRun,
|
|
1071
|
+
draft: (mode !== "live" && (!instanceIsLive || draftInstance)) || dryRun,
|
|
1001
1072
|
yes: Boolean(flags.yes),
|
|
1002
1073
|
confirm: Boolean(flags.confirm),
|
|
1003
1074
|
isTTY: Boolean(process.stdin.isTTY),
|
|
1004
1075
|
});
|
|
1005
1076
|
if (decision.mustAbort) {
|
|
1077
|
+
if (instanceIsLive) {
|
|
1078
|
+
console.error(
|
|
1079
|
+
whoami.liveThemeId != null && String(instance) === String(whoami.liveThemeId)
|
|
1080
|
+
? `⚠ Theme ${instance} is the LIVE theme of ${siteName}: this push writes to it immediately (no preview).`
|
|
1081
|
+
: `⚠ Theme ${instance} may be the LIVE theme of ${siteName} (it could not be told apart from it): a push to it may write live immediately.`,
|
|
1082
|
+
);
|
|
1083
|
+
console.error(` Non-interactive shell: pass --yes to confirm, or push to a draft instead. Nothing was written.`);
|
|
1084
|
+
process.exit(1);
|
|
1085
|
+
}
|
|
1006
1086
|
console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${siteName} immediately (no preview).`);
|
|
1007
1087
|
console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
|
|
1008
1088
|
process.exit(1);
|
|
@@ -1026,7 +1106,7 @@ async function themePush(rest) {
|
|
|
1026
1106
|
// `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
|
|
1027
1107
|
// TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
|
|
1028
1108
|
const prune = Boolean(flags.prune);
|
|
1029
|
-
const liveTarget = mode === "live" ||
|
|
1109
|
+
const liveTarget = mode === "live" || instanceIsLive;
|
|
1030
1110
|
const pruneDecision = livePushDecision({
|
|
1031
1111
|
draft: !prune || dryRun || !liveTarget,
|
|
1032
1112
|
yes: Boolean(flags.yes),
|
|
@@ -1073,7 +1153,7 @@ async function themePush(rest) {
|
|
|
1073
1153
|
dir,
|
|
1074
1154
|
url: creds.url,
|
|
1075
1155
|
token: creds.token,
|
|
1076
|
-
draft: mode === "draft",
|
|
1156
|
+
draft: mode === "draft" || draftInstance,
|
|
1077
1157
|
instance: mode === "instance" ? instance : null,
|
|
1078
1158
|
name: mode === "draft" ? name : null,
|
|
1079
1159
|
dryRun,
|
|
@@ -1091,6 +1171,10 @@ async function themePush(rest) {
|
|
|
1091
1171
|
}
|
|
1092
1172
|
failAndExit({ code: "cli_upgrade_required", status: error.status, message: "The server refused this CLI version; update it: npm i -g @blocofy/cli@latest", details: { missing } });
|
|
1093
1173
|
}
|
|
1174
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme push", positionals));
|
|
1175
|
+
if (error?.code === "draft_target_is_live") {
|
|
1176
|
+
failAndExit({ code: "draft_target_is_live", status: 422, message: "A draft push cannot write the LIVE theme; nothing was written. Pass a draft's handle (`blocofy status`), or drop --draft and confirm a live push.", details: {} });
|
|
1177
|
+
}
|
|
1094
1178
|
if (error?.code === "idempotency_conflict") {
|
|
1095
1179
|
if (!JSON_MODE) {
|
|
1096
1180
|
console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
|
|
@@ -1140,8 +1224,16 @@ async function themePush(rest) {
|
|
|
1140
1224
|
}
|
|
1141
1225
|
if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
|
|
1142
1226
|
if (mode === "draft") {
|
|
1227
|
+
const h = typeof result.targetInstance === "string" ? result.targetInstance : null;
|
|
1228
|
+
const label = result.targetName ? ` "${result.targetName}"` : "";
|
|
1229
|
+
if (h) console.log(result.newDraft ? `Draft: ${h}${label} (new)` : `Draft: ${h}${label} (existing CLI draft, updated)`);
|
|
1230
|
+
// --name only names a draft this push creates. #989 review: no rename advice — a draft renamed by hand to a name
|
|
1231
|
+
// that does not start with "CLI Draft — " is no longer picked automatically.
|
|
1232
|
+
if (name && result.newDraft !== true) {
|
|
1233
|
+
console.log(`Note: --name was not applied — it names only a draft this push creates; this push updated the existing CLI draft${h ? ` ${h}` : ""}.`);
|
|
1234
|
+
}
|
|
1143
1235
|
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
1144
|
-
console.log(
|
|
1236
|
+
console.log(`Publish it live with: blocofy theme publish${h ? ` --instance ${h}` : ""}`);
|
|
1145
1237
|
}
|
|
1146
1238
|
return;
|
|
1147
1239
|
}
|
|
@@ -1221,6 +1313,12 @@ function themePushRefusal(error, { draft = false, idempotencyKey = null } = {})
|
|
|
1221
1313
|
details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
|
|
1222
1314
|
});
|
|
1223
1315
|
switch (error?.code) {
|
|
1316
|
+
case "draft_target_unverifiable":
|
|
1317
|
+
// #989 review P3: the platform could not read which draft to write to; it wrote nothing for this attempt.
|
|
1318
|
+
return {
|
|
1319
|
+
message: `The platform could not verify which draft this push would write to (a read failed on its side). ${nothing("Run the push again in a moment.")}`,
|
|
1320
|
+
details: withEarlier({}),
|
|
1321
|
+
};
|
|
1224
1322
|
case "pointer_version_conflict":
|
|
1225
1323
|
return {
|
|
1226
1324
|
message: `The target theme was deployed again after this push checked it (now at pointer ${body.currentVersion == null ? "none" : `v${body.currentVersion}`}). ${nothing("Run the push again.")}`,
|
|
@@ -1975,6 +2073,13 @@ async function themeDev(rest) {
|
|
|
1975
2073
|
const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
|
|
1976
2074
|
const creds = target.dev;
|
|
1977
2075
|
const port = Number(flags.port) || 3030;
|
|
2076
|
+
// #989: `--instance <handle>` names the draft to sync into, for a site where the platform refuses to pick one. Draft
|
|
2077
|
+
// sync never writes the live theme, so the live theme's handle is refused here.
|
|
2078
|
+
const devInstance = typeof flags.instance === "string" ? flags.instance : null;
|
|
2079
|
+
if (devInstance && instanceMaybeLive(devInstance, target.identity?.liveThemeId ?? null)) {
|
|
2080
|
+
console.error(`✗ ${devInstance} is (or could not be told apart from) the LIVE theme. 'theme dev' syncs to a draft only; pick a draft handle (\`blocofy status\`) or use --no-sync.`);
|
|
2081
|
+
process.exit(2);
|
|
2082
|
+
}
|
|
1978
2083
|
|
|
1979
2084
|
// Dev session: live-domain preview + theme editor URLs (+ a draft to sync into).
|
|
1980
2085
|
// Graceful: if the platform can't provide one, fall back to local-only preview.
|
|
@@ -2052,6 +2157,19 @@ async function themeDev(rest) {
|
|
|
2052
2157
|
// session'dan hiçbir veri kullanmaz. Bu satır `Boolean(session)` iken, session 410 alınca senkron
|
|
2053
2158
|
// da sessizce kapanıyordu — ölü bir uç, çalışan bir özelliği götürüyordu. Tek kapatma yolu --no-sync.
|
|
2054
2159
|
syncDraft: !flags["no-sync"],
|
|
2160
|
+
instance: devInstance,
|
|
2161
|
+
// #989: the platform refused to pick the draft — say which drafts and how to choose, then stop (no retry loop).
|
|
2162
|
+
onSyncRefused: (error) => {
|
|
2163
|
+
try {
|
|
2164
|
+
handle.close();
|
|
2165
|
+
} catch {
|
|
2166
|
+
/* yoksay */
|
|
2167
|
+
}
|
|
2168
|
+
if (error?.code === "draft_target_is_live") {
|
|
2169
|
+
failAndExit({ code: "draft_target_is_live", status: 422, message: draftSyncErrorLine(error), details: {} });
|
|
2170
|
+
}
|
|
2171
|
+
failDraftTargetAmbiguous(error, commandLine("blocofy theme dev", positionals));
|
|
2172
|
+
},
|
|
2055
2173
|
onRetry: (info) => console.error(` ${retryNotice(info)}`),
|
|
2056
2174
|
onWarn: (msg) => console.warn(` ⚠ ${msg}`),
|
|
2057
2175
|
// Her kaydetmede ne olduğunu bas — "reloaded" = watch tetiklendi; "0 views"
|
|
@@ -2060,7 +2178,7 @@ async function themeDev(rest) {
|
|
|
2060
2178
|
onReload: ({ file, synced, clients, error }) => {
|
|
2061
2179
|
const what = file || "change";
|
|
2062
2180
|
if (error) {
|
|
2063
|
-
console.error(` ↻ ${what} —
|
|
2181
|
+
console.error(` ↻ ${what} — ${error} (local view still reloaded)`);
|
|
2064
2182
|
return;
|
|
2065
2183
|
}
|
|
2066
2184
|
const views = clients ? `${clients} view${clients === 1 ? "" : "s"}` : "no views connected";
|
|
@@ -2116,28 +2234,45 @@ async function themePublish(rest) {
|
|
|
2116
2234
|
const creds = target.dev;
|
|
2117
2235
|
let instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
2118
2236
|
if (!instance) {
|
|
2119
|
-
// Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
|
|
2120
|
-
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
// `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
|
|
2124
|
-
// `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
|
|
2125
|
-
// burada tekrarla — yoksa bir kez yayın yapmış her sitede iki taslak görünür ve komut takılır.
|
|
2237
|
+
// Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak `GET /api/dev/site`.
|
|
2238
|
+
// #989 review P2: the SAME rule as a draft push (`findCliDraft`, mirroring the server) and the same refusal — a
|
|
2239
|
+
// guessed draft (a site-state restore draft, a renamed one, one of several) is never published live.
|
|
2126
2240
|
const status = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
|
|
2127
2241
|
const drafts = status?.drafts ?? [];
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2242
|
+
let cliDraft;
|
|
2243
|
+
try {
|
|
2244
|
+
cliDraft = findCliDraft(status);
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
if (error?.code === DRAFT_TARGET_AMBIGUOUS) failDraftTargetAmbiguous(error, commandLine("blocofy theme publish", positionals), { action: "publish" });
|
|
2247
|
+
throw error;
|
|
2248
|
+
}
|
|
2249
|
+
if (cliDraft) {
|
|
2250
|
+
instance = cliDraft.id;
|
|
2131
2251
|
} else if (drafts.length === 0) {
|
|
2132
2252
|
console.error("No draft theme to publish. Create one first: blocofy theme push --draft");
|
|
2133
2253
|
process.exit(1);
|
|
2134
2254
|
} else {
|
|
2135
|
-
|
|
2255
|
+
// Drafts exist, but none is the CLI draft (panel copies, starter themes…): publishing one is a choice to make.
|
|
2256
|
+
console.error("No CLI draft to publish. Publish one of the site's drafts explicitly with --instance <handle>:");
|
|
2136
2257
|
for (const d of drafts) console.error(` ${d.id} ${d.name ?? "(unnamed)"}`);
|
|
2137
2258
|
process.exit(1);
|
|
2138
2259
|
}
|
|
2139
2260
|
}
|
|
2140
|
-
|
|
2261
|
+
let result;
|
|
2262
|
+
try {
|
|
2263
|
+
// K1 (#989): `explicit` only when the user named the theme; the automatic pick is re-checked by the platform.
|
|
2264
|
+
result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance, explicit: typeof flags.instance === "string", onRetry });
|
|
2265
|
+
} catch (error) {
|
|
2266
|
+
if (error?.code === PUBLISH_TARGET_UNCONFIRMED) {
|
|
2267
|
+
failAndExit({
|
|
2268
|
+
code: PUBLISH_TARGET_UNCONFIRMED,
|
|
2269
|
+
status: 409,
|
|
2270
|
+
message: publishTargetUnconfirmedMessage(error, { command: commandLine("blocofy theme publish", positionals) }),
|
|
2271
|
+
details: { reason: error.reason ?? null, instance: error.instance ?? null },
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
throw error;
|
|
2275
|
+
}
|
|
2141
2276
|
console.log(
|
|
2142
2277
|
`✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
|
|
2143
2278
|
);
|
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/target.mjs
CHANGED
|
@@ -217,13 +217,16 @@ function isEmptyOrMissing(dir) {
|
|
|
217
217
|
* Command-class policy (before any credential or network use). Returns `{ newBinding }`: true when a local-write
|
|
218
218
|
* into a missing/empty unbound dir will record provenance after it succeeds.
|
|
219
219
|
*/
|
|
220
|
+
/** PS-22 note: with env credentials there is no context to name — `link` binds to the site those credentials reach. */
|
|
221
|
+
const ENV_LINK_HINT = (dir) => `; with BLOCOFY_URL + BLOCOFY_TOKEN set: blocofy link ${dir}`;
|
|
222
|
+
|
|
220
223
|
export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
|
|
221
224
|
if (!COMMAND_CLASSES.has(commandClass)) throw new Error(`internal: unknown command class ${commandClass}`);
|
|
222
225
|
if (binding) return { newBinding: false };
|
|
223
226
|
if (commandClass === "remote-mutation") {
|
|
224
227
|
throw new TargetError(
|
|
225
228
|
"TARGET_BINDING_REQUIRED",
|
|
226
|
-
`\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name
|
|
229
|
+
`\`${command}\` changes a site, but ${dir} is not bound to one. Nothing was sent. Bind it first: blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}`,
|
|
227
230
|
{ dir },
|
|
228
231
|
);
|
|
229
232
|
}
|
|
@@ -231,7 +234,7 @@ export function enforceBindingPolicy({ commandClass, binding, dir, command }) {
|
|
|
231
234
|
if (isEmptyOrMissing(dir)) return { newBinding: true };
|
|
232
235
|
throw new TargetError(
|
|
233
236
|
"TARGET_BINDING_REQUIRED",
|
|
234
|
-
`${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name
|
|
237
|
+
`${dir} is not empty and not bound to a site. Nothing was written. Bind it first (blocofy link ${dir} --context <name>${ENV_LINK_HINT(dir)}) or pull into an empty directory.`,
|
|
235
238
|
{ dir },
|
|
236
239
|
);
|
|
237
240
|
}
|
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`,
|
|
@@ -322,9 +481,10 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
322
481
|
const remoteOnlyKept = [];
|
|
323
482
|
const remoteOnlyRemoved = [];
|
|
324
483
|
let probeInstance = instance;
|
|
484
|
+
let cliDraft = null;
|
|
325
485
|
if (!instance && draft) {
|
|
326
|
-
|
|
327
|
-
probeInstance =
|
|
486
|
+
cliDraft = findCliDraft(await fetchSiteStatus({ url, token, onRetry }));
|
|
487
|
+
probeInstance = cliDraft?.id ?? null;
|
|
328
488
|
}
|
|
329
489
|
let remote = {};
|
|
330
490
|
if (probeInstance || !draft) {
|
|
@@ -391,7 +551,35 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
391
551
|
throw applyError(error);
|
|
392
552
|
}
|
|
393
553
|
if (!res.ok) throw applyError(await httpError(res));
|
|
394
|
-
|
|
554
|
+
const applied = await res.json();
|
|
555
|
+
// PS-22: which theme received the files. A current server names it on the apply; an older one only in the plan
|
|
556
|
+
// (null for "a new draft"); oldest of all, the CLI draft looked up before the push is the one reused.
|
|
557
|
+
// A server whose plan carries no `newDraft` created one exactly when a draft push found no CLI draft to reuse.
|
|
558
|
+
const newDraft = typeof plan.newDraft === "boolean" ? plan.newDraft : Boolean(draft && !instance && cliDraft == null);
|
|
559
|
+
const targetInstance = applied.targetInstance ?? plan.targetInstance ?? (newDraft ? null : probeInstance) ?? null;
|
|
560
|
+
// #989 review: a draft this push created is named "CLI Draft — <name>" by the platform (date when no --name).
|
|
561
|
+
const targetName = newDraft ? cliDraftName(name) : cliDraft && cliDraft.id === targetInstance ? cliDraft.name ?? null : null;
|
|
562
|
+
return { ...applied, ...extras, targetInstance, newDraft, targetName };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The CLI draft a draft push writes into, or `null` when a push would create one. #989: mirrors the server's rule —
|
|
567
|
+
* exactly one `source: "import"` draft, carrying the platform-generated "CLI Draft" name and not a site-state restore
|
|
568
|
+
* draft — and otherwise throws the server's 409 `draft_target_ambiguous` shape, so `push --diff` and the push's merge
|
|
569
|
+
* probe never read a draft the push itself would refuse. The server stays authoritative (it also refuses a CLI-named
|
|
570
|
+
* draft a site-state theme deploy wrote to, which this list cannot show).
|
|
571
|
+
*/
|
|
572
|
+
export function findCliDraft(status) {
|
|
573
|
+
const candidates = (status?.drafts ?? []).filter((d) => d.source === "import");
|
|
574
|
+
if (candidates.length === 0) return null;
|
|
575
|
+
const listed = candidates.map((d) => ({ instance: d.id, name: d.name ?? null }));
|
|
576
|
+
if (candidates.length > 1) {
|
|
577
|
+
throw draftTargetAmbiguous(candidates.some((d) => SITE_STATE_DRAFT_NAME.test(d.name ?? "")) ? "site_state_restore" : "multiple_candidates", listed);
|
|
578
|
+
}
|
|
579
|
+
const [only] = candidates;
|
|
580
|
+
if (SITE_STATE_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("site_state_restore", listed);
|
|
581
|
+
if (!CLI_DRAFT_NAME.test(only.name ?? "")) throw draftTargetAmbiguous("unrecognized_name", listed);
|
|
582
|
+
return only;
|
|
395
583
|
}
|
|
396
584
|
|
|
397
585
|
/**
|
|
@@ -424,12 +612,14 @@ export async function diffTheme({ dir, url, token, instance = null, onRetry = nu
|
|
|
424
612
|
* içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
|
|
425
613
|
* yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
|
|
426
614
|
*/
|
|
427
|
-
export async function publishInstance({ url, token, instanceId, onRetry = null }) {
|
|
615
|
+
export async function publishInstance({ url, token, instanceId, explicit = false, onRetry = null }) {
|
|
428
616
|
const base = url.replace(/\/+$/, "");
|
|
429
617
|
const res = await fetchWithRetry(`${base}/api/dev/publish`, {
|
|
430
618
|
method: "POST",
|
|
431
619
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
432
|
-
|
|
620
|
+
// K1 (#989): `explicit: true` ONLY when the user named the theme (`--instance`). Without it the platform publishes
|
|
621
|
+
// nothing but the site's provable CLI draft (409 publish_target_unconfirmed otherwise).
|
|
622
|
+
body: JSON.stringify(explicit ? { instanceId, explicit: true } : { instanceId }),
|
|
433
623
|
}, { onRetry });
|
|
434
624
|
if (!res.ok) throw await responseError(res);
|
|
435
625
|
return res.json();
|
package/package.json
CHANGED