@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/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 read-only commands outside a project
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
- Inside a bound project \`use\` is ignored. Commands that change a site (theme push/publish/
331
- rename/dev sync, pages push, settings push, pages media-decide) need a bound project;
332
- pulls into a new empty directory bind it. A wrong project/site pairing changes nothing.
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("Invalid --token — must start with bcf_.");
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 read-only commands outside a project: ${name}`);
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, commandClass: "read" });
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 = resolve(positionals[0] ?? process.cwd());
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". Returns `{ name, dev, api, identity, binding, newBinding, display }`.
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, resolveClass = commandClass }) {
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
- commandClass: resolveClass,
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, bindingLabel, operation: `${command} · ${mode}` });
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
- const prune = Boolean(flags.prune) && !dryRun;
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
- console.log(`✓ Deployed atomically: deployment #${result.deploymentId}, revision #${result.sourceRevisionId}, pointer v${result.pointerVersion}.`);
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: a command that writes local files (migrate-layout --write) never borrows the global default
1647
- // context — outside a binding it needs an explicit --context/env, else it runs offline.
1648
- return await prepareTarget({ command, commandClass: "read", resolveClass: localWrite ? "local-write" : "read", dir, flags, needs: "dev", mode: localWrite ? "read · local write" : "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("Usage: blocofy pages media-uses <page-handle> [--json]");
1838
+ console.error(`Usage: ${usage}`);
1676
1839
  process.exit(1);
1677
1840
  }
1678
- const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-uses", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · page ${page}` })).api;
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("Usage: blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--json]");
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: process.cwd(), flags, needs: "api", mode: `draft · page ${page}` })).api;
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 target = await prepareTarget({ command: "theme publish", commandClass: "remote-mutation", dir: process.cwd(), flags, needs: "dev", mode: `live${typeof flags.instance === "string" ? ` · instance ${flags.instance}` : " · CLI draft"}` });
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: process.cwd(), flags, needs: "dev", mode: `instance ${handle}` })).dev;
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: process.cwd(), flags, needs: "dev", mode: "read" })).dev;
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
- /** The target block as data (used for `--json` and for `blocofy target`). */
20
- export function targetData({ site, url, contextName, bindingLabel, operation }) {
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
- operation,
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
- `Context: ${t.context}`,
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");