@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/README.md CHANGED
@@ -10,63 +10,487 @@ cd path/to/theme
10
10
  npx @blocofy/cli theme dev # http://localhost:3030 — local theme + live data, livereload
11
11
  ```
12
12
 
13
+ `login` saves the site under a named **context** (default: the site's slug) in
14
+ `~/.blocofy/credentials.json`. `blocofy link <dir> --context <name>` (or a pull into an empty
15
+ directory) then binds a project directory to that site so every command run inside it always
16
+ targets the right one — see [Contexts and project binding](#contexts-and-project-binding).
17
+
13
18
  ## Commands
14
19
 
15
- ```bash
16
- blocofy login [--url <url>] [--token <bcf_…>]
17
- # Save your platform URL + dev token (~/.blocofy/credentials.json, 0600).
18
- blocofy theme dev [dir] # Local dev server. Prints 3 views — local preview, a live-domain
19
- # preview link, and the theme editor — all auto-reloading on save.
20
- # --port <n> (default 3030), --no-sync (local preview only),
21
- # --name <name> (name the draft when first created)
22
- blocofy theme pull [dir] # Download the live theme to disk.
23
- blocofy theme push [dir] # Write the local theme to a DRAFT by default (create/update; no
24
- # delete) — preview & publish from the admin panel. --live writes
25
- # to the live site immediately (asks to confirm; add --yes for CI).
26
- # --name <name> names the new draft (draft mode only).
27
- # --prune also removes target files deleted locally (lists them
28
- # first; on the live theme asks to confirm — --yes for CI).
29
- blocofy theme rename <handle> <new name>
30
- # Rename a theme (label only). Works on any theme, live included.
31
- # Handle from the panel theme card or `blocofy status`.
32
- blocofy login --api-key [--api-url <url>]
33
- # Save a v1 API key (blcf_live_…) for the `pages media-*` commands.
34
- # The flag takes NO value: the key is typed into a hidden prompt, so
35
- # it never lands in argv or shell history. Non-interactive shells use
36
- # BLOCOFY_API_KEY + BLOCOFY_API_URL instead (no prompt, nothing
37
- # written without them). --api-url defaults to https://app.blocofy.com.
38
- blocofy pages media-uses <page-handle> [--json]
39
- # List a page's localized-media decisions on its newest draft
40
- # (v1 API, pages:read) with the draft revision id/version.
41
- blocofy pages media-decide <page-handle> --decisions <file.json>
42
- [--expected-revision-id <n> --expected-version <n>] [--json]
43
- # Apply the file's { "decisions": [...] } (max 20) to the draft
44
- # atomically (v1 API, pages:write). Without the --expected-* pair
45
- # the CLI GETs the draft first and uses its current revision;
46
- # items without idempotency_key get a random UUID. Exit 0 applied
47
- # (or "No changes" when already recorded), 1 usage/auth/network/5xx
48
- # (no retry), 2 server refusal (4xx) — {error} JSON on stderr.
20
+ ### Login & contexts
21
+
22
+ ```
23
+ blocofy login [--context <name>] [--url <url>] [--token <bcf_…>] [--keychain]
24
+ ```
25
+ Verify a dev token against its site (`GET /api/dev/whoami`) and save it as a named context
26
+ (default name: the site's slug). Nothing is saved if verification fails. Get a token from the
27
+ admin panel → Settings → Theme CLI tokens.
28
+ - `--keychain` — keep the secret in the macOS keychain (or `BLOCOFY_SECRET_STORE=keychain`);
29
+ default is `~/.blocofy/secrets.json` (0600).
30
+
31
+ ```
32
+ blocofy login --api-key [--context <name>] [--api-url <url>]
33
+ ```
34
+ Verify a v1 API key (`blcf_live_…`, `GET /api/v1/ping`) and add it to a context. The key is
35
+ read from a **hidden prompt** — the flag takes no value, so it never lands in argv or shell
36
+ history. If the context already has a dev token for another site, nothing is saved
37
+ (`TARGET_CREDENTIAL_MISMATCH`).
38
+ - `--api-url <url>` — API origin (default `https://app.blocofy.com`).
39
+ - Non-interactive shells: set `BLOCOFY_API_KEY` + `BLOCOFY_API_URL` instead.
40
+
41
+ ```
42
+ blocofy contexts [--json] # list saved contexts (never prints secrets)
43
+ blocofy use <name> # default context for status / target / pages check outside a project
44
+ blocofy logout --context <name> # remove a context and its secrets
45
+ ```
46
+
47
+ ### Project binding
48
+
49
+ ```
50
+ blocofy link [dir] --context <name> [--adopt]
51
+ ```
52
+ Bind a project directory to the context's (verified) site: writes `.blocofy/project.json`
53
+ (commit it), `.blocofy/local.json` (your context; git-ignored) and `.blocofy/.gitignore`.
54
+ Refuses to rebind a directory bound to another site unless `--adopt`.
55
+
56
+ ```
57
+ blocofy target [dir] [--context <name>] [--json]
58
+ ```
59
+ Show which site a command in `[dir]` would hit (verified), without writing anything.
60
+
61
+ ### Theme
62
+
63
+ ```
64
+ blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
65
+ ```
66
+ Start a dev server and print 3 auto-reloading views — Local, live-domain Preview, and the theme
67
+ Editor. Press `l` / `p` / `e` to open each, `q` to quit. Edit a file and save → every open view
68
+ reloads. Saves sync to a **draft** theme only (never the live site). `dir` defaults to cwd.
69
+ The target site is verified once at start; a long session keeps that target (restart it after
70
+ changing credentials, context or the project binding).
71
+ - `--port <n>` — local port (default 3030).
72
+ - `--no-sync` — local preview only (skip draft sync + remote views).
73
+ - `--name <name>` — name the draft when it is first created (ignored if it already exists).
74
+
75
+ ```
76
+ blocofy theme pull [dir] [--draft] [--instance <handle>]
77
+ ```
78
+ Download the live theme to disk. `dir` defaults to cwd.
79
+ - `--draft` — pull the draft theme (what `theme dev` syncs into) instead of live; creates the
80
+ draft if missing, so it needs a bound project.
81
+ - `--instance <handle>` — pull a specific theme by its handle (admin panel theme card, or
82
+ `blocofy status`).
83
+
84
+ ```
85
+ blocofy theme push [dir] [--live] [--yes] [--instance <handle>] [--prune]
86
+ [--name <name>] [--dry-run | --validate] [--diff]
87
+ [--idempotency-key <k>]
88
+ ```
89
+ By **default** writes to a draft theme (create/update; no delete) — preview & publish it from
90
+ the admin panel, never touching the live site. Publish it with `blocofy theme publish`.
91
+ - `--live` — write to the LIVE site immediately (no preview). Asks for confirmation first;
92
+ non-interactive shells must add `--yes`.
93
+ - `--draft` — explicit draft (same as the default; safe).
94
+ - `--yes` — confirm a `--live` push without prompting (CI/agents).
95
+ - `--instance <handle>` — push to a specific theme by its handle (safe targeted write — no
96
+ live-confirmation prompt).
97
+ - `--name <name>` — name the new draft (draft mode only; ignored on `--live`/`--instance`).
98
+ - `--dry-run` / `--validate` — check the push on the server without writing; the two flags are
99
+ aliases. The dry run covers exactly the files the push would send (remote-only files it keeps
100
+ included; with `--prune`, without the ones it would remove). A current platform plans the whole
101
+ deploy and prints the target, its pointer version and what happens to each file
102
+ (`+` created, `~` updated, `-` removed; unchanged files are counted).
103
+ - `--diff` — show what a push would change vs the target (read-only), then stop.
104
+ - `--idempotency-key <k>` — attach a key so a retried push is not double-applied.
105
+ - `--prune` — also remove target files that no longer exist locally (`locales/` included);
106
+ lists them first, and on the live theme asks to confirm (non-interactive shells add `--yes`).
107
+
108
+ Every push first runs that dry run, then writes the same files. On a current platform the write is
109
+ bound to the dry run: it is refused, with nothing written, if the files differ from what was checked
110
+ (`manifest_mismatch`), the target theme was deployed again in between (`pointer_version_conflict`),
111
+ or the push would now write to another theme than the one its dry run planned against — the live
112
+ theme was switched, or the draft to reuse changed (`target_changed`: nothing was deployed, though a
113
+ draft push may have created a new, empty draft that the next push reuses; run the push again).
114
+ A write that gets no answer (network error, 429/502/503/504) is resent (up to 3 times) under the same
115
+ key; a push the platform already committed is then reported as deployed (`already applied by an
116
+ earlier push with the same idempotency key`). If that resend is refused, the refusal cannot say
117
+ what the earlier attempt did: the message says whether it was committed or its outcome is
118
+ unknown (`details.earlierAttempt`), never "Nothing was written". Check the theme the dry run bound
119
+ the write to (`details.expectedTargetInstance`, not the current target a refusal names) with
120
+ `blocofy theme push --diff --instance <handle>` — or, for a new draft, find it with `blocofy status` —
121
+ before running the push again. A write whose last answer is still not definite (HTTP 500 or another
122
+ 5xx, a 503, a 502 `readback_unverified` with `outcomeUnknown`, or no answer) has an unknown outcome
123
+ (`details.outcome: "unknown"`): the message names the push's key (`details.idempotencyKey`); run the
124
+ same command again with `--idempotency-key <that key>`, and a push that was deployed is reported as
125
+ deployed instead of being written again.
126
+ If the dry run would remove a file the push did not carry, the push stops
127
+ (`THEME_PUSH_TARGET_CHANGED`). The message says which case each file is:
128
+ - a path the push cannot send (not a file inside a theme folder, e.g. a bare `layout` row; listed in
129
+ `details.notCarryable`) stops every run the same way, and only `--prune` gets past it (it removes
130
+ the file);
131
+ - any other file was either added while the push was running, and running the push again keeps it,
132
+ or the push cannot read it (for example a theme file that is not published): then it stops every
133
+ run the same way, and you add `--prune` to remove it or add a local file at that path to replace it.
134
+
135
+ `Deployed atomically` is printed only when the server read the written files back and they match;
136
+ an older platform that does not read back gets `Deployed: … not verified`.
137
+
138
+ ```
139
+ blocofy theme rename <handle> <new name> [--dir <dir>]
140
+ ```
141
+ Rename a theme (the name is just a label). Works on any of your themes, including the live one.
142
+ - `--dir <dir>` — the bound project whose site the theme is on (default: cwd).
143
+
144
+ ```
145
+ blocofy theme publish [dir] [--instance <handle>]
146
+ ```
147
+ Publish a draft theme to the LIVE site: it replaces the live theme for every visitor. With no
148
+ flag, publishes the draft that `theme dev` / `theme push --draft` writes into. The server
149
+ refuses to publish a theme that has no pages (it would 404); preview first. The site is the one
150
+ `[dir]`'s project is bound to (`dir` defaults to cwd), so `blocofy theme push ./shop && blocofy
151
+ theme publish ./shop` always publishes `./shop`'s site, whatever directory you run it from.
152
+ - `--instance <handle>` — publish a specific theme.
153
+
154
+ ### Status
155
+
156
+ ```
157
+ blocofy status [dir]
158
+ ```
159
+ Show the live theme, page distribution per instance, drafts, and a health flag (`ok` /
160
+ `live_instance_empty` / `pages_split`). For a problem it names the theme holding the pages, the
161
+ missing pages, why, and safe preview-first next steps — never a one-line fix.
162
+
163
+ ### Pages
164
+
165
+ ```
49
166
  blocofy pages pull [dir] [--strict]
50
- # Download published pages, one folder per language:
51
- # pages/<locale>/index.json and pages/<locale>/routes/<path>/index.json.
52
- # Old-layout files are reported, never deleted or overwritten. If the site
53
- # cannot export every page, nothing is written (PAGES_EXPORT_INCOMPLETE).
54
- blocofy pages push [dir] [--dry-run] [--strict]
55
- # Update EXISTING pages only. Every file is checked first; any invalid file,
56
- # duplicate target or folder/locale mismatch changes NO page. An error after
57
- # that check may leave some pages applied: the per-file result is printed,
58
- # exit 1, and re-running is safe. --dry-run checks on the server, writes nothing.
167
+ ```
168
+ Download published pages, one folder per language: `pages/<locale>/index.json` (home) and
169
+ `pages/<locale>/routes/<path>/index.json` (everything else). Files from the old flat layout
170
+ (`pages/<slug>.json`) are reported, never deleted or overwritten. If the site cannot export
171
+ every published page, nothing is written, every reason is printed
172
+ (`PAGES_EXPORT_INCOMPLETE`) and the exit code is 2 (`--strict`: warnings exit 1).
173
+
174
+ ```
175
+ blocofy pages push [dir] [--dry-run] [--strict] [--force --reason <text>]
176
+ ```
177
+ Write `pages/**.json` to the site. Updates **existing** pages only — never creates or deletes a
178
+ page; unchanged pages are skipped. Every file is checked first: an invalid file, two files
179
+ pointing at the same page, or a folder/locale mismatch changes **no** page. Every pulled file
180
+ carries `base_revision` (the page as you pulled it); the push first asks for a plan (per page:
181
+ action, live/draft, changed fields), prints it, then pushes exactly that plan.
182
+ - If a page changed on the site since you pulled it (`PAGES_REVISION_CONFLICT`) or a file has no
183
+ `base_revision` (`PAGES_BASE_REVISION_REQUIRED`), nothing is changed: run `blocofy pages pull`,
184
+ merge your edits, and push again.
185
+ - If the site changes between the plan and the push, nothing is changed either
186
+ (`PAGES_PLAN_STALE`) — run the push again.
187
+ - `--force --reason <text>` — overwrite anyway (reason: 1–500 characters); the forced pages are
188
+ listed. Against an older server that cannot check revisions, the push runs as before with a
189
+ warning.
190
+ - `--dry-run` — print the plan, write nothing.
191
+ - If publishing stops unexpectedly after the plan (`PAGES_APPLY_FAILED` or another publish
192
+ error), some pages may already be applied: the per-file result printed is authoritative, the
193
+ exit code is non-zero, and re-running the push is safe.
194
+
195
+ ```
59
196
  blocofy pages check [dir] [--strict]
60
- # Offline file/path/layout checks; with login also the server dry run.
197
+ ```
198
+ Check page files. Offline: paths, JSON, layout, duplicates, missing `base_revision`
199
+ (`PAGES_BASE_REVISION_MISSING` warning). Logged in: also the site's languages and a server-side
200
+ dry run. Exit 1 on errors (`--strict`: warnings too).
201
+
202
+ ```
61
203
  blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
62
- # Move old-layout files (pages/<slug>.json) to language folders. Any
63
- # ambiguity or conflict moves nothing (exit 1).
204
+ ```
205
+ Move old flat-layout files (`pages/<slug>.json`) to language folders. `--dry-run` (default)
206
+ prints the plan; `--write` moves only proven files. Any ambiguity or conflict: nothing is moved,
207
+ exit 1. Files without `"locale"` use the site's default language (needs login); with `--write`
208
+ outside a bound project only explicit `--context`/env credentials are used.
209
+
210
+ ```
211
+ blocofy pages media-uses <page-handle> [--dir <dir>] [--json]
212
+ ```
213
+ List a page's localized-media decisions on its newest **draft** (v1 API, `pages:read`). Prints
214
+ the draft's revision id/version needed by `media-decide`. `--dir <dir>` (both media commands):
215
+ the bound project whose site the page is on (default: cwd).
216
+
217
+ ```
218
+ blocofy pages media-decide <page-handle> --decisions <file.json>
219
+ [--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]
220
+ ```
221
+ Apply one or more media decisions to the page's draft atomically (v1 API, `pages:write`). The
222
+ file is `{ "decisions": [ { path, facet, decision, target_asset?, alt?, caption?, decorative?,
223
+ idempotency_key?, witness? } ] }` (max 20). Without the two `--expected-*` flags the CLI first
224
+ `GET`s the draft and uses its current revision id/version; items without an `idempotency_key`
225
+ get a random UUID. Transient failures (429/502/503/504, network) are retried against the same
226
+ idempotency key, so a retry replays the same batch. Exit 0 on success (or "No changes" when
227
+ every item was already recorded).
228
+
229
+ ### Settings
230
+
231
+ ```
232
+ blocofy settings pull [dir]
233
+ blocofy settings push [dir] (--instance <handle> | --live [--yes])
234
+ ```
235
+ Download / upload `config/settings.json` (theme tokens/settings + color schemes). A push
236
+ **names its target** — there is no implicit live write, and the command refuses with neither
237
+ flag:
238
+ - `--instance <handle>` — write that theme's settings; a draft shows them in its preview and
239
+ goes live with `blocofy theme publish --instance <handle>`.
240
+ - `--live` — write the LIVE theme (asks to confirm; non-interactive shells add `--yes`).
241
+
242
+ After a push the CLI says where it applied (preview now / live now / after deploy).
243
+
244
+ ### Site (declarative state)
245
+
246
+ The `site` commands move a whole site — pages, navigation, theme source, settings, chrome,
247
+ translations, media — as one declarative tree on disk (`blocofy-site.json` + `site/**` +
248
+ `theme/**` + `pages/**` + `media/**`), instead of the per-scope `theme`/`pages`/`settings`
249
+ commands above.
250
+
251
+ ```
252
+ blocofy site export [dir]
253
+ ```
254
+ Download the whole site as a declarative tree into `[dir]`, plus every media file's bytes at
255
+ `media/files/<sha256>` (downloaded and hash-verified). Refuses to overwrite a directory bound to
256
+ another site (same binding rule as every other pull).
257
+
258
+ ```
259
+ blocofy site validate [dir]
260
+ ```
261
+ Check an exported (or hand-authored) tree **offline** — zero network requests: paths, the
262
+ path↔content binding (a page file's locale/slug must match its folder/name, and so on),
263
+ duplicate identities, the size/count limits, and the tree against its own manifest digest.
264
+ - `--strict` — warnings also exit non-zero.
265
+
266
+ ```
267
+ blocofy site migrate [dir] [--dry-run | --write]
268
+ ```
269
+ Turn a directory left by the **older separate** `theme pull` / `pages pull` / `settings pull`
270
+ into the site-state v1 tree layout (theme directories at the root → `theme/**`,
271
+ `config/settings.json` → `theme/config/settings.json`). Purely local: no network request, no
272
+ target/identity check, `.blocofy/` project binding untouched. `pages/**` files are never moved
273
+ (the location is already identical); a file still at the old flat page layout is left alone with
274
+ a note to run `blocofy pages migrate-layout` first.
275
+ - `--dry-run` (default) — print the plan: every move, every file left alone, every conflict.
276
+ - `--write` — perform it.
277
+ - Any ambiguity or conflict (a target already exists with different content, an unreadable or
278
+ symlinked entry, a path the shared site-state rules refuse): zero moves, exit 1.
279
+ - Never invents the parts a directory of separate pulls never had (`blocofy-site.json`,
280
+ `site/locales.json`, globals, navigation, translations, `theme/chrome/**`) — it says so and
281
+ points at `blocofy site export` for those.
282
+
283
+ ```
284
+ blocofy site plan [dir] [--target new|<handle>] [--mode same_site|restore]
285
+ [--accept-live-effects locales] [--json]
286
+ ```
287
+ Ask the site what applying this tree **would** do — no write. Needs **both** a dev token and a
288
+ v1 API key (the dev token is what a theme deploy uses later). Prints the status (`planned` /
289
+ `awaiting_assets` / `awaiting_theme_source` / `draft_complete`), the steps, and any missing
290
+ assets or a differing theme source.
291
+ - `--target new|<handle>` — a fresh draft theme version (default), or a specific one you own
292
+ (from a previous plan/apply's target instance).
293
+ - `--mode same_site|restore` — `same_site` refuses a page that changed on this site since the
294
+ tree was exported; `restore` (default) does not.
295
+ - `--accept-live-effects locales` — required before a state that adds a language may be applied
296
+ (publishing a language prepares its homepage LIVE).
297
+
298
+ ```
299
+ blocofy site apply [dir] [--target new|<handle>] [--mode same_site|restore]
300
+ [--accept-live-effects locales] [--json]
301
+ ```
302
+ Build the state into a **draft** theme version — the live site is never touched. Loops: plan →
303
+ (upload missing media via the v1 API, or deploy the theme source via the same canonical path
304
+ `theme push --instance` uses) → plan → apply, until the target reports `draft_complete` or 5
305
+ passes are used. Safe to re-run: every step is idempotent and a re-plan picks up exactly what is
306
+ left, so an apply interrupted by anything (network, Ctrl-C, a crash) resumes with the same
307
+ command. Preview it from the admin panel, then run `blocofy site publish`.
308
+
309
+ ```
310
+ blocofy site publish [dir] [--target new|<handle>] [--mode same_site|restore] [--yes]
311
+ ```
312
+ Make an applied state the LIVE site: pointer swap, then navigation, then settings. Refuses (exit
313
+ 2) if the state is not fully applied yet — run `site apply` first. Separate live gate: asks to
314
+ confirm (non-interactive shells must add `--yes`).
315
+
316
+ ```
64
317
  blocofy --version
65
318
  blocofy --help
66
319
  ```
67
320
 
321
+ ## Contexts and project binding
322
+
323
+ A **context** is one named operator profile for one site. It references two **separate**
324
+ credentials, each kept in the secret store and never printed: the theme dev token (`bcf_…`,
325
+ verified with `GET /api/dev/whoami`) and the v1 API key (`blcf_live_…`, verified with
326
+ `GET /api/v1/ping`). They stay two credentials with their own scopes — the CLI never merges them
327
+ into one token and never sends one to the other's endpoint. Both pairs of a context must resolve
328
+ to the same site (`TARGET_CREDENTIAL_MISMATCH`); a secret of the wrong type in either slot is
329
+ refused before any request (`TARGET_CREDENTIAL_WRONG_TYPE`, exit 1 — e.g. a `blcf_live_…` key in
330
+ `BLOCOFY_TOKEN`).
331
+
332
+ Every remote command verifies its site first and prints a `Target` block on stderr (`--json`:
333
+ the same as a `{"target":…}` line with `platform_origin`, `context_source`,
334
+ `context_overrides`, `command` and `mode`):
335
+
336
+ ```
337
+ Target: Alpha Bakery · s1a2b3 · alpha.myblocofy.com
338
+ Platform: https://app.blocofy.com
339
+ Context: alpha (from .blocofy/local.json)
340
+ Binding: .blocofy/project.json
341
+ Operation: theme push · draft
342
+ ```
343
+
344
+ The context is chosen in this order:
345
+
346
+ 1. `--context <name>`
347
+ 2. `BLOCOFY_CONTEXT`
348
+ 3. env credentials (`BLOCOFY_URL` + `BLOCOFY_TOKEN` and/or `BLOCOFY_API_URL` + `BLOCOFY_API_KEY`)
349
+ 4. `.blocofy/local.json` (the project's own context choice)
350
+ 5. the one saved context matching the project's site
351
+ 6. (terminal) pick from the matches
352
+ 7. outside a project, and only for `status`, `target` and `pages check`: the `blocofy use` default
353
+
354
+ **Conflicts fail closed.** `BLOCOFY_CONTEXT`, the env credentials and `.blocofy/local.json` are
355
+ each a choice. When two of them name different contexts, the command is refused before
356
+ anything is read or written (`TARGET_CONTEXT_CONFLICT`, exit 3) — e.g. `BLOCOFY_URL`/
357
+ `BLOCOFY_TOKEN` exported in a shell that then runs inside a project whose `local.json` names a
358
+ context. Settle it with `--context <name>` (or unset the others); the `Context` line then lists
359
+ what it overrode, e.g. `alpha (from --context; overrides env credentials)`.
360
+
361
+ Inside a **bound project**, `blocofy use` is ignored — the project's binding decides, not the
362
+ global default context. Outside one, the `use` default serves only `status`, `target` and
363
+ `pages check`, and the `Context` line says so (`from default context (blocofy use)`); every other
364
+ command in an unbound directory needs `--context`, `BLOCOFY_CONTEXT` or env credentials, so a
365
+ `use` in another terminal never changes what a diff, dry run or plan compares against. Commands
366
+ that change a site (`theme push`/`publish`/`rename`, `theme dev` sync, `pages push`, `settings
367
+ push`, `pages media-decide`) need a bound project; a pull into a new empty directory binds it
368
+ automatically. A wrong project/site pairing changes nothing (`TARGET_SITE_MISMATCH` — pass
369
+ `--adopt` on `link` to rebind deliberately).
370
+
371
+ Every command resolves its binding from the directory it acts on — the `[dir]` argument (or
372
+ `--dir <dir>` for `theme rename` and `pages media-uses|media-decide`), else cwd.
373
+
374
+ **CI:** commit `.blocofy/project.json` (never `local.json`) and set the env pairs the job needs
375
+ (`BLOCOFY_URL` + `BLOCOFY_TOKEN` for theme/pages/settings, plus `BLOCOFY_API_URL` +
376
+ `BLOCOFY_API_KEY` for the v1 commands and `site plan`/`apply`). The env credentials are then the
377
+ only choice, and each command still verifies they belong to the committed binding's site.
378
+
379
+ A binding made against an older server has no recorded `platform_origin`: it still matches the
380
+ same site (one warning printed; run `blocofy link --adopt` to record it). A server that reports
381
+ no origin cannot serve a binding that records one — that refuses with `TARGET_UNVERIFIED` (the
382
+ server cannot prove it is the platform the binding was made against).
383
+
384
+ A refusal in this area (`TARGET_SITE_MISMATCH`, `TARGET_UNVERIFIED`, `TARGET_CREDENTIAL_MISMATCH`,
385
+ `TARGET_CONTEXT_CONFLICT`, `TARGET_CONTEXT_REQUIRED`, `TARGET_CONTEXT_UNKNOWN`,
386
+ `TARGET_BINDING_INVALID`, and `TARGET_CREDENTIAL_WRONG_TYPE` with exit 1) always means:
387
+ **nothing was read or written** — the command stops before it touches the site or the local
388
+ project files, and exits 3 (see [Exit codes](#exit-codes)).
389
+
390
+ `blocofy site migrate` is the one exception: it never resolves a target or a context at all (no
391
+ network, no identity call), so it leaves any `.blocofy/` binding in the directory completely
392
+ untouched.
393
+
394
+ ## Exit codes
395
+
396
+ | Code | Meaning |
397
+ | --- | --- |
398
+ | 0 | Ok. |
399
+ | 1 | Usage / network / HTTP 5xx / a local check that refuses before any request (e.g. a `site migrate` conflict, a `pages migrate-layout` ambiguity). |
400
+ | 2 | The server refused the request (HTTP 4xx) — its `{error}` JSON is printed. |
401
+ | 3 | Target/binding refusal — nothing was read or written (see [Contexts and project binding](#contexts-and-project-binding)). |
402
+ | 4 | `site apply` only: not finished within its bounded pass count. Every step already applied is safe — re-run the same command to resume. |
403
+
404
+ `--json`: every failure prints `{"error":{"code","message","details"}}` as the **last** stderr
405
+ line; the target block (`{"target":…}`) and any warning lines are printed on stderr before it.
406
+
407
+ Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (`Retry-After`
408
+ honoured, max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (`pages push`
409
+ carries one `x-idempotency-key` per push). HTTP 500 is never retried, nor is a `theme push` dry
410
+ run answered 502 `readback_unverified` (a verdict on a rolled-back plan, not a transient failure).
411
+ Each retry prints a notice on stderr.
412
+
68
413
  ## Changelog
69
414
 
415
+ - **0.11.0** — One named context per site, one explicit target (customer item 1.8).
416
+ The dev token and the v1 API key remain two separate credentials; a context references both.
417
+ - **Breaking:** conflicting context choices now fail closed. When `BLOCOFY_CONTEXT`, the env
418
+ credentials (`BLOCOFY_URL`/`BLOCOFY_TOKEN`, `BLOCOFY_API_URL`/`BLOCOFY_API_KEY`) and a
419
+ project's `.blocofy/local.json` name different contexts, the command stops with
420
+ `TARGET_CONTEXT_CONFLICT` (exit 3) instead of silently taking the first. A shell that exports
421
+ the env credentials inside a project with a `local.json` must pass `--context <name>` (or
422
+ unset them). An explicit `--context` still wins, and the target block lists what it overrode.
423
+ - **Breaking:** outside a bound project the `blocofy use` default is used only by `status`,
424
+ `target` and `pages check` (as contract C2 specified). `theme push --diff/--dry-run`,
425
+ `pages push --dry-run`, `site plan`, `theme dev --no-sync` and `pages media-uses` in an unbound
426
+ directory now need `--context`, `BLOCOFY_CONTEXT` or env credentials
427
+ (`TARGET_CONTEXT_REQUIRED`).
428
+ - The target block shows the platform the site was verified on and where the context choice
429
+ came from (`Platform:` line; `Context: <name> (from …)`); `--json` adds `platform_origin`,
430
+ `context_source`, `context_overrides`, `command` and `mode`.
431
+ - `theme publish [dir]` and `status [dir]` take the project directory as an argument, and
432
+ `theme rename` / `pages media-uses|media-decide` take `--dir <dir>`. Before, they always used
433
+ cwd, so `theme push ./a && theme publish` run from another project published that project's
434
+ draft. A directory that does not exist, or one given where the command takes none (a second
435
+ positional to `target`/`status`/`theme publish`, any directory positional to
436
+ `pages media-uses|media-decide`), is a usage error (exit 1) before any request.
437
+ - A secret of the wrong type (a `blcf_live_…` key as the dev token, a `bcf_…` token as the API
438
+ key) is refused before any request with `TARGET_CREDENTIAL_WRONG_TYPE`, naming the variable
439
+ or context — it was sent to the wrong endpoint and reported as `TARGET_UNVERIFIED`.
440
+ `login --token blcf_…` points at `login --api-key`.
441
+ - Theme push preflight bound to the write (customer item 6.5):
442
+ - `theme push` checks the MERGED payload (local files plus the remote-only files it keeps) in its
443
+ dry run, then writes exactly those files with the dry run's `manifestHash` and
444
+ `expectedPointerVersion`, and — when the platform names it — the theme the dry run planned
445
+ against (`expectedTargetInstance`; refused with `target_changed` if the target moved). The dry
446
+ run carries its own throwaway idempotency key, so
447
+ `--idempotency-key` retries of a committed push still converge.
448
+ - A write resent after an attempt without an answer is reported as deployed when the platform
449
+ had committed it. A refusal of such a resend says the earlier attempt's outcome is unknown (or
450
+ that it was committed) instead of "Nothing was written", and `details.earlierAttempt` names it.
451
+ - A write that gets no definite answer (a 5xx, a 503, `readback_unverified` with an unknown
452
+ outcome, or no answer after the resends) is reported as an unknown outcome, not a failure: the
453
+ message names this push's key, to run the same command again with `--idempotency-key <key>`
454
+ (the platform then reports the committed deploy instead of writing it again); `--json` details
455
+ carry `outcome: "unknown"` and `idempotencyKey`.
456
+ - A `target_changed` refusal names the theme the dry run planned against and the current target,
457
+ and says "Nothing was deployed" (a `--draft` push may have created a new, empty draft, which
458
+ the next push reuses). After a write's `readback_unverified` the CLI points at
459
+ `theme push --diff --instance <handle>` for the theme the write was bound to (or `blocofy status`
460
+ for a new draft), not at a plain `--diff`, which compares with the live theme only.
461
+ - Per-file outcomes are printed for the push and for `--dry-run`. `Deployed atomically` appears only
462
+ with a verified readback. New refusals with messages: `pointer_version_conflict`,
463
+ `site_state_version_conflict`, `manifest_mismatch`, `readback_unverified` (worded for the dry
464
+ run, where nothing was written, or for the write; `details.phase` says which) and the preflight path
465
+ errors (`path_too_long`, `reserved_path`, `binary_content_rejected`…, naming the file).
466
+ - A push stops with `THEME_PUSH_TARGET_CHANGED` when the dry run would remove a file the push did
467
+ not carry; the message says whether a re-run can keep it or only `--prune` gets past it, and
468
+ `details.notCarryable` lists the paths the push cannot send. `--prune` lists them for
469
+ confirmation instead. `--dry-run --prune` now plans the pruned set.
470
+ - Against an older platform the write body is unchanged from 0.10.
471
+ - **0.10.0** — Named contexts + verified project binding, and a declarative whole-site state.
472
+ - `login` now saves a **named context** (`--context <name>`, default the site's slug) instead
473
+ of one global credentials pair; `blocofy contexts` / `use` / `logout` manage them, and
474
+ `blocofy link [dir] --context <name> [--adopt]` binds a project directory to one verified
475
+ site (`.blocofy/project.json`, committed; `.blocofy/local.json`, git-ignored).
476
+ `blocofy target [dir]` shows which site a command would hit without writing anything.
477
+ `login --keychain` keeps the secret in the macOS keychain instead of
478
+ `~/.blocofy/secrets.json`. A pre-0.10 `credentials.json` is migrated on first use (backup:
479
+ `~/.blocofy/credentials.v1.bak.json`).
480
+ - New `blocofy site export/validate/migrate/plan/apply/publish`: pull a whole site as one
481
+ declarative tree, check it fully offline, turn an older separate-pulls directory into that
482
+ tree layout (`site migrate`), ask what applying it would do, build it into a draft theme
483
+ version, and make it live. `site apply` is resumable — every step is idempotent and picks up
484
+ exactly what is left.
485
+ - `pages push` speaks page revision CAS: every pulled file carries `base_revision`, a push
486
+ previews its plan before writing, and a page changed on the site since the pull (or missing
487
+ `base_revision`) refuses with nothing changed instead of silently overwriting — recover with
488
+ `pages pull` + push again, or `--force --reason <text>`.
489
+ - `settings push` now **requires** `--instance <handle>` or `--live` — there is no longer an
490
+ implicit live write.
491
+ - Consistent exit codes across every command (see [Exit codes](#exit-codes)) and a `--json`
492
+ envelope (`{"error":{code,message,details}}`) as the last stderr line on any failure.
493
+
70
494
  - **0.9.0** — Locale-aware page files for multilingual sites (needs a platform with page file protocol 2;
71
495
  against an older server page commands stop with `PAGES_SERVER_UPGRADE_REQUIRED` and write nothing).
72
496
  `pages pull` writes `pages/<locale>/index.json` and `pages/<locale>/routes/<path>/index.json`, so the same
@@ -148,11 +572,13 @@ editor — without affecting your published theme. Save a file and **every open
148
572
  (the platform-rendered pages connect back to the local dev server's reload channel). Publish
149
573
  the draft from the theme editor when you're ready.
150
574
 
151
- Credentials come from `~/.blocofy/credentials.json` (written by `blocofy login`) or the
575
+ Credentials live in `~/.blocofy/credentials.json` (contexts, no secrets) + either
576
+ `~/.blocofy/secrets.json` (0600) or the macOS keychain — written by `blocofy login`, or the
152
577
  `BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation). The v1 API commands
153
- (`pages media-uses`, `pages media-decide`) use a separate `blcf_live_…` key from the same file
154
- (written by `blocofy login --api-key`) or `BLOCOFY_API_KEY` + `BLOCOFY_API_URL` — both variables
155
- together; with only one set the CLI fails instead of falling back to the file.
578
+ (`pages media-uses`/`media-decide`, `site plan`/`export`/`publish`) use a separate
579
+ `blcf_live_…` key from the same file (written by `blocofy login --api-key`) or `BLOCOFY_API_KEY`
580
+ + `BLOCOFY_API_URL` — both variables together; with only one set the CLI fails instead of
581
+ falling back to the file.
156
582
 
157
583
  ## Theme structure
158
584
 
@@ -165,12 +591,17 @@ A theme is a directory of files grouped by top-level folder:
165
591
  | `block/` | Repeatable pieces used inside sections |
166
592
  | `partial/` | Shared snippets (header/footer, …) |
167
593
  | `asset/` | CSS and static files (`theme.css`) |
594
+ | `locales/` | Legacy Liquid translation files (`<tag>.json`, `<tag>.default.json`) |
168
595
  | `pages/<locale>/index.json`, `pages/<locale>/routes/<path>/index.json` | Page content, one folder per language (`blocofy pages pull/push/check/migrate-layout`). The old `pages/<slug>.json` layout is still read, with a warning |
169
- | `config/settings.json` | Theme settings + color schemes |
170
- | `config/settings_schema.json` | Theme settings panel schema (synced with `pull`/`push`) |
596
+ | `config/settings.json` | Theme settings + color schemes (`blocofy settings pull`; `settings push` needs `--instance <handle>` or `--live`) |
597
+ | `config/settings_schema.json` | Theme settings panel schema (synced with `theme pull`/`push`) |
171
598
 
172
599
  Liquid templates use the `.liquid` extension; files under `asset/` are served as-is.
173
600
 
601
+ `blocofy site export` pulls the same content plus navigation, chrome and translations as one
602
+ declarative tree instead (see [Site (declarative state)](#site-declarative-state)); `blocofy
603
+ site migrate` turns a directory built from the commands above into that tree's layout.
604
+
174
605
  ## Development
175
606
 
176
607
  Zero runtime dependencies (Node built-ins only). Run the tests with: