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