@blocofy/cli 0.10.0 → 0.11.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 +155 -20
- package/bin/blocofy.mjs +210 -42
- package/lib/http.mjs +4 -1
- package/lib/output.mjs +23 -4
- package/lib/site-state.mjs +33 -10
- package/lib/target.mjs +71 -13
- package/lib/theme-sync.mjs +163 -63
- package/package.json +1 -1
package/bin/blocofy.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { printError, printTarget, printWarning, redact, registerSecret, targetDa
|
|
|
34
34
|
import {
|
|
35
35
|
CONTEXT_NAME_RE,
|
|
36
36
|
TargetError,
|
|
37
|
+
assertCredentialTypes,
|
|
37
38
|
claimNewBinding,
|
|
38
39
|
compareOrigin,
|
|
39
40
|
enforceBindingPolicy,
|
|
@@ -70,12 +71,12 @@ const args = process.argv.slice(2);
|
|
|
70
71
|
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
71
72
|
const KNOWN = {
|
|
72
73
|
login: ["url", "token", "api-key", "api-url", "keychain"],
|
|
73
|
-
pages: ["decisions", "expected-revision-id", "expected-version", "json"],
|
|
74
|
+
pages: ["decisions", "expected-revision-id", "expected-version", "json", "dir"],
|
|
74
75
|
themePull: ["draft", "instance"],
|
|
75
76
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
76
77
|
themeDev: ["port", "dry", "no-sync", "name"],
|
|
77
78
|
themePublish: ["instance"],
|
|
78
|
-
themeRename: ["name"],
|
|
79
|
+
themeRename: ["name", "dir"],
|
|
79
80
|
content: [],
|
|
80
81
|
settingsPush: ["instance", "live", "yes", "confirm"],
|
|
81
82
|
pagesPull: ["strict"],
|
|
@@ -103,6 +104,24 @@ function parseArgsOrExit(rest, known) {
|
|
|
103
104
|
return parsed;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 1.8: the project directory a command resolves its binding from — the explicit argument (`[dir]` or `--dir <dir>`)
|
|
109
|
+
* when given, else cwd. A missing directory is a usage error before any request.
|
|
110
|
+
*/
|
|
111
|
+
function commandDir(explicit) {
|
|
112
|
+
if (explicit === undefined) return process.cwd();
|
|
113
|
+
if (typeof explicit !== "string" || explicit === "") throw new TargetError("USAGE", "--dir needs a directory. Nothing was read or written.", {}, 1);
|
|
114
|
+
const dir = resolve(explicit);
|
|
115
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new TargetError("USAGE", `Directory not found: ${dir}. Nothing was read or written.`, { dir }, 1);
|
|
116
|
+
return dir;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Commands whose only positional is the optional `[dir]`. */
|
|
120
|
+
function singleDirArg(positionals, usage) {
|
|
121
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
122
|
+
return commandDir(positionals[0]);
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
/** Human label for a resolved site: "Name (slug)" or just the slug. */
|
|
107
126
|
function siteLabel(site) {
|
|
108
127
|
if (!site) return "";
|
|
@@ -130,7 +149,8 @@ Usage
|
|
|
130
149
|
Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
|
|
131
150
|
|
|
132
151
|
blocofy contexts [--json] list saved contexts (never prints secrets)
|
|
133
|
-
blocofy use <name> default context for
|
|
152
|
+
blocofy use <name> default context for status / target / pages check outside a
|
|
153
|
+
project (never used by any other command)
|
|
134
154
|
blocofy logout --context <name> remove a context and its secrets
|
|
135
155
|
blocofy link [dir] --context <name> [--adopt]
|
|
136
156
|
Bind a project directory to the context's (verified) site: writes .blocofy/project.json
|
|
@@ -176,17 +196,20 @@ Usage
|
|
|
176
196
|
(lists them first; on the live theme asks to confirm — non-interactive
|
|
177
197
|
shells must add --yes)
|
|
178
198
|
|
|
179
|
-
blocofy theme rename <handle> <new name>
|
|
199
|
+
blocofy theme rename <handle> <new name> [--dir <dir>]
|
|
180
200
|
Rename a theme (the name is just a label). Works on any of your themes,
|
|
181
201
|
including the live one. Handle comes from the panel theme card or 'blocofy status'.
|
|
202
|
+
--dir <dir> the bound project whose site this is (default: cwd)
|
|
182
203
|
|
|
183
|
-
blocofy theme publish [--instance <handle>]
|
|
204
|
+
blocofy theme publish [dir] [--instance <handle>]
|
|
184
205
|
Publish a draft theme to the LIVE site: it REPLACES the live theme for every visitor.
|
|
206
|
+
The site is the one [dir]'s project is bound to (dir defaults to cwd), so
|
|
207
|
+
'theme push ./shop && theme publish ./shop' always publishes ./shop's site.
|
|
185
208
|
With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
|
|
186
209
|
The server refuses to publish a theme that has no pages (it would 404); preview first.
|
|
187
210
|
--instance <handle> publish a specific theme (handle from the panel / status)
|
|
188
211
|
|
|
189
|
-
blocofy status
|
|
212
|
+
blocofy status [dir]
|
|
190
213
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
191
214
|
(ok / live_instance_empty / pages_split). For a problem it names the theme holding the
|
|
192
215
|
pages, the missing pages, why, and safe preview-first next steps — never a one-line fix.
|
|
@@ -229,12 +252,13 @@ Usage
|
|
|
229
252
|
is moved, exit 1. Files without "locale" use the site's default language (login needed;
|
|
230
253
|
with --write outside a bound project only an explicit --context/env credentials are used).
|
|
231
254
|
|
|
232
|
-
blocofy pages media-uses <page-handle> [--json]
|
|
255
|
+
blocofy pages media-uses <page-handle> [--dir <dir>] [--json]
|
|
233
256
|
List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
|
|
234
257
|
Prints the draft's revision id/version needed by media-decide. --json: raw response.
|
|
258
|
+
--dir <dir> (both media commands): the bound project whose site the page is on (default: cwd).
|
|
235
259
|
|
|
236
260
|
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
237
|
-
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
261
|
+
[--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]
|
|
238
262
|
Apply one or more media decisions to the page's draft atomically (v1 API, pages:write).
|
|
239
263
|
The file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?,
|
|
240
264
|
decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
|
|
@@ -317,19 +341,31 @@ Examples
|
|
|
317
341
|
blocofy theme pull store-theme --context store && cd store-theme && blocofy theme dev
|
|
318
342
|
blocofy link ~/code/store-theme --context store (an existing checkout)
|
|
319
343
|
blocofy theme push && blocofy theme publish (inside the bound project)
|
|
344
|
+
blocofy theme push ./shop && blocofy theme publish ./shop (from anywhere: ./shop's site)
|
|
320
345
|
blocofy target && blocofy status
|
|
321
346
|
blocofy login --api-key --context store
|
|
322
347
|
blocofy pages media-uses pg_abc123 --json
|
|
323
348
|
blocofy pages media-decide pg_abc123 --decisions decisions.json
|
|
324
349
|
|
|
325
350
|
Targets (which site a command talks to)
|
|
326
|
-
Every remote command verifies its site first and prints a Target block on stderr
|
|
351
|
+
Every remote command verifies its site first and prints a Target block on stderr: the site,
|
|
352
|
+
the Platform it was verified on, the Context and where that choice came from (--context,
|
|
353
|
+
BLOCOFY_CONTEXT, env credentials, .blocofy/local.json, the project's site, the \`use\`
|
|
354
|
+
default), the Binding and the Operation (command · mode). --json: the same as {"target":…}.
|
|
327
355
|
The context is chosen in this order: --context → BLOCOFY_CONTEXT → env credentials
|
|
328
356
|
(BLOCOFY_URL+BLOCOFY_TOKEN and/or BLOCOFY_API_URL+BLOCOFY_API_KEY) → .blocofy/local.json
|
|
329
357
|
→ the one saved context matching the project's site → (terminal) pick from the matches.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
358
|
+
BLOCOFY_CONTEXT, the env credentials and .blocofy/local.json are each a choice: if two of them
|
|
359
|
+
name different contexts the command is refused before anything is read or written
|
|
360
|
+
(TARGET_CONTEXT_CONFLICT) — pass --context <name> to settle it (the Target block then lists
|
|
361
|
+
what it overrode). Inside a bound project \`use\` is ignored; outside one it is used only by
|
|
362
|
+
status, target and pages check — every other command needs --context/BLOCOFY_CONTEXT/env.
|
|
363
|
+
Commands that change a site (theme push/publish/rename/dev sync, pages push, settings push,
|
|
364
|
+
pages media-decide) need a bound project; pulls into a new empty directory bind it. A wrong project/site pairing changes nothing.
|
|
365
|
+
A context holds two SEPARATE credentials — the theme dev token (bcf_…, BLOCOFY_TOKEN) and the
|
|
366
|
+
v1 API key (blcf_live_…, BLOCOFY_API_KEY); one in the other's place is refused before any
|
|
367
|
+
request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1). Each is verified with its own endpoint
|
|
368
|
+
(/api/dev/whoami, /api/v1/ping) and both must resolve to the same site.
|
|
333
369
|
A binding made against an older server has no platform origin: it still matches the same site
|
|
334
370
|
(one warning; run \`blocofy link --adopt\` to record it). A server that reports no origin cannot
|
|
335
371
|
serve a binding that records one (TARGET_UNVERIFIED).
|
|
@@ -554,7 +590,11 @@ async function login(rest) {
|
|
|
554
590
|
process.exit(1);
|
|
555
591
|
}
|
|
556
592
|
if (token && !isValidToken(token)) {
|
|
557
|
-
console.error(
|
|
593
|
+
console.error(
|
|
594
|
+
token.startsWith("blcf_")
|
|
595
|
+
? "Invalid --token — that is a v1 API key (blcf_…), not a theme dev token (bcf_…). The two are separate credentials: add the API key with `blocofy login --api-key`. Nothing was written."
|
|
596
|
+
: "Invalid --token — must start with bcf_.",
|
|
597
|
+
);
|
|
558
598
|
process.exit(1);
|
|
559
599
|
}
|
|
560
600
|
|
|
@@ -637,7 +677,7 @@ async function useCommand(rest) {
|
|
|
637
677
|
store.current_context = name;
|
|
638
678
|
saveStore(store);
|
|
639
679
|
});
|
|
640
|
-
console.log(`✓ Default context for
|
|
680
|
+
console.log(`✓ Default context for status / target / pages check outside a project: ${name}`);
|
|
641
681
|
console.log(" (Inside a bound project the project's site decides; `use` never retargets it.)");
|
|
642
682
|
}
|
|
643
683
|
|
|
@@ -666,10 +706,11 @@ async function linkCommand(rest) {
|
|
|
666
706
|
if (!flagContext && !process.env.BLOCOFY_CONTEXT && !envCtx) {
|
|
667
707
|
throw new TargetError("TARGET_CONTEXT_REQUIRED", "`blocofy link` needs the context to bind: pass --context <name> (see `blocofy contexts`).", {});
|
|
668
708
|
}
|
|
669
|
-
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null
|
|
709
|
+
const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null });
|
|
670
710
|
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
671
711
|
registerSecret(secrets.devToken);
|
|
672
712
|
registerSecret(secrets.apiKey);
|
|
713
|
+
assertCredentialTypes({ resolved, secrets });
|
|
673
714
|
const identity = await verifyTarget({ resolved, secrets, binding: null, retry });
|
|
674
715
|
for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
|
|
675
716
|
|
|
@@ -694,17 +735,23 @@ async function linkCommand(rest) {
|
|
|
694
735
|
const projectPath = writeBinding(dir, { site: identity.site, platformOrigin: identity.platformOrigin, contextName: resolved.name, staleLocal: true });
|
|
695
736
|
recordVerifiedSite(resolved, identity);
|
|
696
737
|
console.log(`✓ Bound ${dir} to ${siteLabel(identity.site) || identity.site.id} (${identity.site.id}) via context "${resolved.name}".`);
|
|
738
|
+
if (resolved.ignored.length) console.log(` ${resolved.source} ${resolved.name} overrode ${overridesLabel(resolved.ignored)}.`);
|
|
697
739
|
console.log(` ${relative(process.cwd(), projectPath) || projectPath} — commit it; .blocofy/local.json stays private (git-ignored).`);
|
|
698
740
|
}
|
|
699
741
|
|
|
700
742
|
async function targetCommand(rest) {
|
|
701
743
|
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
702
|
-
const dir =
|
|
703
|
-
const t = await prepareTarget({ command: "target", commandClass: "read", dir, flags, needs: "any", mode: "read", record: false, quiet: true });
|
|
744
|
+
const dir = singleDirArg(positionals, "blocofy target [dir]");
|
|
745
|
+
const t = await prepareTarget({ command: "target", commandClass: "read", dir, flags, needs: "any", mode: "read", record: false, quiet: true, allowCurrentContext: true });
|
|
704
746
|
if (JSON_MODE) console.log(JSON.stringify({ target: t.display }, null, 2));
|
|
705
747
|
else printTarget(t.display, { stream: process.stdout });
|
|
706
748
|
}
|
|
707
749
|
|
|
750
|
+
/** "BLOCOFY_CONTEXT=beta, env credentials" — the stated context choices an explicit --context overrode. */
|
|
751
|
+
function overridesLabel(ignored) {
|
|
752
|
+
return ignored.map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.name}`)).join(", ");
|
|
753
|
+
}
|
|
754
|
+
|
|
708
755
|
/** An unverified (migrated) named context gets the verified site recorded once. */
|
|
709
756
|
function recordVerifiedSite(resolved, identity) {
|
|
710
757
|
if (resolved.env || resolved.context.site) return;
|
|
@@ -735,9 +782,10 @@ async function promptContext(candidates) {
|
|
|
735
782
|
* resolution → offline precheck → required pair → remote identity (both pairs when present) → binding match →
|
|
736
783
|
* target block. Throws TargetError / CredentialsError; never returns an unverified target.
|
|
737
784
|
*
|
|
738
|
-
* `needs`: "dev" | "api" | "any".
|
|
785
|
+
* `needs`: "dev" | "api" | "any". `allowCurrentContext`: only `status`, `target` and `pages check` may fall back to
|
|
786
|
+
* the `blocofy use` default outside a project (contract C2). Returns `{ name, dev, api, identity, binding, newBinding, display }`.
|
|
739
787
|
*/
|
|
740
|
-
async function prepareTarget({ command, commandClass, dir, flags, needs, mode, record = true, quiet = false,
|
|
788
|
+
async function prepareTarget({ command, commandClass, dir, flags, needs, mode, record = true, quiet = false, allowCurrentContext = false }) {
|
|
741
789
|
const binding = findBinding(dir);
|
|
742
790
|
const { newBinding } = enforceBindingPolicy({ commandClass, binding, dir, command });
|
|
743
791
|
const envCtx = envContext();
|
|
@@ -748,7 +796,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
748
796
|
envCtx,
|
|
749
797
|
getStore: () => (cachedStore ??= loadStore()),
|
|
750
798
|
binding,
|
|
751
|
-
|
|
799
|
+
allowCurrentContext,
|
|
752
800
|
isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
753
801
|
prompt: promptContext,
|
|
754
802
|
});
|
|
@@ -756,6 +804,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
756
804
|
const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
|
|
757
805
|
registerSecret(secrets.devToken);
|
|
758
806
|
registerSecret(secrets.apiKey);
|
|
807
|
+
assertCredentialTypes({ resolved, secrets });
|
|
759
808
|
|
|
760
809
|
const hasDev = Boolean(resolved.context.dev && secrets.devToken);
|
|
761
810
|
const hasApi = Boolean(resolved.context.api && secrets.apiKey);
|
|
@@ -766,9 +815,6 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
766
815
|
if (!hasApi) {
|
|
767
816
|
throw new TargetError("LOGIN_REQUIRED", `API key required: run \`blocofy login --api-key\` (or set BLOCOFY_API_KEY + BLOCOFY_API_URL). The dev token (bcf_) is not accepted for the v1 API.`, { context: resolved.name }, 1);
|
|
768
817
|
}
|
|
769
|
-
if (!isValidApiKey(secrets.apiKey)) {
|
|
770
|
-
throw new TargetError("LOGIN_REQUIRED", `The API key of context "${resolved.name}" is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`, { context: resolved.name }, 1);
|
|
771
|
-
}
|
|
772
818
|
}
|
|
773
819
|
// CF-T4 — site plan/apply need BOTH pairs: the v1 API for the site-state endpoints, the dev token for the
|
|
774
820
|
// canonical theme deploy `awaiting_theme_source` asks for. `verifyTarget` below already asserts they
|
|
@@ -788,7 +834,7 @@ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, r
|
|
|
788
834
|
|
|
789
835
|
const url = needs === "api" ? resolved.context.api?.url : resolved.context.dev?.url ?? resolved.context.api?.url;
|
|
790
836
|
const bindingLabel = binding ? relative(process.cwd(), binding.projectPath) || binding.projectPath : newBinding ? "none (new pull)" : "none";
|
|
791
|
-
const display = targetData({ site: identity.site, url, contextName: resolved.name,
|
|
837
|
+
const display = targetData({ site: identity.site, url, platformOrigin: identity.platformOrigin, contextName: resolved.name, contextSource: resolved.source, contextOverrides: resolved.ignored, bindingLabel, command, mode });
|
|
792
838
|
if (!quiet) printTarget(display, { json: JSON_MODE });
|
|
793
839
|
return {
|
|
794
840
|
name: resolved.name,
|
|
@@ -930,10 +976,11 @@ async function themePush(rest) {
|
|
|
930
976
|
// PS-13 `--prune`: canlı temadan dosya SİLER. Hedef canlıysa (`--live` ya da canlı temanın handle'ı
|
|
931
977
|
// verilmiş `--instance`; whoami çözülemediyse canlı sayılır) canlı-push onay kuralı aynen uygulanır:
|
|
932
978
|
// `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
|
|
933
|
-
|
|
979
|
+
// TPUSH-5: `--dry-run --prune` plans the pruned payload (it writes nothing, so it asks nothing).
|
|
980
|
+
const prune = Boolean(flags.prune);
|
|
934
981
|
const liveTarget = mode === "live" || (mode === "instance" && (whoami.liveThemeId == null || String(instance) === String(whoami.liveThemeId)));
|
|
935
982
|
const pruneDecision = livePushDecision({
|
|
936
|
-
draft: !prune || !liveTarget,
|
|
983
|
+
draft: !prune || dryRun || !liveTarget,
|
|
937
984
|
yes: Boolean(flags.yes),
|
|
938
985
|
confirm: Boolean(flags.confirm),
|
|
939
986
|
isTTY: Boolean(process.stdin.isTTY),
|
|
@@ -1003,6 +1050,8 @@ async function themePush(rest) {
|
|
|
1003
1050
|
}
|
|
1004
1051
|
failAndExit({ code: "idempotency_conflict", status: error.status, message: "The idempotency key was already used with different content. Retry with a new key (or omit --idempotency-key).", details: {} });
|
|
1005
1052
|
}
|
|
1053
|
+
const refusal = themePushRefusal(error, { draft: mode === "draft", idempotencyKey });
|
|
1054
|
+
if (refusal) failAndExit({ code: error.code, status: error.status, ...refusal });
|
|
1006
1055
|
throw error;
|
|
1007
1056
|
}
|
|
1008
1057
|
|
|
@@ -1016,6 +1065,12 @@ async function themePush(rest) {
|
|
|
1016
1065
|
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
1017
1066
|
console.log(`✓ Validation passed (dry run — nothing written).${warnings.length ? ` ${warnings.length} warning(s).` : ""}`);
|
|
1018
1067
|
for (const w of warnings) console.log(` ⚠ ${w}`);
|
|
1068
|
+
// TPUSH-5: a 6.5 server plans the push on the control plane; an older one only validated.
|
|
1069
|
+
if (result.newDraft === true) console.log("Plan against a new draft (created by the push):");
|
|
1070
|
+
else if (result.target === "live" || result.target === "draft") {
|
|
1071
|
+
console.log(`Plan against the ${result.target} theme, pointer ${result.pointerVersion == null ? "none yet" : `v${result.pointerVersion}`}:`);
|
|
1072
|
+
}
|
|
1073
|
+
printThemeOutcomes(result.files);
|
|
1019
1074
|
return;
|
|
1020
1075
|
}
|
|
1021
1076
|
|
|
@@ -1027,7 +1082,15 @@ async function themePush(rest) {
|
|
|
1027
1082
|
if (Array.isArray(result.remoteOnlyRemoved) && result.remoteOnlyRemoved.length) {
|
|
1028
1083
|
console.log(` (removed ${result.remoteOnlyRemoved.length} file(s) absent locally — --prune)`);
|
|
1029
1084
|
}
|
|
1030
|
-
|
|
1085
|
+
printThemeOutcomes(result.files);
|
|
1086
|
+
const ids = `deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}`;
|
|
1087
|
+
// TPUSH-5: "atomically" only when the server read the written files back and they match what was sent.
|
|
1088
|
+
if (result.readback?.verified === true) {
|
|
1089
|
+
console.log(`✓ Deployed atomically and read back (${result.readback.files} file${result.readback.files === 1 ? "" : "s"} verified): ${ids}.`);
|
|
1090
|
+
} else {
|
|
1091
|
+
console.log(`✓ Deployed: ${ids}. The written files were not verified (this server does not read them back).`);
|
|
1092
|
+
}
|
|
1093
|
+
if (result.convergence === "converged") console.log(" (already applied by an earlier push with the same idempotency key)");
|
|
1031
1094
|
if (mode === "draft") {
|
|
1032
1095
|
console.log("Preview & publish it in the admin panel: Theme -> Theme library -> \"Open in editor\".");
|
|
1033
1096
|
console.log("Publish it live with: blocofy theme publish");
|
|
@@ -1056,6 +1119,105 @@ async function themePush(rest) {
|
|
|
1056
1119
|
}
|
|
1057
1120
|
}
|
|
1058
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* TPUSH-5 — per-file outcomes of a theme push or its plan (6.5 server; an older one sends none). Changed files are
|
|
1124
|
+
* listed, unchanged ones only counted.
|
|
1125
|
+
*/
|
|
1126
|
+
function printThemeOutcomes(files) {
|
|
1127
|
+
if (!Array.isArray(files)) return;
|
|
1128
|
+
const count = { created: 0, updated: 0, removed: 0, unchanged: 0 };
|
|
1129
|
+
const mark = { created: "+", updated: "~", removed: "-" };
|
|
1130
|
+
for (const f of files) {
|
|
1131
|
+
if (!(f?.outcome in count)) continue;
|
|
1132
|
+
count[f.outcome] += 1;
|
|
1133
|
+
if (f.outcome !== "unchanged") console.log(` ${mark[f.outcome]} ${f.path}`);
|
|
1134
|
+
}
|
|
1135
|
+
console.log(`Files: ${count.created} created, ${count.updated} updated, ${count.removed} removed, ${count.unchanged} unchanged.`);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* TPUSH-5 — the 6.5 theme push refusals, in words. `null` = not one of them. A refusal writes nothing, but when the
|
|
1140
|
+
* apply was resent after an attempt without a certain answer (`error.earlierAttempt`), that earlier attempt may have
|
|
1141
|
+
* written: then the message says so instead of "Nothing was written", and `details.earlierAttempt` names it.
|
|
1142
|
+
*/
|
|
1143
|
+
function themePushRefusal(error, { draft = false, idempotencyKey = null } = {}) {
|
|
1144
|
+
const body = error?.body ?? {};
|
|
1145
|
+
const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
1146
|
+
// Where an earlier attempt of this push could have written: the theme its dry run bound it to (every attempt is the
|
|
1147
|
+
// same request), never the current target a refusal may name. A new-draft binding (null) is found via the drafts.
|
|
1148
|
+
const bound = error?.expectedTargetInstance;
|
|
1149
|
+
const check =
|
|
1150
|
+
typeof bound === "string"
|
|
1151
|
+
? `\`blocofy theme push --diff --instance ${bound}\``
|
|
1152
|
+
: bound === null
|
|
1153
|
+
? "`blocofy status` (it lists the drafts; one this push created would be there) and `blocofy theme push --diff --instance <handle>`"
|
|
1154
|
+
: "`blocofy status` and `blocofy theme push --diff --instance <handle>`";
|
|
1155
|
+
const target = typeof bound === "string" ? `theme ${bound}` : "the target";
|
|
1156
|
+
// `wrote`/`none`: what this refusal certainly did not do ("deployed" for target_changed, see below).
|
|
1157
|
+
const nothing = (next, { wrote = "wrote", none = "Nothing was written." } = {}) =>
|
|
1158
|
+
earlier === "committed"
|
|
1159
|
+
? `This attempt ${wrote} nothing, but an earlier attempt of this push was committed (its answer said so). Check ${target} with ${check} before running the push again.`
|
|
1160
|
+
: earlier === "unknown"
|
|
1161
|
+
? `This attempt ${wrote} nothing, but an earlier attempt of this push got no answer, so whether it wrote is unknown. Check ${target} with ${check} before running the push again.`
|
|
1162
|
+
: `${none} ${next}`;
|
|
1163
|
+
const withEarlier = (details) => (earlier ? { ...details, earlierAttempt: earlier } : details);
|
|
1164
|
+
// tpush round 5: a write whose last answer is transient (a passed-through 5xx, a 503 fail-closed, 502
|
|
1165
|
+
// readback_unverified with outcomeUnknown, or no answer) has an unknown outcome. It is retryable under the SAME key:
|
|
1166
|
+
// the platform then reports the committed deploy instead of writing it again. Keys are per run, so name this one.
|
|
1167
|
+
const unknownOutcome = (what) => ({
|
|
1168
|
+
...(error?.code ? {} : { code: error?.status ? `HTTP_${error.status}` : "NETWORK_ERROR" }),
|
|
1169
|
+
message:
|
|
1170
|
+
`${what}, so whether this push was deployed${typeof bound === "string" ? ` to theme ${bound}` : ""} is unknown` +
|
|
1171
|
+
`${earlier === "committed" ? " (an earlier attempt's answer said it was committed)" : ""}. ` +
|
|
1172
|
+
`Run the same command again with \`--idempotency-key ${idempotencyKey ?? "<the same key>"}\`: if this push was deployed, the server reports that deploy instead of writing it again.`,
|
|
1173
|
+
details: withEarlier({ phase: "apply", outcome: "unknown", idempotencyKey, ...(error?.status ? { status: error.status } : {}), ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) }),
|
|
1174
|
+
});
|
|
1175
|
+
switch (error?.code) {
|
|
1176
|
+
case "pointer_version_conflict":
|
|
1177
|
+
return {
|
|
1178
|
+
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.")}`,
|
|
1179
|
+
details: withEarlier({ currentVersion: body.currentVersion ?? null }),
|
|
1180
|
+
};
|
|
1181
|
+
case "site_state_version_conflict":
|
|
1182
|
+
return { message: `The theme's settings were saved on the site while this push was running. ${nothing("Run the push again.")}`, details: withEarlier({}) };
|
|
1183
|
+
case "manifest_mismatch":
|
|
1184
|
+
return { message: `The files sent for writing differ from the files the dry run checked. ${nothing("Run the push again.")}`, details: withEarlier({}) };
|
|
1185
|
+
case "target_changed": {
|
|
1186
|
+
const was = error.expectedTargetInstance === undefined ? "the theme its dry run planned against" : error.expectedTargetInstance === null ? "a new draft (its dry run planned one)" : `theme ${error.expectedTargetInstance}`;
|
|
1187
|
+
const now = typeof body.targetInstance === "string" ? `theme ${body.targetInstance}` : "a new draft (there is no draft to reuse any more)";
|
|
1188
|
+
// Definitive, but "nothing written" is not true of it: a draft push that lost the read/provision race may have
|
|
1189
|
+
// provisioned a new empty draft first (kept; the next draft push reuses it). No theme file was deployed.
|
|
1190
|
+
const none = `Nothing was deployed${draft ? " (at most a new, empty draft was created, which the next push reuses)" : ""}.`;
|
|
1191
|
+
return {
|
|
1192
|
+
message: `The push's target changed after its dry run: it planned against ${was}, but it would now write to ${now} (the live theme was switched, or the draft to reuse changed). ${nothing("Run the push again to plan against the current target.", { wrote: "deployed", none })}`,
|
|
1193
|
+
details: withEarlier({ expectedTargetInstance: error.expectedTargetInstance ?? null, targetInstance: typeof body.targetInstance === "string" ? body.targetInstance : null }),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
case "readback_unverified":
|
|
1197
|
+
// The dry run's plan is rolled back on the server: nothing was written, whatever `committed` says.
|
|
1198
|
+
if (error.phase === "plan") {
|
|
1199
|
+
return {
|
|
1200
|
+
message: "The server could not verify the plan of this push's dry run (a plan is rolled back). Nothing was written. Run the push again.",
|
|
1201
|
+
details: { phase: "plan", committed: false },
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
if (body.outcomeUnknown === true) return unknownOutcome("The server could not read the control plane's answer to the write");
|
|
1205
|
+
// The write went to the theme the dry run bound it to; a plain --diff compares with the live theme only.
|
|
1206
|
+
return {
|
|
1207
|
+
message: `The server could not read back the files it wrote${body.committed === true ? " (the deploy was committed)" : body.committed === false ? " (nothing was committed)" : ""}. Check ${target} with ${check}.`,
|
|
1208
|
+
details: { phase: "apply", committed: typeof body.committed === "boolean" ? body.committed : null, ...(bound !== undefined ? { expectedTargetInstance: bound } : {}) },
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (error?.phase === "apply" && (error.status == null || error.status >= 500)) {
|
|
1212
|
+
return unknownOutcome(error.status == null ? "The write got no answer" : `The write got no definite answer (HTTP ${error.status}${error.code ? ` ${error.code}` : ""})`);
|
|
1213
|
+
}
|
|
1214
|
+
// The 6.5 preflight names the file it refused (reserved_path, path_too_long, invalid_json…).
|
|
1215
|
+
if (error?.status === 422 && typeof body.path === "string") {
|
|
1216
|
+
return { message: `${body.path}: ${typeof body.message === "string" ? body.message : error.code}. ${nothing("").trim()}`, details: withEarlier({ path: body.path }) };
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1059
1221
|
/** PS-19 — print platform/CLI findings; returns the exit code they imply. */
|
|
1060
1222
|
function reportPageDiagnostics(diagnostics, { strict = false } = {}) {
|
|
1061
1223
|
for (const d of diagnostics) (d.level === "error" ? console.error : console.warn)(formatDiagnostic(d));
|
|
@@ -1190,7 +1352,7 @@ async function pagesCheck(rest) {
|
|
|
1190
1352
|
console.error(`Directory not found: ${dir}`);
|
|
1191
1353
|
process.exit(1);
|
|
1192
1354
|
}
|
|
1193
|
-
const creds = (await optionalTarget({ command: "pages check", dir, flags }))?.dev;
|
|
1355
|
+
const creds = (await optionalTarget({ command: "pages check", dir, flags, allowCurrentContext: true }))?.dev;
|
|
1194
1356
|
const online = Boolean(creds);
|
|
1195
1357
|
const r = await checkPages({ dir, onRetry, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
1196
1358
|
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
@@ -1641,11 +1803,11 @@ async function contentPull(scope, rest) {
|
|
|
1641
1803
|
* Optional online mode for offline-capable reads (`pages check`, `pages migrate-layout`): no credentials at all, or
|
|
1642
1804
|
* no context choosable outside a project → offline. Inside a bound project every other refusal still applies.
|
|
1643
1805
|
*/
|
|
1644
|
-
async function optionalTarget({ command, dir, flags, localWrite = false }) {
|
|
1806
|
+
async function optionalTarget({ command, dir, flags, localWrite = false, allowCurrentContext = false }) {
|
|
1645
1807
|
try {
|
|
1646
|
-
// Review I3
|
|
1647
|
-
//
|
|
1648
|
-
return await prepareTarget({ command, commandClass: "read",
|
|
1808
|
+
// Review I3 / contract C2: only `pages check` may borrow the global default context outside a binding;
|
|
1809
|
+
// migrate-layout (dry run or --write) needs an explicit --context/env there, else it runs offline.
|
|
1810
|
+
return await prepareTarget({ command, commandClass: "read", allowCurrentContext, dir, flags, needs: "dev", mode: localWrite ? "read · local write" : "read" });
|
|
1649
1811
|
} catch (error) {
|
|
1650
1812
|
if (error instanceof TargetError && (error.code === "LOGIN_REQUIRED" || (error.code === "TARGET_CONTEXT_REQUIRED" && !findBinding(dir)))) return null;
|
|
1651
1813
|
throw error;
|
|
@@ -1671,11 +1833,14 @@ function printMediaUsesView(view) {
|
|
|
1671
1833
|
async function pagesMediaUses(rest) {
|
|
1672
1834
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
1673
1835
|
const page = positionals[0];
|
|
1836
|
+
const usage = "blocofy pages media-uses <page-handle> [--dir <dir>] [--json]";
|
|
1674
1837
|
if (!page) {
|
|
1675
|
-
console.error(
|
|
1838
|
+
console.error(`Usage: ${usage}`);
|
|
1676
1839
|
process.exit(1);
|
|
1677
1840
|
}
|
|
1678
|
-
|
|
1841
|
+
// 1.8: the project directory is `--dir`; a directory given as a positional is refused, never dropped for cwd's.
|
|
1842
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
1843
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-uses", commandClass: "read", dir: commandDir(flags.dir), flags, needs: "api", mode: `read · page ${page}` })).api;
|
|
1679
1844
|
const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
|
|
1680
1845
|
if (flags.json) console.log(JSON.stringify(view, null, 2));
|
|
1681
1846
|
else printMediaUsesView(view);
|
|
@@ -1696,10 +1861,12 @@ async function pagesMediaDecide(rest) {
|
|
|
1696
1861
|
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
1697
1862
|
const page = positionals[0];
|
|
1698
1863
|
const file = typeof flags.decisions === "string" ? resolve(flags.decisions) : null;
|
|
1864
|
+
const usage = "blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]";
|
|
1699
1865
|
if (!page || !file) {
|
|
1700
|
-
console.error(
|
|
1866
|
+
console.error(`Usage: ${usage}`);
|
|
1701
1867
|
process.exit(1);
|
|
1702
1868
|
}
|
|
1869
|
+
if (positionals.length > 1) throw new TargetError("USAGE", `Usage: ${usage}`, {}, 1);
|
|
1703
1870
|
let parsed;
|
|
1704
1871
|
try {
|
|
1705
1872
|
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -1718,7 +1885,7 @@ async function pagesMediaDecide(rest) {
|
|
|
1718
1885
|
process.exit(1);
|
|
1719
1886
|
}
|
|
1720
1887
|
|
|
1721
|
-
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-decide", commandClass: "remote-mutation", dir:
|
|
1888
|
+
const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-decide", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "api", mode: `draft · page ${page}` })).api;
|
|
1722
1889
|
if (expectedRevisionId === null) {
|
|
1723
1890
|
const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
|
|
1724
1891
|
if (view.applicable === false) {
|
|
@@ -1895,8 +2062,9 @@ async function themeDev(rest) {
|
|
|
1895
2062
|
}
|
|
1896
2063
|
|
|
1897
2064
|
async function themePublish(rest) {
|
|
1898
|
-
const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
|
|
1899
|
-
const
|
|
2065
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePublish);
|
|
2066
|
+
const dir = singleDirArg(positionals, "blocofy theme publish [dir] [--instance <handle>]");
|
|
2067
|
+
const target = await prepareTarget({ command: "theme publish", commandClass: "remote-mutation", dir, flags, needs: "dev", mode: `live${typeof flags.instance === "string" ? ` · instance ${flags.instance}` : " · CLI draft"}` });
|
|
1900
2068
|
const creds = target.dev;
|
|
1901
2069
|
let instance = typeof flags.instance === "string" ? flags.instance : null;
|
|
1902
2070
|
if (!instance) {
|
|
@@ -1932,18 +2100,18 @@ async function themeRename(rest) {
|
|
|
1932
2100
|
const handle = positionals[0];
|
|
1933
2101
|
const name = positionals.slice(1).join(" ") || (typeof flags.name === "string" ? flags.name : null);
|
|
1934
2102
|
if (!handle || !name) {
|
|
1935
|
-
console.error("Usage: blocofy theme rename <handle> <new name>");
|
|
2103
|
+
console.error("Usage: blocofy theme rename <handle> <new name> [--dir <dir>]");
|
|
1936
2104
|
console.error(" Rename a theme (handle from the panel theme card or `blocofy status`).");
|
|
1937
2105
|
process.exit(1);
|
|
1938
2106
|
}
|
|
1939
|
-
const creds = (await prepareTarget({ command: "theme rename", commandClass: "remote-mutation", dir:
|
|
2107
|
+
const creds = (await prepareTarget({ command: "theme rename", commandClass: "remote-mutation", dir: commandDir(flags.dir), flags, needs: "dev", mode: `instance ${handle}` })).dev;
|
|
1940
2108
|
const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name, onRetry });
|
|
1941
2109
|
console.log(`✓ Renamed to "${result.name}" (${result.id}).`);
|
|
1942
2110
|
}
|
|
1943
2111
|
|
|
1944
2112
|
async function status(rest) {
|
|
1945
|
-
const { flags } = parseArgsOrExit(rest, []);
|
|
1946
|
-
const creds = (await prepareTarget({ command: "status", commandClass: "read", dir:
|
|
2113
|
+
const { flags, positionals } = parseArgsOrExit(rest, []);
|
|
2114
|
+
const creds = (await prepareTarget({ command: "status", commandClass: "read", dir: singleDirArg(positionals, "blocofy status [dir]"), flags, needs: "dev", mode: "read", allowCurrentContext: true })).dev;
|
|
1947
2115
|
const s = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
|
|
1948
2116
|
const live = s.live_theme_instance;
|
|
1949
2117
|
console.log(`\nSite: ${s.site.slug}${s.url ? ` · ${s.url}` : ""}`);
|
package/lib/http.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Callers only route requests here that are safe to replay: reads, upserts, pointer sets, and mutations carrying
|
|
12
12
|
* an idempotency key. `onRetry({ attempt, retries, reason, waitMs })` fires before every wait (never silent).
|
|
13
|
+
* `isFinal(response)` (optional, given a clone) marks a retry-status answer that is a verdict, not a transient
|
|
14
|
+
* failure: it is returned at once instead of being resent.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
export const RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
@@ -37,7 +39,7 @@ export function parseRetryAfter(value, nowMs = Date.now()) {
|
|
|
37
39
|
* `fetch` with the retry contract above. Options: `retries`, `backoff` (ms per retry index), `onRetry`,
|
|
38
40
|
* `fetchImpl` (default: the global fetch at call time), `sleep(ms)`, `now()`, `timeoutMs` (per attempt).
|
|
39
41
|
*/
|
|
40
|
-
export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs } = {}) {
|
|
42
|
+
export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs, isFinal } = {}) {
|
|
41
43
|
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
42
44
|
for (let attempt = 0; ; attempt += 1) {
|
|
43
45
|
const attemptInit = timeoutMs ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init;
|
|
@@ -52,6 +54,7 @@ export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRI
|
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
56
|
if (!RETRY_STATUSES.has(res.status) || attempt >= retries) return res;
|
|
57
|
+
if (isFinal && (await isFinal(res.clone()))) return res;
|
|
55
58
|
const waitMs = parseRetryAfter(res.headers.get("retry-after"), now()) ?? backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
|
|
56
59
|
await res.arrayBuffer().catch(() => {}); // release the connection before resending
|
|
57
60
|
onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}`, waitMs });
|
package/lib/output.mjs
CHANGED
|
@@ -16,22 +16,41 @@ export function redact(text) {
|
|
|
16
16
|
return out;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const SOURCE_LABELS = {
|
|
20
|
+
env: "env credentials",
|
|
21
|
+
binding: "the one saved context for this project's site",
|
|
22
|
+
prompt: "picked at the prompt",
|
|
23
|
+
current_context: "default context (blocofy use)",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The target block as data (used for `--json` and for `blocofy target`). 1.8: it names the platform the site was
|
|
28
|
+
* verified on (`platform_origin`, the environment), where the context choice came from (`context_source`) and every
|
|
29
|
+
* stated choice an explicit `--context` overrode (`context_overrides`), plus the command and its mode.
|
|
30
|
+
*/
|
|
31
|
+
export function targetData({ site, url, platformOrigin = null, contextName, contextSource = null, contextOverrides = [], bindingLabel, command, mode }) {
|
|
21
32
|
return {
|
|
22
33
|
site: { id: site.id, slug: site.slug ?? null, name: site.name ?? null, domain: site.domain ?? null },
|
|
23
34
|
url: url ?? null,
|
|
35
|
+
platform_origin: platformOrigin ?? null,
|
|
24
36
|
context: contextName,
|
|
37
|
+
context_source: contextSource,
|
|
38
|
+
context_overrides: contextOverrides.map((a) => ({ source: a.source, context: a.name })),
|
|
25
39
|
binding: bindingLabel,
|
|
26
|
-
|
|
40
|
+
command,
|
|
41
|
+
mode,
|
|
42
|
+
operation: `${command} · ${mode}`,
|
|
27
43
|
};
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export function formatTargetBlock(t) {
|
|
31
47
|
const name = t.site.name ?? t.site.slug ?? String(t.site.id);
|
|
48
|
+
const overrides = (t.context_overrides ?? []).map((a) => (a.source === "env" ? "env credentials" : `${a.source}=${a.context}`));
|
|
49
|
+
const source = t.context_source ? ` (from ${SOURCE_LABELS[t.context_source] ?? t.context_source}${overrides.length ? `; overrides ${overrides.join(", ")}` : ""})` : "";
|
|
32
50
|
return [
|
|
33
51
|
`Target: ${name} · ${t.site.id} · ${t.site.domain ?? t.url ?? "?"}`,
|
|
34
|
-
`
|
|
52
|
+
`Platform: ${t.platform_origin ?? "(not reported by the server)"}`,
|
|
53
|
+
`Context: ${t.context}${source}`,
|
|
35
54
|
`Binding: ${t.binding}`,
|
|
36
55
|
`Operation: ${t.operation}`,
|
|
37
56
|
].join("\n");
|