@blocofy/cli 0.7.0 → 0.9.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 +60 -2
- package/bin/blocofy.mjs +360 -27
- package/lib/args.mjs +5 -2
- package/lib/content-sync.mjs +272 -44
- package/lib/credentials.mjs +57 -15
- package/lib/media-uses.mjs +82 -0
- package/lib/page-files.mjs +308 -0
- package/lib/page-path-codec.mjs +193 -0
- package/lib/secret-prompt.mjs +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,12 +29,67 @@ blocofy theme push [dir] # Write the local theme to a DRAFT by default (create/
|
|
|
29
29
|
blocofy theme rename <handle> <new name>
|
|
30
30
|
# Rename a theme (label only). Works on any theme, live included.
|
|
31
31
|
# Handle from the panel theme card or `blocofy status`.
|
|
32
|
+
blocofy login --api-key [--api-url <url>]
|
|
33
|
+
# Save a v1 API key (blcf_live_…) for the `pages media-*` commands.
|
|
34
|
+
# The flag takes NO value: the key is typed into a hidden prompt, so
|
|
35
|
+
# it never lands in argv or shell history. Non-interactive shells use
|
|
36
|
+
# BLOCOFY_API_KEY + BLOCOFY_API_URL instead (no prompt, nothing
|
|
37
|
+
# written without them). --api-url defaults to https://app.blocofy.com.
|
|
38
|
+
blocofy pages media-uses <page-handle> [--json]
|
|
39
|
+
# List a page's localized-media decisions on its newest draft
|
|
40
|
+
# (v1 API, pages:read) with the draft revision id/version.
|
|
41
|
+
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
42
|
+
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
43
|
+
# Apply the file's { "decisions": [...] } (max 20) to the draft
|
|
44
|
+
# atomically (v1 API, pages:write). Without the --expected-* pair
|
|
45
|
+
# the CLI GETs the draft first and uses its current revision;
|
|
46
|
+
# items without idempotency_key get a random UUID. Exit 0 applied
|
|
47
|
+
# (or "No changes" when already recorded), 1 usage/auth/network/5xx
|
|
48
|
+
# (no retry), 2 server refusal (4xx) — {error} JSON on stderr.
|
|
49
|
+
blocofy pages pull [dir] [--strict]
|
|
50
|
+
# Download published pages, one folder per language:
|
|
51
|
+
# pages/<locale>/index.json and pages/<locale>/routes/<path>/index.json.
|
|
52
|
+
# Old-layout files are reported, never deleted or overwritten. If the site
|
|
53
|
+
# cannot export every page, nothing is written (PAGES_EXPORT_INCOMPLETE).
|
|
54
|
+
blocofy pages push [dir] [--dry-run] [--strict]
|
|
55
|
+
# Update EXISTING pages only. Every file is checked first; any invalid file,
|
|
56
|
+
# duplicate target or folder/locale mismatch changes NO page. An error after
|
|
57
|
+
# that check may leave some pages applied: the per-file result is printed,
|
|
58
|
+
# exit 1, and re-running is safe. --dry-run checks on the server, writes nothing.
|
|
59
|
+
blocofy pages check [dir] [--strict]
|
|
60
|
+
# Offline file/path/layout checks; with login also the server dry run.
|
|
61
|
+
blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
|
|
62
|
+
# Move old-layout files (pages/<slug>.json) to language folders. Any
|
|
63
|
+
# ambiguity or conflict moves nothing (exit 1).
|
|
32
64
|
blocofy --version
|
|
33
65
|
blocofy --help
|
|
34
66
|
```
|
|
35
67
|
|
|
36
68
|
## Changelog
|
|
37
69
|
|
|
70
|
+
- **0.9.0** — Locale-aware page files for multilingual sites (needs a platform with page file protocol 2;
|
|
71
|
+
against an older server page commands stop with `PAGES_SERVER_UPGRADE_REQUIRED` and write nothing).
|
|
72
|
+
`pages pull` writes `pages/<locale>/index.json` and `pages/<locale>/routes/<path>/index.json`, so the same
|
|
73
|
+
URL in two languages no longer shares one file. `pages push` preflights the whole directory and the server
|
|
74
|
+
refuses the batch before any write on an invalid file, duplicate target or folder/locale mismatch; a
|
|
75
|
+
failure after preflight prints the per-file result. New `pages check`, `pages push --dry-run` and
|
|
76
|
+
`pages migrate-layout [--dry-run | --write]`. Pull validates every server path, never follows symlinks,
|
|
77
|
+
never writes outside the target directory and stages its writes (0.8.0 could write a server-supplied
|
|
78
|
+
`../` path outside it). An incomplete export prints every reason with `PAGES_EXPORT_INCOMPLETE` and writes
|
|
79
|
+
nothing. Limits: 500 page files, 2 MiB per file, 4 MiB total.
|
|
80
|
+
- **0.8.0** — Page media decisions over the public v1 API: `pages media-uses <page-handle>` lists a
|
|
81
|
+
page's localized-media decisions on its newest draft, and `pages media-decide <page-handle>
|
|
82
|
+
--decisions <file.json>` applies a batch of decisions to that draft atomically (all or nothing;
|
|
83
|
+
a replayed batch answers "No changes"). Both use a **v1 API key** (`blcf_live_…`, scopes
|
|
84
|
+
`pages:read` / `pages:write`) — the `bcf_` dev token is not accepted. `blocofy login --api-key`
|
|
85
|
+
stores the key from a **hidden prompt**; the flag deliberately takes no value so the key never
|
|
86
|
+
enters argv or shell history, and a non-interactive shell without `BLOCOFY_API_KEY` +
|
|
87
|
+
`BLOCOFY_API_URL` exits without writing. The API pair lives next to the dev pair in
|
|
88
|
+
`~/.blocofy/credentials.json` (still 0600): logging in one way keeps the other. Exit codes for
|
|
89
|
+
the new commands: 0 success, 1 usage/auth/network/5xx (no automatic retry), 2 server refusal
|
|
90
|
+
(4xx, with the server's `{error}` JSON on stderr). Existing commands and the dev-token flow are
|
|
91
|
+
unchanged.
|
|
92
|
+
|
|
38
93
|
- **0.7.0** — `theme push --prune`: files that exist on the platform but no longer exist locally are
|
|
39
94
|
removed. Until now a push merged remote-only files back into the upload, so a file deleted locally
|
|
40
95
|
stayed on the theme forever. The list is printed before anything is written; pruning the LIVE theme
|
|
@@ -94,7 +149,10 @@ editor — without affecting your published theme. Save a file and **every open
|
|
|
94
149
|
the draft from the theme editor when you're ready.
|
|
95
150
|
|
|
96
151
|
Credentials come from `~/.blocofy/credentials.json` (written by `blocofy login`) or the
|
|
97
|
-
`BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation).
|
|
152
|
+
`BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation). The v1 API commands
|
|
153
|
+
(`pages media-uses`, `pages media-decide`) use a separate `blcf_live_…` key from the same file
|
|
154
|
+
(written by `blocofy login --api-key`) or `BLOCOFY_API_KEY` + `BLOCOFY_API_URL` — both variables
|
|
155
|
+
together; with only one set the CLI fails instead of falling back to the file.
|
|
98
156
|
|
|
99
157
|
## Theme structure
|
|
100
158
|
|
|
@@ -107,7 +165,7 @@ A theme is a directory of files grouped by top-level folder:
|
|
|
107
165
|
| `block/` | Repeatable pieces used inside sections |
|
|
108
166
|
| `partial/` | Shared snippets (header/footer, …) |
|
|
109
167
|
| `asset/` | CSS and static files (`theme.css`) |
|
|
110
|
-
| `pages/<
|
|
168
|
+
| `pages/<locale>/index.json`, `pages/<locale>/routes/<path>/index.json` | Page content, one folder per language (`blocofy pages pull/push/check/migrate-layout`). The old `pages/<slug>.json` layout is still read, with a warning |
|
|
111
169
|
| `config/settings.json` | Theme settings + color schemes |
|
|
112
170
|
| `config/settings_schema.json` | Theme settings panel schema (synced with `pull`/`push`) |
|
|
113
171
|
|
package/bin/blocofy.mjs
CHANGED
|
@@ -9,17 +9,20 @@
|
|
|
9
9
|
* livereload. No monorepo required.
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
15
|
import { createInterface } from "node:readline/promises";
|
|
16
16
|
|
|
17
17
|
import { parseArgs } from "../lib/args.mjs";
|
|
18
|
-
import { pullContent, pushContent } from "../lib/content-sync.mjs";
|
|
19
|
-
import {
|
|
18
|
+
import { checkPages, migrateLayout, pullContent, pushContent } from "../lib/content-sync.mjs";
|
|
19
|
+
import { PagesCliError, formatDiagnostic } from "../lib/page-files.mjs";
|
|
20
|
+
import { credentialsPath, loadApiCredentials, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
|
|
20
21
|
import { startDevServer } from "../lib/dev-server.mjs";
|
|
21
22
|
import { readLocalTemplates } from "../lib/local-theme.mjs";
|
|
23
|
+
import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
|
|
22
24
|
import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
|
|
25
|
+
import { promptSecret } from "../lib/secret-prompt.mjs";
|
|
23
26
|
import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
|
|
24
27
|
import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
|
|
25
28
|
import { hyperlink, openUrl } from "../lib/term.mjs";
|
|
@@ -32,14 +35,21 @@ const args = process.argv.slice(2);
|
|
|
32
35
|
// bayrak YAZIMSIZ çıkışla reddedilir (eskiden sonraki token'ı değer olarak yutup hedef dizini sessizce
|
|
33
36
|
// kaydırıyordu). `--confirm` ve `--dry` belgelenmemiş ama gerçek bayraklardır; `--dry` (theme dev'in
|
|
34
37
|
// sunucu-başlatmama kancası) `--dry-run`'dan (sunucu-tarafı doğrulama) FARKLIDIR, alias değildir.
|
|
38
|
+
// 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
|
|
39
|
+
// BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
|
|
35
40
|
const KNOWN = {
|
|
36
|
-
login: ["url", "token"],
|
|
41
|
+
login: ["url", "token", "api-key", "api-url"],
|
|
42
|
+
pages: ["decisions", "expected-revision-id", "expected-version", "json"],
|
|
37
43
|
themePull: ["draft", "instance"],
|
|
38
44
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
|
|
39
45
|
themeDev: ["port", "dry", "no-sync", "name"],
|
|
40
46
|
themePublish: ["instance"],
|
|
41
47
|
themeRename: ["name"],
|
|
42
48
|
content: [],
|
|
49
|
+
pagesPull: ["strict"],
|
|
50
|
+
pagesPush: ["dry-run", "strict"],
|
|
51
|
+
pagesCheck: ["strict"],
|
|
52
|
+
pagesMigrate: ["dry-run", "write", "strict"],
|
|
43
53
|
};
|
|
44
54
|
function parseArgsOrExit(rest, known) {
|
|
45
55
|
const parsed = parseArgs(rest, new Set([...known, "help", "version"]));
|
|
@@ -66,6 +76,13 @@ Usage
|
|
|
66
76
|
Save your platform URL + dev token to ~/.blocofy/credentials.json.
|
|
67
77
|
Get a token from the admin panel → Settings → Theme CLI tokens.
|
|
68
78
|
|
|
79
|
+
blocofy login --api-key [--api-url <url>]
|
|
80
|
+
Save a v1 API key (blcf_live_…) for the \`pages media-*\` commands. The key is read
|
|
81
|
+
from a HIDDEN prompt — the flag takes no value, so the key never lands in your shell
|
|
82
|
+
history. Kept alongside the dev token in the same credentials file.
|
|
83
|
+
--api-url <url> API origin (default https://app.blocofy.com)
|
|
84
|
+
Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
|
|
85
|
+
|
|
69
86
|
blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
|
|
70
87
|
Start a dev server and print 3 auto-reloading views — Local, live-domain
|
|
71
88
|
Preview, and the theme Editor. Press l / p / e to open each, q to quit.
|
|
@@ -113,10 +130,47 @@ Usage
|
|
|
113
130
|
Show the live theme, page distribution per instance, drafts, and a health flag
|
|
114
131
|
(ok / live_instance_empty / pages_split). Run before/after publishing.
|
|
115
132
|
|
|
116
|
-
blocofy pages pull [dir]
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
blocofy pages pull [dir] [--strict]
|
|
134
|
+
Download published pages, one folder per language:
|
|
135
|
+
pages/<locale>/index.json the home page ("/")
|
|
136
|
+
pages/<locale>/routes/<path>/index.json every other page ("/about" → routes/about/index.json)
|
|
137
|
+
Files from the old layout (pages/<slug>.json) are reported, never deleted or overwritten.
|
|
138
|
+
If the site cannot export every published page, nothing is written, every reason is
|
|
139
|
+
printed (PAGES_EXPORT_INCOMPLETE) and the exit code is 1 (--strict: warnings also exit 1).
|
|
140
|
+
|
|
141
|
+
blocofy pages push [dir] [--dry-run] [--strict]
|
|
142
|
+
Write pages/**.json to the site. Updates EXISTING pages only — never creates or
|
|
143
|
+
deletes a page; unchanged pages are skipped. Every file is checked first: if any
|
|
144
|
+
file is invalid, two files point at the same page, or a folder's language does not
|
|
145
|
+
match the file's "locale", NO page is changed. If publishing then stops unexpectedly
|
|
146
|
+
(PAGES_APPLY_FAILED or another publish error), some pages may already be applied: the per-file
|
|
147
|
+
result printed is authoritative, the exit code is 1, and running the push again is safe.
|
|
148
|
+
--dry-run: check on the server, write nothing.
|
|
149
|
+
Needs a platform that supports language folders (else PAGES_SERVER_UPGRADE_REQUIRED).
|
|
150
|
+
|
|
151
|
+
blocofy pages check [dir] [--strict]
|
|
152
|
+
Check page files. Offline: paths, JSON, layout, duplicates. Logged in: also the
|
|
153
|
+
site's languages and the server-side dry run. Exit 1 on errors (--strict: warnings too).
|
|
154
|
+
|
|
155
|
+
blocofy pages migrate-layout [dir] [--dry-run | --write] [--strict]
|
|
156
|
+
Move old-layout files (pages/<slug>.json) to language folders. --dry-run (default)
|
|
157
|
+
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).
|
|
159
|
+
|
|
160
|
+
blocofy pages media-uses <page-handle> [--json]
|
|
161
|
+
List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
|
|
162
|
+
Prints the draft's revision id/version needed by media-decide. --json: raw response.
|
|
163
|
+
|
|
164
|
+
blocofy pages media-decide <page-handle> --decisions <file.json>
|
|
165
|
+
[--expected-revision-id <n> --expected-version <n>] [--json]
|
|
166
|
+
Apply one or more media decisions to the page's draft atomically (v1 API, pages:write).
|
|
167
|
+
The file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?,
|
|
168
|
+
decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
|
|
169
|
+
flags the CLI first GETs the draft and uses its current revision id/version; items
|
|
170
|
+
without an idempotency_key get a random UUID.
|
|
171
|
+
Exit codes: 0 applied (or "No changes" when every item was already recorded);
|
|
172
|
+
1 usage/auth/network/5xx (no retry); 2 the server refused (4xx) — the {error} JSON
|
|
173
|
+
is printed to stderr.
|
|
120
174
|
|
|
121
175
|
blocofy settings pull [dir] / settings push [dir]
|
|
122
176
|
Download / upload config/settings.json (theme tokens/settings + color schemes).
|
|
@@ -129,8 +183,12 @@ Examples
|
|
|
129
183
|
blocofy theme pull && blocofy theme dev
|
|
130
184
|
blocofy theme push && blocofy theme publish
|
|
131
185
|
blocofy status
|
|
186
|
+
blocofy login --api-key
|
|
187
|
+
blocofy pages media-uses pg_abc123 --json
|
|
188
|
+
blocofy pages media-decide pg_abc123 --decisions decisions.json
|
|
132
189
|
|
|
133
190
|
Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
|
|
191
|
+
v1 API (pages media-*): \`login --api-key\`, or BLOCOFY_API_KEY + BLOCOFY_API_URL env vars.
|
|
134
192
|
The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
|
|
135
193
|
plain Liquid + static assets.`);
|
|
136
194
|
}
|
|
@@ -146,8 +204,60 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
|
|
|
146
204
|
process.exit(1);
|
|
147
205
|
}
|
|
148
206
|
|
|
207
|
+
/**
|
|
208
|
+
* `blocofy login --api-key [--api-url <url>]` — v1 API key login (0.8.0, D3 §4.7).
|
|
209
|
+
* The key is NEVER taken from argv: a hidden prompt on a TTY, BLOCOFY_API_KEY otherwise.
|
|
210
|
+
* Non-TTY without the env var → message + exit 1, nothing written (fail-closed). No message
|
|
211
|
+
* printed by this function ever contains the key (not even a prefix).
|
|
212
|
+
*/
|
|
213
|
+
async function loginApiKey(flags, positionals) {
|
|
214
|
+
if (positionals.length > 0 || flags.url !== undefined || flags.token !== undefined) {
|
|
215
|
+
console.error("`login --api-key` takes no value and no --url/--token: the API key is read from a hidden prompt (or BLOCOFY_API_KEY). Nothing was written.");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
let apiUrl;
|
|
219
|
+
if (typeof flags["api-url"] === "string") apiUrl = normalizeUrl(flags["api-url"]);
|
|
220
|
+
else if (process.env.BLOCOFY_API_URL) apiUrl = normalizeUrl(process.env.BLOCOFY_API_URL);
|
|
221
|
+
else apiUrl = DEFAULT_API_URL;
|
|
222
|
+
if (!isValidUrl(apiUrl)) {
|
|
223
|
+
console.error("Invalid --api-url — must be a valid http(s):// URL. Nothing was written.");
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let apiKey = null;
|
|
228
|
+
if (process.stdin.isTTY) {
|
|
229
|
+
apiKey = await promptSecret("API key (blcf_live_…, hidden): ");
|
|
230
|
+
if (apiKey === null) {
|
|
231
|
+
console.error("Cancelled. Nothing was written.");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
apiKey = apiKey.trim();
|
|
235
|
+
} else if (process.env.BLOCOFY_API_KEY) {
|
|
236
|
+
apiKey = process.env.BLOCOFY_API_KEY.trim();
|
|
237
|
+
} else {
|
|
238
|
+
console.error("Non-interactive shell: `login --api-key` needs a terminal for the hidden prompt. Set BLOCOFY_API_KEY (+ BLOCOFY_API_URL) instead. Nothing was written.");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
if (!isValidApiKey(apiKey)) {
|
|
242
|
+
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
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
saveCredentials({ apiUrl, apiKey });
|
|
247
|
+
console.log(`✓ API key saved → ${credentialsPath()} (API: ${apiUrl})`);
|
|
248
|
+
console.log("Next: blocofy pages media-uses <page-handle>");
|
|
249
|
+
}
|
|
250
|
+
|
|
149
251
|
async function login(rest) {
|
|
150
|
-
const { flags } = parseArgsOrExit(rest, KNOWN.login);
|
|
252
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.login);
|
|
253
|
+
if (flags["api-key"] || flags["api-url"] !== undefined) {
|
|
254
|
+
if (!flags["api-key"]) {
|
|
255
|
+
console.error("--api-url is only used with --api-key. Nothing was written.");
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
await loginApiKey(flags, positionals);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
151
261
|
let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
|
|
152
262
|
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
153
263
|
|
|
@@ -451,26 +561,123 @@ async function themePush(rest) {
|
|
|
451
561
|
}
|
|
452
562
|
}
|
|
453
563
|
|
|
454
|
-
|
|
455
|
-
|
|
564
|
+
/** PS-19 — print platform/CLI findings; returns the exit code they imply. */
|
|
565
|
+
function reportPageDiagnostics(diagnostics, { strict = false } = {}) {
|
|
566
|
+
for (const d of diagnostics) (d.level === "error" ? console.error : console.warn)(formatDiagnostic(d));
|
|
567
|
+
const errors = diagnostics.filter((d) => d.level === "error").length;
|
|
568
|
+
const warnings = diagnostics.length - errors;
|
|
569
|
+
return errors > 0 || (strict && warnings > 0) ? 1 : 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
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
|
+
function localeLabel(p) {
|
|
589
|
+
return `${p.locale} ${p.slug}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function pagesPush(rest) {
|
|
593
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPush);
|
|
456
594
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
457
595
|
if (!existsSync(dir)) {
|
|
458
596
|
console.error(`Directory not found: ${dir}`);
|
|
459
597
|
process.exit(1);
|
|
460
598
|
}
|
|
461
599
|
const creds = requireCreds();
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
600
|
+
const dryRun = Boolean(flags["dry-run"]);
|
|
601
|
+
const result = await pushContent({ dir, url: creds.url, token: creds.token, scope: "pages", dryRun });
|
|
602
|
+
const code = reportPageDiagnostics(result.diagnostics ?? [], { strict: Boolean(flags.strict) });
|
|
603
|
+
const pages = result.pages ?? [];
|
|
604
|
+
const count = (k, v) => pages.filter((p) => p[k] === v).length;
|
|
605
|
+
const warnings = (result.diagnostics ?? []).filter((d) => d.level === "warning").length;
|
|
606
|
+
if (dryRun) {
|
|
607
|
+
console.log(`Preflight passed: ${count("action", "publish")} updates, ${count("action", "draft")} drafts, ${count("action", "unchanged")} unchanged, 0 conflicts, ${warnings} warning(s).`);
|
|
608
|
+
console.log("Dry run only; no pages were changed.");
|
|
468
609
|
} else {
|
|
610
|
+
for (const p of pages) if (p.outcome !== "unchanged") console.log(` ${p.outcome} ${localeLabel(p)} (${p.path})`);
|
|
469
611
|
console.log(
|
|
470
|
-
`Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped ` +
|
|
612
|
+
`Pages push: ${result.pagesUpdated} updated, ${result.pagesSkipped} skipped, ${warnings} warning(s) ` +
|
|
471
613
|
`(only existing pages are updated — none created or deleted).`,
|
|
472
614
|
);
|
|
473
615
|
}
|
|
616
|
+
process.exit(code);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function pagesPull(rest) {
|
|
620
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesPull);
|
|
621
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
622
|
+
const creds = requireCreds();
|
|
623
|
+
const { count, diagnostics } = await pullContent({ dir, url: creds.url, token: creds.token, scope: "pages" });
|
|
624
|
+
const code = reportPageDiagnostics(diagnostics, { strict: Boolean(flags.strict) });
|
|
625
|
+
console.log(`Downloaded ${count} page file(s) → ${dir}`);
|
|
626
|
+
process.exit(code);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function pagesCheck(rest) {
|
|
630
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesCheck);
|
|
631
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
632
|
+
if (!existsSync(dir)) {
|
|
633
|
+
console.error(`Directory not found: ${dir}`);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
const creds = loadCredentials();
|
|
637
|
+
const online = Boolean(creds?.url && creds?.token);
|
|
638
|
+
const r = await checkPages({ dir, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
639
|
+
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
640
|
+
const errors = r.diagnostics.filter((d) => d.level === "error").length;
|
|
641
|
+
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).`);
|
|
642
|
+
process.exit(code);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function pagesMigrate(rest) {
|
|
646
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pagesMigrate);
|
|
647
|
+
if (flags["dry-run"] && flags.write) {
|
|
648
|
+
console.error("Use either --dry-run or --write, not both. Nothing was moved.");
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
652
|
+
if (!existsSync(dir)) {
|
|
653
|
+
console.error(`Directory not found: ${dir}`);
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
const creds = loadCredentials();
|
|
657
|
+
const online = Boolean(creds?.url && creds?.token);
|
|
658
|
+
const write = Boolean(flags.write);
|
|
659
|
+
const r = await migrateLayout({ dir, write, ...(online ? { url: creds.url, token: creds.token } : {}) });
|
|
660
|
+
const code = reportPageDiagnostics(r.diagnostics, { strict: Boolean(flags.strict) });
|
|
661
|
+
for (const m of r.moves) console.log(` ${write && !r.refused ? "moved" : "move"} ${m.from} → ${m.to}`);
|
|
662
|
+
if (r.refused) console.error(`Migration refused; no files were moved.`);
|
|
663
|
+
else if (write) console.log(`Moved ${r.moved} file(s) to the language-folder layout.`);
|
|
664
|
+
else console.log(`${r.moves.length} file(s) would move. Dry run only; run with --write to move them.`);
|
|
665
|
+
process.exit(r.refused ? 1 : code);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function contentPush(scope, rest) {
|
|
669
|
+
const { positionals } = parseArgsOrExit(rest, KNOWN.content);
|
|
670
|
+
const dir = resolve(positionals[0] ?? process.cwd());
|
|
671
|
+
if (!existsSync(dir)) {
|
|
672
|
+
console.error(`Directory not found: ${dir}`);
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
const creds = requireCreds();
|
|
676
|
+
const result = await pushContent({ dir, url: creds.url, token: creds.token, scope });
|
|
677
|
+
console.log(
|
|
678
|
+
`Settings push: ${result.settingsUpdated ? "theme settings updated" : "theme settings unchanged"}, ` +
|
|
679
|
+
`${result.schemesUpserted} color scheme(s) upserted (${result.fileCount} file).`,
|
|
680
|
+
);
|
|
474
681
|
}
|
|
475
682
|
|
|
476
683
|
async function contentPull(scope, rest) {
|
|
@@ -478,7 +685,131 @@ async function contentPull(scope, rest) {
|
|
|
478
685
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
479
686
|
const creds = requireCreds();
|
|
480
687
|
const { count } = await pullContent({ dir, url: creds.url, token: creds.token, scope });
|
|
481
|
-
console.log(`Downloaded ${count}
|
|
688
|
+
console.log(`Downloaded ${count} settings file(s) → ${dir}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Resolve the v1 API pair (blcf_live_ key only) or exit 1 with guidance. Never prints the key. */
|
|
692
|
+
function requireApiCreds() {
|
|
693
|
+
let creds;
|
|
694
|
+
try {
|
|
695
|
+
creds = loadApiCredentials();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
console.error(error?.message ?? error);
|
|
698
|
+
process.exit(1);
|
|
699
|
+
}
|
|
700
|
+
if (!creds) {
|
|
701
|
+
console.error("API key required: run `blocofy login --api-key` (or set BLOCOFY_API_KEY + BLOCOFY_API_URL). The dev token (bcf_) is not accepted for the v1 API.");
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
if (!isValidApiKey(creds.apiKey)) {
|
|
705
|
+
console.error(`The stored API key (${creds.source}) is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
return creds;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** v1 refusal (4xx) → {error} JSON on stderr, exit 2; anything else → message, exit 1. */
|
|
712
|
+
function exitForApiError(error) {
|
|
713
|
+
if (error instanceof CliRefusal) {
|
|
714
|
+
console.error(JSON.stringify({ error: error.error }));
|
|
715
|
+
process.exit(2);
|
|
716
|
+
}
|
|
717
|
+
console.error(error?.message ?? error);
|
|
718
|
+
process.exit(1);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function printMediaUsesView(view) {
|
|
722
|
+
if (view.applicable === false) {
|
|
723
|
+
console.log(`Page ${view.page?.id}: media decisions not applicable (${view.reason}).`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const uses = Array.isArray(view.uses) ? view.uses : [];
|
|
727
|
+
console.log(`Page ${view.page?.id} — draft revision ${view.revision?.id} v${view.revision?.version} — ${view.locale} ← ${view.source_locale}`);
|
|
728
|
+
console.log(`${view.counts?.total ?? uses.length} use(s), ${view.counts?.blocked ?? 0} blocked`);
|
|
729
|
+
for (const u of uses) {
|
|
730
|
+
const marks = [u.stale ? "stale" : null, u.blocks ? "BLOCKS" : null, u.editable ? null : "read-only"].filter(Boolean);
|
|
731
|
+
const extra = marks.length ? ` [${marks.join(", ")}]` : "";
|
|
732
|
+
const reasons = Array.isArray(u.reasons) && u.reasons.length ? ` — ${u.reasons.join(", ")}` : "";
|
|
733
|
+
console.log(` ${u.decision.padEnd(9)} ${u.path} (${u.facet}, ${u.kind})${extra}${reasons}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function pagesMediaUses(rest) {
|
|
738
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
739
|
+
const page = positionals[0];
|
|
740
|
+
if (!page) {
|
|
741
|
+
console.error("Usage: blocofy pages media-uses <page-handle> [--json]");
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
const { apiUrl, apiKey } = requireApiCreds();
|
|
745
|
+
const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
|
|
746
|
+
if (flags.json) console.log(JSON.stringify(view, null, 2));
|
|
747
|
+
else printMediaUsesView(view);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function parseExpected(flags, name) {
|
|
751
|
+
const raw = flags[name];
|
|
752
|
+
if (raw === undefined) return null;
|
|
753
|
+
const n = Number(raw);
|
|
754
|
+
if (typeof raw !== "string" || !Number.isSafeInteger(n) || n < 0) {
|
|
755
|
+
console.error(`--${name} must be a non-negative integer.`);
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
return n;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function pagesMediaDecide(rest) {
|
|
762
|
+
const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
|
|
763
|
+
const page = positionals[0];
|
|
764
|
+
const file = typeof flags.decisions === "string" ? resolve(flags.decisions) : null;
|
|
765
|
+
if (!page || !file) {
|
|
766
|
+
console.error("Usage: blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--json]");
|
|
767
|
+
process.exit(1);
|
|
768
|
+
}
|
|
769
|
+
let parsed;
|
|
770
|
+
try {
|
|
771
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
772
|
+
} catch (error) {
|
|
773
|
+
console.error(`Cannot read decisions file ${file}: ${error?.message ?? error}`);
|
|
774
|
+
process.exit(1);
|
|
775
|
+
}
|
|
776
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions) || parsed.decisions.length === 0) {
|
|
777
|
+
console.error(`Decisions file must be { "decisions": [ ... ] } with at least one item: ${file}`);
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
let expectedRevisionId = parseExpected(flags, "expected-revision-id");
|
|
781
|
+
let expectedVersion = parseExpected(flags, "expected-version");
|
|
782
|
+
if ((expectedRevisionId === null) !== (expectedVersion === null)) {
|
|
783
|
+
console.error("--expected-revision-id and --expected-version must be given together (or both omitted to use the current draft).");
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const { apiUrl, apiKey } = requireApiCreds();
|
|
788
|
+
if (expectedRevisionId === null) {
|
|
789
|
+
const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
|
|
790
|
+
if (view.applicable === false) {
|
|
791
|
+
console.error(`Page ${page}: media decisions not applicable (${view.reason}). Nothing was written.`);
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
expectedRevisionId = view.revision.id;
|
|
795
|
+
expectedVersion = view.revision.version;
|
|
796
|
+
}
|
|
797
|
+
const decisions = parsed.decisions.map((item) =>
|
|
798
|
+
item && typeof item === "object" && typeof item.idempotency_key !== "string" ? { ...item, idempotency_key: randomUUID() } : item,
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
const out = await decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions });
|
|
802
|
+
if (flags.json) {
|
|
803
|
+
console.log(JSON.stringify(out, null, 2));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (out.written === false) {
|
|
807
|
+
console.log("No changes — the decisions were already recorded (replay).");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const applied = Array.isArray(out.applied) ? out.applied : [];
|
|
811
|
+
console.log(`✓ Applied ${applied.length} decision(s) → draft revision ${out.revision?.id} v${out.revision?.version}`);
|
|
812
|
+
for (const a of applied) console.log(` ${a.decision.padEnd(9)} ${a.path} (${a.facet})${a.replayed ? " [replayed]" : ""}`);
|
|
482
813
|
}
|
|
483
814
|
|
|
484
815
|
async function themeDev(rest) {
|
|
@@ -755,16 +1086,18 @@ if (first === "--version" || first === "-v") {
|
|
|
755
1086
|
console.error(error?.message ?? error);
|
|
756
1087
|
process.exit(1);
|
|
757
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);
|
|
758
1093
|
} else if (first === "pages" && rest[0] === "pull") {
|
|
759
|
-
|
|
760
|
-
console.error(error?.message ?? error);
|
|
761
|
-
process.exit(1);
|
|
762
|
-
});
|
|
1094
|
+
pagesPull(rest.slice(1)).catch(exitForPagesError);
|
|
763
1095
|
} else if (first === "pages" && rest[0] === "push") {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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);
|
|
768
1101
|
} else if (first === "settings" && rest[0] === "pull") {
|
|
769
1102
|
contentPull("settings", rest.slice(1)).catch((error) => {
|
|
770
1103
|
console.error(error?.message ?? error);
|
package/lib/args.mjs
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* (tema dizini) sanılmaz. Boolean flag'ler sonraki token'ı YUTMAZ.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
|
|
9
|
+
* gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
|
|
10
|
+
*/
|
|
11
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write"]);
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|