@blocofy/cli 0.9.0 → 0.11.0

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