@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/lib/content-sync.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
2
|
-
import { dirname, join
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PagesCliError,
|
|
6
|
+
checkLocalBatch,
|
|
7
|
+
folderConflicts,
|
|
8
|
+
classifyPageFile,
|
|
9
|
+
collectPageFiles,
|
|
10
|
+
pageFileSemanticKey,
|
|
11
|
+
pageFilePath,
|
|
12
|
+
resolveContained,
|
|
13
|
+
serializeV2PageFile,
|
|
14
|
+
stagedWrite,
|
|
15
|
+
} from "./page-files.mjs";
|
|
16
|
+
import { checkRelativePagePath, parsePageFilePath } from "./page-path-codec.mjs";
|
|
3
17
|
|
|
4
18
|
/**
|
|
5
|
-
* `blocofy pages pull/push` + `settings pull/push` (#119).
|
|
6
|
-
*
|
|
7
|
-
* `/api/dev/content`
|
|
8
|
-
*
|
|
9
|
-
* günceller, yeni oluşturmaz, silmez.
|
|
19
|
+
* `blocofy pages pull/push/check/migrate-layout` + `settings pull/push` (#119, PS-19). Page files use the
|
|
20
|
+
* locale-aware layout (`pages/<locale>/index.json`, `pages/<locale>/routes/…/index.json`); the platform's
|
|
21
|
+
* `/api/dev/content` protocol v2 is required for every page operation. Tema KODU `theme pull/push`'a gider.
|
|
22
|
+
* Güvenlik platformda: push yalnız mevcut sayfayı günceller, yeni oluşturmaz, silmez.
|
|
10
23
|
*/
|
|
11
24
|
|
|
25
|
+
export const PAGES_PROTOCOL_VERSION = 2;
|
|
26
|
+
|
|
12
27
|
async function errorText(res) {
|
|
13
28
|
const text = await res.text();
|
|
14
29
|
try {
|
|
@@ -18,7 +33,9 @@ async function errorText(res) {
|
|
|
18
33
|
}
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
const baseOf = (url) => url.replace(/\/+$/, "");
|
|
37
|
+
|
|
38
|
+
/** scope "pages" → pages/**.json (lexical order, no symlinks); "settings" → config/settings.json. `{path: content}`. */
|
|
22
39
|
export function readContentFiles(dir, scope) {
|
|
23
40
|
const out = {};
|
|
24
41
|
if (scope === "settings") {
|
|
@@ -26,52 +43,263 @@ export function readContentFiles(dir, scope) {
|
|
|
26
43
|
if (existsSync(p)) out["config/settings.json"] = readFileSync(p, "utf8");
|
|
27
44
|
return out;
|
|
28
45
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const { files, errors } = collectPageFiles(dir);
|
|
47
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
48
|
+
for (const [path, content] of files) out[path] = content;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The server's page capabilities, or `PAGES_SERVER_UPGRADE_REQUIRED`. An old server answers `scope=capabilities`
|
|
54
|
+
* as a full export without `protocol_version`; nothing is written either way.
|
|
55
|
+
*/
|
|
56
|
+
export async function probePagesServer({ url, token }) {
|
|
57
|
+
const res = await fetch(`${baseOf(url)}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } });
|
|
58
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
59
|
+
const body = await res.json().catch(() => null);
|
|
60
|
+
if (body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
61
|
+
throw new PagesCliError(
|
|
62
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
63
|
+
"This site's server does not support locale-aware page files yet. Nothing was changed; update the platform or use an older CLI.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { defaultLocale: body.default_locale, supportedLocales: body.supported_locales ?? [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Site içeriğini diske çek (pull). Pages require protocol v2; every path is contained and staged. */
|
|
70
|
+
export async function pullContent({ dir, url, token, scope, writeFile }) {
|
|
71
|
+
const res = await fetch(`${baseOf(url)}/api/dev/content?scope=${encodeURIComponent(scope)}`, {
|
|
72
|
+
headers: { authorization: `Bearer ${token}` },
|
|
73
|
+
});
|
|
74
|
+
const body = await res.json().catch(() => null);
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
// Nothing is written on a non-2xx: an incomplete export (PAGES_EXPORT_INCOMPLETE) carries every finding and no files.
|
|
77
|
+
throw new PagesCliError(body?.code ?? "PAGES_PULL_FAILED", body?.error ?? `Pull failed (HTTP ${res.status}); nothing was written.`, {
|
|
78
|
+
status: res.status,
|
|
79
|
+
diagnostics: Array.isArray(body?.diagnostics) ? body.diagnostics : [],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const files = body?.files ?? {};
|
|
83
|
+
const wantsPages = scope !== "settings";
|
|
84
|
+
if (wantsPages && body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
85
|
+
throw new PagesCliError(
|
|
86
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
87
|
+
"This site's server still uses the old page layout, which cannot keep languages apart. Nothing was written.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const diagnostics = [...(body?.diagnostics ?? [])];
|
|
92
|
+
const errors = [];
|
|
93
|
+
const entries = [];
|
|
94
|
+
for (const path of Object.keys(files).sort()) {
|
|
95
|
+
const content = files[path];
|
|
96
|
+
if (path === "config/settings.json" && scope !== "pages") {
|
|
97
|
+
entries.push([path, content]);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const unsafe = checkRelativePagePath(path);
|
|
101
|
+
const parsed = unsafe ? null : parsePageFilePath(path);
|
|
102
|
+
if (unsafe || !parsed || (parsed.kind !== "canonical" && parsed.kind !== "hashed") || !wantsPages) {
|
|
103
|
+
errors.push({ level: "error", code: unsafe?.code ?? "PAGES_INVALID_PATH", message: `server sent an unexpected path (${unsafe?.reason ?? "not a canonical page path"})`, path });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const c = classifyPageFile(path, typeof content === "string" ? content : "", {});
|
|
107
|
+
if (!c.ok || c.layout !== "v2") {
|
|
108
|
+
errors.push({ level: "error", code: c.ok ? "PAGES_INVALID_PATH" : c.code, message: `server sent a page file that does not match its path (${c.ok ? "legacy payload" : c.message})`, path });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
entries.push([path, content]);
|
|
112
|
+
}
|
|
113
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "The server response failed validation; nothing was written.", { diagnostics: errors });
|
|
114
|
+
|
|
115
|
+
// Local files this pull does not own: reported, never deleted or overwritten.
|
|
116
|
+
if (wantsPages && existsSync(dir)) {
|
|
117
|
+
const local = collectPageFiles(dir);
|
|
118
|
+
for (const e of local.errors) diagnostics.push({ ...e, level: "warning" });
|
|
119
|
+
const remote = new Set(Object.keys(files));
|
|
120
|
+
for (const [path, content] of local.files) {
|
|
121
|
+
if (remote.has(path)) continue;
|
|
122
|
+
const c = classifyPageFile(path, content, { defaultLocale: body.default_locale });
|
|
123
|
+
if (c.ok && c.layout === "legacy") {
|
|
124
|
+
diagnostics.push({
|
|
125
|
+
level: "warning",
|
|
126
|
+
code: "PAGES_STALE_LEGACY_FILE",
|
|
127
|
+
message: `${path} was not written by this pull. Canonical replacement: ${pageFilePath(c.locale, c.slug)}`,
|
|
128
|
+
path,
|
|
129
|
+
locale: c.locale,
|
|
130
|
+
slug: c.slug,
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
diagnostics.push({ level: "warning", code: "PAGES_STALE_FILE", message: `${path} is not on the site and was not written by this pull.`, path });
|
|
38
134
|
}
|
|
39
|
-
if (!name.endsWith(".json")) continue;
|
|
40
|
-
const key = "pages/" + relative(pagesDir, full).split(sep).join("/");
|
|
41
|
-
out[key] = readFileSync(full, "utf8");
|
|
42
135
|
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const count = stagedWrite(fsRoot(dir), entries, writeFile ? { writeFile } : {});
|
|
139
|
+
return { count, diagnostics, defaultLocale: body?.default_locale ?? null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fsRoot(dir) {
|
|
143
|
+
mkdirSync(dir, { recursive: true });
|
|
144
|
+
return collectPageFiles(dir).root;
|
|
46
145
|
}
|
|
47
146
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Push. `settings` keeps the v1 body. `pages`: server capability probe → local preflight (nothing is sent on a
|
|
149
|
+
* local error) → `POST {protocol_version: 2, dry_run, files}`. The server preflights the whole batch again and
|
|
150
|
+
* writes nothing unless every file passes.
|
|
151
|
+
*/
|
|
152
|
+
export async function pushContent({ dir, url, token, scope, dryRun = false }) {
|
|
153
|
+
const base = baseOf(url);
|
|
154
|
+
if (scope === "settings") {
|
|
155
|
+
const files = readContentFiles(dir, scope);
|
|
156
|
+
const res = await fetch(`${base}/api/dev/content`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
159
|
+
body: JSON.stringify({ files }),
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) throw new Error(await errorText(res));
|
|
162
|
+
return { ...(await res.json()), fileCount: Object.keys(files).length };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const server = await probePagesServer({ url, token });
|
|
166
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
167
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
168
|
+
const localDiagnostics = checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server)));
|
|
169
|
+
const localErrors = localDiagnostics.filter((d) => d.level === "error");
|
|
170
|
+
if (localErrors.length > 0) {
|
|
171
|
+
throw new PagesCliError(localErrors[0].code, "Preflight failed locally; no pages were sent or changed.", { diagnostics: localDiagnostics });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const files = {};
|
|
175
|
+
for (const [path, content] of local) files[path] = content;
|
|
52
176
|
const res = await fetch(`${base}/api/dev/content`, {
|
|
53
177
|
method: "POST",
|
|
54
178
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
55
|
-
body: JSON.stringify({ files }),
|
|
179
|
+
body: JSON.stringify({ protocol_version: PAGES_PROTOCOL_VERSION, dry_run: dryRun, files }),
|
|
56
180
|
});
|
|
57
|
-
|
|
58
|
-
|
|
181
|
+
const body = await res.json().catch(() => null);
|
|
182
|
+
if (!res.ok || body?.ok !== true) {
|
|
183
|
+
throw new PagesCliError(body?.code ?? "PAGES_PUSH_FAILED", body?.error ?? `Push failed (HTTP ${res.status}).`, {
|
|
184
|
+
diagnostics: body?.diagnostics ?? [],
|
|
185
|
+
pages: body?.pages ?? null,
|
|
186
|
+
status: res.status,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return { ...body, fileCount: local.length };
|
|
59
190
|
}
|
|
60
191
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
192
|
+
/**
|
|
193
|
+
* `pages check`. Offline: path, JSON, format, layout and local duplicates. With `server` credentials it also
|
|
194
|
+
* resolves the default/supported locales and asks the server for a dry-run preflight (targets, documents).
|
|
195
|
+
*/
|
|
196
|
+
export async function checkPages({ dir, url, token }) {
|
|
197
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
198
|
+
const diagnostics = [...errors];
|
|
199
|
+
let server = null;
|
|
200
|
+
if (url && token) server = await probePagesServer({ url, token });
|
|
201
|
+
diagnostics.push(...checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server ?? {}))));
|
|
202
|
+
|
|
203
|
+
let plan = null;
|
|
204
|
+
if (server && !diagnostics.some((d) => d.level === "error")) {
|
|
205
|
+
try {
|
|
206
|
+
plan = await pushContent({ dir, url, token, scope: "pages", dryRun: true });
|
|
207
|
+
for (const d of plan.diagnostics ?? []) {
|
|
208
|
+
if (!diagnostics.some((x) => x.code === d.code && x.path === d.path)) diagnostics.push(d);
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof PagesCliError)) throw error;
|
|
212
|
+
diagnostics.push(...(error.diagnostics.length ? error.diagnostics : [{ level: "error", code: error.code, message: error.message }]));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { fileCount: local.length, diagnostics, plan, online: Boolean(server) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `pages migrate-layout`. Plans a move for every legacy file whose (locale, slug) is proven, and refuses the
|
|
220
|
+
* whole migration — zero moves — on any error or ambiguity. `write: true` rewrites each file as v2 at its
|
|
221
|
+
* canonical path (contained, staged per file, verified) and only then removes the legacy file.
|
|
222
|
+
*/
|
|
223
|
+
export async function migrateLayout({ dir, url, token, write = false }) {
|
|
224
|
+
const { root, files: local, errors } = collectPageFiles(dir);
|
|
225
|
+
const diagnostics = [...errors];
|
|
226
|
+
const needsDefault = local.some(([, c]) => {
|
|
227
|
+
try {
|
|
228
|
+
const j = JSON.parse(c);
|
|
229
|
+
return j && typeof j === "object" && !("format_version" in j) && !("locale" in j);
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
66
233
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
234
|
+
let server = null;
|
|
235
|
+
if (url && token) server = await probePagesServer({ url, token });
|
|
236
|
+
|
|
237
|
+
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
238
|
+
diagnostics.push(...checkLocalBatch(classified).filter((d) => d.code !== "PAGES_LEGACY_LAYOUT"));
|
|
239
|
+
if (needsDefault && !server) {
|
|
240
|
+
for (const c of classified) {
|
|
241
|
+
if (c.ok && c.localeSource === "unresolved") {
|
|
242
|
+
diagnostics.push({ level: "error", code: "PAGES_AMBIGUOUS_LAYOUT", message: "file has no locale and the site's default language is unknown offline; log in (blocofy login) or add \"locale\" to the file", path: c.path, slug: c.slug });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const byPath = new Map(classified.filter((c) => c.ok).map((c) => [c.path, c]));
|
|
248
|
+
const moves = [];
|
|
249
|
+
const claimed = new Map();
|
|
250
|
+
for (const c of classified) {
|
|
251
|
+
if (!c.ok || c.layout !== "legacy" || !c.locale) continue;
|
|
252
|
+
const to = pageFilePath(c.locale, c.slug);
|
|
253
|
+
const existing = byPath.get(to);
|
|
254
|
+
if (existing) {
|
|
255
|
+
if (pageFileSemanticKey(existing.parsed) === pageFileSemanticKey(c.parsed)) {
|
|
256
|
+
diagnostics.push({ level: "warning", code: "PAGES_DUPLICATE_EQUIVALENT", message: `${to} already holds the same content; ${c.path} was left in place (remove it yourself)`, path: c.path, locale: c.locale, slug: c.slug });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// checkLocalBatch has already reported the conflicting pair as PAGES_DUPLICATE_TARGET.
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (claimed.has(to)) {
|
|
263
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: `${c.path} and ${claimed.get(to)} both move to ${to}`, path: c.path, locale: c.locale, slug: c.slug });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
claimed.set(to, c.path);
|
|
267
|
+
moves.push({ from: c.path, to, locale: c.locale, slug: c.slug, content: serializeV2PageFile(c.parsed, c.locale) });
|
|
268
|
+
}
|
|
269
|
+
moves.sort((a, b) => (a.from < b.from ? -1 : 1));
|
|
270
|
+
const remaining = local.map(([p]) => p).filter((p) => !moves.some((m) => m.from === p));
|
|
271
|
+
diagnostics.push(...folderConflicts([...remaining, ...moves.map((m) => m.to)]));
|
|
272
|
+
|
|
273
|
+
const refused = diagnostics.some((d) => d.level === "error");
|
|
274
|
+
if (!write || refused) return { moves, moved: 0, diagnostics, refused };
|
|
275
|
+
|
|
276
|
+
// Every target validated before the first move.
|
|
277
|
+
for (const m of moves) {
|
|
278
|
+
const r = resolveContained(root, m.to);
|
|
279
|
+
if (r.error) {
|
|
280
|
+
diagnostics.push({ level: "error", code: r.error.code, message: r.error.reason, path: m.to });
|
|
281
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
282
|
+
}
|
|
283
|
+
if (existsSync(r.target)) {
|
|
284
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: "target appeared during migration", path: m.to });
|
|
285
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
let moved = 0;
|
|
289
|
+
for (const m of moves) {
|
|
290
|
+
const verify = classifyPageFile(m.to, m.content, { defaultLocale: m.locale });
|
|
291
|
+
if (!verify.ok || verify.layout !== "v2") throw new PagesCliError("PAGES_INVALID_PATH", `internal: ${m.to} would not be a valid v2 file; stopped after ${moved} move(s).`);
|
|
292
|
+
const resolved = resolveContained(root, m.to);
|
|
293
|
+
if (resolved.error) throw new PagesCliError(resolved.error.code, `${m.to}: ${resolved.error.reason}; stopped after ${moved} move(s).`);
|
|
294
|
+
const { target } = resolved;
|
|
295
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
296
|
+
const temp = `${target}.blocofy-tmp`;
|
|
297
|
+
writeFileSync(temp, m.content, { flag: "wx" });
|
|
298
|
+
renameSync(temp, target);
|
|
299
|
+
const legacy = resolveContained(root, m.from);
|
|
300
|
+
if (legacy.error) throw new PagesCliError(legacy.error.code, `${m.from}: ${legacy.error.reason}; stopped after ${moved} move(s).`);
|
|
301
|
+
rmSync(legacy.target);
|
|
302
|
+
moved += 1;
|
|
75
303
|
}
|
|
76
|
-
return {
|
|
304
|
+
return { moves, moved, diagnostics, refused: false };
|
|
77
305
|
}
|
package/lib/credentials.mjs
CHANGED
|
@@ -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
|
|
14
|
-
const
|
|
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
|
|
30
|
+
return join(dir(), FILE_NAME);
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|