@blocofy/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,12 +29,42 @@ 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.
32
49
  blocofy --version
33
50
  blocofy --help
34
51
  ```
35
52
 
36
53
  ## Changelog
37
54
 
55
+ - **0.8.0** — Page media decisions over the public v1 API: `pages media-uses <page-handle>` lists a
56
+ page's localized-media decisions on its newest draft, and `pages media-decide <page-handle>
57
+ --decisions <file.json>` applies a batch of decisions to that draft atomically (all or nothing;
58
+ a replayed batch answers "No changes"). Both use a **v1 API key** (`blcf_live_…`, scopes
59
+ `pages:read` / `pages:write`) — the `bcf_` dev token is not accepted. `blocofy login --api-key`
60
+ stores the key from a **hidden prompt**; the flag deliberately takes no value so the key never
61
+ enters argv or shell history, and a non-interactive shell without `BLOCOFY_API_KEY` +
62
+ `BLOCOFY_API_URL` exits without writing. The API pair lives next to the dev pair in
63
+ `~/.blocofy/credentials.json` (still 0600): logging in one way keeps the other. Exit codes for
64
+ the new commands: 0 success, 1 usage/auth/network/5xx (no automatic retry), 2 server refusal
65
+ (4xx, with the server's `{error}` JSON on stderr). Existing commands and the dev-token flow are
66
+ unchanged.
67
+
38
68
  - **0.7.0** — `theme push --prune`: files that exist on the platform but no longer exist locally are
39
69
  removed. Until now a push merged remote-only files back into the upload, so a file deleted locally
40
70
  stayed on the theme forever. The list is printed before anything is written; pruning the LIVE theme
@@ -94,7 +124,10 @@ editor — without affecting your published theme. Save a file and **every open
94
124
  the draft from the theme editor when you're ready.
95
125
 
96
126
  Credentials come from `~/.blocofy/credentials.json` (written by `blocofy login`) or the
97
- `BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation).
127
+ `BLOCOFY_URL` + `BLOCOFY_TOKEN` environment variables (for CI/automation). The v1 API commands
128
+ (`pages media-uses`, `pages media-decide`) use a separate `blcf_live_…` key from the same file
129
+ (written by `blocofy login --api-key`) or `BLOCOFY_API_KEY` + `BLOCOFY_API_URL` — both variables
130
+ together; with only one set the CLI fails instead of falling back to the file.
98
131
 
99
132
  ## Theme structure
100
133
 
package/bin/blocofy.mjs CHANGED
@@ -9,17 +9,19 @@
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
18
  import { pullContent, pushContent } from "../lib/content-sync.mjs";
19
- import { credentialsPath, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
19
+ import { credentialsPath, loadApiCredentials, loadCredentials, saveCredentials } from "../lib/credentials.mjs";
20
20
  import { startDevServer } from "../lib/dev-server.mjs";
21
21
  import { readLocalTemplates } from "../lib/local-theme.mjs";
22
+ import { CliRefusal, DEFAULT_API_URL, decidePageMediaUses, fetchPageMediaUses, isValidApiKey } from "../lib/media-uses.mjs";
22
23
  import { githubNote, retryNotice, statusLine, syncScopeNote } from "../lib/messages.mjs";
24
+ import { promptSecret } from "../lib/secret-prompt.mjs";
23
25
  import { diffTheme, fetchCanonicalSupport, fetchDevSession, fetchSiteStatus, fetchWhoami, publishInstance, pullTheme, pushTheme, renameInstance } from "../lib/theme-sync.mjs";
24
26
  import { isAffirmative, livePushDecision, resolvePushMode } from "../lib/confirm.mjs";
25
27
  import { hyperlink, openUrl } from "../lib/term.mjs";
@@ -32,8 +34,11 @@ const args = process.argv.slice(2);
32
34
  // bayrak YAZIMSIZ çıkışla reddedilir (eskiden sonraki token'ı değer olarak yutup hedef dizini sessizce
33
35
  // kaydırıyordu). `--confirm` ve `--dry` belgelenmemiş ama gerçek bayraklardır; `--dry` (theme dev'in
34
36
  // sunucu-başlatmama kancası) `--dry-run`'dan (sunucu-tarafı doğrulama) FARKLIDIR, alias değildir.
37
+ // 0.8.0: `login --api-key` DEĞERSİZ bayraktır (parser'da boolean) — sır gizli prompt'tan ya da
38
+ // BLOCOFY_API_KEY'den gelir, argv'ye asla girmez. `pages media-uses` / `media-decide` v1 API komutlarıdır.
35
39
  const KNOWN = {
36
- login: ["url", "token"],
40
+ login: ["url", "token", "api-key", "api-url"],
41
+ pages: ["decisions", "expected-revision-id", "expected-version", "json"],
37
42
  themePull: ["draft", "instance"],
38
43
  themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune"],
39
44
  themeDev: ["port", "dry", "no-sync", "name"],
@@ -66,6 +71,13 @@ Usage
66
71
  Save your platform URL + dev token to ~/.blocofy/credentials.json.
67
72
  Get a token from the admin panel → Settings → Theme CLI tokens.
68
73
 
74
+ blocofy login --api-key [--api-url <url>]
75
+ Save a v1 API key (blcf_live_…) for the \`pages media-*\` commands. The key is read
76
+ from a HIDDEN prompt — the flag takes no value, so the key never lands in your shell
77
+ history. Kept alongside the dev token in the same credentials file.
78
+ --api-url <url> API origin (default https://app.blocofy.com)
79
+ Non-interactive shells: set BLOCOFY_API_KEY + BLOCOFY_API_URL instead.
80
+
69
81
  blocofy theme dev [dir] [--port <n>] [--no-sync] [--name <name>]
70
82
  Start a dev server and print 3 auto-reloading views — Local, live-domain
71
83
  Preview, and the theme Editor. Press l / p / e to open each, q to quit.
@@ -118,6 +130,21 @@ Usage
118
130
  push: write pages/*.json to the site. Updates EXISTING pages only —
119
131
  never creates or deletes a page; unchanged pages are skipped.
120
132
 
133
+ blocofy pages media-uses <page-handle> [--json]
134
+ List a page's localized-media decisions on its newest DRAFT (v1 API, pages:read).
135
+ Prints the draft's revision id/version needed by media-decide. --json: raw response.
136
+
137
+ blocofy pages media-decide <page-handle> --decisions <file.json>
138
+ [--expected-revision-id <n> --expected-version <n>] [--json]
139
+ Apply one or more media decisions to the page's draft atomically (v1 API, pages:write).
140
+ The file is { "decisions": [ { path, facet, decision, target_asset?, alt?, caption?,
141
+ decorative?, idempotency_key?, witness? } ] } (max 20). Without the two --expected-*
142
+ flags the CLI first GETs the draft and uses its current revision id/version; items
143
+ without an idempotency_key get a random UUID.
144
+ Exit codes: 0 applied (or "No changes" when every item was already recorded);
145
+ 1 usage/auth/network/5xx (no retry); 2 the server refused (4xx) — the {error} JSON
146
+ is printed to stderr.
147
+
121
148
  blocofy settings pull [dir] / settings push [dir]
122
149
  Download / upload config/settings.json (theme tokens/settings + color schemes).
123
150
 
@@ -129,8 +156,12 @@ Examples
129
156
  blocofy theme pull && blocofy theme dev
130
157
  blocofy theme push && blocofy theme publish
131
158
  blocofy status
159
+ blocofy login --api-key
160
+ blocofy pages media-uses pg_abc123 --json
161
+ blocofy pages media-decide pg_abc123 --decisions decisions.json
132
162
 
133
163
  Auth: ~/.blocofy/credentials.json (from \`login\`), or BLOCOFY_URL + BLOCOFY_TOKEN env vars.
164
+ v1 API (pages media-*): \`login --api-key\`, or BLOCOFY_API_KEY + BLOCOFY_API_URL env vars.
134
165
  The CLI does not build assets — bring your own (npm/Vite/Tailwind); the platform serves
135
166
  plain Liquid + static assets.`);
136
167
  }
@@ -146,8 +177,60 @@ async function promptValid(rl, question, normalize, valid, hint, tries = 3) {
146
177
  process.exit(1);
147
178
  }
148
179
 
180
+ /**
181
+ * `blocofy login --api-key [--api-url <url>]` — v1 API key login (0.8.0, D3 §4.7).
182
+ * The key is NEVER taken from argv: a hidden prompt on a TTY, BLOCOFY_API_KEY otherwise.
183
+ * Non-TTY without the env var → message + exit 1, nothing written (fail-closed). No message
184
+ * printed by this function ever contains the key (not even a prefix).
185
+ */
186
+ async function loginApiKey(flags, positionals) {
187
+ if (positionals.length > 0 || flags.url !== undefined || flags.token !== undefined) {
188
+ 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.");
189
+ process.exit(1);
190
+ }
191
+ let apiUrl;
192
+ if (typeof flags["api-url"] === "string") apiUrl = normalizeUrl(flags["api-url"]);
193
+ else if (process.env.BLOCOFY_API_URL) apiUrl = normalizeUrl(process.env.BLOCOFY_API_URL);
194
+ else apiUrl = DEFAULT_API_URL;
195
+ if (!isValidUrl(apiUrl)) {
196
+ console.error("Invalid --api-url — must be a valid http(s):// URL. Nothing was written.");
197
+ process.exit(1);
198
+ }
199
+
200
+ let apiKey = null;
201
+ if (process.stdin.isTTY) {
202
+ apiKey = await promptSecret("API key (blcf_live_…, hidden): ");
203
+ if (apiKey === null) {
204
+ console.error("Cancelled. Nothing was written.");
205
+ process.exit(1);
206
+ }
207
+ apiKey = apiKey.trim();
208
+ } else if (process.env.BLOCOFY_API_KEY) {
209
+ apiKey = process.env.BLOCOFY_API_KEY.trim();
210
+ } else {
211
+ 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.");
212
+ process.exit(1);
213
+ }
214
+ if (!isValidApiKey(apiKey)) {
215
+ 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.");
216
+ process.exit(1);
217
+ }
218
+
219
+ saveCredentials({ apiUrl, apiKey });
220
+ console.log(`✓ API key saved → ${credentialsPath()} (API: ${apiUrl})`);
221
+ console.log("Next: blocofy pages media-uses <page-handle>");
222
+ }
223
+
149
224
  async function login(rest) {
150
- const { flags } = parseArgsOrExit(rest, KNOWN.login);
225
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.login);
226
+ if (flags["api-key"] || flags["api-url"] !== undefined) {
227
+ if (!flags["api-key"]) {
228
+ console.error("--api-url is only used with --api-key. Nothing was written.");
229
+ process.exit(1);
230
+ }
231
+ await loginApiKey(flags, positionals);
232
+ return;
233
+ }
151
234
  let url = typeof flags.url === "string" ? normalizeUrl(flags.url) : "";
152
235
  let token = typeof flags.token === "string" ? flags.token.trim() : "";
153
236
 
@@ -481,6 +564,130 @@ async function contentPull(scope, rest) {
481
564
  console.log(`Downloaded ${count} ${scope === "settings" ? "settings" : "page"} file(s) → ${dir}`);
482
565
  }
483
566
 
567
+ /** Resolve the v1 API pair (blcf_live_ key only) or exit 1 with guidance. Never prints the key. */
568
+ function requireApiCreds() {
569
+ let creds;
570
+ try {
571
+ creds = loadApiCredentials();
572
+ } catch (error) {
573
+ console.error(error?.message ?? error);
574
+ process.exit(1);
575
+ }
576
+ if (!creds) {
577
+ 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.");
578
+ process.exit(1);
579
+ }
580
+ if (!isValidApiKey(creds.apiKey)) {
581
+ console.error(`The stored API key (${creds.source}) is not a v1 key — it must start with blcf_live_. Run \`blocofy login --api-key\`.`);
582
+ process.exit(1);
583
+ }
584
+ return creds;
585
+ }
586
+
587
+ /** v1 refusal (4xx) → {error} JSON on stderr, exit 2; anything else → message, exit 1. */
588
+ function exitForApiError(error) {
589
+ if (error instanceof CliRefusal) {
590
+ console.error(JSON.stringify({ error: error.error }));
591
+ process.exit(2);
592
+ }
593
+ console.error(error?.message ?? error);
594
+ process.exit(1);
595
+ }
596
+
597
+ function printMediaUsesView(view) {
598
+ if (view.applicable === false) {
599
+ console.log(`Page ${view.page?.id}: media decisions not applicable (${view.reason}).`);
600
+ return;
601
+ }
602
+ const uses = Array.isArray(view.uses) ? view.uses : [];
603
+ console.log(`Page ${view.page?.id} — draft revision ${view.revision?.id} v${view.revision?.version} — ${view.locale} ← ${view.source_locale}`);
604
+ console.log(`${view.counts?.total ?? uses.length} use(s), ${view.counts?.blocked ?? 0} blocked`);
605
+ for (const u of uses) {
606
+ const marks = [u.stale ? "stale" : null, u.blocks ? "BLOCKS" : null, u.editable ? null : "read-only"].filter(Boolean);
607
+ const extra = marks.length ? ` [${marks.join(", ")}]` : "";
608
+ const reasons = Array.isArray(u.reasons) && u.reasons.length ? ` — ${u.reasons.join(", ")}` : "";
609
+ console.log(` ${u.decision.padEnd(9)} ${u.path} (${u.facet}, ${u.kind})${extra}${reasons}`);
610
+ }
611
+ }
612
+
613
+ async function pagesMediaUses(rest) {
614
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
615
+ const page = positionals[0];
616
+ if (!page) {
617
+ console.error("Usage: blocofy pages media-uses <page-handle> [--json]");
618
+ process.exit(1);
619
+ }
620
+ const { apiUrl, apiKey } = requireApiCreds();
621
+ const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
622
+ if (flags.json) console.log(JSON.stringify(view, null, 2));
623
+ else printMediaUsesView(view);
624
+ }
625
+
626
+ function parseExpected(flags, name) {
627
+ const raw = flags[name];
628
+ if (raw === undefined) return null;
629
+ const n = Number(raw);
630
+ if (typeof raw !== "string" || !Number.isSafeInteger(n) || n < 0) {
631
+ console.error(`--${name} must be a non-negative integer.`);
632
+ process.exit(1);
633
+ }
634
+ return n;
635
+ }
636
+
637
+ async function pagesMediaDecide(rest) {
638
+ const { flags, positionals } = parseArgsOrExit(rest, KNOWN.pages);
639
+ const page = positionals[0];
640
+ const file = typeof flags.decisions === "string" ? resolve(flags.decisions) : null;
641
+ if (!page || !file) {
642
+ console.error("Usage: blocofy pages media-decide <page-handle> --decisions <file.json> [--expected-revision-id <n> --expected-version <n>] [--json]");
643
+ process.exit(1);
644
+ }
645
+ let parsed;
646
+ try {
647
+ parsed = JSON.parse(readFileSync(file, "utf8"));
648
+ } catch (error) {
649
+ console.error(`Cannot read decisions file ${file}: ${error?.message ?? error}`);
650
+ process.exit(1);
651
+ }
652
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.decisions) || parsed.decisions.length === 0) {
653
+ console.error(`Decisions file must be { "decisions": [ ... ] } with at least one item: ${file}`);
654
+ process.exit(1);
655
+ }
656
+ let expectedRevisionId = parseExpected(flags, "expected-revision-id");
657
+ let expectedVersion = parseExpected(flags, "expected-version");
658
+ if ((expectedRevisionId === null) !== (expectedVersion === null)) {
659
+ console.error("--expected-revision-id and --expected-version must be given together (or both omitted to use the current draft).");
660
+ process.exit(1);
661
+ }
662
+
663
+ const { apiUrl, apiKey } = requireApiCreds();
664
+ if (expectedRevisionId === null) {
665
+ const view = await fetchPageMediaUses({ apiUrl, apiKey, page });
666
+ if (view.applicable === false) {
667
+ console.error(`Page ${page}: media decisions not applicable (${view.reason}). Nothing was written.`);
668
+ process.exit(1);
669
+ }
670
+ expectedRevisionId = view.revision.id;
671
+ expectedVersion = view.revision.version;
672
+ }
673
+ const decisions = parsed.decisions.map((item) =>
674
+ item && typeof item === "object" && typeof item.idempotency_key !== "string" ? { ...item, idempotency_key: randomUUID() } : item,
675
+ );
676
+
677
+ const out = await decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions });
678
+ if (flags.json) {
679
+ console.log(JSON.stringify(out, null, 2));
680
+ return;
681
+ }
682
+ if (out.written === false) {
683
+ console.log("No changes — the decisions were already recorded (replay).");
684
+ return;
685
+ }
686
+ const applied = Array.isArray(out.applied) ? out.applied : [];
687
+ console.log(`✓ Applied ${applied.length} decision(s) → draft revision ${out.revision?.id} v${out.revision?.version}`);
688
+ for (const a of applied) console.log(` ${a.decision.padEnd(9)} ${a.path} (${a.facet})${a.replayed ? " [replayed]" : ""}`);
689
+ }
690
+
484
691
  async function themeDev(rest) {
485
692
  const { flags, positionals } = parseArgsOrExit(rest, KNOWN.themeDev);
486
693
  const themeDir = resolve(positionals[0] ?? process.cwd());
@@ -755,6 +962,10 @@ if (first === "--version" || first === "-v") {
755
962
  console.error(error?.message ?? error);
756
963
  process.exit(1);
757
964
  });
965
+ } else if (first === "pages" && rest[0] === "media-uses") {
966
+ pagesMediaUses(rest.slice(1)).catch(exitForApiError);
967
+ } else if (first === "pages" && rest[0] === "media-decide") {
968
+ pagesMediaDecide(rest.slice(1)).catch(exitForApiError);
758
969
  } else if (first === "pages" && rest[0] === "pull") {
759
970
  contentPull("pages", rest.slice(1)).catch((error) => {
760
971
  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
- /** Sonraki token'ı değer olarak almayan flag'ler. */
8
- const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version"]);
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"]);
9
12
 
10
13
  /**
11
14
  * `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
@@ -8,20 +8,43 @@ import { join } from "node:path";
8
8
  * variables (BLOCOFY_URL/BLOCOFY_TOKEN) take precedence over the file (CI/agents).
9
9
  * The token is stored in plaintext on disk (the usual dev-CLI pattern), so the file
10
10
  * is locked down to 0600.
11
+ *
12
+ * 0.8.0: the same file also holds the v1 API pair `{ apiUrl, apiKey }` written by
13
+ * `blocofy login --api-key`. The two pairs coexist: `saveCredentials(patch)` MERGES the
14
+ * patch into the existing JSON (an unreadable/corrupt file counts as `{}`), so an API login
15
+ * keeps the dev pair and a later dev login keeps the API pair. `loadCredentials()` (dev
16
+ * pair) is unchanged; `loadApiCredentials()` resolves the v1 pair (BLOCOFY_API_KEY +
17
+ * BLOCOFY_API_URL first, then the file).
11
18
  */
12
19
 
13
- const DIR = join(homedir(), ".blocofy");
14
- const FILE = join(DIR, "credentials.json");
20
+ const DIR_NAME = ".blocofy";
21
+ const FILE_NAME = "credentials.json";
22
+
23
+ // Resolved per call (not at import): tests redirect HOME to a temp dir, and os.homedir()
24
+ // honours $HOME on POSIX.
25
+ function dir() {
26
+ return join(homedir(), DIR_NAME);
27
+ }
15
28
 
16
29
  export function credentialsPath() {
17
- return FILE;
30
+ return join(dir(), FILE_NAME);
18
31
  }
19
32
 
20
- /** Write URL + token with 0600 permissions. */
21
- export function saveCredentials({ url, token }) {
22
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
23
- writeFileSync(FILE, JSON.stringify({ url, token }, null, 2) + "\n", { mode: 0o600 });
24
- chmodSync(FILE, 0o600); // create-mode is ignored if the file already exists → enforce it
33
+ function readExisting() {
34
+ try {
35
+ const data = JSON.parse(readFileSync(credentialsPath(), "utf8"));
36
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
37
+ } catch {
38
+ return {}; // missing/corrupt → start clean
39
+ }
40
+ }
41
+
42
+ /** Merge `patch` into the credentials file and write it with 0600 permissions. */
43
+ export function saveCredentials(patch) {
44
+ mkdirSync(dir(), { recursive: true, mode: 0o700 });
45
+ const merged = { ...readExisting(), ...patch };
46
+ writeFileSync(credentialsPath(), JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
47
+ chmodSync(credentialsPath(), 0o600); // create-mode is ignored if the file already exists → enforce it
25
48
  }
26
49
 
27
50
  /**
@@ -34,13 +57,32 @@ export function loadCredentials() {
34
57
  if (envUrl && envToken) {
35
58
  return { url: stripSlash(envUrl), token: envToken, source: "env" };
36
59
  }
37
- try {
38
- const data = JSON.parse(readFileSync(FILE, "utf8"));
39
- if (data && typeof data.url === "string" && typeof data.token === "string") {
40
- return { url: stripSlash(data.url), token: data.token, source: "file" };
41
- }
42
- } catch {
43
- // missing/corrupt file → null
60
+ const data = readExisting();
61
+ if (typeof data.url === "string" && typeof data.token === "string") {
62
+ return { url: stripSlash(data.url), token: data.token, source: "file" };
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Resolve the v1 API pair: BLOCOFY_API_KEY + BLOCOFY_API_URL (BOTH required — with only one
69
+ * set this throws and does NOT fall through to the file, so a half-configured CI job fails
70
+ * loudly instead of silently using a developer's file), then the file's `{ apiUrl, apiKey }`.
71
+ * Returns `{ apiUrl, apiKey, source }` or null. The thrown message never contains the key.
72
+ */
73
+ export function loadApiCredentials() {
74
+ const envKey = process.env.BLOCOFY_API_KEY;
75
+ const envUrl = process.env.BLOCOFY_API_URL;
76
+ if (envKey && envUrl) {
77
+ return { apiUrl: stripSlash(envUrl), apiKey: envKey, source: "env" };
78
+ }
79
+ if (envKey || envUrl) {
80
+ const missing = envKey ? "BLOCOFY_API_URL" : "BLOCOFY_API_KEY";
81
+ throw new Error(`${missing} is not set — BLOCOFY_API_KEY and BLOCOFY_API_URL must be set together (the credentials file is not consulted while either is set).`);
82
+ }
83
+ const data = readExisting();
84
+ if (typeof data.apiUrl === "string" && typeof data.apiKey === "string") {
85
+ return { apiUrl: stripSlash(data.apiUrl), apiKey: data.apiKey, source: "file" };
44
86
  }
45
87
  return null;
46
88
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * v1 client for a page's localized-media decisions (D3, CLI 0.8.0):
3
+ * GET /api/v1/pages/{id}/media-uses (pages:read) → PageMediaUsesView | PageMediaUsesNotApplicable
4
+ * POST /api/v1/pages/{id}/media-uses (pages:write) → DecidePageMediaUsesOutput
5
+ *
6
+ * Plan: multisite-cms docs/architecture/plans/2026-09-16-d3-page-media-use-public-surfaces.md §4.5–4.7.
7
+ *
8
+ * Authentication is a `blcf_live_…` API key (Bearer). The dev `bcf_…` token is NOT accepted for v1.
9
+ *
10
+ * NO RETRIES. A decision batch is applied atomically on the server but a lost response is only
11
+ * safe to replay with the SAME idempotency keys, which is the caller's decision, not this
12
+ * client's — so a 5xx/network failure surfaces once and the command exits 1. A 4xx is a server
13
+ * REFUSAL (`CliRefusal`): the `{ error }` envelope is carried verbatim for the command to print
14
+ * as JSON on stderr with exit code 2.
15
+ */
16
+
17
+ export const API_KEY_PREFIX = "blcf_live_";
18
+ export const DEFAULT_API_URL = "https://app.blocofy.com";
19
+
20
+ /** v1 accepts ONLY a live API key. Never echo the key back in a message. */
21
+ export function isValidApiKey(key) {
22
+ return typeof key === "string" && key.startsWith(API_KEY_PREFIX) && key.length > API_KEY_PREFIX.length;
23
+ }
24
+
25
+ /** A 4xx response from v1: `status` + the server's `error` object ({ code, message, details? }). */
26
+ export class CliRefusal extends Error {
27
+ constructor(status, error) {
28
+ super(`HTTP ${status} ${error?.code ?? ""}: ${error?.message ?? ""}`.trim());
29
+ this.name = "CliRefusal";
30
+ this.status = status;
31
+ this.error = error;
32
+ }
33
+ }
34
+
35
+ function mediaUsesUrl(apiUrl, page) {
36
+ return `${String(apiUrl).replace(/\/+$/, "")}/api/v1/pages/${encodeURIComponent(page)}/media-uses`;
37
+ }
38
+
39
+ async function request(method, { apiUrl, apiKey, page }, body) {
40
+ const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
41
+ if (body !== undefined) headers["content-type"] = "application/json";
42
+ const res = await fetch(mediaUsesUrl(apiUrl, page), {
43
+ method,
44
+ headers,
45
+ body: body === undefined ? undefined : JSON.stringify(body),
46
+ });
47
+ const text = await res.text();
48
+ let json;
49
+ try {
50
+ json = text ? JSON.parse(text) : null;
51
+ } catch {
52
+ json = null;
53
+ }
54
+ if (res.ok) {
55
+ if (json === null) throw new Error(`HTTP ${res.status}: empty or non-JSON response`);
56
+ return json;
57
+ }
58
+ if (res.status >= 400 && res.status < 500) {
59
+ const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
60
+ throw new CliRefusal(res.status, error);
61
+ }
62
+ // 5xx (no retry) — the message never includes the request headers.
63
+ const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
64
+ throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`);
65
+ }
66
+
67
+ /** GET the page's media-use view (the newest draft's `revision{id,version}` is inside). */
68
+ export function fetchPageMediaUses({ apiUrl, apiKey, page }) {
69
+ return request("GET", { apiUrl, apiKey, page });
70
+ }
71
+
72
+ /**
73
+ * POST a decision batch. The body is exactly `{ expected_revision_id, expected_version, decisions }`
74
+ * — items are sent as given (idempotency keys are the caller's responsibility).
75
+ */
76
+ export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions }) {
77
+ return request(
78
+ "POST",
79
+ { apiUrl, apiKey, page },
80
+ { expected_revision_id: expectedRevisionId, expected_version: expectedVersion, decisions },
81
+ );
82
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Hidden (non-echoing) secret prompt for `blocofy login --api-key` (0.8.0, D3 §4.7).
3
+ *
4
+ * Reads one line from a TTY in raw mode so the key never echoes to the terminal (and so it is
5
+ * never in argv or shell history — the flag takes no value). On a non-TTY stdin it resolves
6
+ * `null` WITHOUT reading anything: the caller must then use BLOCOFY_API_KEY or fail closed.
7
+ * Backspace edits, Enter finishes, Ctrl-C / Ctrl-D (empty) cancel with `null`. Raw mode is
8
+ * always restored.
9
+ */
10
+ export function promptSecret(question, { input = process.stdin, output = process.stdout } = {}) {
11
+ if (!input.isTTY || typeof input.setRawMode !== "function") return Promise.resolve(null);
12
+ return new Promise((resolve) => {
13
+ let buffer = "";
14
+ const finish = (value) => {
15
+ input.removeListener("data", onData);
16
+ input.setRawMode(false);
17
+ input.pause();
18
+ output.write("\n");
19
+ resolve(value);
20
+ };
21
+ const onData = (chunk) => {
22
+ for (const ch of String(chunk)) {
23
+ if (ch === "\r" || ch === "\n") return finish(buffer);
24
+ if (ch === "") return finish(null); // Ctrl-C
25
+ if (ch === "") return finish(buffer.length ? buffer : null); // Ctrl-D
26
+ if (ch === "" || ch === "\b") {
27
+ buffer = buffer.slice(0, -1);
28
+ continue;
29
+ }
30
+ buffer += ch;
31
+ }
32
+ };
33
+ output.write(question);
34
+ input.setRawMode(true);
35
+ input.setEncoding("utf8");
36
+ input.resume();
37
+ input.on("data", onData);
38
+ });
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocofy/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Develop your Blocofy theme locally against live data with instant preview — login, theme dev, pull, push.",
5
5
  "type": "module",
6
6
  "license": "MIT",