@blocofy/cli 0.8.0 → 0.10.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
@@ -9,20 +9,52 @@
9
9
  * livereload. No monorepo required.
10
10
  */
11
11
  import { randomUUID } from "node:crypto";
12
- import { existsSync, readFileSync } from "node:fs";
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
13
  import { createRequire } from "node:module";
14
- import { resolve } from "node:path";
14
+ import { join, relative, resolve } from "node:path";
15
15
  import { createInterface } from "node:readline/promises";
16
16
 
17
17
  import { parseArgs } from "../lib/args.mjs";
18
- import { pullContent, pushContent } from "../lib/content-sync.mjs";
19
- import { credentialsPath, loadApiCredentials, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
18
+ import { FORCE_REASON_MAX, checkPages, forcedPaths, migrateLayout, pullContent, pushContent } from "../lib/content-sync.mjs";
19
+ import { PagesCliError, formatDiagnostic } from "../lib/page-files.mjs";
20
+ import {
21
+ CredentialsError,
22
+ ENV_CONTEXT,
23
+ credentialsPath,
24
+ defaultSecretStoreName,
25
+ envContext,
26
+ loadStore,
27
+ readSecrets,
28
+ removeSecrets,
29
+ saveStore,
30
+ withStoreLock,
31
+ writeSecret,
32
+ } from "../lib/credentials.mjs";
33
+ import { printError, printTarget, printWarning, redact, registerSecret, targetData } from "../lib/output.mjs";
34
+ import {
35
+ CONTEXT_NAME_RE,
36
+ TargetError,
37
+ claimNewBinding,
38
+ compareOrigin,
39
+ enforceBindingPolicy,
40
+ findBinding,
41
+ precheckContext,
42
+ resolveContext,
43
+ verifyApi,
44
+ verifyDev,
45
+ verifyTarget,
46
+ writeBinding,
47
+ } from "../lib/target.mjs";
20
48
  import { startDevServer } from "../lib/dev-server.mjs";
21
49
  import { readLocalTemplates } from "../lib/local-theme.mjs";
22
50
  import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
23
- import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
51
+ import { githubNote, healthAdvice, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
24
52
  import { promptSecret } from "../lib/secret-prompt.mjs";
25
- import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
53
+ import { MANIFEST_PATH, buildManifest, validateSiteStateTree, verifyManifest } from "../lib/site-state.mjs";
54
+ import { SiteStateFsError, hashBuffer, readSiteStateTree, stagedWriteTree } from "../lib/site-state-fs.mjs";
55
+ import { migrateSiteState } from "../lib/site-migrate.mjs";
56
+ import { applySiteState, downloadAssetBytes, fetchSiteStateExport, planSiteState, publishSiteState, uploadMediaAsset } from "../lib/site-state-client.mjs";
57
+ import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
26
58
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
27
59
  import { hyperlink, openUrl } from "../lib/term.mjs";
28
60
  import { isValidToken, isValidUrl, normalizeUrl } from "../lib/validate.mjs";
@@ -37,7 +69,7 @@ const args = process.argv.slice(2);
37
69
  // 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
38
70
  // BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
39
71
  const KNOWN = {
40
- login: ["url", "token", "api-key", "api-url"],
72
+ login: ["url", "token", "api-key", "api-url", "keychain"],
41
73
  pages: ["decisions", "expected-revision-id", "expected-version", "json"],
42
74
  themePull: ["draft", "instance"],
43
75
  themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
@@ -45,11 +77,27 @@ const KNOWN = {
45
77
  themePublish: ["instance"],
46
78
  themeRename: ["name"],
47
79
  content: [],
80
+ settingsPush: ["instance", "live", "yes", "confirm"],
81
+ pagesPull: ["strict"],
82
+ pagesPush: ["dry-run", "strict", "force", "reason"],
83
+ pagesCheck: ["strict"],
84
+ pagesMigrate: ["dry-run", "write", "strict"],
85
+ siteExport: [],
86
+ siteValidate: ["strict"],
87
+ siteMigrate: ["dry-run", "write"],
88
+ sitePlan: ["target", "mode", "accept-live-effects"],
89
+ siteApply: ["target", "mode", "accept-live-effects"],
90
+ sitePublish: ["target", "mode", "yes"],
48
91
  };
92
+ // CF-T1/T2: every command accepts the global `--context <name>` and `--json` (machine-readable refusals).
49
93
  function parseArgsOrExit(rest, known) {
50
- const parsed = parseArgs(rest, new Set([...known, "help", "version"]));
94
+ const parsed = parseArgs(rest, new Set([...known, "context", "json", "help", "version"]));
51
95
  if (parsed.unknownFlag) {
52
- console.error(`Unknown flag ${parsed.unknownFlag}. Nothing was written. See \`blocofy --help\`.`);
96
+ printError({ code: "USAGE_UNKNOWN_FLAG", message: `Unknown flag ${parsed.unknownFlag}. Nothing was written. See \`blocofy --help\`.`, details: { flag: parsed.unknownFlag } }, { json: args.includes("--json") });
97
+ process.exit(1);
98
+ }
99
+ if (parsed.flags.context !== undefined && (typeof parsed.flags.context !== "string" || !CONTEXT_NAME_RE.test(parsed.flags.context))) {
100
+ console.error("--context needs a context name (letters, digits, . _ -). Nothing was written.");
53
101
  process.exit(1);
54
102
  }
55
103
  return parsed;
@@ -67,29 +115,45 @@ function printHelp() {
67
115
  Develop your theme locally against live data, preview it three ways, and publish.
68
116
 
69
117
  Usage
70
- blocofy login [--url <url>] [--token <bcf_…>]
71
- Save your platform URL + dev token to ~/.blocofy/credentials.json.
118
+ blocofy login [--context <name>] [--url <url>] [--token <bcf_…>] [--keychain]
119
+ Verify a dev token against its site (GET /api/dev/whoami) and save it as a named
120
+ context (default name: the site's slug). Nothing is saved if verification fails.
72
121
  Get a token from the admin panel → Settings → Theme CLI tokens.
122
+ --keychain keep the secret in the macOS keychain (or BLOCOFY_SECRET_STORE=keychain);
123
+ default: ~/.blocofy/secrets.json (0600)
73
124
 
74
- blocofy login --api-key [--api-url <url>]
75
- Save a v1 API key (blcf_live_…) for the \`pages media-*\` commands. The key is read
76
- from a HIDDEN prompt — the flag takes no value, so the key never lands in your shell
77
- history. Kept alongside the dev token in the same credentials file.
125
+ blocofy login --api-key [--context <name>] [--api-url <url>]
126
+ Verify a v1 API key (blcf_live_…, GET /api/v1/ping) and add it to a context. The key is
127
+ read from a HIDDEN prompt — the flag takes no value. If the context already has a dev
128
+ token for another site, nothing is saved (TARGET_CREDENTIAL_MISMATCH).
78
129
  --api-url <url> API origin (default https://app.blocofy.com)
79
130
  Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
80
131
 
132
+ blocofy contexts [--json] list saved contexts (never prints secrets)
133
+ blocofy use <name> default context for read-only commands outside a project
134
+ blocofy logout --context <name> remove a context and its secrets
135
+ blocofy link [dir] --context <name> [--adopt]
136
+ Bind a project directory to the context's (verified) site: writes .blocofy/project.json
137
+ (commit it), .blocofy/local.json (your context; git-ignored) and .blocofy/.gitignore.
138
+ Refuses to rebind a directory bound to another site unless --adopt.
139
+ blocofy target [dir] [--context <name>] [--json]
140
+ Show which site a command in [dir] would hit (verified), without writing anything.
141
+
81
142
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
82
143
  Start a dev server and print 3 auto-reloading views — Local, live-domain
83
144
  Preview, and the theme Editor. Press l / p / e to open each, q to quit.
84
145
  Edit a file and save → every open view reloads. Saves sync to a DRAFT theme
85
146
  only (never the live site). (dir defaults to cwd)
147
+ The target site is verified ONCE at start; a long session keeps that target (restart
148
+ it after changing credentials, context or the project binding).
86
149
  --port <n> local port (default 3030)
87
150
  --no-sync local preview only (skip draft sync + remote views)
88
151
  --name <name> name the draft when it is first created (ignored if it already exists)
89
152
 
90
153
  blocofy theme pull [dir] [--draft] [--instance <handle>]
91
154
  Download the live theme to disk. (dir defaults to cwd)
92
- --draft pull the draft theme (what 'theme dev' syncs into) instead of live
155
+ --draft pull the draft theme (what 'theme dev' syncs into) instead of live;
156
+ creates the draft if missing, so it needs a bound project
93
157
  --instance <handle> pull a specific theme by its handle (from the admin
94
158
  panel theme card, or \`blocofy status\`)
95
159
 
@@ -108,27 +172,62 @@ Usage
108
172
  --validate alias for --dry-run (validate only, nothing written)
109
173
  --diff show what a push WOULD change vs the target (read-only), then stop
110
174
  --idempotency-key <k> attach an idempotency key so a retried push is not double-applied
111
- --prune also REMOVE target files that no longer exist locally (lists them first;
112
- on the live theme asks to confirm — non-interactive shells must add --yes)
175
+ --prune also REMOVE target files that no longer exist locally, locales/ included
176
+ (lists them first; on the live theme asks to confirm — non-interactive
177
+ shells must add --yes)
113
178
 
114
179
  blocofy theme rename <handle> <new name>
115
180
  Rename a theme (the name is just a label). Works on any of your themes,
116
181
  including the live one. Handle comes from the panel theme card or 'blocofy status'.
117
182
 
118
183
  blocofy theme publish [--instance <handle>]
119
- Publish a draft theme to the LIVE site. With no flag, publishes the draft that
120
- 'theme dev' / 'theme push --draft' writes into. The server refuses to publish a
121
- theme that has no pages (it would 404) — so publishing is always safe.
184
+ Publish a draft theme to the LIVE site: it REPLACES the live theme for every visitor.
185
+ With no flag, publishes the draft that 'theme dev' / 'theme push --draft' writes into.
186
+ The server refuses to publish a theme that has no pages (it would 404); preview first.
122
187
  --instance <handle> publish a specific theme (handle from the panel / status)
123
188
 
124
189
  blocofy status
125
190
  Show the live theme, page distribution per instance, drafts, and a health flag
126
- (ok / live_instance_empty / pages_split). Run before/after publishing.
127
-
128
- blocofy pages pull [dir] / pages push [dir]
129
- pull: download published pages → pages/<slug>.json.
130
- push: write pages/*.json to the site. Updates EXISTING pages only —
131
- never creates or deletes a page; unchanged pages are skipped.
191
+ (ok / live_instance_empty / pages_split). For a problem it names the theme holding the
192
+ pages, the missing pages, why, and safe preview-first next steps — never a one-line fix.
193
+
194
+ blocofy pages pull [dir] [--strict]
195
+ Download published pages, one folder per language:
196
+ pages/<locale>/index.json the home page ("/")
197
+ pages/<locale>/routes/<path>/index.json every other page ("/about" → routes/about/index.json)
198
+ Files from the old layout (pages/<slug>.json) are reported, never deleted or overwritten.
199
+ If the site cannot export every published page, nothing is written, every reason is
200
+ printed (PAGES_EXPORT_INCOMPLETE) and the exit code is 2 (--strict: warnings exit 1).
201
+
202
+ blocofy pages push [dir] [--dry-run] [--strict] [--force --reason <text>]
203
+ Write pages/**.json to the site. Updates EXISTING pages only — never creates or
204
+ deletes a page; unchanged pages are skipped. Every file is checked first: if any
205
+ file is invalid, two files point at the same page, or a folder's language does not
206
+ match the file's "locale", NO page is changed. If publishing then stops unexpectedly
207
+ (PAGES_APPLY_FAILED or another publish error), some pages may already be applied: the per-file
208
+ result printed is authoritative, the exit code is non-zero, and running the push again is safe.
209
+ Stale files: every pulled file carries "base_revision" (the page as you pulled it). The push
210
+ first asks for a plan (per page: action, live/draft, changed fields), prints it, then pushes
211
+ exactly that plan. If a page changed on the site since you pulled it (PAGES_REVISION_CONFLICT)
212
+ or a file has no base_revision (PAGES_BASE_REVISION_REQUIRED), nothing is changed (exit 2):
213
+ run \`blocofy pages pull\`, merge your edits, push again. If the site changes between the plan
214
+ and the push, nothing is changed either (PAGES_PLAN_STALE, exit 2) — run the push again.
215
+ --force --reason <text>: overwrite anyway (reason: 1-500 characters); the
216
+ forced pages are listed. An older server cannot check revisions: the push runs
217
+ as before with a warning.
218
+ --dry-run: print the plan, write nothing. --json: the server's plan/result JSON on stdout.
219
+ Needs a platform that supports language folders (else PAGES_SERVER_UPGRADE_REQUIRED).
220
+
221
+ blocofy pages check [dir] [--strict]
222
+ Check page files. Offline: paths, JSON, layout, duplicates, missing base_revision
223
+ (PAGES_BASE_REVISION_MISSING warning). Logged in: also the site's languages and the
224
+ server-side dry run. Exit 1 on errors (--strict: warnings too).
225
+
226
+ blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
227
+ Move old-layout files (pages/<slug>.json) to language folders. --dry-run (default)
228
+ prints the plan; --write moves only proven files. Any ambiguity or conflict: nothing
229
+ is moved, exit 1. Files without "locale" use the site's default language (login needed;
230
+ with --write outside a bound project only an explicit --context/env credentials are used).
132
231
 
133
232
  blocofy pages media-uses <page-handle> [--json]
134
233
  List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
@@ -141,27 +240,111 @@ Usage
141
240
  decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
142
241
  flags the CLI first GETs the draft and uses its current revision id/version; items
143
242
  without an idempotency_key get a random UUID.
243
+ Transient failures (429/502/503/504, network) are retried: every item carries an
244
+ idempotency key before the first attempt, so a retry replays the same batch.
144
245
  Exit codes: 0 applied (or "No changes" when every item was already recorded);
145
- 1 usage/auth/network/5xx (no retry); 2 the server refused (4xx) — the {error} JSON
246
+ 1 usage/auth/network/5xx; 2 the server refused (4xx) — the {error} JSON
146
247
  is printed to stderr.
147
248
 
148
- blocofy settings pull [dir] / settings push [dir]
249
+ blocofy settings pull [dir]
250
+ blocofy settings push [dir] (--instance <handle> | --live [--yes])
149
251
  Download / upload config/settings.json (theme tokens/settings + color schemes).
252
+ A push names its target (no implicit live write):
253
+ --instance <handle> write that theme's settings; a draft shows them in its preview
254
+ and goes live with 'blocofy theme publish --instance <handle>'
255
+ --live write the LIVE theme (asks to confirm; non-interactive shells add --yes)
256
+ After a push the CLI says where it applied (preview now / live now / after deploy).
257
+
258
+ blocofy site export [dir]
259
+ Download the whole site as a declarative tree (pages, navigation, theme source, settings,
260
+ chrome, translations, media/assets.json) into [dir], plus every media file's bytes at
261
+ media/files/<sha256> (downloaded and hash-verified). Refuses to overwrite a directory
262
+ bound to another site (same binding rule as every other pull).
263
+
264
+ blocofy site validate [dir]
265
+ Check an exported (or hand-authored) tree OFFLINE — zero network requests: paths, the
266
+ path↔content binding (a page file's locale/slug must match its folder/name, and so on),
267
+ duplicate identities, the size/count limits, and the tree against its own manifest digest.
268
+ Exit 1 on errors (--strict: warnings too).
269
+
270
+ blocofy site migrate [dir] [--dry-run | --write]
271
+ Turn a directory left by the older separate 'theme pull' / 'pages pull' / 'settings pull'
272
+ into the site-state v1 tree layout (theme-dirs-at-root → theme/**, config/settings.json →
273
+ theme/config/settings.json). Purely local: no network request, no target/identity check,
274
+ '.blocofy/' project binding untouched. 'pages/**' files are never moved (already canonical);
275
+ a file still at the old flat page layout is left alone with a note to run
276
+ 'blocofy pages migrate-layout' first. --dry-run (default) prints the plan: every move,
277
+ every file left alone, every conflict. --write performs it. Any ambiguity or conflict (a
278
+ target already exists with different content, an unreadable or symlinked entry, a path the
279
+ shared site-state rules refuse): zero moves, exit 1. Never invents the parts a directory of
280
+ separate pulls never had (blocofy-site.json, site/locales.json, globals, navigation,
281
+ translations, theme/chrome/**) — says so, and points at 'blocofy site export' for those.
282
+
283
+ blocofy site plan [dir] [--target new|<handle>] [--mode same_site|restore]
284
+ [--accept-live-effects locales] [--json]
285
+ Ask the site what applying this tree WOULD do — no write. Needs BOTH a dev token and a
286
+ v1 API key (the dev token is what a theme deploy uses later). Prints the status
287
+ (planned / awaiting_assets / awaiting_theme_source / draft_complete), the steps, and any
288
+ missing assets or a differing theme source.
289
+ --target new|<handle> a fresh draft theme version (default), or a specific one you
290
+ own (from a previous plan/apply's target instance)
291
+ --mode same_site|restore same_site refuses a page that changed on this site since the
292
+ tree was exported; restore (default) does not
293
+ --accept-live-effects locales required before a state that adds a language may be
294
+ applied (publishing a language prepares its homepage
295
+ LIVE, contract §A3)
296
+
297
+ blocofy site apply [dir] [--target new|<handle>] [--mode same_site|restore]
298
+ [--accept-live-effects locales] [--json]
299
+ Build the state into a DRAFT theme version — the live site is never touched. Loops:
300
+ plan → (upload missing media via the v1 API, or deploy the theme source via the same
301
+ canonical path 'theme push --instance' uses) → plan → apply, until the target reports
302
+ draft_complete or 5 passes are used. Safe to re-run: every step is idempotent and a
303
+ re-plan picks up exactly what is left, so an apply interrupted by anything (network,
304
+ Ctrl-C, a crash) resumes with the same command.
305
+ Preview it from the admin panel, then: blocofy site publish.
306
+
307
+ blocofy site publish [dir] [--target new|<handle>] [--mode same_site|restore] [--yes]
308
+ Make an applied state the LIVE site: pointer swap, then navigation, then settings.
309
+ Refuses (exit 2) if the state is not fully applied yet — run 'site apply' first.
310
+ Separate live gate: asks to confirm (non-interactive shells must add --yes).
150
311
 
151
312
  blocofy --version
152
313
  blocofy --help
153
314
 
154
315
  Examples
155
- blocofy login --url https://store.myblocofy.com --token bcf_xxxxxxxx
156
- blocofy theme pull && blocofy theme dev
157
- blocofy theme push && blocofy theme publish
158
- blocofy status
159
- blocofy login --api-key
316
+ blocofy login --url https://store.myblocofy.com --token bcf_xxxxxxxx (context "store")
317
+ blocofy theme pull store-theme --context store && cd store-theme && blocofy theme dev
318
+ blocofy link ~/code/store-theme --context store (an existing checkout)
319
+ blocofy theme push && blocofy theme publish (inside the bound project)
320
+ blocofy target && blocofy status
321
+ blocofy login --api-key --context store
160
322
  blocofy pages media-uses pg_abc123 --json
161
323
  blocofy pages media-decide pg_abc123 --decisions decisions.json
162
324
 
163
- Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
164
- v1 API (pages media-*): \`login --api-key\`, or BLOCOFY_API_KEY + BLOCOFY_API_URL env vars.
325
+ Targets (which site a command talks to)
326
+ Every remote command verifies its site first and prints a Target block on stderr.
327
+ The context is chosen in this order: --context → BLOCOFY_CONTEXT → env credentials
328
+ (BLOCOFY_URL+BLOCOFY_TOKEN and/or BLOCOFY_API_URL+BLOCOFY_API_KEY) → .blocofy/local.json
329
+ → 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.
333
+ A binding made against an older server has no platform origin: it still matches the same site
334
+ (one warning; run \`blocofy link --adopt\` to record it). A server that reports no origin cannot
335
+ serve a binding that records one (TARGET_UNVERIFIED).
336
+ Exit codes (every command): 0 ok · 1 usage/network/5xx/local check · 2 server refusal (HTTP 4xx)
337
+ · 3 target/binding refusal · 4 'site apply' only: not finished within its bounded pass count —
338
+ every step already applied is safe, re-run the same command to resume.
339
+ --json: every failure prints {"error":{"code","message","details"}} as the LAST stderr line; the
340
+ target block ({"target":…}) and any warning lines are printed on stderr before it.
341
+ Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (Retry-After honoured,
342
+ max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (pages push carries one
343
+ x-idempotency-key per push). HTTP 500 is never retried. Each retry prints a notice on stderr.
344
+
345
+ Auth: ~/.blocofy/credentials.json (contexts, no secrets) + ~/.blocofy/secrets.json (0600) or the
346
+ macOS keychain. A pre-0.10 credentials file is migrated on first use (backup:
347
+ ~/.blocofy/credentials.v1.bak.json — copy it back to roll back).
165
348
  The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
166
349
  plain Liquid + static assets.`);
167
350
  }
@@ -177,11 +360,136 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
177
360
  process.exit(1);
178
361
  }
179
362
 
363
+ // ── contexts, login, binding (CF-T1/T2, contract C1/C2) ─────────────────────────────────────────────────────
364
+
365
+ const JSON_MODE = args.includes("--json");
366
+ /** CF-T3: every retried request (lib/http.mjs) announces itself on stderr — never silent. */
367
+ const onRetry = (info) => console.error(retryNotice(info));
368
+ const retry = { onRetry };
369
+
180
370
  /**
181
- * `blocofy login --api-key [--api-url <url>]` — v1 API key login (0.8.0, D3 §4.7).
182
- * The key is NEVER taken from argv: a hidden prompt on a TTY, BLOCOFY_API_KEY otherwise.
183
- * Non-TTY without the env var → message + exit 1, nothing written (fail-closed). No message
184
- * printed by this function ever contains the key (not even a prefix).
371
+ * CF-T9 — the one failure exit (contract C2). Exit codes: 0 ok · 1 usage/network/5xx/local validation · 2 server
372
+ * refusal (HTTP 4xx) · 3 target/binding refusal. Human output goes first (page diagnostics, per-file results); the
373
+ * shared, redacted `printError` line is always LAST on stderr — under --json it is the `{"error":{code,message,details}}`
374
+ * envelope (the target block, a `{"target":…}` line, was printed before it).
375
+ */
376
+ function exitCodeFor(error) {
377
+ if (error instanceof TargetError || error instanceof CredentialsError) return error.exitCode ?? 1;
378
+ if (error instanceof CliRefusal) return 2;
379
+ const status = Number(error?.status);
380
+ return status >= 400 && status < 500 ? 2 : 1;
381
+ }
382
+
383
+ function envelopeFor(error) {
384
+ if (error instanceof CliRefusal) {
385
+ const e = error.error ?? {};
386
+ return { code: e.code ?? `http_${error.status}`, message: e.message ?? error.message, details: e.details ?? {} };
387
+ }
388
+ const status = Number.isFinite(Number(error?.status)) && error?.status != null ? Number(error.status) : null;
389
+ const code = typeof error?.code === "string" && error.code ? error.code : status ? `HTTP_${status}` : error instanceof TypeError ? "NETWORK_ERROR" : "ERROR";
390
+ const details = { ...(error?.details ?? {}) };
391
+ if (status) details.status = status;
392
+ if (error instanceof PagesCliError || error instanceof SiteStateFsError) {
393
+ if (error.diagnostics?.length) details.diagnostics = error.diagnostics;
394
+ if (Array.isArray(error.pages)) details.pages = error.pages;
395
+ }
396
+ return { code, message: error?.message || String(error), details };
397
+ }
398
+
399
+ function failAndExit(error) {
400
+ const code = exitCodeFor(error);
401
+ if ((error instanceof PagesCliError || error instanceof SiteStateFsError) && !JSON_MODE) {
402
+ if (error.diagnostics?.length) reportPageDiagnostics(error.diagnostics);
403
+ }
404
+ if ((error instanceof PagesCliError || error instanceof SiteStateFsError) && Array.isArray(error.pages) && error.pages.length && !JSON_MODE) {
405
+ console.error("Per-file result:");
406
+ for (const p of error.pages) console.error(` ${p.outcome ?? p.action} ${p.path}`);
407
+ }
408
+ if (error instanceof CliRefusal && !JSON_MODE) {
409
+ // Existing media-* contract: the server's {error} JSON verbatim on stderr.
410
+ process.stderr.write(redact(JSON.stringify({ error: error.error })) + "\n");
411
+ } else if ((error instanceof PagesCliError || error instanceof SiteStateFsError) && !JSON_MODE) {
412
+ process.stderr.write(redact(`${error.diagnostics?.length ? "\n" : ""}error [${error.code}]:\n ${error.message}`) + "\n");
413
+ } else if (!JSON_MODE && !(error instanceof TargetError || error instanceof CredentialsError) && !(typeof error?.code === "string" && error.code)) {
414
+ process.stderr.write(redact(error?.message || String(error)) + "\n");
415
+ } else {
416
+ printError(envelopeFor(error), { json: JSON_MODE });
417
+ }
418
+ process.exit(code);
419
+ }
420
+
421
+ function contextNameOrExit(name) {
422
+ if (name === ENV_CONTEXT || !CONTEXT_NAME_RE.test(name)) {
423
+ throw new TargetError("TARGET_CONTEXT_INVALID", `"${name}" cannot be used as a context name (letters, digits, . _ -; "env" is reserved).`, { context: name }, 1);
424
+ }
425
+ return name;
426
+ }
427
+
428
+ /**
429
+ * A new pair may only join a context whose other pair is for the same site. `existing` is the stored context;
430
+ * `identity` the freshly verified one. Checks the recorded site, else verifies the other pair live.
431
+ */
432
+ async function assertPairFitsContext(name, existing, identity, otherKind) {
433
+ if (!existing) return;
434
+ const mismatch = (otherSite) => {
435
+ const code = existing[otherKind] ? "TARGET_CREDENTIAL_MISMATCH" : "TARGET_SITE_MISMATCH";
436
+ throw new TargetError(
437
+ code,
438
+ `Context "${name}" is for site ${otherSite.slug ?? otherSite.id}, but these credentials are for ${identity.site.slug ?? identity.site.id}. Nothing was saved. Use another --context name (or \`blocofy logout --context ${name}\` first).`,
439
+ { context: name, context_site_id: otherSite.id, new_site_id: identity.site.id },
440
+ );
441
+ };
442
+ if (existing.site) {
443
+ const o = compareOrigin(existing.platform_origin, identity.platformOrigin);
444
+ // "upgrade" (recorded null, server now reports one): this login records it.
445
+ if (String(existing.site.id) !== String(identity.site.id) || o === "mismatch" || o === "unproven") mismatch(existing.site);
446
+ return;
447
+ }
448
+ if (!existing[otherKind]) return;
449
+ const secrets = readSecrets(name, existing);
450
+ if (otherKind === "dev" && secrets.devToken) {
451
+ registerSecret(secrets.devToken);
452
+ const other = await verifyDev({ url: existing.dev.url, token: secrets.devToken, retry });
453
+ if (String(other.site.id) !== String(identity.site.id) || other.platformOrigin !== identity.platformOrigin) mismatch(other.site);
454
+ } else if (otherKind === "api" && secrets.apiKey) {
455
+ registerSecret(secrets.apiKey);
456
+ const other = await verifyApi({ url: existing.api.url, apiKey: secrets.apiKey, retry });
457
+ if (String(other.site.id) !== String(identity.site.id) || other.platformOrigin !== identity.platformOrigin) mismatch(other.site);
458
+ }
459
+ }
460
+
461
+ /** Save one verified pair into a context (secret first, then the context file). */
462
+ function saveVerifiedPair(name, kind, args) {
463
+ withStoreLock(() => saveVerifiedPairLocked(name, kind, args));
464
+ }
465
+
466
+ function saveVerifiedPairLocked(name, kind, { url, secret, identity, storeName }) {
467
+ const store = loadStore();
468
+ const existing = store.contexts[name];
469
+ writeSecret(name, kind, storeName, secret);
470
+ if (existing?.[kind] && existing[kind].secret.store !== storeName) {
471
+ try {
472
+ removeSecrets(name, { [kind]: existing[kind] });
473
+ } catch {
474
+ /* the old copy stays in the other store; the context now points at the new one */
475
+ }
476
+ }
477
+ const site = { id: identity.site.id, slug: identity.site.slug ?? existing?.site?.slug ?? null, name: identity.site.name ?? existing?.site?.name ?? null, domain: identity.site.domain ?? existing?.site?.domain ?? null };
478
+ store.contexts[name] = {
479
+ ...(existing ?? {}),
480
+ platform_origin: identity.platformOrigin,
481
+ site,
482
+ [kind]: { url, secret: { store: storeName } },
483
+ verified_at: new Date().toISOString(),
484
+ };
485
+ if (!store.current_context) store.current_context = name;
486
+ saveStore(store);
487
+ }
488
+
489
+ /**
490
+ * `blocofy login --api-key [--context <n>] [--api-url <url>]` — v1 API key login (0.8.0, D3 §4.7; CF-T1).
491
+ * The key is NEVER taken from argv: a hidden prompt on a TTY, BLOCOFY_API_KEY otherwise. GET /api/v1/ping must
492
+ * succeed before anything is saved. No message printed by this function ever contains the key.
185
493
  */
186
494
  async function loginApiKey(flags, positionals) {
187
495
  if (positionals.length > 0 || flags.url !== undefined || flags.token !== undefined) {
@@ -215,9 +523,15 @@ async function loginApiKey(flags, positionals) {
215
523
  console.error("Invalid API key — a v1 key starts with blcf_live_ (a bcf_ dev token is not accepted for the v1 API). Nothing was written.");
216
524
  process.exit(1);
217
525
  }
218
-
219
- saveCredentials({ apiUrl, apiKey });
220
- console.log(`✓ API key saved → ${credentialsPath()} (API: ${apiUrl})`);
526
+ registerSecret(apiKey);
527
+ const storeName = defaultSecretStoreName({ keychain: Boolean(flags.keychain) });
528
+
529
+ const identity = await verifyApi({ url: apiUrl, apiKey, retry });
530
+ const name = contextNameOrExit(typeof flags.context === "string" ? flags.context : identity.site.slug ?? "");
531
+ await assertPairFitsContext(name, loadStore().contexts[name], identity, "dev");
532
+ saveVerifiedPair(name, "api", { url: apiUrl, secret: apiKey, identity, storeName });
533
+ console.log(`✓ API key saved to context "${name}" → ${credentialsPath()} (API: ${apiUrl})`);
534
+ console.log(` Site: ${siteLabel(identity.site) || identity.site.id}`);
221
535
  console.log("Next: blocofy pages media-uses <page-handle>");
222
536
  }
223
537
 
@@ -271,41 +585,256 @@ async function login(rest) {
271
585
  rl.close();
272
586
  }
273
587
  }
588
+ registerSecret(token);
589
+ const storeName = defaultSecretStoreName({ keychain: Boolean(flags.keychain) });
590
+
591
+ // CF-T1: the token's REAL site (resolved server-side from the token) must be verified BEFORE saving — a
592
+ // wrong-tenant token or an unreachable platform saves nothing (TARGET_UNVERIFIED, exit 3).
593
+ const identity = await verifyDev({ url, token, retry });
594
+ const name = contextNameOrExit(typeof flags.context === "string" ? flags.context : identity.site.slug);
595
+ await assertPairFitsContext(name, loadStore().contexts[name], identity, "api");
596
+ saveVerifiedPair(name, "dev", { url, secret: token, identity, storeName });
597
+ console.log(`✓ Saved context "${name}" → ${credentialsPath()}`);
598
+ console.log(` Site: ${siteLabel(identity.site)} — commands using context "${name}" target this site.`);
599
+ console.log(`Next: bind a project directory: blocofy link <dir> --context ${name} (or pull into an empty one: blocofy theme pull <dir> --context ${name})`);
600
+ }
274
601
 
275
- saveCredentials({ url, token });
276
- console.log(`✓ Saved credentials → ${credentialsPath()}`);
602
+ async function contextsCommand(rest) {
603
+ const { positionals } = parseArgsOrExit(rest, []);
604
+ if (positionals.length) throw new TargetError("USAGE", "Usage: blocofy contexts [--json]", {}, 1);
605
+ const store = loadStore();
606
+ const rows = Object.entries(store.contexts).map(([name, c]) => ({
607
+ name,
608
+ current: store.current_context === name,
609
+ site: c.site ?? null,
610
+ platform_origin: c.platform_origin ?? null,
611
+ dev: c.dev ? { url: c.dev.url, store: c.dev.secret.store } : null,
612
+ api: c.api ? { url: c.api.url, store: c.api.secret.store } : null,
613
+ verified_at: c.verified_at ?? null,
614
+ }));
615
+ if (JSON_MODE) {
616
+ console.log(JSON.stringify({ current_context: store.current_context, contexts: rows }, null, 2));
617
+ return;
618
+ }
619
+ if (rows.length === 0) {
620
+ console.log("No saved contexts. Run `blocofy login`.");
621
+ return;
622
+ }
623
+ for (const r of rows) {
624
+ const site = r.site ? `${siteLabel(r.site)} · ${r.site.id}` : "(not verified yet)";
625
+ const pairs = [r.dev ? `dev ${r.dev.url} [${r.dev.store}]` : null, r.api ? `api ${r.api.url} [${r.api.store}]` : null].filter(Boolean).join(", ");
626
+ console.log(`${r.current ? "*" : " "} ${r.name} ${site} ${pairs}`);
627
+ }
628
+ }
629
+
630
+ async function useCommand(rest) {
631
+ const { positionals } = parseArgsOrExit(rest, []);
632
+ const name = positionals[0];
633
+ if (!name || positionals.length > 1) throw new TargetError("USAGE", "Usage: blocofy use <context>", {}, 1);
634
+ withStoreLock(() => {
635
+ const store = loadStore();
636
+ if (!store.contexts[name]) throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}". List them with \`blocofy contexts\`.`, { context: name });
637
+ store.current_context = name;
638
+ saveStore(store);
639
+ });
640
+ console.log(`✓ Default context for read-only commands outside a project: ${name}`);
641
+ console.log(" (Inside a bound project the project's site decides; `use` never retargets it.)");
642
+ }
643
+
644
+ async function logoutCommand(rest) {
645
+ const { flags, positionals } = parseArgsOrExit(rest, []);
646
+ const name = typeof flags.context === "string" ? flags.context : null;
647
+ if (!name || positionals.length) throw new TargetError("USAGE", "Usage: blocofy logout --context <name>", {}, 1);
648
+ withStoreLock(() => {
649
+ const store = loadStore();
650
+ const ctx = store.contexts[name];
651
+ if (!ctx) throw new TargetError("TARGET_CONTEXT_UNKNOWN", `No context named "${name}".`, { context: name });
652
+ removeSecrets(name, ctx);
653
+ delete store.contexts[name];
654
+ if (store.current_context === name) store.current_context = null;
655
+ saveStore(store);
656
+ });
657
+ console.log(`✓ Removed context "${name}" and its secrets.`);
658
+ }
659
+
660
+ async function linkCommand(rest) {
661
+ const { flags, positionals } = parseArgsOrExit(rest, ["adopt"]);
662
+ const dir = resolve(positionals[0] ?? process.cwd());
663
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new TargetError("USAGE", `Directory not found: ${dir}`, { dir }, 1);
664
+ const envCtx = envContext();
665
+ const flagContext = typeof flags.context === "string" ? flags.context : null;
666
+ if (!flagContext && !process.env.BLOCOFY_CONTEXT && !envCtx) {
667
+ throw new TargetError("TARGET_CONTEXT_REQUIRED", "`blocofy link` needs the context to bind: pass --context <name> (see `blocofy contexts`).", {});
668
+ }
669
+ const resolved = await resolveContext({ flagContext, envContextName: process.env.BLOCOFY_CONTEXT || null, envCtx, getStore: () => loadStore(), binding: null, commandClass: "read" });
670
+ const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
671
+ registerSecret(secrets.devToken);
672
+ registerSecret(secrets.apiKey);
673
+ const identity = await verifyTarget({ resolved, secrets, binding: null, retry });
674
+ for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
675
+
676
+ let own = null;
677
+ if (existsSync(join(dir, ".blocofy", "project.json"))) {
678
+ try {
679
+ own = findBinding(dir);
680
+ } catch (error) {
681
+ // Review M2: --adopt replaces an unreadable binding (the TARGET_BINDING_INVALID message recommends it).
682
+ if (!(flags.adopt && error instanceof TargetError && error.code === "TARGET_BINDING_INVALID")) throw error;
683
+ }
684
+ }
685
+ const ownOrigin = own ? compareOrigin(own.project.platform_origin, identity.platformOrigin) : "match";
686
+ if (own && (String(own.project.site_id) !== String(identity.site.id) || (ownOrigin !== "match" && ownOrigin !== "upgrade")) && !flags.adopt) {
687
+ throw new TargetError(
688
+ "TARGET_SITE_MISMATCH",
689
+ `${dir} is already bound to site ${own.project.site_slug ?? own.project.site_id}; context "${resolved.name}" is for ${identity.site.slug ?? identity.site.id}. Nothing was written. Pass --adopt to rebind it.`,
690
+ { dir, binding_site_id: own.project.site_id, remote_site_id: identity.site.id },
691
+ );
692
+ }
693
+ // Review M3: an env-context link owns no local.json; a stale one (naming another context) is removed.
694
+ const projectPath = writeBinding(dir, { site: identity.site, platformOrigin: identity.platformOrigin, contextName: resolved.name, staleLocal: true });
695
+ recordVerifiedSite(resolved, identity);
696
+ console.log(`✓ Bound ${dir} to ${siteLabel(identity.site) || identity.site.id} (${identity.site.id}) via context "${resolved.name}".`);
697
+ console.log(` ${relative(process.cwd(), projectPath) || projectPath} — commit it; .blocofy/local.json stays private (git-ignored).`);
698
+ }
277
699
 
278
- // Token'ın GERÇEK site'ını göster — site sunucuda TOKEN'dan çözülür, URL kozmetik.
279
- // Yanlış tenant'ın token'ıyla login olduysan ("klarosa URL + ksc token") hemen
280
- // görürsün. whoami yoksa/erişilemezse sessiz geç.
700
+ async function targetCommand(rest) {
701
+ 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 });
704
+ if (JSON_MODE) console.log(JSON.stringify({ target: t.display }, null, 2));
705
+ else printTarget(t.display, { stream: process.stdout });
706
+ }
707
+
708
+ /** An unverified (migrated) named context gets the verified site recorded once. */
709
+ function recordVerifiedSite(resolved, identity) {
710
+ if (resolved.env || resolved.context.site) return;
711
+ withStoreLock(() => {
712
+ const store = loadStore();
713
+ const ctx = store.contexts[resolved.name];
714
+ if (!ctx || ctx.site) return;
715
+ ctx.site = { id: identity.site.id, slug: identity.site.slug ?? null, name: identity.site.name ?? null, domain: identity.site.domain ?? null };
716
+ ctx.platform_origin = identity.platformOrigin;
717
+ ctx.verified_at = new Date().toISOString();
718
+ saveStore(store);
719
+ });
720
+ }
721
+
722
+ async function promptContext(candidates) {
723
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
281
724
  try {
282
- const who = await fetchWhoami({ url, token });
283
- if (who?.site) console.log(` Site: ${siteLabel(who.site)} — your theme commands target this tenant.`);
284
- } catch {
285
- /* best-effort */
725
+ process.stderr.write(`Several saved contexts match this project's site:\n${candidates.map((c, i) => ` ${i + 1}) ${c}`).join("\n")}\n`);
726
+ const answer = (await rl.question("Use which context? [number] ")).trim();
727
+ return candidates[Number(answer) - 1] ?? null;
728
+ } finally {
729
+ rl.close();
286
730
  }
287
- console.log(`Next: cd into your theme directory, then run blocofy theme dev`);
288
731
  }
289
732
 
290
- /** Resolve credentials (URL + token) or exit with guidance. */
291
- function requireCreds() {
292
- const creds = loadCredentials();
293
- if (!creds || !creds.url || !creds.token) {
294
- console.error("Login required: run `blocofy login` (or set BLOCOFY_URL + BLOCOFY_TOKEN).");
295
- process.exit(1);
733
+ /**
734
+ * The one gate every remote command passes BEFORE its first request (contract C2): binding policy → context
735
+ * resolution → offline precheck → required pair → remote identity (both pairs when present) → binding match →
736
+ * target block. Throws TargetError / CredentialsError; never returns an unverified target.
737
+ *
738
+ * `needs`: "dev" | "api" | "any". Returns `{ name, dev, api, identity, binding, newBinding, display }`.
739
+ */
740
+ async function prepareTarget({ command, commandClass, dir, flags, needs, mode, record = true, quiet = false, resolveClass = commandClass }) {
741
+ const binding = findBinding(dir);
742
+ const { newBinding } = enforceBindingPolicy({ commandClass, binding, dir, command });
743
+ const envCtx = envContext();
744
+ let cachedStore = null;
745
+ const resolved = await resolveContext({
746
+ flagContext: typeof flags.context === "string" ? flags.context : null,
747
+ envContextName: process.env.BLOCOFY_CONTEXT || null,
748
+ envCtx,
749
+ getStore: () => (cachedStore ??= loadStore()),
750
+ binding,
751
+ commandClass: resolveClass,
752
+ isTTY: Boolean(process.stdin.isTTY && process.stderr.isTTY),
753
+ prompt: promptContext,
754
+ });
755
+ precheckContext({ binding, resolved });
756
+ const secrets = resolved.env ? resolved.env.secrets : readSecrets(resolved.name, resolved.context);
757
+ registerSecret(secrets.devToken);
758
+ registerSecret(secrets.apiKey);
759
+
760
+ const hasDev = Boolean(resolved.context.dev && secrets.devToken);
761
+ const hasApi = Boolean(resolved.context.api && secrets.apiKey);
762
+ if (needs === "dev" && !hasDev) {
763
+ throw new TargetError("LOGIN_REQUIRED", `Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN).`, { context: resolved.name }, 1);
764
+ }
765
+ if (needs === "api" || needs === "both") {
766
+ if (!hasApi) {
767
+ 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
+ }
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
+ }
773
+ // CF-T4 — site plan/apply need BOTH pairs: the v1 API for the site-state endpoints, the dev token for the
774
+ // canonical theme deploy `awaiting_theme_source` asks for. `verifyTarget` below already asserts they
775
+ // resolve to the same site whenever both are present (dev && api), which "both" makes unconditional.
776
+ if (needs === "both" && !hasDev) {
777
+ throw new TargetError(
778
+ "LOGIN_REQUIRED",
779
+ `Login required: context "${resolved.name}" has no dev token. Run \`blocofy login${resolved.env ? "" : ` --context ${resolved.name}`}\` (or set BLOCOFY_URL + BLOCOFY_TOKEN). Site plan/apply need both a dev token and a v1 API key.`,
780
+ { context: resolved.name },
781
+ 1,
782
+ );
296
783
  }
297
- return creds;
784
+
785
+ const identity = await verifyTarget({ resolved, secrets, binding, retry });
786
+ for (const w of identity.warnings ?? []) printWarning(w, { json: JSON_MODE });
787
+ if (record) recordVerifiedSite(resolved, identity);
788
+
789
+ const url = needs === "api" ? resolved.context.api?.url : resolved.context.dev?.url ?? resolved.context.api?.url;
790
+ 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}` });
792
+ if (!quiet) printTarget(display, { json: JSON_MODE });
793
+ return {
794
+ name: resolved.name,
795
+ dev: hasDev ? { url: resolved.context.dev.url, token: secrets.devToken } : null,
796
+ api: hasApi ? { apiUrl: resolved.context.api.url, apiKey: secrets.apiKey } : null,
797
+ identity,
798
+ binding,
799
+ newBinding,
800
+ display,
801
+ };
298
802
  }
299
803
 
804
+ /**
805
+ * Review M4: a pull into a new directory claims the binding exclusively BEFORE fetching/writing content; the claim is
806
+ * released if the pull fails. `fn` performs the pull.
807
+ */
808
+ async function withNewBindingClaim(target, dir, fn) {
809
+ if (!target.newBinding) return fn();
810
+ const claim = claimNewBinding(dir, { site: target.identity.site, platformOrigin: target.identity.platformOrigin });
811
+ try {
812
+ return await fn();
813
+ } catch (error) {
814
+ claim.release();
815
+ throw error;
816
+ }
817
+ }
818
+
819
+ /** After a successful pull into a new directory: record provenance (project.json + local.json + .gitignore). */
820
+ function bindAfterPull(target, dir) {
821
+ if (!target.newBinding) return;
822
+ writeBinding(dir, { site: target.identity.site, platformOrigin: target.identity.platformOrigin, contextName: target.name });
823
+ console.log(` Bound ${dir} to ${siteLabel(target.identity.site) || target.identity.site.id} (.blocofy/project.json).`);
824
+ }
825
+
826
+
300
827
  async function themePull(rest) {
301
828
  const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themePull);
302
829
  const dir = resolve(positionals[0] ?? process.cwd());
303
- const creds = requireCreds();
304
830
  const draft = Boolean(flags.draft);
305
831
  const instance = typeof flags.instance === "string" ? flags.instance : null;
306
- const { count } = await pullTheme({ dir, url: creds.url, token: creds.token, draft, instance });
307
832
  const what = instance ? `instance ${instance}` : draft ? "draft" : "live";
833
+ // Review M1: a draft pull provisions the draft server-side (`?draft=1`), so it is a remote mutation: binding required.
834
+ const target = await prepareTarget({ command: draft ? "theme pull --draft" : "theme pull", commandClass: draft ? "remote-mutation" : "local-write", dir, flags, needs: "dev", mode: what });
835
+ const { count } = await withNewBindingClaim(target, dir, () => pullTheme({ dir, url: target.dev.url, token: target.dev.token, draft, instance, onRetry }));
308
836
  console.log(`Downloaded ${count} ${what} theme files → ${dir}`);
837
+ bindAfterPull(target, dir);
309
838
  }
310
839
 
311
840
  async function themePush(rest) {
@@ -315,10 +844,23 @@ async function themePush(rest) {
315
844
  console.error(`Theme directory not found: ${dir}`);
316
845
  process.exit(1);
317
846
  }
318
- const creds = requireCreds();
319
847
  const instanceFlag = typeof flags.instance === "string" ? flags.instance : null;
320
848
  const name = typeof flags.name === "string" ? flags.name : null;
321
849
  const dryRun = Boolean(flags["dry-run"] || flags.validate);
850
+ // Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
851
+ // açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
852
+ // modu canlıya yazar ve onay gerektirir.
853
+ const { mode, instance } = resolvePushMode({
854
+ live: Boolean(flags.live),
855
+ draft: Boolean(flags.draft),
856
+ instance: instanceFlag,
857
+ });
858
+ // CF-T2: `--diff` and `--dry-run` are reads; every other push is a remote mutation (binding required). The
859
+ // target is verified (whoami, both pairs, binding) before the first theme request — no best-effort swallow.
860
+ const readOnly = Boolean(flags.diff) || dryRun;
861
+ 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;
862
+ const target = await prepareTarget({ command: "theme push", commandClass: readOnly ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: `${opMode}${flags.prune && !readOnly ? " · prune" : ""}` });
863
+ const creds = target.dev;
322
864
  // 0.5.0: her push'a otomatik idempotency key — kanonik dal (protokol + key) ancak böyle seçilir; key
323
865
  // OLMADAN header'lar tek başına legacy writer'a düşer ve --live push pinned render'a YANSIMAZ (M4
324
866
  // read cutover'ının ana CLI şikâyeti). Push-OPERASYONU-başına üretilir: fetchWithRetry'nin 5xx/429
@@ -330,14 +872,8 @@ async function themePush(rest) {
330
872
  // `--diff`: read-only preview vs the LIVE theme (or --instance). No write; the draft target is not
331
873
  // diffable (a draft GET would PROVISION the draft server-side — a read-only command must not mutate).
332
874
  if (flags.diff) {
333
- let diffSite = null;
334
- try {
335
- diffSite = (await fetchWhoami({ url: creds.url, token: creds.token })).site;
336
- } catch {
337
- /* best-effort — etiket kozmetik, diff yine koşar */
338
- }
339
- const diffTarget = diffSite ? ` of ${siteLabel(diffSite)}` : "";
340
- const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag });
875
+ const diffTarget = ` of ${siteLabel(target.identity.site)}`;
876
+ const d = await diffTheme({ dir, url: creds.url, token: creds.token, instance: instanceFlag, onRetry });
341
877
  console.log(instanceFlag ? `Diff vs theme ${instanceFlag}${diffTarget}:` : `Diff vs the LIVE theme${diffTarget} (push default writes to a DRAFT):`);
342
878
  const total = d.added.length + d.changed.length + d.removed.length;
343
879
  if (total === 0) {
@@ -351,33 +887,15 @@ async function themePush(rest) {
351
887
  return;
352
888
  }
353
889
 
354
- // Yeni varsayılan hedef: DRAFT (güvenli). `--live` eski anında-canlı davranışını
355
- // açıkça geri getirir; `--instance` belirli bir temayı adresler. Sadece "live"
356
- // modu canlıya yazar ve onay gerektirir.
357
- const { mode, instance } = resolvePushMode({
358
- live: Boolean(flags.live),
359
- draft: Boolean(flags.draft),
360
- instance: instanceFlag,
361
- });
362
-
363
- // Hedef tenant'ı çöz ve GÖSTER — site sunucuda TOKEN'dan çözülür (URL kozmetik),
364
- // yanlış-tenant'a yazımı görünür kılar ("klarosa sandım, ksc'ye yazdım"). whoami
365
- // yoksa/erişilemezse sessiz geç; etiketi confirmation mesajlarında da kullan.
366
- let site = null;
367
- let whoami = null;
368
- try {
369
- whoami = await fetchWhoami({ url: creds.url, token: creds.token });
370
- site = whoami.site;
371
- } catch {
372
- /* best-effort */
373
- }
374
- const target = site ? siteLabel(site) : creds.url;
890
+ // Hedef tenant'ı GÖSTER — site sunucuda TOKEN'dan çözülür ve prepareTarget'ta DOĞRULANDI (CF-T2).
891
+ const whoami = target.identity;
892
+ const siteName = siteLabel(whoami.site) || String(whoami.site.id);
375
893
  if (mode === "instance") {
376
- console.log(`→ Pushing to theme ${instance}${site ? ` of ${target}` : ""}`);
894
+ console.log(`→ Pushing to theme ${instance} of ${siteName}`);
377
895
  } else if (mode === "live") {
378
- console.log(dryRun ? `→ Validating against the LIVE theme of ${target} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${target}`);
896
+ console.log(dryRun ? `→ Validating against the LIVE theme of ${siteName} (dry run — nothing will be written)` : `→ Pushing to the LIVE theme of ${siteName}`);
379
897
  } else {
380
- console.log(`→ Pushing to a draft${site ? ` of ${target}` : ""}`);
898
+ console.log(`→ Pushing to a draft of ${siteName}`);
381
899
  }
382
900
 
383
901
  // Canlı push (`--live`) ANINDA canlı temayı değiştirir (önizleme yok). Agent/CI
@@ -391,7 +909,7 @@ async function themePush(rest) {
391
909
  isTTY: Boolean(process.stdin.isTTY),
392
910
  });
393
911
  if (decision.mustAbort) {
394
- console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${target} immediately (no preview).`);
912
+ console.error(`⚠ 'theme push --live' writes to the LIVE theme of ${siteName} immediately (no preview).`);
395
913
  console.error(` Non-interactive shell: pass --live --yes to confirm, or omit --live to push to a safe draft.`);
396
914
  process.exit(1);
397
915
  }
@@ -399,7 +917,7 @@ async function themePush(rest) {
399
917
  const rl = createInterface({ input: process.stdin, output: process.stdout });
400
918
  let answer;
401
919
  try {
402
- answer = await rl.question(`⚠ Push to the LIVE theme of ${target}? Immediate, no preview. [y/N] `);
920
+ answer = await rl.question(`⚠ Push to the LIVE theme of ${siteName}? Immediate, no preview. [y/N] `);
403
921
  } finally {
404
922
  rl.close();
405
923
  }
@@ -413,7 +931,7 @@ async function themePush(rest) {
413
931
  // verilmiş `--instance`; whoami çözülemediyse canlı sayılır) canlı-push onay kuralı aynen uygulanır:
414
932
  // `--yes`/`--confirm` → onaylı, TTY → liste basıldıktan sonra y/N, non-TTY → yazımsız çıkış.
415
933
  const prune = Boolean(flags.prune) && !dryRun;
416
- const liveTarget = mode === "live" || (mode === "instance" && (whoami == null || String(instance) === String(whoami.liveThemeId)));
934
+ const liveTarget = mode === "live" || (mode === "instance" && (whoami.liveThemeId == null || String(instance) === String(whoami.liveThemeId)));
417
935
  const pruneDecision = livePushDecision({
418
936
  draft: !prune || !liveTarget,
419
937
  yes: Boolean(flags.yes),
@@ -421,7 +939,7 @@ async function themePush(rest) {
421
939
  isTTY: Boolean(process.stdin.isTTY),
422
940
  });
423
941
  if (pruneDecision.mustAbort) {
424
- console.error(`⚠ 'theme push --prune' removes files from the LIVE theme of ${target}.`);
942
+ console.error(`⚠ 'theme push --prune' removes files from the LIVE theme of ${siteName}.`);
425
943
  console.error(` Non-interactive shell: pass --prune --yes to confirm. Nothing was written.`);
426
944
  process.exit(1);
427
945
  }
@@ -431,7 +949,7 @@ async function themePush(rest) {
431
949
  if (!pruneDecision.needsPrompt) return true;
432
950
  const rl = createInterface({ input: process.stdin, output: process.stdout });
433
951
  try {
434
- return isAffirmative(await rl.question(`⚠ Remove these files from the LIVE theme of ${target}? [y/N] `));
952
+ return isAffirmative(await rl.question(`⚠ Remove these files from the LIVE theme of ${siteName}? [y/N] `));
435
953
  } finally {
436
954
  rl.close();
437
955
  }
@@ -446,7 +964,7 @@ async function themePush(rest) {
446
964
  // 0.5.0: eski (protokolsüz) bir sunucu `dryRun` alanını bilmez ve SESSİZCE GERÇEK YAZIM yapardı —
447
965
  // dry-run yalnız sunucu kanonik protokolü beyan ediyorsa koşar (sorgusuz, mutasyonsuz GET ön kontrolü).
448
966
  if (dryRun) {
449
- const { supported } = await fetchCanonicalSupport({ url: creds.url, token: creds.token });
967
+ const { supported } = await fetchCanonicalSupport({ url: creds.url, token: creds.token, onRetry });
450
968
  if (!supported) {
451
969
  console.error("✗ --dry-run needs a server that speaks the canonical protocol; this one does not.");
452
970
  console.error(" An old server would IGNORE dryRun and write for real. Nothing was sent.");
@@ -465,21 +983,25 @@ async function themePush(rest) {
465
983
  name: mode === "draft" ? name : null,
466
984
  dryRun,
467
985
  idempotencyKey,
468
- onRetry: (info) => console.error(retryNotice(info)),
986
+ onRetry,
469
987
  prune,
470
988
  confirmPrune,
471
989
  });
472
990
  } catch (error) {
473
991
  if (error?.code === "cli_upgrade_required") {
474
- const missing = Array.isArray(error?.body?.fence?.missing) ? error.body.fence.missing.join(", ") : "";
475
- console.error("✗ Sunucu bu CLI sürümünü reddetti (cli_upgrade_required).");
476
- console.error(` Güncelle: npm i -g @blocofy/cli@latest${missing ? `\n Sunucunun istediği eksik yetenekler: ${missing}` : ""}`);
477
- process.exit(1);
992
+ const missing = Array.isArray(error?.body?.fence?.missing) ? error.body.fence.missing : [];
993
+ if (!JSON_MODE) {
994
+ console.error("✗ Sunucu bu CLI sürümünü reddetti (cli_upgrade_required).");
995
+ console.error(` Güncelle: npm i -g @blocofy/cli@latest${missing.length ? `\n Sunucunun istediği eksik yetenekler: ${missing.join(", ")}` : ""}`);
996
+ }
997
+ 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 } });
478
998
  }
479
999
  if (error?.code === "idempotency_conflict") {
480
- console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
481
- console.error(" `--idempotency-key` verdiysen yeni bir anahtarla dene; vermediysen tekrar `blocofy theme push` yeterli (her koşu taze anahtar üretir).");
482
- process.exit(1);
1000
+ if (!JSON_MODE) {
1001
+ console.error("✗ Idempotency çakışması: aynı anahtar daha önce FARKLI içerikle kullanılmış (409).");
1002
+ console.error(" `--idempotency-key` verdiysen yeni bir anahtarla dene; vermediysen tekrar `blocofy theme push` yeterli (her koşu taze anahtar üretir).");
1003
+ }
1004
+ 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: {} });
483
1005
  }
484
1006
  throw error;
485
1007
  }
@@ -534,64 +1056,600 @@ async function themePush(rest) {
534
1056
  }
535
1057
  }
536
1058
 
537
- async function contentPush(scope, rest) {
538
- const { positionals } = parseArgsOrExit(rest, KNOWN.content);
1059
+ /** PS-19 — print platform/CLI findings; returns the exit code they imply. */
1060
+ function reportPageDiagnostics(diagnostics, { strict = false } = {}) {
1061
+ for (const d of diagnostics) (d.level === "error" ? console.error : console.warn)(formatDiagnostic(d));
1062
+ const errors = diagnostics.filter((d) => d.level === "error").length;
1063
+ const warnings = diagnostics.length - errors;
1064
+ return errors > 0 || (strict && warnings > 0) ? 1 : 0;
1065
+ }
1066
+
1067
+ function localeLabel(p) {
1068
+ return `${p.locale} ${p.slug}`;
1069
+ }
1070
+
1071
+ /** CF-T3 — plan totals, as the target block's operation and the table footer show them. */
1072
+ function planTotals(pages) {
1073
+ const count = (action) => pages.filter((p) => p.action === action).length;
1074
+ return { live: count("publish"), draft: count("draft"), unchanged: count("unchanged"), conflicts: pages.filter((p) => p.conflict === true).length };
1075
+ }
1076
+
1077
+ /** CF-T3 — the per-page plan a revision-checking server returned (stdout). */
1078
+ function printPlanTable(plan) {
1079
+ const pages = plan.pages ?? [];
1080
+ const rows = pages.map((p) => [p.action ?? "", p.target ?? "", p.locale ?? "", p.path ?? "", (p.changed_fields ?? []).join(", ") || "-", p.conflict ? "[conflict]" : ""]);
1081
+ const header = ["ACTION", "TARGET", "LOCALE", "PATH", "CHANGED", ""];
1082
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1083
+ const line = (r) => " " + r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
1084
+ console.log("Plan:");
1085
+ console.log(line(header));
1086
+ for (const r of rows) console.log(line(r));
1087
+ const t = planTotals(pages);
1088
+ console.log(`Totals: live ${t.live} · draft ${t.draft} · unchanged ${t.unchanged} · conflicts ${t.conflicts}`);
1089
+ }
1090
+
1091
+ async function pagesPush(rest) {
1092
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPush);
539
1093
  const dir = resolve(positionals[0] ?? process.cwd());
540
1094
  if (!existsSync(dir)) {
541
1095
  console.error(`Directory not found: ${dir}`);
542
1096
  process.exit(1);
543
1097
  }
544
- const creds = requireCreds();
545
- const result = await pushContent({ dir, url: creds.url, token: creds.token, scope });
546
- if (scope === "settings") {
1098
+ const dryRun = Boolean(flags["dry-run"]);
1099
+ const force = Boolean(flags.force);
1100
+ const reason = typeof flags.reason === "string" ? flags.reason : null;
1101
+ // CF-T3 — force always carries a reason (1–500 characters); a reason without force is a mistake, not a no-op.
1102
+ if (flags.reason !== undefined && !force) {
1103
+ printError({ code: "USAGE_REASON_WITHOUT_FORCE", message: "--reason is only used with --force. Nothing was sent." }, { json: JSON_MODE });
1104
+ process.exit(1);
1105
+ }
1106
+ if (force && (reason === null || reason.trim() === "" || reason.length > FORCE_REASON_MAX)) {
1107
+ printError({ code: "USAGE_FORCE_REASON_REQUIRED", message: `--force needs --reason <text> (1-${FORCE_REASON_MAX} characters): why newer content on the site may be overwritten. Nothing was sent.` }, { json: JSON_MODE });
1108
+ process.exit(1);
1109
+ }
1110
+ const baseMode = `${dryRun ? "read · dry run" : "live"}${force ? " · FORCE" : ""}`;
1111
+ const target = await prepareTarget({ command: "pages push", commandClass: dryRun ? "read" : "remote-mutation", dir, flags, needs: "dev", mode: baseMode, quiet: true });
1112
+ // The target block is printed once: with the plan totals when the server returns a plan, else as soon as it is known.
1113
+ let shown = false;
1114
+ const showTarget = (operation = target.display.operation) => {
1115
+ if (shown) return;
1116
+ shown = true;
1117
+ printTarget({ ...target.display, operation }, { json: JSON_MODE });
1118
+ };
1119
+ const creds = target.dev;
1120
+ let result;
1121
+ try {
1122
+ result = await pushContent({
1123
+ dir,
1124
+ url: creds.url,
1125
+ token: creds.token,
1126
+ scope: "pages",
1127
+ dryRun,
1128
+ force,
1129
+ forceReason: reason,
1130
+ onRetry,
1131
+ onServer: (server) => {
1132
+ if (server.revisionCas) return;
1133
+ showTarget();
1134
+ printWarning({ code: "PAGES_REVISION_CAS_UNAVAILABLE", message: "stale-file protection is not available on this server: a page file pulled before someone else's edit can overwrite it. Pull right before you push." }, { json: JSON_MODE });
1135
+ },
1136
+ onPlan: (plan) => {
1137
+ const t = planTotals(plan.pages ?? []);
1138
+ showTarget(`pages push · ${dryRun ? "dry run · " : ""}${force ? "FORCE · " : ""}live ${t.live} · draft ${t.draft} · unchanged ${t.unchanged}`);
1139
+ if (!JSON_MODE) printPlanTable(plan);
1140
+ },
1141
+ });
1142
+ } catch (error) {
1143
+ showTarget();
1144
+ throw error;
1145
+ }
1146
+ showTarget();
1147
+ const code = reportPageDiagnostics(result.diagnostics ?? [], { strict: Boolean(flags.strict) });
1148
+ if (JSON_MODE) {
1149
+ const { fileCount: _f, revisionCas: _r, ...body } = result;
1150
+ console.log(JSON.stringify(body, null, 2));
1151
+ process.exit(code);
1152
+ }
1153
+ const pages = result.pages ?? [];
1154
+ const count = (k, v) => pages.filter((p) => p[k] === v).length;
1155
+ const warnings = (result.diagnostics ?? []).filter((d) => d.level === "warning").length;
1156
+ if (dryRun) {
1157
+ console.log(`Preflight passed: ${count("action", "publish")} updates, ${count("action", "draft")} drafts, ${count("action", "unchanged")} unchanged, ${count("conflict", true)} conflicts, ${warnings} warning(s).`);
1158
+ console.log("Dry run only; no pages were changed.");
1159
+ } else {
1160
+ for (const p of pages) if (p.outcome !== "unchanged") console.log(` ${p.outcome} ${localeLabel(p)} (${p.path})`);
547
1161
  console.log(
548
- `Settings push: ${result.settingsUpdated ? "theme settings updated" : "theme settings unchanged"}, ` +
549
- `${result.schemesUpserted} color scheme(s) upserted (${result.fileCount} file).`,
1162
+ `Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped, ${warnings} warning(s) ` +
1163
+ `(only existing pages are updated — none created or deleted).`,
550
1164
  );
1165
+ }
1166
+ const forced = forcedPaths(result.forced);
1167
+ if (forced.length) {
1168
+ console.log(dryRun ? "Forced (would overwrite without a base check):" : "Forced:");
1169
+ for (const path of forced) console.log(` ${path}`);
1170
+ }
1171
+ process.exit(code);
1172
+ }
1173
+
1174
+ async function pagesPull(rest) {
1175
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPull);
1176
+ const dir = resolve(positionals[0] ?? process.cwd());
1177
+ const target = await prepareTarget({ command: "pages pull", commandClass: "local-write", dir, flags, needs: "dev", mode: "published pages" });
1178
+ const creds = target.dev;
1179
+ const { count, diagnostics } = await withNewBindingClaim(target, dir, () => pullContent({ dir, url: creds.url, token: creds.token, scope: "pages", onRetry }));
1180
+ const code = reportPageDiagnostics(diagnostics, { strict: Boolean(flags.strict) });
1181
+ console.log(`Downloaded ${count} page file(s) → ${dir}`);
1182
+ bindAfterPull(target, dir);
1183
+ process.exit(code);
1184
+ }
1185
+
1186
+ async function pagesCheck(rest) {
1187
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesCheck);
1188
+ const dir = resolve(positionals[0] ?? process.cwd());
1189
+ if (!existsSync(dir)) {
1190
+ console.error(`Directory not found: ${dir}`);
1191
+ process.exit(1);
1192
+ }
1193
+ const creds = (await optionalTarget({ command: "pages check", dir, flags }))?.dev;
1194
+ const online = Boolean(creds);
1195
+ const r = await checkPages({ dir, onRetry, ...(online ? { url: creds.url, token: creds.token } : {}) });
1196
+ const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
1197
+ const errors = r.diagnostics.filter((d) => d.level === "error").length;
1198
+ console.log(`Checked ${r.fileCount} page file(s) ${r.online ? "(with the site's languages and a server dry run)" : "(offline — log in to also check against the site)"}: ${errors} error(s), ${r.diagnostics.length - errors} warning(s).`);
1199
+ process.exit(code);
1200
+ }
1201
+
1202
+ async function pagesMigrate(rest) {
1203
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesMigrate);
1204
+ if (flags["dry-run"] && flags.write) {
1205
+ console.error("Use either --dry-run or --write, not both. Nothing was moved.");
1206
+ process.exit(1);
1207
+ }
1208
+ const dir = resolve(positionals[0] ?? process.cwd());
1209
+ if (!existsSync(dir)) {
1210
+ console.error(`Directory not found: ${dir}`);
1211
+ process.exit(1);
1212
+ }
1213
+ const creds = (await optionalTarget({ command: "pages migrate-layout", dir, flags, localWrite: Boolean(flags.write) }))?.dev;
1214
+ const online = Boolean(creds);
1215
+ const write = Boolean(flags.write);
1216
+ const r = await migrateLayout({ dir, write, onRetry, ...(online ? { url: creds.url, token: creds.token } : {}) });
1217
+ const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
1218
+ for (const m of r.moves) console.log(` ${write && !r.refused ? "moved" : "move"} ${m.from} → ${m.to}`);
1219
+ if (r.refused) console.error(`Migration refused; no files were moved.`);
1220
+ else if (write) console.log(`Moved ${r.moved} file(s) to the language-folder layout.`);
1221
+ else console.log(`${r.moves.length} file(s) would move. Dry run only; run with --write to move them.`);
1222
+ process.exit(r.refused ? 1 : code);
1223
+ }
1224
+
1225
+ // ── site state (CF-T4): export | validate | plan | apply | publish ─────────────────────────────────────────
1226
+
1227
+ /**
1228
+ * A site-state tree on disk → what a plan/apply/publish request body needs: `files` (every tree path
1229
+ * EXCEPT `blocofy-site.json` itself and `media/files/<sha256>` bytes — contract §A1, those never travel in
1230
+ * a request) and a freshly recomputed `manifest` (the digest must describe the CURRENT files; a stale
1231
+ * committed one would just be refused). Provenance fields (`platform_origin`, `source_site`, `exported_at`)
1232
+ * are kept from the tree's own `blocofy-site.json` when it parses — that is what `site export` wrote — and
1233
+ * only synthesized from the verified target identity for a tree that never had one.
1234
+ *
1235
+ * Throws `SiteStateFsError` when the tree read found a symlink or other unreadable entry: nothing is sent.
1236
+ */
1237
+ function loadSiteStateForRequest(dir, identity) {
1238
+ const tree = readSiteStateTree(dir);
1239
+ if (tree.diagnostics.length > 0) {
1240
+ throw new SiteStateFsError("SITE_STATE_INVALID_PATH", "The local tree has unreadable entries; nothing was sent.", tree.diagnostics);
1241
+ }
1242
+ const requestFiles = { ...tree.files };
1243
+ delete requestFiles[MANIFEST_PATH];
1244
+
1245
+ let provenance = null;
1246
+ if (tree.files[MANIFEST_PATH] !== undefined) {
1247
+ try {
1248
+ const parsed = JSON.parse(tree.files[MANIFEST_PATH]);
1249
+ if (parsed && typeof parsed === "object") {
1250
+ provenance = {
1251
+ platformOrigin: typeof parsed.platform_origin === "string" ? parsed.platform_origin : null,
1252
+ sourceSite: parsed.source_site ?? null,
1253
+ exportedAt: typeof parsed.exported_at === "string" ? parsed.exported_at : null,
1254
+ };
1255
+ }
1256
+ } catch {
1257
+ /* fall back to the verified identity below */
1258
+ }
1259
+ }
1260
+ const manifest = buildManifest({
1261
+ files: requestFiles,
1262
+ platformOrigin: provenance?.platformOrigin ?? identity.platformOrigin ?? null,
1263
+ sourceSite: provenance?.sourceSite ?? { id: identity.site.id, slug: identity.site.slug ?? "" },
1264
+ exportedAt: provenance?.exportedAt ?? new Date().toISOString(),
1265
+ });
1266
+ return { tree, requestFiles, manifest };
1267
+ }
1268
+
1269
+ /** `--target new|<handle>` (default new), `--mode same_site|restore` (default restore), `--accept-live-effects locales`. */
1270
+ function siteStateTargetFlags(flags) {
1271
+ const targetFlag = typeof flags.target === "string" && flags.target ? flags.target : "new";
1272
+ const mode = typeof flags.mode === "string" && flags.mode ? flags.mode : "restore";
1273
+ if (mode !== "same_site" && mode !== "restore") {
1274
+ console.error('--mode must be "same_site" or "restore". Nothing was sent.');
1275
+ process.exit(1);
1276
+ }
1277
+ const acceptLiveEffects =
1278
+ typeof flags["accept-live-effects"] === "string"
1279
+ ? flags["accept-live-effects"].split(",").map((s) => s.trim()).filter(Boolean)
1280
+ : [];
1281
+ return { targetFlag, mode, acceptLiveEffects };
1282
+ }
1283
+
1284
+ function siteStateBody({ manifest, requestFiles, opts }) {
1285
+ return { manifest, files: requestFiles, mode: opts.mode, target: { instance: opts.targetFlag }, accept_live_effects: opts.acceptLiveEffects };
1286
+ }
1287
+
1288
+ /** CF-T4 — the target block's operation: "plan/apply/publish · <draft instance> · mode <m> [· LIVE EFFECTS: …]". */
1289
+ function siteStateOperationLabel(opts, op) {
1290
+ const t = opts.targetFlag === "new" ? "new draft instance" : `instance ${opts.targetFlag}`;
1291
+ const live = opts.acceptLiveEffects.length ? ` · LIVE EFFECTS: ${opts.acceptLiveEffects.join(",")}` : "";
1292
+ return `${op} · ${t} · mode ${opts.mode}${live}`;
1293
+ }
1294
+
1295
+ function printSiteStatePlan(plan) {
1296
+ console.log(`Status: ${plan.status} (target instance: ${plan.target_instance ?? "not created yet"})`);
1297
+ if (plan.steps.length === 0) {
1298
+ console.log("No steps: this state is already applied to the target.");
1299
+ } else {
1300
+ console.log("Steps:");
1301
+ for (const s of plan.steps) {
1302
+ console.log(` ${String(s.seq).padStart(2)} ${s.owner.padEnd(14)} ${s.action.padEnd(16)} ${s.key}${s.live_effect ? " [LIVE EFFECT]" : ""}`);
1303
+ }
1304
+ }
1305
+ if (plan.assets_missing.length > 0) console.log(`Assets missing on the site: ${plan.assets_missing.join(", ")}`);
1306
+ if (plan.theme_source) {
1307
+ console.log(`Theme source differs (digest ${plan.theme_source.digest.slice(0, 12)}…); deploy it to ${plan.theme_source.instance ?? "the new draft instance"}.`);
1308
+ }
1309
+ for (const p of plan.preconditions ?? []) console.warn(`precondition [${p.code}]: ${p.message}`);
1310
+ for (const d of plan.diagnostics ?? []) console.warn(formatDiagnostic(d));
1311
+ }
1312
+
1313
+ async function siteExport(rest) {
1314
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.siteExport);
1315
+ const dir = resolve(positionals[0] ?? process.cwd());
1316
+ const target = await prepareTarget({ command: "site export", commandClass: "local-write", dir, flags, needs: "api", mode: "export" });
1317
+ const { apiUrl, apiKey } = target.api;
1318
+ const { fileCount, assetCount } = await withNewBindingClaim(target, dir, async () => {
1319
+ const data = await fetchSiteStateExport({ apiUrl, apiKey, onRetry });
1320
+ const entries = [[MANIFEST_PATH, JSON.stringify(data.manifest, null, 2) + "\n"]];
1321
+ for (const [path, content] of Object.entries(data.files ?? {})) entries.push([path, content]);
1322
+ const assets = Array.isArray(data.assets) ? data.assets : [];
1323
+ for (const asset of assets) {
1324
+ const buf = await downloadAssetBytes({ url: asset.url, onRetry });
1325
+ const got = hashBuffer(buf);
1326
+ if (got !== asset.sha256) {
1327
+ throw new Error(`Downloaded ${asset.filename ?? asset.sha256} does not match its sha256 (expected ${asset.sha256}, got ${got}); nothing was written.`);
1328
+ }
1329
+ entries.push([`media/files/${asset.sha256}`, buf]);
1330
+ }
1331
+ // Staged, all-or-nothing: nothing lands on disk until every text file AND every downloaded, hash-verified
1332
+ // asset is ready.
1333
+ stagedWriteTree(dir, entries);
1334
+ return { fileCount: entries.length - assets.length, assetCount: assets.length };
1335
+ });
1336
+ console.log(`Exported ${fileCount} file(s) + ${assetCount} asset(s) → ${dir}`);
1337
+ bindAfterPull(target, dir);
1338
+ }
1339
+
1340
+ async function siteValidate(rest) {
1341
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.siteValidate);
1342
+ const dir = resolve(positionals[0] ?? process.cwd());
1343
+ if (!existsSync(dir)) {
1344
+ console.error(`Directory not found: ${dir}`);
1345
+ process.exit(1);
1346
+ }
1347
+ const tree = readSiteStateTree(dir);
1348
+ const combined = { ...tree.files };
1349
+ for (const a of tree.assets) combined[a.path] = ""; // content irrelevant to the structural checks below
1350
+ const findings = [...tree.diagnostics, ...validateSiteStateTree(combined)];
1351
+
1352
+ if (tree.files[MANIFEST_PATH] !== undefined) {
1353
+ let manifest = null;
1354
+ try {
1355
+ manifest = JSON.parse(tree.files[MANIFEST_PATH]);
1356
+ } catch {
1357
+ /* reported below */
1358
+ }
1359
+ if (manifest === null) {
1360
+ findings.push({ level: "error", code: "SITE_STATE_INVALID_FILE", message: "blocofy-site.json is not valid JSON", path: MANIFEST_PATH });
1361
+ } else {
1362
+ const withoutManifest = { ...tree.files };
1363
+ delete withoutManifest[MANIFEST_PATH];
1364
+ const refusal = verifyManifest(manifest, withoutManifest);
1365
+ if (refusal) findings.push({ level: "error", code: refusal.code, message: refusal.message, path: MANIFEST_PATH });
1366
+ }
1367
+ }
1368
+
1369
+ const code = reportPageDiagnostics(findings, { strict: Boolean(flags.strict) });
1370
+ const errors = findings.filter((f) => f.level === "error").length;
1371
+ console.log(`Checked ${Object.keys(tree.files).length + tree.assets.length} file(s): ${errors} error(s), ${findings.length - errors} warning(s).`);
1372
+ process.exit(code);
1373
+ }
1374
+
1375
+ /**
1376
+ * `site migrate` — purely local (no `prepareTarget`, no identity call, `.blocofy/` never read): turns a
1377
+ * directory left by the older separate `theme pull` / `pages pull` / `settings pull` into the site-state
1378
+ * v1 tree layout. Always reports the plan first (every move, every file left alone, every conflict), then
1379
+ * with --write performs it.
1380
+ */
1381
+ async function siteMigrate(rest) {
1382
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.siteMigrate);
1383
+ if (flags["dry-run"] && flags.write) {
1384
+ console.error("Use either --dry-run or --write, not both. Nothing was moved.");
1385
+ process.exit(1);
1386
+ }
1387
+ const dir = resolve(positionals[0] ?? process.cwd());
1388
+ if (!existsSync(dir)) {
1389
+ console.error(`Directory not found: ${dir}`);
1390
+ process.exit(1);
1391
+ }
1392
+ const write = Boolean(flags.write);
1393
+ const r = migrateSiteState({ dir, write });
1394
+
1395
+ for (const m of r.moves) console.log(` ${write && !r.refused ? "moved" : "move "} ${m.from} → ${m.to}`);
1396
+ for (const u of r.untouched) console.log(` leave ${u.path} (${u.reason})`);
1397
+ for (const d of r.diagnostics) console.error(formatDiagnostic(d));
1398
+
1399
+ if (r.refused) {
1400
+ console.error("Migration refused; nothing was moved.");
1401
+ } else if (r.moves.length === 0) {
1402
+ console.log("Nothing to do: this directory is already in the site-state tree layout.");
1403
+ } else if (write) {
1404
+ console.log(`Moved ${r.moved} file(s) into the site-state tree layout.`);
551
1405
  } else {
1406
+ console.log(`${r.moves.length} file(s) would move. Dry run only; run with --write to move them.`);
1407
+ }
1408
+ if (!r.refused && r.needsExport) {
552
1409
  console.log(
553
- `Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped ` +
554
- `(only existing pages are updated — none created or deleted).`,
1410
+ "This tree still has no blocofy-site.json (and none of site/locales.json, globals, media-policy, " +
1411
+ "content-model, translations, navigation, theme/chrome/**): run `blocofy site export` against a live " +
1412
+ "site to complete it before `site plan`/`apply`/`publish`.",
555
1413
  );
556
1414
  }
1415
+ process.exit(r.refused ? 1 : 0);
557
1416
  }
558
1417
 
559
- async function contentPull(scope, rest) {
560
- const { positionals } = parseArgsOrExit(rest, KNOWN.content);
1418
+ async function sitePlan(rest) {
1419
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.sitePlan);
561
1420
  const dir = resolve(positionals[0] ?? process.cwd());
562
- const creds = requireCreds();
563
- const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
564
- console.log(`Downloaded ${count} ${scope === "settings" ? "settings" : "page"} file(s) → ${dir}`);
1421
+ if (!existsSync(dir)) {
1422
+ console.error(`Directory not found: ${dir}`);
1423
+ process.exit(1);
1424
+ }
1425
+ const opts = siteStateTargetFlags(flags);
1426
+ const target = await prepareTarget({ command: "site plan", commandClass: "read", dir, flags, needs: "both", mode: siteStateOperationLabel(opts, "plan") });
1427
+ const { requestFiles, manifest } = loadSiteStateForRequest(dir, target.identity);
1428
+ const plan = await planSiteState({ apiUrl: target.api.apiUrl, apiKey: target.api.apiKey, body: siteStateBody({ manifest, requestFiles, opts }), onRetry });
1429
+ if (JSON_MODE) console.log(JSON.stringify(plan));
1430
+ else printSiteStatePlan(plan);
565
1431
  }
566
1432
 
567
- /** Resolve the v1 API pair (blcf_live_ key only) or exit 1 with guidance. Never prints the key. */
568
- function requireApiCreds() {
569
- let creds;
1433
+ /** CF-T4 — bounded: plan → (upload assets | deploy theme) → plan → apply, repeated, never more than this many passes. */
1434
+ const MAX_APPLY_PASSES = 5;
1435
+
1436
+ async function siteApply(rest) {
1437
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.siteApply);
1438
+ const dir = resolve(positionals[0] ?? process.cwd());
1439
+ if (!existsSync(dir)) {
1440
+ console.error(`Directory not found: ${dir}`);
1441
+ process.exit(1);
1442
+ }
1443
+ const opts = siteStateTargetFlags(flags);
1444
+ const target = await prepareTarget({ command: "site apply", commandClass: "remote-mutation", dir, flags, needs: "both", mode: siteStateOperationLabel(opts, "apply") });
1445
+ const { apiUrl, apiKey } = target.api;
1446
+ const { requestFiles, manifest, tree } = loadSiteStateForRequest(dir, target.identity);
1447
+ const assetsBySha = new Map(tree.assets.map((a) => [a.sha256, a]));
1448
+ // media/assets.json carries each asset's real filename + mime; the filesystem entry alone (media/files/<sha256>)
1449
+ // does not, and uploading with the right content-type is what lets the server categorize/reject it correctly.
1450
+ const assetMetaBySha = new Map();
570
1451
  try {
571
- creds = loadApiCredentials();
572
- } catch (error) {
573
- console.error(error?.message ?? error);
1452
+ for (const entry of JSON.parse(requestFiles["media/assets.json"] ?? "[]")) {
1453
+ if (entry && typeof entry.sha256 === "string") assetMetaBySha.set(entry.sha256, entry);
1454
+ }
1455
+ } catch {
1456
+ /* an unparsable media/assets.json is caught by `site validate`; upload falls back to generic metadata */
1457
+ }
1458
+ const body = () => siteStateBody({ manifest, requestFiles, opts });
1459
+
1460
+ const deployThemeSource = async (themeSource) => {
1461
+ const themeDir = join(tree.root, "theme");
1462
+ if (!existsSync(themeDir)) {
1463
+ console.error(`This state's theme source (theme/) is missing locally at ${themeDir}; nothing was deployed.`);
1464
+ process.exit(1);
1465
+ }
1466
+ console.error(` deploying theme source to ${themeSource.instance}…`);
1467
+ await pushTheme({ dir: themeDir, url: target.dev.url, token: target.dev.token, instance: themeSource.instance, idempotencyKey: themeSource.idempotency_key, prune: true, onRetry });
1468
+ };
1469
+
1470
+ let last = null;
1471
+ for (let pass = 1; pass <= MAX_APPLY_PASSES; pass++) {
1472
+ const plan = await planSiteState({ apiUrl, apiKey, body: body(), onRetry });
1473
+ last = plan;
1474
+
1475
+ if (plan.status === "awaiting_assets") {
1476
+ for (const sha of plan.assets_missing) {
1477
+ const asset = assetsBySha.get(sha);
1478
+ if (!asset) {
1479
+ console.error(`Asset ${sha} is missing on the site and not found locally at media/files/${sha}. Export the site state again, or add the file. Nothing more was sent.`);
1480
+ process.exit(1);
1481
+ }
1482
+ const meta = assetMetaBySha.get(sha);
1483
+ console.error(` uploading ${meta?.filename ?? asset.path}…`);
1484
+ await uploadMediaAsset({
1485
+ apiUrl,
1486
+ apiKey,
1487
+ filename: typeof meta?.filename === "string" ? meta.filename : asset.path.split("/").pop(),
1488
+ content: readFileSync(asset.abs),
1489
+ mime: typeof meta?.mime === "string" ? meta.mime : undefined,
1490
+ onRetry,
1491
+ });
1492
+ }
1493
+ continue;
1494
+ }
1495
+ if (plan.status === "awaiting_theme_source") {
1496
+ await deployThemeSource(plan.theme_source);
1497
+ continue;
1498
+ }
1499
+ if (plan.steps.length === 0) break; // draft_complete (or published — an apply target is never live)
1500
+
1501
+ const applied = await applySiteState({ apiUrl, apiKey, body: { ...body(), expected_plan_hash: plan.plan_hash }, onRetry });
1502
+ last = applied;
1503
+ if (applied.status === "awaiting_theme_source") {
1504
+ await deployThemeSource(applied.theme_source);
1505
+ continue;
1506
+ }
1507
+ if (applied.status === "draft_complete") break;
1508
+ // else still "planned" (e.g. the server's own bounded per-call convergence left steps): loop, re-plan.
1509
+ }
1510
+
1511
+ const done = last?.status === "draft_complete" || last?.status === "published";
1512
+ if (JSON_MODE) console.log(JSON.stringify(last));
1513
+ if (done) {
1514
+ console.log(`✓ draft_complete — target instance ${last.target_instance}.`);
1515
+ return;
1516
+ }
1517
+ console.log(
1518
+ `Not finished after ${MAX_APPLY_PASSES} pass(es) — target instance ${last?.target_instance ?? "?"}, status ${last?.status ?? "unknown"}. ` +
1519
+ "Run `blocofy site apply` again to continue; every step already applied is safe to resume from.",
1520
+ );
1521
+ process.exit(4);
1522
+ }
1523
+
1524
+ async function sitePublish(rest) {
1525
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.sitePublish);
1526
+ const dir = resolve(positionals[0] ?? process.cwd());
1527
+ if (!existsSync(dir)) {
1528
+ console.error(`Directory not found: ${dir}`);
1529
+ process.exit(1);
1530
+ }
1531
+ const opts = siteStateTargetFlags(flags);
1532
+ const target = await prepareTarget({ command: "site publish", commandClass: "remote-mutation", dir, flags, needs: "api", mode: `${siteStateOperationLabel(opts, "publish")} · LIVE` });
1533
+ const { apiUrl, apiKey } = target.api;
1534
+ const siteName = siteLabel(target.identity.site) || String(target.identity.site.id);
1535
+
1536
+ // Separate live gate (contract §A3): TTY asks y/N, a non-interactive shell must pass --yes — same
1537
+ // decision `settings push --live` uses, before ANY site-state request is sent.
1538
+ const decision = livePushDecision({ draft: false, yes: Boolean(flags.yes), confirm: false, isTTY: Boolean(process.stdin.isTTY) });
1539
+ if (decision.mustAbort) {
1540
+ console.error(`⚠ 'site publish' makes this state the LIVE site of ${siteName}.`);
1541
+ console.error(" Non-interactive shell: pass --yes to confirm. Nothing was sent.");
1542
+ process.exit(1);
1543
+ }
1544
+ if (decision.needsPrompt) {
1545
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1546
+ let answer;
1547
+ try {
1548
+ answer = await rl.question(`⚠ Publish this state as the LIVE site of ${siteName}? [y/N] `);
1549
+ } finally {
1550
+ rl.close();
1551
+ }
1552
+ if (!isAffirmative(answer)) {
1553
+ console.error("Aborted. Nothing was sent.");
1554
+ process.exit(1);
1555
+ }
1556
+ }
1557
+
1558
+ const { requestFiles, manifest } = loadSiteStateForRequest(dir, target.identity);
1559
+ const result = await publishSiteState({ apiUrl, apiKey, body: siteStateBody({ manifest, requestFiles, opts }), onRetry });
1560
+ if (JSON_MODE) console.log(JSON.stringify(result));
1561
+ console.log(
1562
+ `✓ Published — target instance ${result.target_instance}${result.swapped ? " (now live)" : " (already live)"}; ` +
1563
+ `${result.navigation.length} menu(s) written, globals ${result.globals ? "updated" : "unchanged"}.`,
1564
+ );
1565
+ }
1566
+
1567
+ async function contentPush(scope, rest) {
1568
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.settingsPush);
1569
+ const dir = resolve(positionals[0] ?? process.cwd());
1570
+ // T10.1 "never implicitly live": the target is named — a theme handle or an explicit --live. Refused before any request.
1571
+ if (flags.instance === true || flags.instance === "") {
1572
+ console.error("--instance needs a theme handle (from the admin panel theme card, or `blocofy status`). Nothing was sent.");
574
1573
  process.exit(1);
575
1574
  }
576
- if (!creds) {
577
- console.error("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.");
1575
+ const instance = typeof flags.instance === "string" ? flags.instance : null;
1576
+ if (instance && flags.live) {
1577
+ console.error("Pass either --instance <handle> or --live, not both. Nothing was sent.");
578
1578
  process.exit(1);
579
1579
  }
580
- if (!isValidApiKey(creds.apiKey)) {
581
- console.error(`The stored API key (${creds.source}) is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`);
1580
+ if (!instance && !flags.live) {
1581
+ console.error("settings push needs a target. Nothing was sent.");
1582
+ console.error(" --instance <handle> write a specific theme (a draft stays out of the live site until published)");
1583
+ console.error(" --live write the LIVE theme (asks to confirm; non-interactive shells add --yes)");
1584
+ process.exit(1);
1585
+ }
1586
+ if (!existsSync(dir)) {
1587
+ console.error(`Directory not found: ${dir}`);
582
1588
  process.exit(1);
583
1589
  }
584
- return creds;
1590
+ const target = await prepareTarget({ command: `${scope} push`, commandClass: "remote-mutation", dir, flags, needs: "dev", mode: instance ? `instance ${instance}` : "live" });
1591
+ const creds = target.dev;
1592
+ if (!instance) {
1593
+ const siteName = siteLabel(target.identity.site) || String(target.identity.site.id);
1594
+ const decision = livePushDecision({ draft: false, yes: Boolean(flags.yes), confirm: Boolean(flags.confirm), isTTY: Boolean(process.stdin.isTTY) });
1595
+ if (decision.mustAbort) {
1596
+ console.error(`⚠ 'settings push --live' writes the theme settings of the LIVE theme of ${siteName}.`);
1597
+ console.error(" Non-interactive shell: pass --live --yes to confirm, or target a draft with --instance <handle>. Nothing was sent.");
1598
+ process.exit(1);
1599
+ }
1600
+ if (decision.needsPrompt) {
1601
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1602
+ let answer;
1603
+ try {
1604
+ answer = await rl.question(`⚠ Push settings to the LIVE theme of ${siteName}? [y/N] `);
1605
+ } finally {
1606
+ rl.close();
1607
+ }
1608
+ if (!isAffirmative(answer)) {
1609
+ console.error("Aborted. Nothing was sent.");
1610
+ process.exit(1);
1611
+ }
1612
+ }
1613
+ }
1614
+ const result = await pushContent({ dir, url: creds.url, token: creds.token, scope, instance, onRetry });
1615
+ console.log(
1616
+ `Settings push: ${result.settingsUpdated ? "theme settings updated" : "theme settings unchanged"}, ` +
1617
+ `${result.schemesUpserted} color scheme(s) upserted (${result.fileCount} file).`,
1618
+ );
1619
+ // T10.1 — where the write is visible (an older server sends none of these fields).
1620
+ const h = result.instance ?? instance;
1621
+ if (result.live_requires === "theme_publish") {
1622
+ console.log(`Applied to draft theme ${h} — visible in its preview; not live until \`blocofy theme publish --instance ${h}\`.`);
1623
+ } else if (result.live_requires === "theme_deploy") {
1624
+ console.log(`Saved; the live site shows it after the next theme deploy/publish (live_applied: false). Visible in the preview now.`);
1625
+ } else if (result.live_applied === true) {
1626
+ console.log(`Applied to the live theme${h ? ` ${h}` : ""} — live now.`);
1627
+ }
585
1628
  }
586
1629
 
587
- /** v1 refusal (4xx) → {error} JSON on stderr, exit 2; anything else → message, exit 1. */
588
- function exitForApiError(error) {
589
- if (error instanceof CliRefusal) {
590
- console.error(JSON.stringify({ error: error.error }));
591
- process.exit(2);
1630
+ async function contentPull(scope, rest) {
1631
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.content);
1632
+ const dir = resolve(positionals[0] ?? process.cwd());
1633
+ const target = await prepareTarget({ command: `${scope} pull`, commandClass: "local-write", dir, flags, needs: "dev", mode: "live" });
1634
+ const creds = target.dev;
1635
+ const { count } = await withNewBindingClaim(target, dir, () => pullContent({ dir, url: creds.url, token: creds.token, scope, onRetry }));
1636
+ console.log(`Downloaded ${count} settings file(s) → ${dir}`);
1637
+ bindAfterPull(target, dir);
1638
+ }
1639
+
1640
+ /**
1641
+ * Optional online mode for offline-capable reads (`pages check`, `pages migrate-layout`): no credentials at all, or
1642
+ * no context choosable outside a project → offline. Inside a bound project every other refusal still applies.
1643
+ */
1644
+ async function optionalTarget({ command, dir, flags, localWrite = false }) {
1645
+ 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" });
1649
+ } catch (error) {
1650
+ if (error instanceof TargetError && (error.code === "LOGIN_REQUIRED" || (error.code === "TARGET_CONTEXT_REQUIRED" && !findBinding(dir)))) return null;
1651
+ throw error;
592
1652
  }
593
- console.error(error?.message ?? error);
594
- process.exit(1);
595
1653
  }
596
1654
 
597
1655
  function printMediaUsesView(view) {
@@ -617,8 +1675,8 @@ async function pagesMediaUses(rest) {
617
1675
  console.error("Usage: blocofy pages media-uses <page-handle> [--json]");
618
1676
  process.exit(1);
619
1677
  }
620
- const { apiUrl, apiKey } = requireApiCreds();
621
- const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
1678
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-uses", commandClass: "read", dir: process.cwd(), flags, needs: "api", mode: `read · page ${page}` })).api;
1679
+ const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
622
1680
  if (flags.json) console.log(JSON.stringify(view, null, 2));
623
1681
  else printMediaUsesView(view);
624
1682
  }
@@ -660,9 +1718,9 @@ async function pagesMediaDecide(rest) {
660
1718
  process.exit(1);
661
1719
  }
662
1720
 
663
- const { apiUrl, apiKey } = requireApiCreds();
1721
+ const { apiUrl, apiKey } = (await prepareTarget({ command: "pages media-decide", commandClass: "remote-mutation", dir: process.cwd(), flags, needs: "api", mode: `draft · page ${page}` })).api;
664
1722
  if (expectedRevisionId === null) {
665
- const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
1723
+ const view = await fetchPageMediaUses({ apiUrl, apiKey, page, onRetry });
666
1724
  if (view.applicable === false) {
667
1725
  console.error(`Page ${page}: media decisions not applicable (${view.reason}). Nothing was written.`);
668
1726
  process.exit(1);
@@ -674,7 +1732,7 @@ async function pagesMediaDecide(rest) {
674
1732
  item && typeof item === "object" && typeof item.idempotency_key !== "string" ? { ...item, idempotency_key: randomUUID() } : item,
675
1733
  );
676
1734
 
677
- const out = await decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions });
1735
+ const out = await decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions, onRetry });
678
1736
  if (flags.json) {
679
1737
  console.log(JSON.stringify(out, null, 2));
680
1738
  return;
@@ -697,7 +1755,10 @@ async function themeDev(rest) {
697
1755
  process.exit(1);
698
1756
  }
699
1757
 
700
- const creds = requireCreds();
1758
+ // CF-T2: draft sync mutates the site (remote-mutation, binding required); `--no-sync` only renders (read).
1759
+ const noSync = Boolean(flags["no-sync"]);
1760
+ const target = await prepareTarget({ command: "theme dev", commandClass: noSync ? "read" : "remote-mutation", dir: themeDir, flags, needs: "dev", mode: noSync ? "read · local preview" : "draft" });
1761
+ const creds = target.dev;
701
1762
  const port = Number(flags.port) || 3030;
702
1763
 
703
1764
  // Dev session: live-domain preview + theme editor URLs (+ a draft to sync into).
@@ -706,7 +1767,7 @@ async function themeDev(rest) {
706
1767
  if (!flags["no-sync"]) {
707
1768
  try {
708
1769
  const name = typeof flags.name === "string" ? flags.name : null;
709
- session = await fetchDevSession({ url: creds.url, token: creds.token, name });
1770
+ session = await fetchDevSession({ url: creds.url, token: creds.token, name, onRetry });
710
1771
  } catch (error) {
711
1772
  console.warn(
712
1773
  `Warning: dev session unavailable (${error?.message || error}). ` +
@@ -721,7 +1782,7 @@ async function themeDev(rest) {
721
1782
 
722
1783
  // Çözülen site'ı göster (session TOKEN'dan çözer) → hangi tenant'ı düzenlediğin belli olsun.
723
1784
  const siteLine = session?.site ? `${siteLabel(session.site)} · ` : "";
724
- console.log(`\nblocofy theme dev — ${siteLine}${creds.url} (token ${creds.token.slice(0, 8)}…)\n`);
1785
+ console.log(`\nblocofy theme dev — ${siteLine}${creds.url} (context ${target.name})\n`);
725
1786
  console.log(` (l) Local ${hyperlink(localUrl)}`);
726
1787
  if (previewUrl) console.log(` (p) Preview ${hyperlink(previewUrl)}`);
727
1788
  if (editorUrl) console.log(` (e) Editor ${hyperlink(editorUrl)}`);
@@ -835,7 +1896,8 @@ async function themeDev(rest) {
835
1896
 
836
1897
  async function themePublish(rest) {
837
1898
  const { flags } = parseArgsOrExit(rest, KNOWN.themePublish);
838
- const creds = requireCreds();
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"}` });
1900
+ const creds = target.dev;
839
1901
  let instance = typeof flags.instance === "string" ? flags.instance : null;
840
1902
  if (!instance) {
841
1903
  // Belirtilmediyse: `theme dev` / `theme push --draft`'ın yazdığı taslağı yayınla. Kaynak
@@ -845,7 +1907,7 @@ async function themePublish(rest) {
845
1907
  // `drafts` canlı OLMAYAN HER instance'ı içerir; sunucunun `ensureDraftInstance`'ı ise
846
1908
  // `source === "import"` olanı seçer (yayınlanan taslak import'tan çıkarılır). Aynı seçimi
847
1909
  // burada tekrarla — yoksa bir kez yayın yapmış her sitede iki taslak görünür ve komut takılır.
848
- const status = await fetchSiteStatus({ url: creds.url, token: creds.token });
1910
+ const status = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
849
1911
  const drafts = status?.drafts ?? [];
850
1912
  const cliDrafts = drafts.filter((d) => d.source === "import");
851
1913
  if (cliDrafts.length === 1) {
@@ -859,7 +1921,7 @@ async function themePublish(rest) {
859
1921
  process.exit(1);
860
1922
  }
861
1923
  }
862
- const result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance });
1924
+ const result = await publishInstance({ url: creds.url, token: creds.token, instanceId: instance, onRetry });
863
1925
  console.log(
864
1926
  `✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
865
1927
  );
@@ -874,14 +1936,15 @@ async function themeRename(rest) {
874
1936
  console.error(" Rename a theme (handle from the panel theme card or `blocofy status`).");
875
1937
  process.exit(1);
876
1938
  }
877
- const creds = requireCreds();
878
- const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name });
1939
+ const creds = (await prepareTarget({ command: "theme rename", commandClass: "remote-mutation", dir: process.cwd(), flags, needs: "dev", mode: `instance ${handle}` })).dev;
1940
+ const result = await renameInstance({ url: creds.url, token: creds.token, instance: handle, name, onRetry });
879
1941
  console.log(`✓ Renamed to "${result.name}" (${result.id}).`);
880
1942
  }
881
1943
 
882
- async function status() {
883
- const creds = requireCreds();
884
- const s = await fetchSiteStatus({ url: creds.url, token: creds.token });
1944
+ 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;
1947
+ const s = await fetchSiteStatus({ url: creds.url, token: creds.token, onRetry });
885
1948
  const live = s.live_theme_instance;
886
1949
  console.log(`\nSite: ${s.site.slug}${s.url ? ` · ${s.url}` : ""}`);
887
1950
  console.log(
@@ -890,24 +1953,8 @@ async function status() {
890
1953
  : `Live theme: none`,
891
1954
  );
892
1955
  console.log(`Health: ${s.health}`);
893
- // orphan_missing_slugs yeni sunucu alanı (PR #661). Eski/deploy-edilmemiş sunucuda
894
- // yok → null; o durumda eski (yumuşatılmış) sayaç satırına düş.
895
- const missing = Array.isArray(s.orphan_missing_slugs) ? s.orphan_missing_slugs : null;
896
- if (s.health === "live_instance_empty") {
897
- console.error(
898
- ` ⚠ The live theme has no pages — the site will 404. Publish a theme with content: blocofy theme publish`,
899
- );
900
- } else if (s.health === "pages_split") {
901
- if (missing && missing.length) {
902
- console.warn(
903
- ` ⚠ These pages are published only on a non-live theme, so visitors can't reach them (404 risk): ` +
904
- `${missing.join(", ")}\n` +
905
- ` Fix: publish the theme that has them — blocofy theme publish`,
906
- );
907
- } else {
908
- console.warn(` ⚠ ${s.orphaned_pages} page(s) published on a non-live theme.`);
909
- }
910
- }
1956
+ // CF-T9: no imperative one-line fix — which theme holds the pages, why, and safe (preview-first) next steps.
1957
+ for (const line of healthAdvice(s)) console.error(line);
911
1958
  if (Array.isArray(s.drafts) && s.drafts.length > 0) {
912
1959
  console.log(`Drafts: ${s.drafts.map((d) => `${d.id}${d.name ? ` ${d.name}` : ""}`).join(", ")}`);
913
1960
  }
@@ -916,6 +1963,40 @@ async function status() {
916
1963
 
917
1964
  const [first, ...rest] = args;
918
1965
 
1966
+ function commandKey(a, b) {
1967
+ return `${a} ${b ?? ""}`;
1968
+ }
1969
+
1970
+ // command → handler. Every failure exits through failAndExit (exit codes 1/2/3, --json envelope last).
1971
+ const COMMANDS = {
1972
+ login: login,
1973
+ contexts: contextsCommand,
1974
+ use: useCommand,
1975
+ logout: logoutCommand,
1976
+ link: linkCommand,
1977
+ target: targetCommand,
1978
+ status: status,
1979
+ "theme dev": themeDev,
1980
+ "theme pull": themePull,
1981
+ "theme push": themePush,
1982
+ "theme publish": themePublish,
1983
+ "theme rename": themeRename,
1984
+ "pages media-uses": pagesMediaUses,
1985
+ "pages media-decide": pagesMediaDecide,
1986
+ "pages pull": pagesPull,
1987
+ "pages push": pagesPush,
1988
+ "pages check": pagesCheck,
1989
+ "pages migrate-layout": pagesMigrate,
1990
+ "settings pull": (r) => contentPull("settings", r),
1991
+ "settings push": (r) => contentPush("settings", r),
1992
+ "site export": siteExport,
1993
+ "site validate": siteValidate,
1994
+ "site migrate": siteMigrate,
1995
+ "site plan": sitePlan,
1996
+ "site apply": siteApply,
1997
+ "site publish": sitePublish,
1998
+ };
1999
+
919
2000
  if (first === "--version" || first === "-v") {
920
2001
  console.log(VERSION);
921
2002
  } else if (!first || first === "--help" || first === "-h" || first === "help") {
@@ -927,65 +2008,10 @@ if (first === "--version" || first === "-v") {
927
2008
  } else if (args.includes("--version") || args.includes("-v")) {
928
2009
  // Aynı simetri: `theme push <dir> --version` da yalnız sürüm basar, asla yazmaz.
929
2010
  console.log(VERSION);
930
- } else if (first === "login") {
931
- login(rest).catch((error) => {
932
- console.error(error?.message ?? error);
933
- process.exit(1);
934
- });
935
- } else if (first === "theme" && rest[0] === "dev") {
936
- themeDev(rest.slice(1)).catch((error) => {
937
- console.error(error?.message ?? error);
938
- process.exit(1);
939
- });
940
- } else if (first === "theme" && rest[0] === "pull") {
941
- themePull(rest.slice(1)).catch((error) => {
942
- console.error(error?.message ?? error);
943
- process.exit(1);
944
- });
945
- } else if (first === "theme" && rest[0] === "push") {
946
- themePush(rest.slice(1)).catch((error) => {
947
- console.error(error?.message ?? error);
948
- process.exit(1);
949
- });
950
- } else if (first === "theme" && rest[0] === "publish") {
951
- themePublish(rest.slice(1)).catch((error) => {
952
- console.error(error?.message ?? error);
953
- process.exit(1);
954
- });
955
- } else if (first === "theme" && rest[0] === "rename") {
956
- themeRename(rest.slice(1)).catch((error) => {
957
- console.error(error?.message ?? error);
958
- process.exit(1);
959
- });
960
- } else if (first === "status") {
961
- status().catch((error) => {
962
- console.error(error?.message ?? error);
963
- process.exit(1);
964
- });
965
- } else if (first === "pages" && rest[0] === "media-uses") {
966
- pagesMediaUses(rest.slice(1)).catch(exitForApiError);
967
- } else if (first === "pages" && rest[0] === "media-decide") {
968
- pagesMediaDecide(rest.slice(1)).catch(exitForApiError);
969
- } else if (first === "pages" && rest[0] === "pull") {
970
- contentPull("pages", rest.slice(1)).catch((error) => {
971
- console.error(error?.message ?? error);
972
- process.exit(1);
973
- });
974
- } else if (first === "pages" && rest[0] === "push") {
975
- contentPush("pages", rest.slice(1)).catch((error) => {
976
- console.error(error?.message ?? error);
977
- process.exit(1);
978
- });
979
- } else if (first === "settings" && rest[0] === "pull") {
980
- contentPull("settings", rest.slice(1)).catch((error) => {
981
- console.error(error?.message ?? error);
982
- process.exit(1);
983
- });
984
- } else if (first === "settings" && rest[0] === "push") {
985
- contentPush("settings", rest.slice(1)).catch((error) => {
986
- console.error(error?.message ?? error);
987
- process.exit(1);
988
- });
2011
+ } else if (COMMANDS[commandKey(first, rest[0])] || COMMANDS[first]) {
2012
+ const keyed = COMMANDS[commandKey(first, rest[0])];
2013
+ const handler = keyed ?? COMMANDS[first];
2014
+ handler(keyed ? rest.slice(1) : rest).catch(failAndExit);
989
2015
  } else {
990
2016
  console.error(`Unknown command: ${args.join(" ")}\n`);
991
2017
  printHelp();