@blocofy/cli
v0.16.0
Published
Develop your Blocofy theme locally against live data with instant preview — login, theme dev, pull, push.
Maintainers
Readme
@blocofy/cli
Develop your Blocofy theme locally against live data, see an instant preview, and publish. The CLI does not build assets — generate them with your own tools (npm/Vite/Tailwind); the platform serves plain Liquid + static assets.
npx @blocofy/cli login # browser login: pick your site, approve (draft-only)
npx @blocofy/cli init my-site # a theme work of its own + its files + a preview link; live site unchanged
cd my-site
npx @blocofy/cli theme push --draft --work <wk_…>login saves the site under a named context (default: the site's slug) in
~/.blocofy/credentials.json. blocofy link <dir> --context <name> (or a pull into an empty
directory) then binds a project directory to that site so every command run inside it always
targets the right one — see Contexts and project binding.
Commands
Login & contexts
blocofy login [--context <name>] [--site <handle|slug>] [--api-url <url>] [--no-browser] [--insecure-storage]Browser login (default, 0.16.0). The CLI opens your system browser on the platform
(Authorization Code + PKCE S256), you pick the site and approve; the browser comes back to a
one-shot listener on http://127.0.0.1:<port>/callback (or [::1]; localhost is never used).
The CLI checks state and the RFC 9207 iss against the platform's discovery metadata before
it exchanges the code, and says "connected" only after GET /api/v1/ping confirms the site, the
profile and the CLI audience. The login is draft-only ("Tema geliştirme", theme-dev@1): it
can start and fill theme works, push to its own work's draft and create preview links, but it can
never change the live site or publish — publishing is the panel's (or a human approval's) job.
- Storage: the OS secure store — macOS Keychain, Windows DPAPI-protected file
(
%APPDATA%\blocofy\secrets.dpapi), Linux Secret Service (secret-tool). With no OS store the login is refused, never silently written to a plain file;--insecure-storage(orBLOCOFY_SECRET_STORE=file) chooses~/.blocofy/secrets.json(0600) explicitly and is announced. - The access token lives 10 minutes and renews itself with a single-use refresh token, one
renewal at a time across every running
blocofy(lock~/.blocofy/.refresh-<context>.lock,/tokentimeout 10 s). A refused renewal (revoked, expired, reused) marks the contextreauth_required: runblocofy login --context <name>again. --siterefuses (and revokes) a login for any other site;--no-browserprints the URL only;--api-urlnames the platform (defaulthttps://app.blocofy.com).- Needs a terminal. CI (
CIset, no TTY) and SSH sessions never open a browser: use env credentials or the advanced options below.
blocofy login --url <url> --token [--context <name>] [--keychain] # advanced: pasted dev tokenVerify a dev token (bcf_…, or a profiled bcf2_… — needs CLI 0.16.0+) against its site
(GET /api/dev/whoami) and save it as a named context (default name: the site's slug). Nothing
is saved if verification fails. --token without a value reads it from a hidden prompt;
--token <value> still works but warns (TOKEN_IN_ARGV: argv is visible in ps and shell
history). Get a token from the admin panel → Settings → Theme CLI tokens.
--keychain— keep the secret in the macOS keychain (orBLOCOFY_SECRET_STORE=keychain); default is~/.blocofy/secrets.json(0600). The output names the store used.
blocofy login --api-key [--context <name>] [--api-url <url>]Verify a v1 API key (blcf_live_… or a profiled blcf_k2_…, GET /api/v1/ping) and add it to a context. The key is
read from a hidden prompt — the flag takes no value, so it never lands in argv or shell
history. If the context already has a dev token for another site, nothing is saved
(TARGET_CREDENTIAL_MISMATCH).
--api-url <url>— API origin (defaulthttps://app.blocofy.com).- Non-interactive shells: set
BLOCOFY_API_KEY+BLOCOFY_API_URLinstead.
blocofy contexts [--json] # list saved contexts (never prints secrets)
blocofy use <name> # default context for status / target / pages check outside a project
blocofy logout [--context <name>] # remove a context and its secrets (default: the `use` context)logout reports two separate results. Server (browser logins only): revoked (the platform's
RFC 7009 /revoke answered 200), unreachable / refused / unavailable (NOT revoked — the
output names the panel page where you cut it: Settings → Connections and keys), or
not_applicable (a pasted token/key: revoke it in the panel). Local: cleared | failed,
done whatever the server said. Exit 0 only when both are done; otherwise exit 4.
New project: blocofy init
blocofy init [dir] [--site <handle|slug>] [--context <name>] [--api-url <url>] [--no-browser] [--insecure-storage]Sets up a new project directory end to end, without touching the live site:
- Pre-checks, before any request: a missing or empty directory starts; a directory this CLI
initialised resumes; a non-empty directory that is not a project is refused
(
INIT_DIR_NOT_EMPTY, exit 3, nothing written); a directory bound to another site is refused (INIT_SITE_MISMATCH, exit 3); a project bound bylink/pullis not taken over (INIT_ALREADY_BOUND). - The credential:
--context, env credentials, the directory's saved context, or (on a terminal) a browser login. Non-interactive runs never open a browser and need--site. - Reads the live theme, starts a theme work (
POST /api/v1/theme-work, idempotency keyinit:<key>from.blocofy/init.json— written before the first request), downloads the work's theme files, writes.blocofy/project.json(+ the additiveprofile) and.blocofy/local.json(context + the work), creates a preview link for the work's home page, and reads the live theme again. - Success = the preview link exists and the live theme read back is the one read before: "Taslak hazır. Canlı siten değişmedi." If the live theme changed meanwhile (not by init), nothing is called a success: exit 5.
.blocofy/init.json (git-ignored, no secret, no link) records the step reached
(started → work_created → files_pulled → pinned → previewed). Run the same command again after a
timeout, a crash or Ctrl-C: it reads the work back from the platform and continues — the same key
always returns the same work, never a second one. Two inits in one directory at once: one runs,
the other is refused (INIT_IN_PROGRESS). Do not delete init.json to "start over": use a new,
empty directory.
Project binding
blocofy link [dir] --context <name> [--adopt]Bind a project directory to the context's (verified) site: writes .blocofy/project.json
(commit it), .blocofy/local.json (your context; git-ignored) and .blocofy/.gitignore.
Refuses to rebind a directory bound to another site unless --adopt.
blocofy target [dir] [--context <name>] [--json]Show which site a command in [dir] would hit (verified), without writing anything.
Theme
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>] [--instance <handle>]Start a local dev server that renders your local theme files with the site's live content.
Press l to open it, q to quit. Edit a file and save → the view reloads. Saves sync to a
draft theme only (never the live site). dir defaults to cwd. The platform's remote preview
and editor views for this command are retired; to share a draft page, create a preview link for
it (v1 API POST /themes/{id}/preview-links or MCP create_preview_link; one page per link).
The target site is verified once at start; a long session keeps that target (restart it after
changing credentials, context or the project binding).
--port <n>— local port (default 3030).--no-sync— local preview only (skip draft sync).--name <name>— name the draft when it is first created (ignored if it already exists).--instance <handle>— sync into this draft. Needed when the platform cannot tell which draft is the CLI draft (see "Which draft a draft command writes to" below); the live theme's handle is refused.
blocofy theme pull [dir] [--draft] [--instance <handle>]Download the live theme to disk. dir defaults to cwd.
--draft— pull the CLI draft (whattheme devandtheme push --draftwrite into) instead of live. Read-only: it never creates the draft. With no CLI draft yet the platform answerstarget_missingand the CLI stops (exit 2, nothing written) and tells you to runblocofy theme push --draftfirst. It still needs a bound project.--instance <handle>— pull a specific theme by its handle (admin panel theme card, orblocofy status).
blocofy theme push [dir] [--live] [--yes] [--instance <handle>] [--prune]
[--name <name>] [--dry-run | --validate] [--diff]
[--idempotency-key <k>]By default writes to a draft theme (create/update; no delete) — preview & publish it from
the admin panel, never touching the live site. Publish it with blocofy theme publish.
--live— write to the LIVE site immediately (no preview). Asks for confirmation first; non-interactive shells must add--yes.--draft— explicit draft (same as the default; safe).--yes— confirm a--livepush without prompting (CI/agents).--instance <handle>— push to a specific theme by its handle. The live theme's handle asks for the same confirmation as--live(non-interactive shells add--yes); so does any handle the CLI cannot tell apart from the live theme (the live theme is unknown, or a raw numeric id was given). With--draft --instance <handle>the push is a draft write to that draft: it never writes the live theme (refused locally and by the platform,draft_target_is_live). On a site with no live theme (or when the live theme cannot be read) the CLI cannot tell any handle apart from the live one, so it stays on the safe side by design: every--instancepush asks for confirmation (--yesin non-interactive shells), and--draft --instanceandtheme dev --instancerefuse.--name <name>— name the new draft: it is created asCLI Draft — <name>(draft mode only; ignored on--live/--instance). When the push reuses the existing CLI draft instead, it says--namewas not applied. Do not rename the CLI draft by hand to a name that does not start withCLI Draft —: the next draft push would refuse it and ask for--instance.--dry-run/--validate— check the push on the server without writing; the two flags are aliases. The dry run covers exactly the files the push would send (remote-only files it keeps included; with--prune, without the ones it would remove). A current platform plans the whole deploy and prints the target, its pointer version and what happens to each file (+created,~updated,-removed; unchanged files are counted).--diff— show what a push would change vs the target (read-only), then stop. A draft push compares with the CLI draft it writes to (named by handle); with no CLI draft yet, with the live theme (the push would create the draft).--livecompares with live,--instancewith that theme.
Which draft a draft command writes to. A draft push, theme pull --draft and the theme dev
sync write to (or read) the CLI draft only when the platform can tell which draft that is: exactly
one draft created by a CLI push, still carrying a name the platform gives it (CLI Draft,
CLI Draft — YYYY-MM-DD or CLI Draft — <name>), and not a draft a site-state restore built.
Otherwise — two such drafts, a site-state restore draft, or a CLI draft renamed by hand — the command
writes nothing, lists the candidate drafts and exits 2 (draft_target_ambiguous). The message
pre-fills --instance <handle> only for a CLI-named draft that is not a restore draft; naming any
other listed draft would overwrite it. On a site whose only candidate is a site-state restore draft,
create a new draft theme in the admin panel and pass its handle with --instance, or publish or
delete the restore draft first. theme publish without --instance uses the same rule and never
publishes a guessed draft. With no draft at all, the first push creates one as before.
When the platform cannot create the new draft. A draft push (or the theme dev sync) that needs
a new CLI draft can be refused by the platform's capacity check. Nothing is written in any of these
cases; the CLI explains it in plain words first, then prints the error [code] line (the --json
envelope keeps the code and details):
quota_exceeded(exit 2) — worded bydetails.resource, with the usage the platform reports: the site's theme space is full (theme_bytes) or a site not on theme space reached its old draft count (theme_drafts) — remove a draft you no longer need (neither is a plan limit); the plan's storage is full (storage_mb) — the plan message.capacity_unavailable/resource_busy(exit 1) — temporary. The CLI has already retried within its normal retry policy (waiting as long as the server'sRetry-Afterasks); try again later.source_stale(exit 2) — the live theme the new draft copies changed meanwhile; run the command again.theme devreports each of them in one line and tries again on the next save.
After a draft push the CLI prints the draft it wrote to (Draft: <handle> "<name>" (new) or
(existing CLI draft, updated)) and the blocofy theme publish --instance <handle> command.
--idempotency-key <k>— attach a key so a retried push is not double-applied.--prune— also remove target files that no longer exist locally (locales/included); lists them first, and on the live theme asks to confirm (non-interactive shells add--yes).
Every push first runs that dry run, then writes the same files. On a current platform the write is
bound to the dry run: it is refused, with nothing written, if the files differ from what was checked
(manifest_mismatch), the target theme was deployed again in between (pointer_version_conflict),
or the push would now write to another theme than the one its dry run planned against — the live
theme was switched, or the draft to reuse changed (target_changed: nothing was deployed, though a
draft push may have created a new, empty draft that the next push reuses; run the push again).
A write that gets no answer (network error, 429/502/503/504) is resent (up to 3 times) under the same
key; a push the platform already committed is then reported as deployed (already applied by an
earlier push with the same idempotency key). If that resend is refused, the refusal cannot say
what the earlier attempt did: the message says whether it was committed or its outcome is
unknown (details.earlierAttempt), never "Nothing was written". Check the theme the dry run bound
the write to (details.expectedTargetInstance, not the current target a refusal names) with
blocofy theme push --diff --instance <handle> — or, for a new draft, find it with blocofy status —
before running the push again. A write whose last answer is still not definite (HTTP 500 or another
5xx, a 503, a 502 readback_unverified with outcomeUnknown, or no answer) has an unknown outcome
(details.outcome: "unknown"): the message names the push's key (details.idempotencyKey); run the
same command again with --idempotency-key <that key>, and a push that was deployed is reported as
deployed instead of being written again.
If the dry run would remove a file the push did not carry, the push stops
(THEME_PUSH_TARGET_CHANGED). The message says which case each file is:
- a path the push cannot send (not a file inside a theme folder, e.g. a bare
layoutrow; listed indetails.notCarryable) stops every run the same way, and only--prunegets past it (it removes the file); - any other file was either added while the push was running, and running the push again keeps it,
or the push cannot read it (for example a theme file that is not published): then it stops every
run the same way, and you add
--pruneto remove it or add a local file at that path to replace it.
Deployed atomically is printed only when the server read the written files back and they match;
an older platform that does not read back gets Deployed: … not verified.
blocofy theme rename <handle> <new name> [--dir <dir>]Rename a theme (the name is just a label). Works on any of your themes, including the live one.
--dir <dir>— the bound project whose site the theme is on (default: cwd).
blocofy theme publish [dir] [--instance <handle>]Publish a draft theme to the LIVE site: it replaces the live theme for every visitor. With no
flag, publishes the draft that theme dev / theme push --draft writes into. The server
refuses to publish a theme that has no pages (it would 404); preview first. The site is the one
[dir]'s project is bound to (dir defaults to cwd), so blocofy theme push ./shop && blocofy
theme publish ./shop always publishes ./shop's site, whatever directory you run it from.
--instance <handle>— publish a specific theme. Without it the platform publishes only the site's CLI draft; any other theme (a site-state restore draft, a renamed or panel-copied theme) is refused withpublish_target_unconfirmed(exit 2, nothing published) — name it with--instance.
Theme work (new)
A work is a private, safe copy of the site's current live theme: you change it, visitors never
see it, and a person approves its publication in the admin panel (the CLI never publishes a work).
Work commands use the v1 API key (blocofy login --api-key, or BLOCOFY_API_KEY +
BLOCOFY_API_URL): the work belongs to the key that started it. The dev token alone is refused
(LOGIN_REQUIRED) before any request.
blocofy theme work start [dir] [--intent "<text>"] [--idempotency-key <k>]
blocofy theme work status <wk_…> [--dir <dir>]
blocofy theme work resume <wk_…> [--dir <dir>] [--require-fresh]
blocofy theme work cancel <wk_…> [--dir <dir>]
blocofy theme work seal <wk_…> [--dir <dir>]
blocofy theme work request-approval <wk_…> [--dir <dir>] [--open] [--wait [--interval <s>]]
blocofy theme push [dir] --draft --work <wk_…>sealprepares the work for review: its content is frozen and can no longer change. It asks for no approval and publishes nothing. On a platform without this step it says so (seal_unsupported, exit 2);request-approvalprepares the work itself.request-approvalasks a person to publish the work (an open work is prepared for review first) and prints the approval URL. A signed-in site owner, or a team member with the theme permission, approves on that Blocofy page. The URL carries no token: having it is not a permission to publish.--openalso opens it in your browser (only when it is this platform's approval page).--waitpolls the read-only publish status until the work is published or the request ends; the CLI never publishes a work. How--waitends:| Exit | Code (
--jsonenvelope) | Meaning | | --- | --- | --- | | 0 | — | Approved and published; the work is live. | | 2 |approval_stale| The live site changed after the request; nothing published. Start a new work from the current live site and ask again. | | 2 |approval_expired| Nobody decided within 15 minutes; nothing published. Ask again. | | 2 |approval_declined| The request was declined; nothing published. | | 2 |approval_superseded| The work was reopened for changes; this request is no longer valid. Ask again. | | 2 |work_cancelled| The work was cancelled; nothing published. | | 1 |publish_failed| The work could not be completed; nothing published. Checktheme work status. | | 1 |wait_timeout| No decision yet; the CLI stopped waiting. The request stays valid until it expires. |A refusal before the wait starts (for example
work_state_conflict) exits as usual (2 for a 4xx, 1 for a 5xx); see Exit codes.statusalso shows where the publication stands (preparing, ready for review, waiting for approval with its URL, published, needs update).startprints the work's handle (wk_…) and saves it in.blocofy/local.json(git-ignored; the handle alone grants nothing). It sends anIdempotency-Key(yours, or a printedcli-work-<uuid>): the same key returns the same work, never a second one. When the answer is lost (network, 5xx), the CLI prints the exact command to run again with the same key.status,resumeandcancelalways take the handle; with none they suggest the project's saved work and stop.statusandresumesay whether the site changed since the work started; a changed site is never overwritten.resume --require-freshrefuses (work_stale) instead.cancelreads the work'sstate_versionand sends it; the live site is untouched and the work's theme stays in the theme library.theme push --work <wk_…>writes into that work's own draft theme (--workimplies--draft; it cannot be combined with--liveor--instance). It needs both the dev token and the API key of the same site, and refuses unless the work is open (work_state_conflict, nothing written).Refusals print a plain Turkish explanation first, then the
error [code]line (--json: the envelope only):not_found,work_forbidden,work_state_conflict,work_stale,work_sealed,work_base_unavailable,quota_exceeded,capacity_unavailable,resource_busy,idempotency_key_reuse(exit 2 for a 4xx, 1 for a 5xx).
Status
blocofy status [dir]Show the live theme, page distribution per instance, drafts, and a health flag (ok /
live_instance_empty / pages_split). For a problem it names the theme holding the pages, the
missing pages, why, and safe preview-first next steps — never a one-line fix.
Pages
blocofy pages pull [dir] [--strict]Download published pages, one folder per language: pages/<locale>/index.json (home) and
pages/<locale>/routes/<path>/index.json (everything else). Files from the old flat layout
(pages/<slug>.json) are reported, never deleted or overwritten. If the site cannot export
every published page, nothing is written, every reason is printed
(PAGES_EXPORT_INCOMPLETE) and the exit code is 2 (--strict: warnings exit 1).
blocofy pages push [dir] [--dry-run] [--strict] [--force --reason <text>]Write pages/**.json to the site. Updates existing pages only — never creates or deletes a
page; unchanged pages are skipped. Every file is checked first: an invalid file, two files
pointing at the same page, or a folder/locale mismatch changes no page. Every pulled file
carries base_revision (the page as you pulled it); the push first asks for a plan (per page:
action, live/draft, changed fields), prints it, then pushes exactly that plan.
- If a page changed on the site since you pulled it (
PAGES_REVISION_CONFLICT) or a file has nobase_revision(PAGES_BASE_REVISION_REQUIRED), nothing is changed: runblocofy pages pull, merge your edits, and push again. - If the site changes between the plan and the push, nothing is changed either
(
PAGES_PLAN_STALE) — run the push again. --force --reason <text>— overwrite anyway (reason: 1–500 characters); the forced pages are listed. Against an older server that cannot check revisions, the push runs as before with a warning.--dry-run— print the plan, write nothing.- If publishing stops unexpectedly after the plan (
PAGES_APPLY_FAILEDor another publish error), some pages may already be applied: the per-file result printed is authoritative, the exit code is non-zero, and re-running the push is safe.
blocofy pages check [dir] [--strict]Check page files. Offline: paths, JSON, layout, duplicates, missing base_revision
(PAGES_BASE_REVISION_MISSING warning). Logged in: also the site's languages and a server-side
dry run. Exit 1 on errors (--strict: warnings too).
blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]Move old flat-layout files (pages/<slug>.json) to language folders. --dry-run (default)
prints the plan; --write moves only proven files. Any ambiguity or conflict: nothing is moved,
exit 1. Files without "locale" use the site's default language (needs login); with --write
outside a bound project only explicit --context/env credentials are used.
blocofy pages media-uses <page-handle> [--dir <dir>] [--json]List a page's localized-media decisions on its newest draft (v1 API, pages:read). Prints
the draft's revision id/version needed by media-decide. --dir <dir> (both media commands):
the bound project whose site the page is on (default: cwd).
blocofy pages media-decide <page-handle> --decisions <file.json>
[--expected-revision-id <n> --expected-version <n>] [--dir <dir>] [--json]Apply one or more media decisions to the page's draft atomically (v1 API, pages:write). The
file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?, decorative?,
idempotency_key?, witness? } ] } (max 20). Without the two --expected-* flags the CLI first
GETs the draft and uses its current revision id/version; items without an idempotency_key
get a random UUID. Transient failures (429/502/503/504, network) are retried against the same
idempotency key, so a retry replays the same batch. Exit 0 on success (or "No changes" when
every item was already recorded).
Translations
blocofy translations export --locale <tag> --out <file> [--force] [--format json|xliff] [--only all|missing|stale|pending] [--json]
blocofy translations import <file.json|file.xlf> [--dry-run] [--publish] [--on-source-change skip|apply] [--json]export writes every text of one language that needs translating (pages, image texts, records,
menus, site settings, theme texts) to ONE file. The platform answers in windows; the command
follows every window until the last and merges them, then lists what was left out and why.
--format xliff writes XLIFF 1.2 for translation tools (the platform's own dialect, byte for
byte); --only pending limits the file to texts that are missing or need an update. The export
asks for every kind, so the API key needs the read scopes of all of them: pages:read,
content:read, navigation:write (menus have no read scope), settings:read, themes:read and
models:read; a key without one of them is refused for the whole export. An existing --out file
is never replaced silently: the command refuses before any request unless --force is given, and
the file is written through a temporary file and a rename.
import reads a JSON or XLIFF package, drops empty translations, and sends the rest as JSON in
chunks of at most 500 units (a page or record is never split; one group of up to 5,000 units is
sent whole). Every chunk is checked locally before the first request. It prints the count per
state (written, unchanged, empty, source changed, target changed, invalid, blocked). New pages and
records are created as drafts and page content goes to the draft; menus, site settings, theme texts,
a live record's text and a live page's title and SEO texts change at once (a live page's URL only
with --publish, once that page's publish succeeded). --publish also publishes the pages and records this
import wrote (needs pages:write and content:write) and reports what could not be published;
--dry-run writes nothing and reports what would change. The API key needs the write scopes of
the kinds in the package. A refusal (for example an invalid text) exits 2 with the server's error
JSON on stderr; if a later chunk fails, the chunks already written stay written, the report of
those chunks (counts, what was published or held back, a failed cache refresh) is printed before the
error (--json: one object with "stopped": true on stdout), and running the command again is safe
(written texts answer "unchanged"). Transient failures (429/502/503/504, network) are retried, except
for a chunk imported with --publish: a resend could not see what the first attempt already
published, so such a chunk is sent once. A 5xx or a lost connection stops the command with
TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN (exit 1), naming the chunk that may or may not have been
applied and published; a 429 stops it with TRANSLATIONS_IMPORT_NOT_APPLIED (exit 1), since that
chunk was not applied.
Settings
blocofy settings pull [dir]
blocofy settings push [dir] (--instance <handle> | --live [--yes])Download / upload config/settings.json (theme tokens/settings + color schemes). A push
names its target — there is no implicit live write, and the command refuses with neither
flag:
--instance <handle>— write that theme's settings; a draft shows them in its preview and goes live withblocofy theme publish --instance <handle>.--live— write the LIVE theme (asks to confirm; non-interactive shells add--yes).
After a push the CLI says where it applied (preview now / live now / after deploy).
Site (declarative state)
The site commands move a whole site — pages, navigation, theme source, settings, chrome,
translations, media — as one declarative tree on disk (blocofy-site.json + site/** +
theme/** + pages/** + media/**), instead of the per-scope theme/pages/settings
commands above.
blocofy site export [dir]Download the whole site as a declarative tree into [dir], plus every media file's bytes at
media/files/<sha256> (downloaded and hash-verified). Refuses to overwrite a directory bound to
another site (same binding rule as every other pull).
blocofy site validate [dir]Check an exported (or hand-authored) tree offline — zero network requests: paths, the path↔content binding (a page file's locale/slug must match its folder/name, and so on), duplicate identities, the size/count limits, and the tree against its own manifest digest.
--strict— warnings also exit non-zero.
blocofy site migrate [dir] [--dry-run | --write]Turn a directory left by the older separate theme pull / pages pull / settings pull
into the site-state v1 tree layout (theme directories at the root → theme/**,
config/settings.json → theme/config/settings.json). Purely local: no network request, no
target/identity check, .blocofy/ project binding untouched. pages/** files are never moved
(the location is already identical); a file still at the old flat page layout is left alone with
a note to run blocofy pages migrate-layout first.
--dry-run(default) — print the plan: every move, every file left alone, every conflict.--write— perform it.- Any ambiguity or conflict (a target already exists with different content, an unreadable or symlinked entry, a path the shared site-state rules refuse): zero moves, exit 1.
- Never invents the parts a directory of separate pulls never had (
blocofy-site.json,site/locales.json, globals, navigation, translations,theme/chrome/**) — it says so and points atblocofy site exportfor those.
blocofy site plan [dir] [--target new|<handle>] [--mode same_site|restore]
[--accept-live-effects locales] [--json]Ask the site what applying this tree would do — no write. Needs both a dev token and a
v1 API key (the dev token is what a theme deploy uses later). Prints the status (planned /
awaiting_assets / awaiting_theme_source / draft_complete), the steps, and any missing
assets or a differing theme source.
--target new|<handle>— a fresh draft theme version (default), or a specific one you own (from a previous plan/apply's target instance).--mode same_site|restore—same_siterefuses a page that changed on this site since the tree was exported;restore(default) does not.--accept-live-effects locales— required before a state that adds a language may be applied (publishing a language prepares its homepage LIVE).
blocofy site apply [dir] [--target new|<handle>] [--mode same_site|restore]
[--accept-live-effects locales] [--json]Build the state into a draft theme version — the live site is never touched. Loops: plan →
(upload missing media via the v1 API, or deploy the theme source via the same canonical path
theme push --instance uses) → plan → apply, until the target reports draft_complete or 5
passes are used. Safe to re-run: every step is idempotent and a re-plan picks up exactly what is
left, so an apply interrupted by anything (network, Ctrl-C, a crash) resumes with the same
command. Preview it from the admin panel, then run blocofy site publish.
blocofy site publish [dir] [--target new|<handle>] [--mode same_site|restore] [--yes]Make an applied state the LIVE site: pointer swap, then navigation, then settings. Refuses (exit
2) if the state is not fully applied yet — run site apply first. Separate live gate: asks to
confirm (non-interactive shells must add --yes).
blocofy --version
blocofy --helpContexts and project binding
A context is one named operator profile for one site. It references two separate
credentials, each kept in the secret store and never printed: the theme dev token (bcf_…,
verified with GET /api/dev/whoami) and the v1 API key (blcf_live_…, verified with
GET /api/v1/ping). They stay two credentials with their own scopes — the CLI never merges them
into one token and never sends one to the other's endpoint. Both pairs of a context must resolve
to the same site (TARGET_CREDENTIAL_MISMATCH); a secret of the wrong type in either slot is
refused before any request (TARGET_CREDENTIAL_WRONG_TYPE, exit 1 — e.g. a blcf_live_… key in
BLOCOFY_TOKEN).
Every remote command verifies its site first and prints a Target block on stderr (--json:
the same as a {"target":…} line with platform_origin, context_source,
context_overrides, command and mode):
Target: Alpha Bakery · s1a2b3 · alpha.myblocofy.com
Platform: https://app.blocofy.com
Context: alpha (from .blocofy/local.json)
Binding: .blocofy/project.json
Operation: theme push · draftThe context is chosen in this order:
--context <name>BLOCOFY_CONTEXT- env credentials (
BLOCOFY_URL+BLOCOFY_TOKENand/orBLOCOFY_API_URL+BLOCOFY_API_KEY) .blocofy/local.json(the project's own context choice)- the one saved context matching the project's site
- (terminal) pick from the matches
- outside a project, and only for
status,targetandpages check: theblocofy usedefault
Conflicts fail closed. BLOCOFY_CONTEXT, the env credentials and .blocofy/local.json are
each a choice. When two of them name different contexts, the command is refused before
anything is read or written (TARGET_CONTEXT_CONFLICT, exit 3) — e.g. BLOCOFY_URL/
BLOCOFY_TOKEN exported in a shell that then runs inside a project whose local.json names a
context. Settle it with --context <name> (or unset the others); the Context line then lists
what it overrode, e.g. alpha (from --context; overrides env credentials).
Inside a bound project, blocofy use is ignored — the project's binding decides, not the
global default context. Outside one, the use default serves only status, target and
pages check, and the Context line says so (from default context (blocofy use)); every other
command in an unbound directory needs --context, BLOCOFY_CONTEXT or env credentials, so a
use in another terminal never changes what a diff, dry run or plan compares against. Commands
that change a site (theme push/publish/rename, theme dev sync, pages push, settings
push, pages media-decide) need a bound project; a pull into a new empty directory binds it
automatically. A wrong project/site pairing changes nothing (TARGET_SITE_MISMATCH — pass
--adopt on link to rebind deliberately).
Every command resolves its binding from the directory it acts on — the [dir] argument (or
--dir <dir> for theme rename and pages media-uses|media-decide), else cwd.
CI: commit .blocofy/project.json (never local.json) and set the env pairs the job needs
(BLOCOFY_URL + BLOCOFY_TOKEN for theme/pages/settings, plus BLOCOFY_API_URL +
BLOCOFY_API_KEY for the v1 commands and site plan/apply). The env credentials are then the
only choice, and each command still verifies they belong to the committed binding's site.
A binding made against an older server has no recorded platform_origin: it still matches the
same site (one warning printed; run blocofy link --adopt to record it). A server that reports
no origin cannot serve a binding that records one — that refuses with TARGET_UNVERIFIED (the
server cannot prove it is the platform the binding was made against).
A refusal in this area (TARGET_SITE_MISMATCH, TARGET_UNVERIFIED, TARGET_CREDENTIAL_MISMATCH,
TARGET_CONTEXT_CONFLICT, TARGET_CONTEXT_REQUIRED, TARGET_CONTEXT_UNKNOWN,
TARGET_BINDING_INVALID, and TARGET_CREDENTIAL_WRONG_TYPE with exit 1) always means:
nothing was read or written — the command stops before it touches the site or the local
project files, and exits 3 (see Exit codes).
blocofy site migrate is the one exception: it never resolves a target or a context at all (no
network, no identity call), so it leaves any .blocofy/ binding in the directory completely
untouched.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Ok. |
| 1 | Usage / network / HTTP 5xx / a local check that refuses before any request (e.g. a site migrate conflict, a pages migrate-layout ambiguity). |
| 2 | The server refused the request (HTTP 4xx) — its {error} JSON is printed. |
| 3 | Target/binding refusal — nothing was read or written (see Contexts and project binding). |
| 4 | site apply: not finished within its bounded pass count. Every step already applied is safe — re-run the same command to resume. logout: the local login could not be removed, or the platform login was NOT revoked (cut it in the panel). |
| 5 | init: the live theme changed while init ran (init never writes it) — not reported as a success. |
--json: every failure prints {"error":{"code","message","details"}} as the last stderr
line; the target block ({"target":…}) and any warning lines are printed on stderr before it.
Retries: network errors and HTTP 429/502/503/504 are retried up to 3 times (Retry-After
honoured, max 30s per wait; else 0.3s/0.9s/2s), resending the identical request (pages push
carries one x-idempotency-key per push). HTTP 500 is never retried, nor is a theme push dry
run answered 502 readback_unverified (a verdict on a rolled-back plan, not a transient failure).
translations import --publish is never retried either: a resend could not see what the first
attempt already published, so a failed chunk stops the command with
TRANSLATIONS_IMPORT_OUTCOME_UNKNOWN (5xx, network) or TRANSLATIONS_IMPORT_NOT_APPLIED (429).
Each retry prints a notice on stderr.
Changelog
0.16.0 (unreleased) — Browser login,
blocofy init, honestlogout(ADR-0014 wave P4). Pairs with the platform release of the credential profiles (draft-only "Tema geliştirme" by default). Minimum platform: the browser login needs a platform that publishes the CLI client (blocofy-cli), RFC 9207iss,/revokeand theprofile/audience/dev_endpointfields ofGET /api/v1/ping; an older platform is told apart and refused with "uselogin --token".blocofy loginopens the browser (PKCE S256, loopback127.0.0.1/[::1]only,stateandisschecked,resource=<origin>/api/v1). Connected = the identity probe, not a config write. Unattended (CI / no TTY) and SSH runs never open a browser. The pasted dev token stays as the advanced path (login --url <site> --token, hidden prompt; a token in argv warns).- Secure storage: OS store by default (Keychain / DPAPI / Secret Service); no store → refused;
the 0600 file only with
--insecure-storage/BLOCOFY_SECRET_STORE=file. Unverified on real machines: the Windows (DPAPI via PowerShell) and Linux (Secret Service viasecret-tool) stores are covered only by tests with a stand-in for those programs; macOS Keychain uses the existing, field-tested adapter. - Draft-only logins default to their saved work: for a browser login (or any draft-only profile)
theme pushandtheme devwrite into the theme work saved byinit/theme work start(.blocofy/local.json, else.blocofy/init.json);--work/--instancestill win. Without a saved work they stop with a hint (THEME_WORK_REQUIRED, exit 3) before any request. - Refresh single-flight across processes (lock with pid/hostname/acquired_at, ESRCH or 30 s
staleness, 10 s
/tokentimeout, never retried); reuse/revocation →reauth_required. The platform's expliciterror_description: "refresh_token_reused"is honoured first; a 503 withoutRetry-Afteris treated as spent only as a fallback for older platforms. initpreview: uses the work-levelPOST /api/v1/theme-work/{handle}/preview-linkswhen the platform has it (an older platform's 404/405 falls back to finding the work theme's home page).blocofy logoutreports the platform revocation and the local removal separately (exit 4 when either is not done);--contextdefaults to theusecontext.blocofy init [dir](see above): idempotent theme-work start, secret-free.blocofy/init.json, refuses dirty / other-site directories, resumes from every step, success only with a preview and the live theme read back unchanged (exit 5 otherwise).- New platform refusals in plain Turkish, printed before the usual
error [code]line and never retried:live_effect_not_permitted,credential_reapproval_required(due date + re-approval link),profile_unsupported,audience_mismatch,ai_draft,work_copy_not_writable. - Profiled credentials:
bcf2_dev tokens andblcf_k2_API keys are accepted (0.15 and older refusebcf2_tokens: upgrade the CLI before using one).
0.15.0 (2026-10-05) — Theme space, theme work and human-approved publishing. Pairs with the platform release that replaces the 5-draft limit with a per-site theme space.
- Capacity refusals in plain words. A theme write refused by the platform explains why in
plain Turkish first, then prints the usual
error [code]line (--json: the envelope only):quota_exceeded(exit 2) is worded bydetails.resource.theme_bytes: the site's theme space is full — on any push, a push to an existing draft with--instanceincluded. It shows used / allowance fromdetails, says to free space by deleting unused draft themes from the theme library (cancelled works' themes included; cancelling an open work also releases its reserved publish space; an archived work's theme still counts), or to contact support, and never suggests a plan upgrade or says "new draft" (theme space is not a plan quota).theme_drafts(the old draft count, on sites that have not moved to theme space) is not a plan limit either: it says to remove a draft you no longer need, never to upgrade. Onlystorage_mbkeeps the plan message. The envelope carries the server'sdetails.work_revision_limit(exit 2;theme work seal, orrequest-approvalwhen it prepares the work): a work can be sent for review at most 20 times (details.limit); start a new work.capacity_unavailable/resource_busy(exit 1, after the usual retries that honourRetry-After; the envelope carriesretryAfterSeconds): a temporary refusal, try again later. A 503 on the write is no longer reported as "outcome unknown" (the platform wrote nothing), unless an earlier attempt of the same push got no answer.source_stale(exit 2): the live theme changed while a new draft was copied; run it again.theme devreports the same refusals in one line per save.
theme pull --draftis read-only. Help and README no longer claim it creates the draft. With no CLI draft the platform answers 404target_missing; the CLI tells you to runblocofy theme push --draftfirst (exit 2, nothing written). The command still needs a bound project (kept for platforms from before this change).- New: theme work.
theme work start|status|resume|cancelandtheme push --work <wk_…>(v1 API key; see "Theme work"). A work is a private copy of the current live theme, named only by its handle; the handle is saved in.blocofy/local.json. A plaintheme pushis unchanged and never calls the work API. - New: human-approved publishing.
theme work seal(prepare for review, asks for no approval) andtheme work request-approval [--open] [--wait [--interval <s>]]: prints the approval URL (no token in it), optionally opens it, and--waitpolls the status read-only — exit 0 when published, 2 when the request ends without a publication (approval_stale,approval_expired,approval_declined,approval_superseded,work_cancelled), 1 onwait_timeoutorpublish_failed(table under "Theme work").theme work statusshows the publish status. The CLI never publishes a work. theme publishnames a work copy's refusal. Publishing a theme work's copy directly is refused by the platform (work_not_publishable, exit 2, nothing published); the CLI now keeps that code (it printedHTTP_409before) and says to send the work for review and ask for approval.- Unchanged: retry classes,
draft_target_ambiguoushandling, idempotency keys, target guards and protocol headers.
- Capacity refusals in plain words. A theme write refused by the platform explains why in
plain Turkish first, then prints the usual
0.14.0 — Draft commands no longer guess their draft (platform #989).
- Behaviour change:
theme push(draft),theme push --diff,theme pull --draftand thetheme devsync refuse, writing nothing, when the platform answersdraft_target_ambiguous(or the site status shows the same case): the message lists the candidate drafts and the exact--instance <handle>command; exit code 2; never retried. A CLI draft you renamed is refused too. - The refusal pre-fills
--instanceonly for a safe candidate (CLI-named, not a restore draft) and otherwise warns that naming a listed draft would overwrite it. theme dev --instance <handle>andtheme push --draft --instance <handle>write to a chosen draft (sent withdraft: true; never the live theme).theme devreports a draft-sync failure in one line, also at startup (it was silent there):draft_target_unverifiable(the next save tries again) anddraft_target_is_live(stops).- Behaviour change:
theme publishwithout--instanceuses the same rule; it never publishes a guessed draft. - Behaviour change:
--name Xcreates the draft asCLI Draft — X; the rename advice after a reused draft is gone (a draft renamed by hand to another name is refused). - Behaviour change (CI):
theme push --instance <the live theme's handle>asks for the same confirmation as--live(--yesin non-interactive shells); so does a handle the CLI cannot tell apart from the live theme (live theme unknown, or a raw numeric id). theme publish --instance <handle>tells the platform the theme was named explicitly (explicit: true); the automatic pick sends no flag and the platform re-checks it. A theme that is not the site's CLI draft (a site-state restore draft, a renamed or panel-copied theme) is published through the CLI only with--instance; otherwise the platform refuses withpublish_target_unconfirmed(exit 2, nothing published). Older CLIs get that refusal as a generic error and can never publish such a theme.
- Behaviour change:
0.13.0 — Draft push names its target (customer items PS-22/PS-23/PS-26).
theme push(draft) prints the draft it wrote to — handle, name, new or existing — andblocofy theme publish --instance <handle>. When it updated the existing CLI draft, it says--namewas not applied and prints the rename command.- Behaviour change:
theme push --diffwithout--live/--instancenow compares with the CLI draft the push writes to (found read-only via the site status, never provisioned), not with the live theme. With no CLI draft yet it says so and compares with live. Release it after the platform's PS-22 fix (uncached theme reads): before that fix a draft read could return its pre-push files for up to five minutes. theme devhelp no longer lists the retired live-domain and editor views.TARGET_BINDING_REQUIREDalso namesblocofy link <dir>for env credentials.
0.12.0 — Translation packages (#925).
blocofy translations export --locale <tag> --out <file> [--format json|xliff] [--only …]writes every text of one language that needs translating to one JSON or XLIFF 1.2 file. It follows every export window and lists what the platform left out, by reason.blocofy translations import <file> [--dry-run] [--publish] [--on-source-change skip|apply]reads a JSON or XLIFF package and imports it in chunks of at most 500 units (a page or record is never split, up to 5,000 units). It prints the count per state, what was published, and whether the site cache could not be refreshed. A chunk imported with--publishis never resent automatically: when it gets no definite answer the command stops withTRANSLATIONS_IMPORT_OUTCOME_UNKNOWN(a 429:TRANSLATIONS_IMPORT_NOT_APPLIED), exit 1, names the chunk, and running it again is safe. A run that stops part-way still prints the report of the chunks done before it.exportrefuses an existing--outfile unless--force.
0.11.0 — One named context per site, one explicit target (customer item 1.8). The dev token and the v1 API key remain two separate credentials; a context references both.
- Breaking: conflicting context choices now fail closed. When
BLOCOFY_CONTEXT, the env credentials (BLOCOFY_URL/BLOCOFY_TOKEN,BLOCOFY_API_URL/BLOCOFY_API_KEY) and a project's.blocofy/local.jsonname different contexts, the command stops withTARGET_CONTEXT_CONFLICT(exit 3) instead of silently taking the first. A shell that exports the env credentials inside a project with alocal.jsonmust pass--context <name>(or unset them). An explicit--contextstill wins, and the target block lists what it overrode. - Breaking: outside a bound project the
blocofy usedefault is used only bystatus,targetandpages check(as contract C2 specified).theme push --diff/--dry-run,pages push --dry-run,site plan,theme dev --no-syncandpages media-usesin an unbound directory now need--context,BLOCOFY_CONTEXTor env credentials (TARGET_CONTEXT_REQUIRED). - The target block shows the platform the site was verified on and where the context choice
came from (
Platform:line;Context: <name> (from …));--jsonaddsplatform_origin,context_source,context_overrides,commandandmode. theme publish [dir]andstatus [dir]take the project directory as an argument, andtheme rename/pages media-uses|media-decidetake--dir <dir>. Before, they always used cwd, sotheme push ./a && theme publishrun from another project published that project's draft. A directory that does not exist, or one given where the command takes none (a second positional totarget/status/theme publish, any directory positional topages media-uses|media-decide), is a usage error (exit 1) before any request.- A secret of the wrong type (a
blcf_live_…key as the dev token, abcf_…token as the API key) is refused before any request withTARGET_CREDENTIAL_WRONG_TYPE, naming the variable or context — it was sent to the wrong endpoint and reported asTARGET_UNVERIFIED.login --token blcf_…points atlogin --api-key. - Theme push preflight bound to the write (customer item 6.5):
theme pushchecks the MERGED payload (local files plus the remote-only files it keeps) in its dry run, then writes exactly those files with the dry run'smanifestHashandexpectedPointerVersion, and — when the platform names it — the theme the dry run planned against (expectedTargetInstance; refused withtarget_changedif the target moved). The dry run carries its own throwaway idempotency key, so--idempotency-keyretries of a committed push still converge.- A write resent after an attempt without an answer is reported as deployed when the platform
had committed it. A refusal of such a resend says the earlier attempt's outcome is unknown (or
that it was committed) instead of "Nothing was written", and
details.earlierAttemptnames it. - A write that gets no definite answer (a 5xx, a 503,
readback_unverifiedwith an unknown outcome, or no answer after the resends) is reported as an unknown outcome, not a failure: the message names this push's key, to run the same command again with--idempotency-key <key>(the platform then reports the committed deploy instead of writing it again);--jsondetails carryoutcome: "unknown"andidempotencyKey. - A
target_changedrefusal names the theme the dry run planned against and the current target, and says "Nothing was deployed" (a--draftpush may have created a new, empty draft, which the next push reuses). After a write'sreadback_unverifiedthe CLI points attheme push --diff --instance <handle>for the theme the write was bound to (orblocofy statusfor a new draft), not at a plain--diff, which compares with the live theme only. - Per-file outcomes are printed for the push and for
--dry-run.Deployed atomicallyappears only with a verified readback. New refusals with messages:pointer_version_conflict,site_state_version_conflict,manifest_mismatch,readback_unverified(worded for the dry run, where nothing was written, or for the write;details.phasesays which) and the preflight path errors (path_too_long,reserved_path,binary_content_rejected…, naming the file). - A push stops with
THEME_PUSH_TARGET_CHANGEDwhen the dry run would remove a file the push did not carry; the message says whether a re-run can keep it or only--prunegets past it, anddetails.notCarryablelists the paths the push cannot send.--prunelists them for confirmation instead.--dry-run --prunenow plans the pruned set. - Against an older platform the write body is unchanged from 0.10.
- Breaking: conflicting context choices now fail closed. When
0.10.0 — Named contexts + verified project binding, and a declarative whole-site state.
loginnow saves a named context (--context <name>, default the site's slug) instead of one global credentials pair;blocofy contexts/use/logoutmanage them, andblocofy link [dir] --context <name> [--adopt]binds a project directory to one verified site (.blocofy/project.json, committed;.blocofy/local.json, git-ignored).blocofy target [dir]shows which site a command would hit without writing anything.login --keychainkeeps the secret in the macOS keychain instead of~/.blocofy/secrets.json. A pre-0.10credentials.jsonis migrated on first use (backup:~/.blocofy/credentials.v1.bak.json).- New
blocofy site export/validate/migrate/plan/apply/publish: pull a whole site as one declarative tree, check it fully offline, turn an older separate-pulls directory into that tree layout (site migrate), ask what applying it would do, build it into a draft theme version, and make it live.site applyis resumable — every step is idempotent and picks up exactly what is left. pages pushspeaks page revision CAS: every pulled file carriesbase_revision, a push previews its plan before writing, and a page changed on the site since the pull (or missingbase_revision) refuses with nothing changed instead of silently overwriting — recover withpages pull+ push again, or--force --reason <text>.settings pushnow requires--instance <handle>or--live— there is no longer an implicit live write.- Consistent exit codes across every command (see Exit codes) and a
--jsonenvelope ({"error":{code,message,details}}) as the last stderr line on any failure.
0.9.0 — Locale-aware page files for multilingual sites (needs a platform with page file protocol 2; against an older server page commands stop with
PAGES_SERVER_UPGRADE_REQUIREDand write nothing).pages pullwritespages/<locale>/index.jsonandpages/<locale>/routes/<path>/index.json, so the same URL in two languages no longer shares one file.pages pushpreflights the whole directory and the server refuses the batch before any write on an invalid file, duplicate target or folder/locale mismatch; a failure after preflight prints the per-file result. Newpages check,pages push --dry-runandpages migrate-layout [--dry-run | --write]. Pull validates every server path, never follows symlinks, never writes outside the target directory and stages its writes (0.8.0 could write a server-supplied../path outside it). An incomplete export prints every reason withPAGES_EXPORT_INCOMPLETEand writes nothing. Limits: 500 page files, 2 MiB per file, 4 MiB total.0.8.0 — Page media decisions over the public v1 API:
pages media-uses <page-handle>lists a page's localized-media decisions on its newest draft, andpages media-decide <page-handle> --decisions <file.json>applies a batch of decisions to that draft atomically (all or nothing; a replayed batch answers "No changes"). Both use a v1 API key (blcf_live_…, scopespages:read/pages:write) — thebcf_dev token is not accepted.blocofy login --api-keystores the key from a hidden prompt; the flag deliberately takes no value so the key never enters argv or shell history, and a non-interactive shell withoutBLOCOFY_API_KEY+BLOCOFY_API_URLexits without writing. The API pair lives next to the dev pair in~/.blocofy/credentials.json(still 0600): logging in one way keeps the other. Exit codes for the new commands: 0 success, 1 usage/auth/network/5xx (no automatic retry), 2 server refusal (4xx, with the server's{error}JSON on stderr). Existing commands and the dev-token flow are unchanged.0.7.0 —
theme push --prune: files that exist on the platform but no longer exist locally are removed. Until now a push merged remote-only files back into the upload, so a file deleted locally stayed on the theme forever. The list is printed before anything is written; pruning the LIVE theme (--live, or--instancepointing at the live theme) asks for confirmation or--yes, and a non-interactive shell without--yesexits without writing. Removal goes through the platform's atomic deploy, so it is revision-tracked. A draft push also no longer creates a draft as a side effect of its pre-push remote read: the CLI probes the existing CLI draft by handle and skips the probe when there is none. Previously a failure during that read left an empty draft behind and printed a bareHTTP 500.0.6.0 —
theme dev's local view finally shows what you are working on. It used to render your DRAFT theme against the site's LIVE page content, so draft page documents were invisible; each render now carries the draft theme instance and the platform resolves that instance's pages. Three failures caused by a retired server endpoint (/api/dev/session, gone since the platform's preview-security work) are fixed at the root: draft sync was switched off whenever that endpoint failed even though sync never used it (it posts to/api/dev/theme);theme publishwithout--instancecalled it with no error handling and died with an empty message — it now resolves the CLI draft fromblocofy status(source: "import"); and an empty response body produced the undiagnosabledev session unavailable ()— errors now fall back toHTTP <status>and a 410 explains itself. If the server ever stops returning a draft handle, the CLI now says so instead of silently rendering live content. Remote (shared-link and editor) preview is NOT restored — that surface was retired on the platform side;theme devprints the local view only.0.5.0 — M4 canonical deploy protocol.
theme pushnow deploys through the platform's atomic source pipeline: the CLI declares the protocol handshake and auto-generates a per-push idempotency key (transport retries converge;--idempotency-key <k>overrides it for scripting). A--livepush is finally visible on the pinned production render again. "Push does not delete" still holds — remote-only files are carried along, and server-retained rows survive. New flags:--dry-run/--validate(server-side validation, nothing written — refused against a server that predates the protocol, which would otherwise silently write),--diff(read-only compare vs the LIVE theme).--helpon ANY subcommand now prints help and never runs the command (0.4.0 executed a real push); unknown flags exit write-free instead of silently swallowing the next argument.layer/joined the synced theme directories. Server errorscli_upgrade_required(426) andidempotency_conflict(409) get human messages. Token format check aligned to the server minimum (total length ≥24 incl. thebcf_prefix; real tokens are 47 chars). Two honest costs of the canonical pipeline: re-pushing identical bytes still records a NEW deployment (the revision itself is deduplicated server-side; on a repo-linked live target it also produces a reconcile bot commit), and the no-delete guarantee is bought by carrying remote-only files in the payload — a very large theme (local+remote > 256 files or > 4 MiB) can now hit the server's payload limits where 0.4.0's upsert did not.0.4.0 —
theme push/theme devaccept--name <name>to name a new draft (applied only when the draft is first created; reuse ignores it). Newtheme rename <handle> <new name>renames a theme (the name is a label; works on any theme, including the live one).0.3.0 —
theme pushnow writes to a draft by default; use--livefor the old immediate-live behavior (with a confirmation prompt;--yesto skip it in CI).blocofy statusnow names the exact pages at 404 risk when a theme's pages are split off the live theme.
How it works
theme dev starts a local HTTP server. For each page request it reads your local theme
files and sends them to the platform's dev-render endpoint (/api/dev/render). The platform
renders them with the site's live data and returns HTML — so the CLI ships no rendering
engine and you see exactly the production output.
It also continuously syncs your local files to a draft theme, so the same work can be opened
in the admin theme editor without affecting your published theme. Save a file and the local view
reloads. Publish the draft from the theme editor, or with blocofy theme publish, when you're ready.
Credentials live in ~/.blocofy/credentials.json (contexts, no secrets) + either
~/.blocofy/secrets.json (0600) or the macOS keychain — written by blocofy login, or the
BLOCOFY_URL + BLOCOFY_TOKEN environment variables (for CI/automation). The v1 API commands
(pages media-uses/media-decide, site plan/export/publish) use a separate
blcf_live_… key from the same file (written by blocofy login --api-key) or BLOCOFY_API_KEY
BLOCOFY_API_URL— both variables together; with only one set the CLI fails instead of falling back to the file.
Theme structure
A theme is a directory of files grouped by top-level folder:
| Folder | Contents |
| --- | --- |
| layout/ | Page shell (theme.liquid) |
| section/ | Page sections (Hero.liquid, FeaturedCards.liquid, …) |
| block/ | Repeatable pieces used inside sections |
| partial/ | Shared snippets (header/footer, …) |
| asset/ | CSS and static files (theme.css) |
| locales/ | Legacy Liquid translation files (<tag>.json, <tag>.default.json) |
| 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 |
| config/settings.json | Theme settings + color schemes (blocofy settings pull; settings push needs `--i
