@blocofy/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`
@@ -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 fetch(`${baseOf(url)}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } });
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 { defaultLocale: body.default_locale, supportedLocales: body.supported_locales ?? [] };
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 fetch(`${baseOf(url)}/api/dev/content?scope=${encodeURIComponent(scope)}`, {
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
- const res = await fetch(`${base}/api/dev/content`, {
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) throw new Error(await errorText(res));
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
- const res = await fetch(`${base}/api/dev/content`, {
177
- method: "POST",
178
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
179
- body: JSON.stringify({ protocol_version: PAGES_PROTOCOL_VERSION, dry_run: dryRun, files }),
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
- 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
- });
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
- diagnostics.push(...checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server ?? {}))));
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"));
@@ -1,92 +1,248 @@
1
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- * Blocofy CLI credentials. `blocofy login` writes the platform URL + dev token to
7
- * `~/.blocofy/credentials.json` (0600); `blocofy theme dev` reads it. Environment
8
- * variables (BLOCOFY_URL/BLOCOFY_TOKEN) take precedence over the file (CI/agents).
9
- * The token is stored in plaintext on disk (the usual dev-CLI pattern), so the file
10
- * is locked down to 0600.
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
- * 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).
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 DIR_NAME = ".blocofy";
21
- const FILE_NAME = "credentials.json";
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
- // 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);
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
- export function credentialsPath() {
30
- return join(dir(), FILE_NAME);
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
- function readExisting() {
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
- const data = JSON.parse(readFileSync(credentialsPath(), "utf8"));
36
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
83
+ data = JSON.parse(raw);
37
84
  } catch {
38
- return {}; // missing/corrupt → start clean
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
- /** Merge `patch` into the credentials file and write it with 0600 permissions. */
43
- export function saveCredentials(patch) {
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
- 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
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
- * Resolve credentials: environment first (both vars), then the file. Returns
52
- * `{ url, token, source }` or null. Trailing slashes are stripped from the URL.
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 loadCredentials() {
55
- const envUrl = process.env.BLOCOFY_URL;
56
- const envToken = process.env.BLOCOFY_TOKEN;
57
- if (envUrl && envToken) {
58
- return { url: stripSlash(envUrl), token: envToken, source: "env" };
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
- const data = readExisting();
61
- if (typeof data.url === "string" && typeof data.token === "string") {
62
- return { url: stripSlash(data.url), token: data.token, source: "file" };
162
+ try {
163
+ return fn();
164
+ } finally {
165
+ rmSync(lockPath(), { force: true });
63
166
  }
64
- return null;
65
167
  }
66
168
 
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" };
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
- 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).`);
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
- const data = readExisting();
84
- if (typeof data.apiUrl === "string" && typeof data.apiKey === "string") {
85
- return { apiUrl: stripSlash(data.apiUrl), apiKey: data.apiKey, source: "file" };
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
- function stripSlash(url) {
91
- return url.replace(/\/+$/, "");
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,60 @@
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
+ */
14
+
15
+ export const RETRY_STATUSES = new Set([429, 502, 503, 504]);
16
+ export const DEFAULT_RETRIES = 3;
17
+ export const DEFAULT_BACKOFF_MS = [300, 900, 2000];
18
+ export const MAX_RETRY_WAIT_MS = 30000;
19
+
20
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
+
22
+ /** `Retry-After` → milliseconds (capped), or null when absent/unparseable. */
23
+ export function parseRetryAfter(value, nowMs = Date.now()) {
24
+ if (typeof value !== "string" || value.trim() === "") return null;
25
+ const v = value.trim();
26
+ let ms;
27
+ if (/^\d+$/.test(v)) ms = Number(v) * 1000;
28
+ else if (/[A-Za-z]/.test(v)) {
29
+ const at = Date.parse(v);
30
+ if (Number.isNaN(at)) return null;
31
+ ms = at - nowMs;
32
+ } else return null;
33
+ return Math.min(Math.max(ms, 0), MAX_RETRY_WAIT_MS);
34
+ }
35
+
36
+ /**
37
+ * `fetch` with the retry contract above. Options: `retries`, `backoff` (ms per retry index), `onRetry`,
38
+ * `fetchImpl` (default: the global fetch at call time), `sleep(ms)`, `now()`, `timeoutMs` (per attempt).
39
+ */
40
+ export async function fetchWithRetry(input, init = {}, { retries = DEFAULT_RETRIES, backoff = DEFAULT_BACKOFF_MS, onRetry, fetchImpl, sleep = realSleep, now = Date.now, timeoutMs } = {}) {
41
+ const doFetch = fetchImpl ?? globalThis.fetch;
42
+ for (let attempt = 0; ; attempt += 1) {
43
+ const attemptInit = timeoutMs ? { ...init, signal: AbortSignal.timeout(timeoutMs) } : init;
44
+ let res;
45
+ try {
46
+ res = await doFetch(input, attemptInit);
47
+ } catch (error) {
48
+ if (attempt >= retries) throw error;
49
+ const waitMs = backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
50
+ onRetry?.({ attempt: attempt + 1, retries, reason: error?.name === "TimeoutError" ? "timeout" : error?.message ?? String(error), waitMs });
51
+ await sleep(waitMs);
52
+ continue;
53
+ }
54
+ if (!RETRY_STATUSES.has(res.status) || attempt >= retries) return res;
55
+ const waitMs = parseRetryAfter(res.headers.get("retry-after"), now()) ?? backoff[attempt] ?? backoff[backoff.length - 1] ?? 0;
56
+ await res.arrayBuffer().catch(() => {}); // release the connection before resending
57
+ onRetry?.({ attempt: attempt + 1, retries, reason: `HTTP ${res.status}`, waitMs });
58
+ await sleep(waitMs);
59
+ }
60
+ }