@blocofy/cli 0.14.0 → 0.16.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 +229 -15
- package/bin/blocofy.mjs +993 -53
- package/lib/args.mjs +2 -2
- package/lib/credentials.mjs +150 -6
- package/lib/dev-server.mjs +6 -3
- package/lib/init.mjs +382 -0
- package/lib/loopback.mjs +111 -0
- package/lib/media-uses.mjs +4 -2
- package/lib/oauth.mjs +295 -0
- package/lib/refresh.mjs +244 -0
- package/lib/secret-store.mjs +177 -10
- package/lib/target.mjs +25 -11
- package/lib/theme-sync.mjs +160 -5
- package/lib/theme-work.mjs +369 -0
- package/lib/validate.mjs +4 -1
- package/package.json +1 -1
package/lib/secret-store.mjs
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* CF-T1 (contract C1) — where credential SECRETS live. `credentials.json` only records which store holds a
|
|
8
|
-
* context's secret (`secret: { store: "file" | "keychain" }`); the value itself is in
|
|
9
|
+
* context's secret (`secret: { store: "file" | "keychain" | "dpapi" | "secret-service" }`); the value itself is in:
|
|
9
10
|
*
|
|
10
|
-
* file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key } }`.
|
|
11
|
-
* keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api`.
|
|
12
|
-
* Opt-in
|
|
11
|
+
* file `~/.blocofy/secrets.json` (0600, atomic tmp+rename) `{ "<context>": { dev_token, api_key, oauth_tokens } }`.
|
|
12
|
+
* keychain macOS Keychain via the `security` binary: service `blocofy-cli`, account `<context>:dev|api|oauth`.
|
|
13
|
+
* Opt-in for a pasted token (`BLOCOFY_SECRET_STORE=keychain` or `login --keychain`); other platforms → error.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* ADR-0014 §5.4 (wave P4) — the browser login's token set (kind `oauth`: one JSON string `{access_token,
|
|
16
|
+
* refresh_token, expires_at}`) lives in the OS secure store by default (`oauthSecretStore`):
|
|
17
|
+
*
|
|
18
|
+
* macOS keychain (above)
|
|
19
|
+
* Windows dpapi `%APPDATA%\blocofy\secrets.dpapi`: each value protected with DPAPI (user scope) by
|
|
20
|
+
* PowerShell `ConvertFrom-SecureString`; the secret goes in on stdin, never argv
|
|
21
|
+
* Linux secret-service libsecret's `secret-tool` (stdin), when a DBus session is present
|
|
22
|
+
* any file only when chosen explicitly (`--insecure-storage` / `BLOCOFY_SECRET_STORE=file`) and
|
|
23
|
+
* announced; an unavailable OS store is an error, never a silent plaintext downgrade
|
|
24
|
+
*
|
|
25
|
+
* Every store exposes `get(context, kind)`, `set(context, kind, value)`, `remove(context, kind?)` with kind
|
|
26
|
+
* `dev|api|oauth`. No method ever puts a secret into an error message.
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
export const KEYCHAIN_SERVICE = "blocofy-cli";
|
|
19
|
-
const FIELD = { dev: "dev_token", api: "api_key" };
|
|
30
|
+
const FIELD = { dev: "dev_token", api: "api_key", oauth: "oauth_tokens" };
|
|
31
|
+
/** Every store name a context may record (`secret: { store }`). */
|
|
32
|
+
export const STORE_NAMES = ["file", "keychain", "dpapi", "secret-service"];
|
|
20
33
|
|
|
21
34
|
export class SecretStoreError extends Error {
|
|
22
35
|
constructor(code, message) {
|
|
@@ -125,9 +138,163 @@ export function createKeychainSecretStore({ exec = defaultExec, platform = proce
|
|
|
125
138
|
};
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
const storeAccount = (context, kind) => `${context}:${kind}`;
|
|
142
|
+
|
|
143
|
+
/** Windows: the protect/unprotect scripts. The value travels on stdin both ways; argv carries only the script. */
|
|
144
|
+
const PS_PROTECT =
|
|
145
|
+
"$s=[Console]::In.ReadToEnd(); $ss=ConvertTo-SecureString -String $s -AsPlainText -Force; [Console]::Out.Write((ConvertFrom-SecureString -SecureString $ss))";
|
|
146
|
+
const PS_UNPROTECT =
|
|
147
|
+
"$e=[Console]::In.ReadToEnd().Trim(); $ss=ConvertTo-SecureString -String $e; $b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss); " +
|
|
148
|
+
"try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($b)) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }";
|
|
149
|
+
const powershell = (exec, script, input) => exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input });
|
|
150
|
+
|
|
151
|
+
/** The DPAPI file's default place: `%APPDATA%\blocofy\secrets.dpapi` (user scope). */
|
|
152
|
+
export function dpapiPath(env = process.env) {
|
|
153
|
+
return join(env.APPDATA || join(homedir(), "AppData", "Roaming"), "blocofy", "secrets.dpapi");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Windows DPAPI adapter: the file maps `<context>:<kind>` to a DPAPI-protected blob (useless to another user or
|
|
158
|
+
* machine). An unreadable blob is an error (SECRET_STORE_FAILED), never "not logged in".
|
|
159
|
+
*/
|
|
160
|
+
export function createDpapiSecretStore({ exec = defaultExec, platform = process.platform, filePath = dpapiPath() } = {}) {
|
|
161
|
+
const ensure = () => {
|
|
162
|
+
if (platform !== "win32") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The DPAPI secret store is only available on Windows.");
|
|
163
|
+
};
|
|
164
|
+
const readAll = () => {
|
|
165
|
+
let raw;
|
|
166
|
+
try {
|
|
167
|
+
raw = readFileSync(filePath, "utf8");
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error?.code === "ENOENT") return {};
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const data = JSON.parse(raw);
|
|
174
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data;
|
|
175
|
+
} catch {
|
|
176
|
+
/* fall through */
|
|
177
|
+
}
|
|
178
|
+
throw new SecretStoreError("CREDENTIALS_CORRUPT", `The protected secrets file is not valid JSON: ${filePath}. Nothing was written.`);
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
name: "dpapi",
|
|
182
|
+
get(context, kind) {
|
|
183
|
+
ensure();
|
|
184
|
+
const blob = readAll()[storeAccount(context, kind)];
|
|
185
|
+
if (typeof blob !== "string" || !blob) return null;
|
|
186
|
+
const r = powershell(exec, PS_UNPROTECT, blob);
|
|
187
|
+
if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not read the secret from the Windows protected store (${filePath}); it was not used.`);
|
|
188
|
+
return r.stdout || null;
|
|
189
|
+
},
|
|
190
|
+
set(context, kind, value) {
|
|
191
|
+
ensure();
|
|
192
|
+
const r = powershell(exec, PS_PROTECT, String(value));
|
|
193
|
+
const blob = String(r.stdout ?? "").trim();
|
|
194
|
+
if (r.status !== 0 || r.error || !blob) throw new SecretStoreError("SECRET_STORE_FAILED", "Could not protect the secret with Windows DPAPI. Nothing was saved.");
|
|
195
|
+
const data = readAll();
|
|
196
|
+
data[storeAccount(context, kind)] = blob;
|
|
197
|
+
writeJsonAtomic(filePath, data);
|
|
198
|
+
},
|
|
199
|
+
remove(context, kind = null) {
|
|
200
|
+
ensure();
|
|
201
|
+
const data = readAll();
|
|
202
|
+
let changed = false;
|
|
203
|
+
for (const k of kind ? [kind] : Object.keys(FIELD)) {
|
|
204
|
+
if (storeAccount(context, k) in data) {
|
|
205
|
+
delete data[storeAccount(context, k)];
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (changed) writeJsonAtomic(filePath, data);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Linux Secret Service (libsecret) adapter through `secret-tool`; the secret goes in on stdin. */
|
|
215
|
+
export function createSecretServiceStore({ exec = defaultExec, platform = process.platform } = {}) {
|
|
216
|
+
const ensure = () => {
|
|
217
|
+
if (platform !== "linux") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "The Secret Service store is only used on Linux.");
|
|
218
|
+
};
|
|
219
|
+
const attrs = (context, kind) => ["service", KEYCHAIN_SERVICE, "account", storeAccount(context, kind)];
|
|
220
|
+
return {
|
|
221
|
+
name: "secret-service",
|
|
222
|
+
get(context, kind) {
|
|
223
|
+
ensure();
|
|
224
|
+
const r = exec("secret-tool", ["lookup", ...attrs(context, kind)]);
|
|
225
|
+
if (r.error && r.error.code === "ENOENT") throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", "`secret-tool` (libsecret) is not installed; the saved login cannot be read.");
|
|
226
|
+
if (r.status !== 0) return null;
|
|
227
|
+
const v = String(r.stdout ?? "").replace(/\r?\n$/, "");
|
|
228
|
+
return v || null;
|
|
229
|
+
},
|
|
230
|
+
set(context, kind, value) {
|
|
231
|
+
ensure();
|
|
232
|
+
const r = exec("secret-tool", ["store", `--label=Blocofy CLI (${context})`, ...attrs(context, kind)], { input: String(value) });
|
|
233
|
+
if (r.status !== 0 || r.error) throw new SecretStoreError("SECRET_STORE_FAILED", `Could not save the secret to the Secret Service (secret-tool exit ${r.status}). Nothing was saved.`);
|
|
234
|
+
},
|
|
235
|
+
remove(context, kind = null) {
|
|
236
|
+
ensure();
|
|
237
|
+
for (const k of kind ? [kind] : Object.keys(FIELD)) exec("secret-tool", ["clear", ...attrs(context, k)]);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
128
242
|
/** `store` name → adapter. Unknown names are a usage error. */
|
|
129
|
-
export function secretStoreFor(name, { filePath, exec, platform } = {}) {
|
|
243
|
+
export function secretStoreFor(name, { filePath, exec, platform, dpapiPath: dpapiFile } = {}) {
|
|
130
244
|
if (name === "file") return createFileSecretStore(filePath);
|
|
131
245
|
if (name === "keychain") return createKeychainSecretStore({ exec, platform });
|
|
132
|
-
|
|
246
|
+
if (name === "dpapi") return createDpapiSecretStore({ exec, platform, ...(dpapiFile ? { filePath: dpapiFile } : {}) });
|
|
247
|
+
if (name === "secret-service") return createSecretServiceStore({ exec, platform });
|
|
248
|
+
throw new SecretStoreError("SECRET_STORE_UNAVAILABLE", `Unknown secret store "${name}" (use ${STORE_NAMES.map((n) => `"${n}"`).join(", ")}).`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Where a store keeps secrets, in words (login output names it). */
|
|
252
|
+
export function storeLabel(name) {
|
|
253
|
+
switch (name) {
|
|
254
|
+
case "keychain":
|
|
255
|
+
return "macOS Keychain";
|
|
256
|
+
case "dpapi":
|
|
257
|
+
return "Windows DPAPI-protected file (OS protected file)";
|
|
258
|
+
case "secret-service":
|
|
259
|
+
return "Linux Secret Service (libsecret)";
|
|
260
|
+
case "file":
|
|
261
|
+
return "plain file ~/.blocofy/secrets.json (0600) — chosen explicitly";
|
|
262
|
+
default:
|
|
263
|
+
return String(name);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const OS_STORE = { darwin: "keychain", win32: "dpapi", linux: "secret-service" };
|
|
268
|
+
|
|
269
|
+
/** Is the Secret Service usable here? A DBus session and an installed `secret-tool`. */
|
|
270
|
+
function secretServiceAvailable(env, exec) {
|
|
271
|
+
if (!env.DBUS_SESSION_BUS_ADDRESS) return false;
|
|
272
|
+
const r = exec("secret-tool", ["--help"]);
|
|
273
|
+
return !(r.error && r.error.code === "ENOENT");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* ADR-0014 §5.4 — the store for the browser login's tokens. `insecure` (`--insecure-storage`) or
|
|
278
|
+
* `BLOCOFY_SECRET_STORE=file` choose the 0600 file (`explicit: true`; the caller announces it); another
|
|
279
|
+
* `BLOCOFY_SECRET_STORE` value names an OS store, which must be this OS's. Otherwise this platform's OS store; none
|
|
280
|
+
* available → SECRET_STORE_UNAVAILABLE (never a silent plaintext downgrade).
|
|
281
|
+
*/
|
|
282
|
+
export function oauthSecretStore({ platform = process.platform, env = process.env, insecure = false, exec = defaultExec } = {}) {
|
|
283
|
+
const unavailable = (why) =>
|
|
284
|
+
new SecretStoreError(
|
|
285
|
+
"SECRET_STORE_UNAVAILABLE",
|
|
286
|
+
`${why} The CLI login is never saved in a plain file automatically. To keep it in ~/.blocofy/secrets.json (0600) instead, choose that explicitly: blocofy login --insecure-storage (or BLOCOFY_SECRET_STORE=file). Nothing was saved.`,
|
|
287
|
+
);
|
|
288
|
+
const requested = env.BLOCOFY_SECRET_STORE;
|
|
289
|
+
if (insecure || requested === "file") return { name: "file", explicit: true };
|
|
290
|
+
if (requested !== undefined && requested !== "") {
|
|
291
|
+
if (!STORE_NAMES.includes(requested)) throw unavailable(`BLOCOFY_SECRET_STORE="${requested}" is not a known store (${STORE_NAMES.join(", ")}).`);
|
|
292
|
+
if (OS_STORE[platform] !== requested) throw unavailable(`The "${requested}" store is not available on this operating system (${platform}).`);
|
|
293
|
+
}
|
|
294
|
+
const name = OS_STORE[platform];
|
|
295
|
+
if (!name) throw unavailable(`No OS secure store is supported on this operating system (${platform}).`);
|
|
296
|
+
if (name === "secret-service" && !secretServiceAvailable(env, exec)) {
|
|
297
|
+
throw unavailable("No Secret Service is available (it needs a desktop DBus session and `secret-tool` from libsecret).");
|
|
298
|
+
}
|
|
299
|
+
return { name, explicit: false };
|
|
133
300
|
}
|
package/lib/target.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { fetchWithRetry } from "./http.mjs";
|
|
6
|
-
import { API_KEY_PREFIX } from "./media-uses.mjs";
|
|
6
|
+
import { API_KEY_PREFIX, isValidApiKey } from "./media-uses.mjs";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* CF-T1/T2 (contract C2) — project binding + target resolution + remote identity verification.
|
|
@@ -108,7 +108,7 @@ function lstatOrNull(p) {
|
|
|
108
108
|
* before the first write); each file is written to a fresh temp file in the same directory (`wx`, 0644) and
|
|
109
109
|
* renamed into place. `staleLocal: true` (link) removes a `local.json` that an env-context binding does not own.
|
|
110
110
|
*/
|
|
111
|
-
export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false }) {
|
|
111
|
+
export function writeBinding(root, { site, platformOrigin, contextName, staleLocal = false, profile = null, ignore = ["local.json"] }) {
|
|
112
112
|
const d = join(root, PROJECT_DIR);
|
|
113
113
|
const refuse = (path, what) => {
|
|
114
114
|
throw new TargetError("TARGET_BINDING_INVALID", `${path} ${what}; the project binding was not written.`, { binding: path });
|
|
@@ -122,10 +122,12 @@ export function writeBinding(root, { site, platformOrigin, contextName, staleLoc
|
|
|
122
122
|
if (st.isSymbolicLink() || !st.isDirectory()) refuse(d, "is not a real directory");
|
|
123
123
|
}
|
|
124
124
|
const project = { schema_version: 1, site_id: site.id, site_slug: site.slug ?? null, platform_origin: origin(platformOrigin) };
|
|
125
|
+
// ADR-0014 §5.6 step 6 — additive: the profile the project was set up with (an older CLI ignores it; the server decides).
|
|
126
|
+
if (profile && typeof profile.id === "string") project.profile = { id: profile.id, version: profile.version ?? null };
|
|
125
127
|
const writes = [["project.json", JSON.stringify(project, null, 2) + "\n"]];
|
|
126
128
|
const ownsLocal = contextName && contextName !== ENV_CONTEXT;
|
|
127
129
|
if (ownsLocal) writes.push(["local.json", JSON.stringify({ context: contextName }, null, 2) + "\n"]);
|
|
128
|
-
writes.push([".gitignore", "
|
|
130
|
+
writes.push([".gitignore", ignore.join("\n") + "\n"]);
|
|
129
131
|
for (const name of ["project.json", "local.json", ".gitignore"]) {
|
|
130
132
|
const f = lstatOrNull(join(d, name));
|
|
131
133
|
if (f && (f.isSymbolicLink() || !f.isFile())) refuse(join(d, name), "is a symlink or not a regular file");
|
|
@@ -352,7 +354,7 @@ export function assertCredentialTypes({ resolved, secrets }) {
|
|
|
352
354
|
const refuse = (kind, secret) => {
|
|
353
355
|
const env = Boolean(resolved.env);
|
|
354
356
|
const where = kind === "dev" ? (env ? "BLOCOFY_TOKEN" : `The dev token of context "${resolved.name}"`) : env ? "BLOCOFY_API_KEY" : `The API key of context "${resolved.name}"`;
|
|
355
|
-
const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX);
|
|
357
|
+
const other = kind === "dev" ? secret.startsWith("blcf_") : secret.startsWith(DEV_TOKEN_PREFIX) || secret.startsWith("bcf2_");
|
|
356
358
|
const holds = kind === "dev" ? (other ? "holds a v1 API key (blcf_…)" : "is not a dev token") : other ? "holds a theme dev token (bcf_…)" : "is not a v1 API key";
|
|
357
359
|
const expected = kind === "dev" ? `a theme dev token starts with ${DEV_TOKEN_PREFIX}` : `a v1 API key starts with ${API_KEY_PREFIX}`;
|
|
358
360
|
const fix = env
|
|
@@ -369,8 +371,10 @@ export function assertCredentialTypes({ resolved, secrets }) {
|
|
|
369
371
|
1,
|
|
370
372
|
);
|
|
371
373
|
};
|
|
372
|
-
|
|
373
|
-
if (resolved.
|
|
374
|
+
// A CLI login's context carries its own short-lived token in both places; the refresh layer vouches for it.
|
|
375
|
+
if (resolved.oauth) return;
|
|
376
|
+
if (resolved.context.dev && typeof secrets.devToken === "string" && !(secrets.devToken.startsWith(DEV_TOKEN_PREFIX) || secrets.devToken.startsWith("bcf2_"))) refuse("dev", secrets.devToken);
|
|
377
|
+
if (resolved.context.api && typeof secrets.apiKey === "string" && !isValidApiKey(secrets.apiKey)) refuse("api", secrets.apiKey);
|
|
374
378
|
}
|
|
375
379
|
|
|
376
380
|
// ── remote identity ─────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -386,7 +390,17 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
|
|
|
386
390
|
return { error: `unreachable (${error?.name === "TimeoutError" ? "timeout" : "network error"})` };
|
|
387
391
|
}
|
|
388
392
|
const text = await res.text().catch(() => "");
|
|
389
|
-
if (!res.ok)
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
// ADR-0014: keep the server's refusal code (v1 `{error:{code}}`, renderer `{error, code}`) — e.g. audience_mismatch.
|
|
395
|
+
let code = null;
|
|
396
|
+
try {
|
|
397
|
+
const j = JSON.parse(text);
|
|
398
|
+
code = typeof j?.error?.code === "string" ? j.error.code : typeof j?.code === "string" ? j.code : null;
|
|
399
|
+
} catch {
|
|
400
|
+
code = null;
|
|
401
|
+
}
|
|
402
|
+
return { error: `HTTP ${res.status}${code ? ` ${code}` : ""}`, serverCode: code };
|
|
403
|
+
}
|
|
390
404
|
try {
|
|
391
405
|
return { body: JSON.parse(text) };
|
|
392
406
|
} catch {
|
|
@@ -394,15 +408,15 @@ async function getJson(fetchImpl, url, secret, retry = {}) {
|
|
|
394
408
|
}
|
|
395
409
|
}
|
|
396
410
|
|
|
397
|
-
function unverified(what, url, reason) {
|
|
398
|
-
return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason });
|
|
411
|
+
function unverified(what, url, reason, serverCode = null) {
|
|
412
|
+
return new TargetError("TARGET_UNVERIFIED", `Could not verify the target site via ${what} (${reason}). Nothing was sent or written.`, { endpoint: url, reason, ...(serverCode ? { server_code: serverCode } : {}) });
|
|
399
413
|
}
|
|
400
414
|
|
|
401
415
|
/** `GET {url}/api/dev/whoami` → `{ site, platformOrigin, liveThemeId }` or TARGET_UNVERIFIED. */
|
|
402
416
|
export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retry = {} }) {
|
|
403
417
|
const endpoint = `${url.replace(/\/+$/, "")}/api/dev/whoami`;
|
|
404
418
|
const r = await getJson(fetchImpl, endpoint, token, retry);
|
|
405
|
-
if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error);
|
|
419
|
+
if (r.error) throw unverified("/api/dev/whoami", endpoint, r.error, r.serverCode);
|
|
406
420
|
const site = r.body?.site;
|
|
407
421
|
if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id)) || typeof site.slug !== "string") {
|
|
408
422
|
throw unverified("/api/dev/whoami", endpoint, "malformed response (no site id/slug)");
|
|
@@ -414,7 +428,7 @@ export async function verifyDev({ url, token, fetchImpl = globalThis.fetch, retr
|
|
|
414
428
|
export async function verifyApi({ url, apiKey, fetchImpl = globalThis.fetch, retry = {} }) {
|
|
415
429
|
const endpoint = `${url.replace(/\/+$/, "")}/api/v1/ping`;
|
|
416
430
|
const r = await getJson(fetchImpl, endpoint, apiKey, retry);
|
|
417
|
-
if (r.error) throw unverified("/api/v1/ping", endpoint, r.error);
|
|
431
|
+
if (r.error) throw unverified("/api/v1/ping", endpoint, r.error, r.serverCode);
|
|
418
432
|
const site = r.body?.site;
|
|
419
433
|
if (!isObj(site) || !(typeof site.id === "string" || Number.isFinite(site.id))) {
|
|
420
434
|
throw unverified("/api/v1/ping", endpoint, "malformed response (no site id)");
|
package/lib/theme-sync.mjs
CHANGED
|
@@ -50,9 +50,9 @@ async function errorText(res) {
|
|
|
50
50
|
|
|
51
51
|
/** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
|
|
52
52
|
async function responseError(res) {
|
|
53
|
+
// #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
|
|
54
|
+
const body = await res.clone().json().catch(() => null);
|
|
53
55
|
if (res.status === 409) {
|
|
54
|
-
// #989: keep `reason`/`candidates` (errorText keeps only `error`). Read from a clone so the fallback below still can.
|
|
55
|
-
const body = await res.clone().json().catch(() => null);
|
|
56
56
|
if (body?.error === DRAFT_TARGET_AMBIGUOUS) return withDraftTargetDetails(Object.assign(new Error(DRAFT_TARGET_AMBIGUOUS), { status: 409, code: DRAFT_TARGET_AMBIGUOUS }), body);
|
|
57
57
|
if (body?.error === PUBLISH_TARGET_UNCONFIRMED) {
|
|
58
58
|
return Object.assign(new Error(PUBLISH_TARGET_UNCONFIRMED), {
|
|
@@ -67,8 +67,14 @@ async function responseError(res) {
|
|
|
67
67
|
const detail = await errorText(res);
|
|
68
68
|
const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
|
|
69
69
|
err.status = res.status;
|
|
70
|
+
const retryAfter = res.headers.get("retry-after");
|
|
71
|
+
if (retryAfter) err.retryAfter = retryAfter;
|
|
70
72
|
if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
|
|
73
|
+
// `{ error: "<sentence>", code }` (e.g. dev/publish `work_not_publishable`): the code sits beside the sentence.
|
|
74
|
+
else if (typeof body?.code === "string" && /^[a-z][a-z0-9_]*$/.test(body.code)) err.code = body.code;
|
|
71
75
|
else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
|
|
76
|
+
// ADR-0014: a credential refusal's details (e.g. credential_reapproval_required's due date) for the CLI's words.
|
|
77
|
+
if (body && typeof body.details === "object" && body.details !== null) err.serverDetails = body.details;
|
|
72
78
|
return err;
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -89,6 +95,8 @@ async function httpError(res) {
|
|
|
89
95
|
err.status = res.status;
|
|
90
96
|
err.code = typeof body?.error === "string" ? body.error : null;
|
|
91
97
|
err.body = body;
|
|
98
|
+
const retryAfter = res.headers.get("retry-after");
|
|
99
|
+
if (retryAfter) err.retryAfter = retryAfter;
|
|
92
100
|
return withDraftTargetDetails(err, body);
|
|
93
101
|
}
|
|
94
102
|
|
|
@@ -160,7 +168,7 @@ const isRestore = (c) => c.restore === true || SITE_STATE_DRAFT_NAME.test(c.name
|
|
|
160
168
|
/**
|
|
161
169
|
* #989 re-review P3 — one line for a `theme dev` draft-sync failure, in the push/pull wording: 503
|
|
162
170
|
* `draft_target_unverifiable` (transient; the next save tries again) and 422 `draft_target_is_live` get their own
|
|
163
|
-
* sentence, anything else its message. Never swallowed.
|
|
171
|
+
* sentence, the theme-capacity refusals (`themeCapacityRefusal`) theirs, anything else its message. Never swallowed.
|
|
164
172
|
*/
|
|
165
173
|
export function draftSyncErrorLine(err) {
|
|
166
174
|
if (err?.code === "draft_target_unverifiable") {
|
|
@@ -169,9 +177,151 @@ export function draftSyncErrorLine(err) {
|
|
|
169
177
|
if (err?.code === "draft_target_is_live") {
|
|
170
178
|
return "draft sync refused: that is the LIVE theme, and a draft sync never writes live. Nothing was written; pass a draft's handle with --instance (`blocofy status`).";
|
|
171
179
|
}
|
|
180
|
+
const capacity = themeCapacityRefusal(err);
|
|
181
|
+
if (capacity) return `draft sync: ${capacity.short}`;
|
|
172
182
|
return `draft sync failed: ${err?.message ?? String(err)}`;
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
const trNumber = new Intl.NumberFormat("tr-TR", { maximumFractionDigits: 1 });
|
|
186
|
+
const isAmount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
187
|
+
const bytesLabel = (b) => (b >= 1024 ** 3 ? `${trNumber.format(b / 1024 ** 3)} GB` : `${trNumber.format(b / 1024 ** 2)} MB`);
|
|
188
|
+
|
|
189
|
+
/** The admission's typed meter in words (V2 storage bytes; V1 / other `used / limit`), or null. */
|
|
190
|
+
function quotaMeter(d) {
|
|
191
|
+
if (!d || typeof d !== "object") return null;
|
|
192
|
+
if (isAmount(d.usedBytes) && isAmount(d.limitBytes)) {
|
|
193
|
+
const need = isAmount(d.estimateBytes) ? `; yeni taslak yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : "";
|
|
194
|
+
return `Kullanılan depolama: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.limitBytes)}${need}.`;
|
|
195
|
+
}
|
|
196
|
+
if (isAmount(d.used) && isAmount(d.limit)) {
|
|
197
|
+
const what = d.resource === "theme_drafts" ? "Taslak tema sayısı" : typeof d.resource === "string" ? d.resource : "Kullanım";
|
|
198
|
+
return `${what}: ${d.used} / ${d.limit}.`;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const CAPACITY_CODES = new Set(["target_missing", "quota_exceeded", "capacity_unavailable", "resource_busy", "source_stale"]);
|
|
204
|
+
/** Transient admission refusals: every attempt answering one of these wrote nothing (a definite answer). */
|
|
205
|
+
export const CAPACITY_RETRYABLE = new Set(["capacity_unavailable", "resource_busy"]);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Theme-capacity program (ADR-0013) — the dev theme endpoint's refusals around the CLI draft, in words:
|
|
209
|
+
* - 404 `target_missing`: `pull --draft` found no CLI draft. A read never creates a theme; `push --draft` does.
|
|
210
|
+
* - 422 `quota_exceeded`, by `details.resource`:
|
|
211
|
+
* `theme_bytes` — the site's theme space (a technical allowance, not the plan) is full. Any theme write can meet
|
|
212
|
+
* it, a push to an existing draft included: no "new draft" wording, no plan upgrade; free space by deleting
|
|
213
|
+
* unused draft themes (cancelled works' themes included; cancel releases only a work's reserved publish space,
|
|
214
|
+
* archive frees no theme bytes), or contact support.
|
|
215
|
+
* `theme_drafts` (the legacy draft count of a site not on theme space) — not tied to the plan: remove a draft
|
|
216
|
+
* you no longer need; never a plan upgrade.
|
|
217
|
+
* `storage_mb` (the plan's storage) — the NEW draft was refused for the plan: the plan message.
|
|
218
|
+
* - 503 `capacity_unavailable` / `resource_busy`: transient (Retry-After), never the plan. lib/http.mjs has already
|
|
219
|
+
* resent the request within its retry policy; this is the last answer.
|
|
220
|
+
* - 409 `source_stale`: the live theme the new draft copies changed meanwhile; running the command again copies it anew.
|
|
221
|
+
* The platform wrote nothing for that answer. Returns `{ lines, short, message, details }`: `lines` the plain Turkish
|
|
222
|
+
* explanation (stderr, human mode; first line non-technical), `short` one line for `theme dev`, `message`/`details` the
|
|
223
|
+
* technical `error [code]` line and `--json` envelope. `null` for any other code (draft_target_ambiguous keeps its own).
|
|
224
|
+
* `pushCommand` is the user's push command line (its directory argument kept). A refusal of a RESENT push apply
|
|
225
|
+
* (`earlierAttempt`) says nothing about that earlier attempt, so it never claims "nothing was written" then.
|
|
226
|
+
*/
|
|
227
|
+
export function themeCapacityRefusal(error, { pushCommand = "blocofy theme push" } = {}) {
|
|
228
|
+
const code = error?.code;
|
|
229
|
+
if (!CAPACITY_CODES.has(code)) return null;
|
|
230
|
+
const earlier = error?.phase === "apply" && (error.earlierAttempt === "unknown" || error.earlierAttempt === "committed") ? error.earlierAttempt : null;
|
|
231
|
+
const done =
|
|
232
|
+
earlier === "committed"
|
|
233
|
+
? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi işlendi (yanıtı öyle söyledi)."
|
|
234
|
+
: earlier === "unknown"
|
|
235
|
+
? "Bu deneme hiçbir şey yazmadı; ancak bu push'un önceki bir denemesi kesin yanıt alamadı, yazıp yazmadığı bilinmiyor."
|
|
236
|
+
: "Hiçbir şey yazılmadı.";
|
|
237
|
+
const doneEn =
|
|
238
|
+
earlier === "committed"
|
|
239
|
+
? "This attempt wrote nothing, but an earlier attempt of this push was committed (its answer said so)."
|
|
240
|
+
: earlier === "unknown"
|
|
241
|
+
? "This attempt wrote nothing, but an earlier attempt of this push got no definite answer, so whether it wrote is unknown."
|
|
242
|
+
: "Nothing was written.";
|
|
243
|
+
const check = earlier ? [" Tekrar çalıştırmadan önce sitenin taslaklarını `blocofy status` ile kontrol et."] : [];
|
|
244
|
+
const checkEn = earlier ? " Check the site's drafts with `blocofy status` before running the push again." : "";
|
|
245
|
+
const extra = earlier ? { earlierAttempt: earlier } : {};
|
|
246
|
+
|
|
247
|
+
if (code === "target_missing") {
|
|
248
|
+
return {
|
|
249
|
+
lines: [
|
|
250
|
+
"Bu sitede henüz bir CLI taslağı yok; indirilecek taslak bulunamadı. Hiçbir dosya yazılmadı.",
|
|
251
|
+
` Önce taslağı oluştur: ${pushCommand} --draft`,
|
|
252
|
+
" (Canlı temayı indirmek istiyorsan --draft olmadan çalıştır.)",
|
|
253
|
+
],
|
|
254
|
+
short: "Bu sitede henüz bir CLI taslağı yok.",
|
|
255
|
+
message: `No CLI draft on this site yet (a read never creates one). Nothing was written. Create it first: ${pushCommand} --draft`,
|
|
256
|
+
details: {},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (code === "quota_exceeded") {
|
|
260
|
+
const d = error?.body?.details && typeof error.body.details === "object" ? error.body.details : null;
|
|
261
|
+
if (d?.resource === "theme_bytes") {
|
|
262
|
+
// The site's theme space (a technical allowance, not the plan): any theme write can meet it — a push to an
|
|
263
|
+
// existing draft included — so no "new draft" wording and no plan upgrade; space is freed by removing drafts.
|
|
264
|
+
const head = "Sitenin tema alanı dolu; bu değişiklik için yer kalmadı.";
|
|
265
|
+
const meter = isAmount(d.usedBytes) && isAmount(d.allowanceBytes)
|
|
266
|
+
? `Kullanılan tema alanı: ${bytesLabel(d.usedBytes)} / ${bytesLabel(d.allowanceBytes)}${isAmount(d.estimateBytes) ? `; bu değişiklik yaklaşık ${bytesLabel(d.estimateBytes)} gerektiriyor` : ""}.`
|
|
267
|
+
: null;
|
|
268
|
+
return {
|
|
269
|
+
lines: [
|
|
270
|
+
`${head} ${done}`,
|
|
271
|
+
...(meter ? [` ${meter}`] : []),
|
|
272
|
+
" Yer açmak için kullanmadığın taslak temaları (iptal edilmiş çalışmaların temaları dahil) tema kütüphanesinden sil (admin paneli → Tasarım); açık bir çalışmayı iptal etmek ayrıca onun için ayrılan yayın alanını bırakır.",
|
|
273
|
+
" Sorun sürerse destek ekibine yaz.",
|
|
274
|
+
...check,
|
|
275
|
+
],
|
|
276
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; yer açmak için kullanılmayan bir taslağı sil.`,
|
|
277
|
+
message: `The site's theme space is full; this change does not fit (quota_exceeded, theme_bytes). ${doneEn} Free space by deleting unused draft themes from the theme library (cancelled works' themes included); cancelling an open work also releases its reserved publish space. Or contact support.${checkEn}`,
|
|
278
|
+
details: { ...d, ...extra },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const meter = quotaMeter(d);
|
|
282
|
+
if (d?.resource === "theme_drafts") {
|
|
283
|
+
// The legacy draft count of a site not (yet) on theme space: not tied to the plan, so no upgrade advice.
|
|
284
|
+
const head = "Sitenin taslak tema sınırına ulaşıldı; yeni taslak oluşturulamadı.";
|
|
285
|
+
return {
|
|
286
|
+
lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Artık kullanmadığın bir taslak temayı sil (admin paneli → Tasarım).", ...check],
|
|
287
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı; kullanmadığın bir taslağı sil.`,
|
|
288
|
+
message: `The site's draft limit refused the new draft this push needs (quota_exceeded, theme_drafts). ${doneEn} Remove a draft you no longer need.${checkEn}`,
|
|
289
|
+
details: { ...d, ...extra },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const head = `${d?.resource === "storage_mb" || isAmount(d?.usedBytes) ? "Depolama alanın dolu" : "Planının sınırına ulaşıldı"}; yeni taslak oluşturulamadı.`;
|
|
293
|
+
return {
|
|
294
|
+
lines: [`${head} ${done}`, ...(meter ? [` ${meter}`] : []), " Yeni taslak için planını yükseltebilirsin: admin paneli → Ayarlar → Plan & faturalandırma.", ...check],
|
|
295
|
+
short: `${head}${meter ? ` ${meter}` : ""} Hiçbir şey yazılmadı.`,
|
|
296
|
+
message: `The plan's capacity refused the new draft this push needs (quota_exceeded). ${doneEn}${checkEn}`,
|
|
297
|
+
details: { ...(d ?? {}), ...extra },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (code === "source_stale") {
|
|
301
|
+
const head = "Canlı tema bu sırada değişti; yeni taslak onun güncel hâlinden oluşturulamadı.";
|
|
302
|
+
return {
|
|
303
|
+
lines: [`${head} ${done}`, ` Komutu yeniden çalıştır; taslak canlının güncel hâlinden oluşturulur: ${pushCommand}`, ...check],
|
|
304
|
+
short: `${head} Hiçbir şey yazılmadı; bir sonraki kayıt yeniden dener.`,
|
|
305
|
+
message: `The live theme a new draft copies changed while it was prepared (source_stale). ${doneEn} Run the command again.${checkEn}`,
|
|
306
|
+
details: extra,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// capacity_unavailable / resource_busy: transient, already resent within the CLI's retry policy.
|
|
310
|
+
// Neither is about the plan or a full space, and either can answer a push to an existing draft: no "new draft" wording.
|
|
311
|
+
const head =
|
|
312
|
+
code === "resource_busy"
|
|
313
|
+
? "Site şu anda meşgul (başka bir yazım sürüyor); işlem şimdilik yapılamadı."
|
|
314
|
+
: "Platform tema alanını şu an doğrulayamadı; bu geçici bir durum, işlem şimdilik yapılamadı.";
|
|
315
|
+
const raw = String(error?.retryAfter ?? "").trim();
|
|
316
|
+
const seconds = /^\d+$/.test(raw) ? Number(raw) : null;
|
|
317
|
+
return {
|
|
318
|
+
lines: [`${head} ${done}`, ` Biraz sonra tekrar dene${seconds ? ` (en az ${seconds} sn sonra)` : ""}: ${pushCommand}`, ...check],
|
|
319
|
+
short: `${head} Hiçbir şey yazılmadı. Biraz sonra tekrar dene; bir sonraki kayıt yeniden dener.`,
|
|
320
|
+
message: `${code === "resource_busy" ? "The site is busy with another write" : "The platform could not check the theme space right now (temporary)"}; the change was not applied (${code}). ${doneEn} Try again later.${checkEn}`,
|
|
321
|
+
details: { ...(seconds != null ? { retryAfterSeconds: seconds } : {}), ...extra },
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
175
325
|
/** A local #989 refusal with the server's shape (used where the CLI selects the draft itself, e.g. `push --diff`). */
|
|
176
326
|
function draftTargetAmbiguous(reason, candidates) {
|
|
177
327
|
for (const c of candidates) c.restore = SITE_STATE_DRAFT_NAME.test(c.name ?? "");
|
|
@@ -527,6 +677,8 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
527
677
|
// An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
|
|
528
678
|
// refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
|
|
529
679
|
let earlierAttempt = null;
|
|
680
|
+
// ADR-0013: a 503 capacity_unavailable / resource_busy is a definite "nothing was written" answer, not an unknown one.
|
|
681
|
+
let definiteNothing = false;
|
|
530
682
|
const applyError = (err) => {
|
|
531
683
|
Object.assign(err, { phase: "apply" });
|
|
532
684
|
// target_changed names the current target; the one this push planned against is known only here.
|
|
@@ -538,11 +690,14 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
538
690
|
try {
|
|
539
691
|
res = await post({ ...target, ...bindings }, idempotencyKey, {
|
|
540
692
|
onRetry: (info) => {
|
|
541
|
-
earlierAttempt ??= "unknown";
|
|
693
|
+
if (!definiteNothing) earlierAttempt ??= "unknown";
|
|
694
|
+
definiteNothing = false;
|
|
542
695
|
onRetry?.(info);
|
|
543
696
|
},
|
|
544
697
|
isFinal: async (attempt) => {
|
|
545
|
-
|
|
698
|
+
const answer = await attempt.json().catch(() => null);
|
|
699
|
+
if (answer?.committed === true) earlierAttempt = "committed";
|
|
700
|
+
definiteNothing = CAPACITY_RETRYABLE.has(answer?.error);
|
|
546
701
|
return false;
|
|
547
702
|
},
|
|
548
703
|
});
|