@blocofy/cli 0.9.0 → 0.11.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 +483 -52
- package/bin/blocofy.mjs +1348 -276
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +129 -30
- package/lib/credentials.mjs +216 -60
- package/lib/http.mjs +63 -0
- package/lib/local-theme.mjs +7 -2
- package/lib/media-uses.mjs +27 -16
- package/lib/messages.mjs +42 -2
- package/lib/output.mjs +72 -0
- package/lib/page-files.mjs +12 -2
- package/lib/secret-store.mjs +133 -0
- package/lib/site-migrate.mjs +132 -0
- package/lib/site-state-client.mjs +92 -0
- package/lib/site-state-fs.mjs +137 -0
- package/lib/site-state.mjs +705 -0
- package/lib/target.mjs +485 -0
- package/lib/theme-sync.mjs +272 -139
- package/package.json +1 -1
package/lib/args.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
|
|
9
9
|
* gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
|
|
10
|
+
* CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
|
|
10
11
|
*/
|
|
11
|
-
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write"]);
|
|
12
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force"]);
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|
package/lib/content-sync.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
serializeV2PageFile,
|
|
14
15
|
stagedWrite,
|
|
15
16
|
} from "./page-files.mjs";
|
|
17
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
16
18
|
import { checkRelativePagePath, parsePageFilePath } from "./page-path-codec.mjs";
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -20,6 +22,10 @@ import { checkRelativePagePath, parsePageFilePath } from "./page-path-codec.mjs"
|
|
|
20
22
|
* locale-aware layout (`pages/<locale>/index.json`, `pages/<locale>/routes/…/index.json`); the platform's
|
|
21
23
|
* `/api/dev/content` protocol v2 is required for every page operation. Tema KODU `theme pull/push`'a gider.
|
|
22
24
|
* Güvenlik platformda: push yalnız mevcut sayfayı günceller, yeni oluşturmaz, silmez.
|
|
25
|
+
*
|
|
26
|
+
* CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs). Reads are safe to resend; a settings push is an
|
|
27
|
+
* upsert; a pages push carries one `x-idempotency-key` per push operation, resent unchanged on every retry, and the
|
|
28
|
+
* server treats a re-sent, already-applied page as `unchanged`.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
export const PAGES_PROTOCOL_VERSION = 2;
|
|
@@ -53,9 +59,9 @@ export function readContentFiles(dir, scope) {
|
|
|
53
59
|
* The server's page capabilities, or `PAGES_SERVER_UPGRADE_REQUIRED`. An old server answers `scope=capabilities`
|
|
54
60
|
* as a full export without `protocol_version`; nothing is written either way.
|
|
55
61
|
*/
|
|
56
|
-
export async function probePagesServer({ url, token }) {
|
|
57
|
-
const res = await
|
|
58
|
-
if (!res.ok) throw new Error(await errorText(res));
|
|
62
|
+
export async function probePagesServer({ url, token, onRetry = null }) {
|
|
63
|
+
const res = await fetchWithRetry(`${baseOf(url)}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
64
|
+
if (!res.ok) throw Object.assign(new Error(await errorText(res)), { status: res.status });
|
|
59
65
|
const body = await res.json().catch(() => null);
|
|
60
66
|
if (body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
61
67
|
throw new PagesCliError(
|
|
@@ -63,14 +69,19 @@ export async function probePagesServer({ url, token }) {
|
|
|
63
69
|
"This site's server does not support locale-aware page files yet. Nothing was changed; update the platform or use an older CLI.",
|
|
64
70
|
);
|
|
65
71
|
}
|
|
66
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
defaultLocale: body.default_locale,
|
|
74
|
+
supportedLocales: body.supported_locales ?? [],
|
|
75
|
+
// CF-T3 — base_revision / expected_plan_hash / force are only sent to a server that says it checks them.
|
|
76
|
+
revisionCas: body.page_revision_cas === 1 && body.plan_hash === 1,
|
|
77
|
+
};
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/** 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
|
|
81
|
+
export async function pullContent({ dir, url, token, scope, writeFile, onRetry = null }) {
|
|
82
|
+
const res = await fetchWithRetry(`${baseOf(url)}/api/dev/content?scope=${encodeURIComponent(scope)}`, {
|
|
72
83
|
headers: { authorization: `Bearer ${token}` },
|
|
73
|
-
});
|
|
84
|
+
}, { onRetry });
|
|
74
85
|
const body = await res.json().catch(() => null);
|
|
75
86
|
if (!res.ok) {
|
|
76
87
|
// Nothing is written on a non-2xx: an incomplete export (PAGES_EXPORT_INCOMPLETE) carries every finding and no files.
|
|
@@ -144,25 +155,72 @@ function fsRoot(dir) {
|
|
|
144
155
|
return collectPageFiles(dir).root;
|
|
145
156
|
}
|
|
146
157
|
|
|
158
|
+
/** CF-T3 — the most a `--force` reason may be (the platform's FORCE_REASON_MAX). */
|
|
159
|
+
export const FORCE_REASON_MAX = 500;
|
|
160
|
+
|
|
161
|
+
/** CF-T3 — paths of `forced`, whether the server lists strings or `{path, base_revision, current_revision}` objects. */
|
|
162
|
+
export function forcedPaths(forced) {
|
|
163
|
+
return (Array.isArray(forced) ? forced : []).map((f) => (typeof f === "string" ? f : f?.path)).filter((p) => typeof p === "string");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pushRefusal(res, body, fallback) {
|
|
167
|
+
return new PagesCliError(body?.code ?? "PAGES_PUSH_FAILED", body?.error ?? fallback, {
|
|
168
|
+
diagnostics: body?.diagnostics ?? [],
|
|
169
|
+
pages: body?.pages ?? null,
|
|
170
|
+
status: res.status,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
147
174
|
/**
|
|
148
175
|
* Push. `settings` keeps the v1 body. `pages`: server capability probe → local preflight (nothing is sent on a
|
|
149
176
|
* local error) → `POST {protocol_version: 2, dry_run, files}`. The server preflights the whole batch again and
|
|
150
177
|
* writes nothing unless every file passes.
|
|
178
|
+
*
|
|
179
|
+
* CF-T3 (contract C4) — on a server advertising `page_revision_cas` a push is two requests: a dry run (the plan,
|
|
180
|
+
* handed to `onPlan` before anything is written), then the real push carrying the plan's `expected_plan_hash` and the
|
|
181
|
+
* push's `x-idempotency-key`. A refused dry run (a stale file, a file without base_revision) throws and nothing else is
|
|
182
|
+
* sent; a plan that no longer holds at push time is 409 `PAGES_PLAN_STALE`. `force` + `forceReason` travel on both
|
|
183
|
+
* requests. An older server gets exactly the old single request (no CAS fields); `onServer(server)` lets the caller warn.
|
|
151
184
|
*/
|
|
152
|
-
export async function pushContent({ dir, url, token, scope, dryRun = false }) {
|
|
185
|
+
export async function pushContent({ dir, url, token, scope, instance = null, dryRun = false, idempotencyKey = null, onRetry = null, force = false, forceReason = null, onServer = null, onPlan = null }) {
|
|
153
186
|
const base = baseOf(url);
|
|
154
187
|
if (scope === "settings") {
|
|
155
188
|
const files = readContentFiles(dir, scope);
|
|
156
|
-
|
|
189
|
+
// T10.1 — an older server ignores `instance` and would write the LIVE theme: send it only to a server that says
|
|
190
|
+
// it honours it (scope=capabilities `settings_instance: 1`); otherwise refuse before anything is sent.
|
|
191
|
+
if (instance) {
|
|
192
|
+
const res = await fetchWithRetry(`${base}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
193
|
+
if (!res.ok) throw Object.assign(new Error(await errorText(res)), { status: res.status });
|
|
194
|
+
const caps = await res.json().catch(() => null);
|
|
195
|
+
if (caps?.protocol_version !== PAGES_PROTOCOL_VERSION || caps?.settings_instance !== 1) {
|
|
196
|
+
throw new PagesCliError(
|
|
197
|
+
"SETTINGS_INSTANCE_UNSUPPORTED",
|
|
198
|
+
"This site's server cannot target a theme with `settings push --instance` (it would write the live theme instead). Nothing was sent; update the platform or push with --live.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const res = await fetchWithRetry(`${base}/api/dev/content`, {
|
|
157
203
|
method: "POST",
|
|
158
204
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
159
|
-
body: JSON.stringify({ files }),
|
|
160
|
-
});
|
|
161
|
-
if (!res.ok)
|
|
205
|
+
body: JSON.stringify(instance ? { files, instance } : { files }),
|
|
206
|
+
}, { onRetry });
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
const text = await res.text();
|
|
209
|
+
let body = null;
|
|
210
|
+
try {
|
|
211
|
+
body = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
/* plain-text body */
|
|
214
|
+
}
|
|
215
|
+
const error = Object.assign(new Error(body?.error ?? text), { status: res.status });
|
|
216
|
+
if (typeof body?.code === "string") error.code = body.code;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
162
219
|
return { ...(await res.json()), fileCount: Object.keys(files).length };
|
|
163
220
|
}
|
|
164
221
|
|
|
165
|
-
const server = await probePagesServer({ url, token });
|
|
222
|
+
const server = await probePagesServer({ url, token, onRetry });
|
|
223
|
+
onServer?.(server);
|
|
166
224
|
const { files: local, errors } = collectPageFiles(dir);
|
|
167
225
|
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
168
226
|
const localDiagnostics = checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server)));
|
|
@@ -173,37 +231,78 @@ export async function pushContent({ dir, url, token, scope, dryRun = false }) {
|
|
|
173
231
|
|
|
174
232
|
const files = {};
|
|
175
233
|
for (const [path, content] of local) files[path] = content;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
234
|
+
// One key per push operation: every transport retry resends this exact request (same key, same body).
|
|
235
|
+
const key = idempotencyKey ?? `cli-${randomUUID()}`;
|
|
236
|
+
const post = (payload, withKey) =>
|
|
237
|
+
fetchWithRetry(`${base}/api/dev/content`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...(withKey ? { "x-idempotency-key": key } : {}) },
|
|
240
|
+
body: JSON.stringify({ protocol_version: PAGES_PROTOCOL_VERSION, ...payload, files }),
|
|
241
|
+
}, { onRetry });
|
|
242
|
+
|
|
243
|
+
if (!server.revisionCas) {
|
|
244
|
+
const res = await post({ dry_run: dryRun }, true);
|
|
245
|
+
const body = await res.json().catch(() => null);
|
|
246
|
+
if (!res.ok || body?.ok !== true) throw pushRefusal(res, body, `Push failed (HTTP ${res.status}).`);
|
|
247
|
+
return { ...body, fileCount: local.length, revisionCas: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const forceFields = force ? { force: true, force_reason: forceReason } : {};
|
|
251
|
+
const planRes = await post({ dry_run: true, ...forceFields }, false);
|
|
252
|
+
const plan = await planRes.json().catch(() => null);
|
|
253
|
+
if (!planRes.ok || plan?.ok !== true) {
|
|
254
|
+
const error = pushRefusal(planRes, plan, `Push plan failed (HTTP ${planRes.status}); nothing was changed.`);
|
|
255
|
+
if (error.code === "PAGES_REVISION_CONFLICT" || error.code === "PAGES_BASE_REVISION_REQUIRED") {
|
|
256
|
+
const stale = error.diagnostics.filter((d) => d.code === "PAGES_REVISION_CONFLICT" || d.code === "PAGES_BASE_REVISION_REQUIRED").length;
|
|
257
|
+
error.message =
|
|
258
|
+
`${stale} page file(s) no longer describe the page on the site (changed since you pulled, or no base_revision); nothing was pushed or changed. ` +
|
|
259
|
+
"Run `blocofy pages pull` to refresh, or `--force --reason <text>` to overwrite.";
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (typeof plan.plan_hash !== "string") {
|
|
264
|
+
throw new PagesCliError("PAGES_PUSH_FAILED", "The server's plan has no plan_hash; nothing was changed.", { status: planRes.status });
|
|
265
|
+
}
|
|
266
|
+
onPlan?.(plan);
|
|
267
|
+
if (dryRun) return { ...plan, fileCount: local.length, revisionCas: true };
|
|
268
|
+
|
|
269
|
+
const res = await post({ dry_run: false, expected_plan_hash: plan.plan_hash, ...forceFields }, true);
|
|
181
270
|
const body = await res.json().catch(() => null);
|
|
182
271
|
if (!res.ok || body?.ok !== true) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
272
|
+
const error = pushRefusal(res, body, `Push failed (HTTP ${res.status}).`);
|
|
273
|
+
if (error.code === "PAGES_PLAN_STALE") {
|
|
274
|
+
error.message = "The site changed between the plan and the push; no pages were changed. Run the push again to review the new plan.";
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
188
277
|
}
|
|
189
|
-
return { ...body, fileCount: local.length };
|
|
278
|
+
return { ...body, fileCount: local.length, revisionCas: true };
|
|
190
279
|
}
|
|
191
280
|
|
|
192
281
|
/**
|
|
193
282
|
* `pages check`. Offline: path, JSON, format, layout and local duplicates. With `server` credentials it also
|
|
194
283
|
* resolves the default/supported locales and asks the server for a dry-run preflight (targets, documents).
|
|
195
284
|
*/
|
|
196
|
-
export async function checkPages({ dir, url, token }) {
|
|
285
|
+
export async function checkPages({ dir, url, token, onRetry = null }) {
|
|
197
286
|
const { files: local, errors } = collectPageFiles(dir);
|
|
198
287
|
const diagnostics = [...errors];
|
|
199
288
|
let server = null;
|
|
200
|
-
if (url && token) server = await probePagesServer({ url, token });
|
|
201
|
-
|
|
289
|
+
if (url && token) server = await probePagesServer({ url, token, onRetry });
|
|
290
|
+
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
291
|
+
diagnostics.push(...checkLocalBatch(classified));
|
|
292
|
+
// CF-T3 — a v2 file without base_revision will be refused by a revision-checking server; say so before the push.
|
|
293
|
+
// Not on an older server: it never stamps the field, so the warning could never be fixed there.
|
|
294
|
+
if (!server || server.revisionCas) {
|
|
295
|
+
for (const c of classified) {
|
|
296
|
+
if (c.ok && c.layout === "v2" && c.parsed.base_revision == null) {
|
|
297
|
+
diagnostics.push({ level: "warning", code: "PAGES_BASE_REVISION_MISSING", message: "no base_revision: the site cannot tell whether this page changed since you pulled it, so a push will be refused. Run `blocofy pages pull` to refresh the file (or push with --force --reason <text>).", path: c.path, locale: c.locale, slug: c.slug });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
202
301
|
|
|
203
302
|
let plan = null;
|
|
204
303
|
if (server && !diagnostics.some((d) => d.level === "error")) {
|
|
205
304
|
try {
|
|
206
|
-
plan = await pushContent({ dir, url, token, scope: "pages", dryRun: true });
|
|
305
|
+
plan = await pushContent({ dir, url, token, scope: "pages", dryRun: true, onRetry });
|
|
207
306
|
for (const d of plan.diagnostics ?? []) {
|
|
208
307
|
if (!diagnostics.some((x) => x.code === d.code && x.path === d.path)) diagnostics.push(d);
|
|
209
308
|
}
|
|
@@ -220,7 +319,7 @@ export async function checkPages({ dir, url, token }) {
|
|
|
220
319
|
* whole migration — zero moves — on any error or ambiguity. `write: true` rewrites each file as v2 at its
|
|
221
320
|
* canonical path (contained, staged per file, verified) and only then removes the legacy file.
|
|
222
321
|
*/
|
|
223
|
-
export async function migrateLayout({ dir, url, token, write = false }) {
|
|
322
|
+
export async function migrateLayout({ dir, url, token, write = false, onRetry = null }) {
|
|
224
323
|
const { root, files: local, errors } = collectPageFiles(dir);
|
|
225
324
|
const diagnostics = [...errors];
|
|
226
325
|
const needsDefault = local.some(([, c]) => {
|
|
@@ -232,7 +331,7 @@ export async function migrateLayout({ dir, url, token, write = false }) {
|
|
|
232
331
|
}
|
|
233
332
|
});
|
|
234
333
|
let server = null;
|
|
235
|
-
if (url && token) server = await probePagesServer({ url, token });
|
|
334
|
+
if (url && token) server = await probePagesServer({ url, token, onRetry });
|
|
236
335
|
|
|
237
336
|
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
238
337
|
diagnostics.push(...checkLocalBatch(classified).filter((d) => d.code !== "PAGES_LEGACY_LAYOUT"));
|
package/lib/credentials.mjs
CHANGED
|
@@ -1,92 +1,248 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, writeSync, chmodSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { SecretStoreError, secretStoreFor, writeJsonAtomic } from "./secret-store.mjs";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* CF-T1 (contract C1) — Blocofy CLI credential store v2.
|
|
9
|
+
*
|
|
10
|
+
* `~/.blocofy/credentials.json` (0600, dir 0700, atomic tmp+rename) holds NAMED CONTEXTS and no secrets:
|
|
11
|
+
*
|
|
12
|
+
* { "schema_version": 2, "current_context": "<name>|null",
|
|
13
|
+
* "contexts": { "<name>": { platform_origin, site: {id,slug,name,domain}|null,
|
|
14
|
+
* dev: { url, secret: { store } }?, api: { url, secret: { store } }?, verified_at } } }
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Secrets live in a secret store (lib/secret-store.mjs): `~/.blocofy/secrets.json` by default, the macOS keychain
|
|
17
|
+
* on opt-in. A pre-v2 flat file `{ url, token, apiUrl, apiKey }` is migrated losslessly on first read into
|
|
18
|
+
* `contexts.default` (unverified, `site: null`); the original bytes are kept in `credentials.v1.bak.json`
|
|
19
|
+
* (rollback: copy it back over credentials.json). A corrupt or half file is never rewritten: CREDENTIALS_CORRUPT.
|
|
20
|
+
*
|
|
21
|
+
* Environment variables form an ephemeral "env" context: BLOCOFY_URL+BLOCOFY_TOKEN and/or
|
|
22
|
+
* BLOCOFY_API_URL+BLOCOFY_API_KEY. A half-set pair is a loud error (never a silent fall-through to the file).
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
|
-
const
|
|
21
|
-
const
|
|
25
|
+
export const SCHEMA_VERSION = 2;
|
|
26
|
+
export const ENV_CONTEXT = "env";
|
|
27
|
+
|
|
28
|
+
/** A local credential problem (exit 1). Messages carry paths, never file content or secrets. */
|
|
29
|
+
export class CredentialsError extends Error {
|
|
30
|
+
constructor(code, message, details = {}) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CredentialsError";
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.details = details;
|
|
35
|
+
this.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Resolved per call (not at import): tests redirect HOME, and os.homedir() honours $HOME on POSIX.
|
|
40
|
+
const dir = () => join(homedir(), ".blocofy");
|
|
41
|
+
export const credentialsPath = () => join(dir(), "credentials.json");
|
|
42
|
+
export const secretsPath = () => join(dir(), "secrets.json");
|
|
43
|
+
export const backupPath = () => join(dir(), "credentials.v1.bak.json");
|
|
44
|
+
|
|
45
|
+
const stripSlash = (url) => String(url).replace(/\/+$/, "");
|
|
46
|
+
const isStr = (v) => typeof v === "string" && v.length > 0;
|
|
47
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
48
|
+
|
|
49
|
+
export const emptyStore = () => ({ schema_version: SCHEMA_VERSION, current_context: null, contexts: {} });
|
|
50
|
+
|
|
51
|
+
function corrupt(reason) {
|
|
52
|
+
return new CredentialsError("CREDENTIALS_CORRUPT", `The credentials file cannot be used (${reason}): ${credentialsPath()}. Nothing was written. Fix or remove it, then run \`blocofy login\`.`, { path: credentialsPath() });
|
|
53
|
+
}
|
|
22
54
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function dir() {
|
|
26
|
-
return join(homedir(), DIR_NAME);
|
|
55
|
+
function validPair(pair) {
|
|
56
|
+
return pair === undefined || (isObj(pair) && isStr(pair.url) && isObj(pair.secret) && (pair.secret.store === "file" || pair.secret.store === "keychain"));
|
|
27
57
|
}
|
|
28
58
|
|
|
29
|
-
|
|
30
|
-
|
|
59
|
+
function validV2(data) {
|
|
60
|
+
if (data.schema_version !== SCHEMA_VERSION || !isObj(data.contexts)) return false;
|
|
61
|
+
if (data.current_context !== null && data.current_context !== undefined && typeof data.current_context !== "string") return false;
|
|
62
|
+
for (const ctx of Object.values(data.contexts)) {
|
|
63
|
+
if (!isObj(ctx) || !validPair(ctx.dev) || !validPair(ctx.api)) return false;
|
|
64
|
+
if (ctx.site !== null && ctx.site !== undefined && !(isObj(ctx.site) && (isStr(ctx.site.id) || Number.isFinite(ctx.site.id)))) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
31
67
|
}
|
|
32
68
|
|
|
33
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Read the store (migrating a v1 file). `{ secretStore }` options pass through to the secret adapters (tests).
|
|
71
|
+
* A missing file is an empty store and is NOT created.
|
|
72
|
+
*/
|
|
73
|
+
export function loadStore(options = {}) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(credentialsPath(), "utf8");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === "ENOENT") return emptyStore();
|
|
79
|
+
throw new CredentialsError("CREDENTIALS_UNREADABLE", `Cannot read ${credentialsPath()} (${error?.code ?? "error"}).`, { path: credentialsPath() });
|
|
80
|
+
}
|
|
81
|
+
let data;
|
|
34
82
|
try {
|
|
35
|
-
|
|
36
|
-
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
83
|
+
data = JSON.parse(raw);
|
|
37
84
|
} catch {
|
|
38
|
-
|
|
85
|
+
throw corrupt("not valid JSON");
|
|
39
86
|
}
|
|
87
|
+
if (!isObj(data)) throw corrupt("not a JSON object");
|
|
88
|
+
if ("schema_version" in data) {
|
|
89
|
+
if (!validV2(data)) throw corrupt("unexpected structure");
|
|
90
|
+
return { schema_version: SCHEMA_VERSION, current_context: data.current_context ?? null, contexts: data.contexts };
|
|
91
|
+
}
|
|
92
|
+
return migrateV1(raw, data, options);
|
|
40
93
|
}
|
|
41
94
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
95
|
+
/** v1 `{url, token, apiUrl, apiKey}` → v2 `contexts.default`. Validates EVERYTHING before the first write. */
|
|
96
|
+
function migrateV1(raw, data, options) {
|
|
97
|
+
const known = ["url", "token", "apiUrl", "apiKey"];
|
|
98
|
+
if (Object.keys(data).length === 0) return emptyStore();
|
|
99
|
+
for (const k of known) if (k in data && !isStr(data[k])) throw corrupt(`field "${k}" is not a string`);
|
|
100
|
+
const hasDev = isStr(data.url) || isStr(data.token);
|
|
101
|
+
const hasApi = isStr(data.apiUrl) || isStr(data.apiKey);
|
|
102
|
+
if (hasDev && !(isStr(data.url) && isStr(data.token))) throw corrupt("the dev URL/token pair is incomplete");
|
|
103
|
+
if (hasApi && !(isStr(data.apiUrl) && isStr(data.apiKey))) throw corrupt("the API URL/key pair is incomplete");
|
|
104
|
+
if (!hasDev && !hasApi) throw corrupt("no credential pair found");
|
|
105
|
+
|
|
106
|
+
const context = { platform_origin: null, site: null, verified_at: null };
|
|
107
|
+
if (hasDev) context.dev = { url: stripSlash(data.url), secret: { store: "file" } };
|
|
108
|
+
if (hasApi) context.api = { url: stripSlash(data.apiUrl), secret: { store: "file" } };
|
|
109
|
+
const store = { schema_version: SCHEMA_VERSION, current_context: "default", contexts: { default: context } };
|
|
110
|
+
|
|
111
|
+
// 1) exact-bytes backup (never overwrite an older, different backup)
|
|
44
112
|
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
113
|
+
let backup = backupPath();
|
|
114
|
+
if (existsSync(backup) && readFileSync(backup, "utf8") !== raw) backup = join(dir(), `credentials.v1.bak.${Date.now()}.json`);
|
|
115
|
+
if (!existsSync(backup)) {
|
|
116
|
+
writeFileSync(backup, raw, { mode: 0o600 });
|
|
117
|
+
chmodSync(backup, 0o600);
|
|
118
|
+
}
|
|
119
|
+
// 2) secrets, 3) the v2 file (a crash between steps re-runs an idempotent migration from the untouched v1 file)
|
|
120
|
+
const secrets = secretStoreFor("file", { filePath: secretsPath(), ...options.secretStore });
|
|
121
|
+
if (hasDev) secrets.set("default", "dev", data.token);
|
|
122
|
+
if (hasApi) secrets.set("default", "api", data.apiKey);
|
|
123
|
+
saveStore(store);
|
|
124
|
+
return store;
|
|
48
125
|
}
|
|
49
126
|
|
|
127
|
+
const LOCK_STALE_MS = 10000;
|
|
128
|
+
const LOCK_WAIT_MS = 15000;
|
|
129
|
+
export const lockPath = () => join(dir(), ".lock");
|
|
130
|
+
|
|
50
131
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
132
|
+
* Review M5 — run the synchronous load-modify-save `fn` under an exclusive `~/.blocofy/.lock` (O_EXCL create), so two
|
|
133
|
+
* concurrent logins/logouts cannot overwrite each other's context or secret. A lock older than 10 s is stale (a
|
|
134
|
+
* crashed process) and is taken over; waiting longer than 15 s is CREDENTIALS_LOCKED.
|
|
53
135
|
*/
|
|
54
|
-
export function
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
136
|
+
export function withStoreLock(fn) {
|
|
137
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
138
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
139
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
140
|
+
for (;;) {
|
|
141
|
+
try {
|
|
142
|
+
const fd = openSync(lockPath(), "wx", 0o600);
|
|
143
|
+
writeSync(fd, String(process.pid));
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
break;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error?.code !== "EEXIST") throw error;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
if (Date.now() - statSync(lockPath()).mtimeMs > LOCK_STALE_MS) {
|
|
151
|
+
rmSync(lockPath(), { force: true });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
continue; // released between the open and the stat
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() > deadline) {
|
|
158
|
+
throw new CredentialsError("CREDENTIALS_LOCKED", `Another blocofy command is updating ${credentialsPath()}. Nothing was written; try again (or remove ${lockPath()} if no blocofy command is running).`, { path: lockPath() });
|
|
159
|
+
}
|
|
160
|
+
Atomics.wait(pause, 0, 0, 20);
|
|
59
161
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
162
|
+
try {
|
|
163
|
+
return fn();
|
|
164
|
+
} finally {
|
|
165
|
+
rmSync(lockPath(), { force: true });
|
|
63
166
|
}
|
|
64
|
-
return null;
|
|
65
167
|
}
|
|
66
168
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
169
|
+
export function saveStore(store) {
|
|
170
|
+
writeJsonAtomic(credentialsPath(), { schema_version: SCHEMA_VERSION, current_context: store.current_context ?? null, contexts: store.contexts });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The secret-store name for NEW secrets: `--keychain` flag or BLOCOFY_SECRET_STORE, default "file". */
|
|
174
|
+
export function defaultSecretStoreName({ keychain = false, env = process.env } = {}) {
|
|
175
|
+
if (keychain) return "keychain";
|
|
176
|
+
const v = env.BLOCOFY_SECRET_STORE;
|
|
177
|
+
if (v === undefined || v === "" || v === "file") return "file";
|
|
178
|
+
if (v === "keychain") return "keychain";
|
|
179
|
+
throw new CredentialsError("SECRET_STORE_UNAVAILABLE", `BLOCOFY_SECRET_STORE must be "file" or "keychain".`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function openSecretStore(name, options = {}) {
|
|
183
|
+
try {
|
|
184
|
+
return secretStoreFor(name, { filePath: secretsPath(), ...options });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw asCredentialsError(error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asCredentialsError(error) {
|
|
191
|
+
if (error instanceof SecretStoreError) return new CredentialsError(error.code, error.message);
|
|
192
|
+
return error;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `{ devToken, apiKey }` for a stored context (null where the pair or its secret is absent). */
|
|
196
|
+
export function readSecrets(name, ctx, options = {}) {
|
|
197
|
+
try {
|
|
198
|
+
const get = (kind) => (ctx[kind] ? openSecretStore(ctx[kind].secret.store, options).get(name, kind) : null);
|
|
199
|
+
return { devToken: get("dev"), apiKey: get("api") };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw asCredentialsError(error);
|
|
78
202
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function writeSecret(name, kind, storeName, value, options = {}) {
|
|
206
|
+
try {
|
|
207
|
+
openSecretStore(storeName, options).set(name, kind, value);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw asCredentialsError(error);
|
|
82
210
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function removeSecrets(name, ctx, options = {}) {
|
|
214
|
+
for (const kind of ["dev", "api"]) {
|
|
215
|
+
if (!ctx?.[kind]) continue;
|
|
216
|
+
try {
|
|
217
|
+
openSecretStore(ctx[kind].secret.store, options).remove(name, kind);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw asCredentialsError(error);
|
|
220
|
+
}
|
|
86
221
|
}
|
|
87
|
-
return null;
|
|
88
222
|
}
|
|
89
223
|
|
|
90
|
-
|
|
91
|
-
|
|
224
|
+
/**
|
|
225
|
+
* The ephemeral "env" context, or null when no variable is set. Half-set pairs throw (the message names the
|
|
226
|
+
* missing variable only).
|
|
227
|
+
*/
|
|
228
|
+
export function envContext(env = process.env) {
|
|
229
|
+
const pair = (urlVar, secretVar) => {
|
|
230
|
+
const url = env[urlVar];
|
|
231
|
+
const secret = env[secretVar];
|
|
232
|
+
if (url && secret) return { url: stripSlash(url), secret };
|
|
233
|
+
if (url || secret) {
|
|
234
|
+
const missing = url ? secretVar : urlVar;
|
|
235
|
+
throw new CredentialsError("ENV_CREDENTIALS_INCOMPLETE", `${missing} is not set — ${urlVar} and ${secretVar} must be set together (the credentials file is not consulted while either is set).`);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
const dev = pair("BLOCOFY_URL", "BLOCOFY_TOKEN");
|
|
240
|
+
const api = pair("BLOCOFY_API_URL", "BLOCOFY_API_KEY");
|
|
241
|
+
if (!dev && !api) return null;
|
|
242
|
+
return {
|
|
243
|
+
name: ENV_CONTEXT,
|
|
244
|
+
source: "env",
|
|
245
|
+
context: { platform_origin: null, site: null, ...(dev ? { dev: { url: dev.url } } : {}), ...(api ? { api: { url: api.url } } : {}) },
|
|
246
|
+
secrets: { devToken: dev?.secret ?? null, apiKey: api?.secret ?? null },
|
|
247
|
+
};
|
|
92
248
|
}
|
package/lib/http.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF-T3 (contract C4, CLI half) — the ONE retry policy for every CLI request that is safe to resend.
|
|
3
|
+
*
|
|
4
|
+
* Retried: a thrown fetch (network error, connection reset, per-attempt timeout) and HTTP 429 / 502 / 503 / 504.
|
|
5
|
+
* NOT retried: 500 and every other status (a 500 may mean the server applied part of the work; a 4xx is a refusal).
|
|
6
|
+
* At most 3 retries (4 attempts). Wait before retry n: the response's `Retry-After` (delta-seconds or HTTP-date,
|
|
7
|
+
* capped at 30 s) when present, else 300 ms / 900 ms / 2000 ms. The SAME request is resent: the caller's `init`
|
|
8
|
+
* (method, headers incl. `x-idempotency-key`, body string) is reused unchanged, so a replay converges on the
|
|
9
|
+
* server's idempotency handling instead of applying a second, different mutation.
|
|
10
|
+
*
|
|
11
|
+
* Callers only route requests here that are safe to replay: reads, upserts, pointer sets, and mutations carrying
|
|
12
|
+
* an idempotency key. `onRetry({ attempt, retries, reason, waitMs })` fires before every wait (never silent).
|
|
13
|
+
* `isFinal(response)` (optional, given a clone) marks a retry-status answer that is a verdict, not a transient
|
|
14
|
+
* failure: it is returned at once instead of being resent.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const RETRY_STATUSES = new Set([429, 502, 503, 504]);
|
|
18
|
+
export const DEFAULT_RETRIES = 3;
|
|
19
|
+
export const DEFAULT_BACKOFF_MS = [300, 900, 2000];
|
|
20
|
+
export const MAX_RETRY_WAIT_MS = 30000;
|
|
21
|
+
|
|
22
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
|
|
24
|
+
/** `Retry-After` → milliseconds (capped), or null when absent/unparseable. */
|
|
25
|
+
export function parseRetryAfter(value, nowMs = Date.now()) {
|
|
26
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
27
|
+
const v = value.trim();
|
|
28
|
+
let ms;
|
|
29
|
+
if (/^\d+$/.test(v)) ms = Number(v) * 1000;
|
|
30
|
+
else if (/[A-Za-z]/.test(v)) {
|
|
31
|
+
const at = Date.parse(v);
|
|
32
|
+
if (Number.isNaN(at)) return null;
|
|
33
|
+
ms = at - nowMs;
|
|
34
|
+
} else return null;
|
|
35
|
+
return Math.min(Math.max(ms, 0), MAX_RETRY_WAIT_MS);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `fetch` with the retry contract above. Options: `retries`, `backoff` (ms per retry index), `onRetry`,
|
|
40
|
+
* `fetchImpl` (default: the global fetch at call time), `sleep(ms)`, `now()`, `timeoutMs` (per attempt).
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs, isFinal } = {}) {
|
|
43
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
44
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
45
|
+
const attemptInit = timeoutMs ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init;
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await doFetch(input, attemptInit);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (attempt >= retries) throw error;
|
|
51
|
+
const waitMs = backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
|
|
52
|
+
onRetry?.({ attempt: attempt + 1, retries, reason: error?.name === "TimeoutError" ? "timeout" : error?.message ?? String(error), waitMs });
|
|
53
|
+
await sleep(waitMs);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!RETRY_STATUSES.has(res.status) || attempt >= retries) return res;
|
|
57
|
+
if (isFinal && (await isFinal(res.clone()))) return res;
|
|
58
|
+
const waitMs = parseRetryAfter(res.headers.get("retry-after"), now()) ?? backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
|
|
59
|
+
await res.arrayBuffer().catch(() => {}); // release the connection before resending
|
|
60
|
+
onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}`, waitMs });
|
|
61
|
+
await sleep(waitMs);
|
|
62
|
+
}
|
|
63
|
+
}
|