@blocofy/cli 0.8.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/README.md +359 -38
- package/bin/blocofy.mjs +1260 -234
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +375 -48
- package/lib/credentials.mjs +216 -60
- package/lib/http.mjs +60 -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 +53 -0
- package/lib/page-files.mjs +318 -0
- package/lib/page-path-codec.mjs +193 -0
- 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 +682 -0
- package/lib/target.mjs +427 -0
- package/lib/theme-sync.mjs +113 -80
- package/package.json +1 -1
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,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
|
+
}
|
package/lib/local-theme.mjs
CHANGED
|
@@ -8,8 +8,13 @@ import { extname, join, relative, sep } from "node:path";
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
// Mirror of the platform's theme-paths.ts THEME_DIRS (incl. `layer`, added M5 B1 — 0.5.0 closes the
|
|
11
|
-
// drift where a pulled layer/ file was silently dropped from the next push
|
|
12
|
-
|
|
11
|
+
// drift where a pulled layer/ file was silently dropped from the next push; `locales`, added CF-T5, for
|
|
12
|
+
// legacy locale files `locales/<tag>.json` / `locales/<tag>.default.json`). `locales` is not a Liquid
|
|
13
|
+
// kind (raw content, like `asset` — no `.liquid` stripped on push or re-added on pull). The CLI does not
|
|
14
|
+
// validate the tag regex or require a `.json` extension under `locales/` — it reads/writes/sends whatever
|
|
15
|
+
// is there and lets the platform's own write-time validation (tag format, JSON-ness) reject a bad file;
|
|
16
|
+
// duplicating that check here would drift from the server's rules.
|
|
17
|
+
export const THEME_DIRS = new Set(["layout", "section", "block", "layer", "partial", "asset", "template", "locales"]);
|
|
13
18
|
const LIQUID_KINDS = new Set(["layout", "section", "block", "partial", "template"]);
|
|
14
19
|
|
|
15
20
|
/**
|
package/lib/media-uses.mjs
CHANGED
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Authentication is a `blcf_live_…` API key (Bearer). The dev `bcf_…` token is NOT accepted for v1.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* RETRIES (CF-T3, lib/http.mjs: 429/502/503/504 + network errors, Retry-After honoured). The GET is a
|
|
11
|
+
* read and always retried. A decision POST is applied atomically on the server, and a lost response is
|
|
12
|
+
* only safe to replay with the SAME idempotency keys — so the POST is retried ONLY when every decision
|
|
13
|
+
* item carries an `idempotency_key` (the CLI adds a UUID to every item lacking one BEFORE the first
|
|
14
|
+
* attempt, so each retry resends the identical body and the server replays instead of applying twice).
|
|
15
|
+
* A batch with an unkeyed item is sent once. A final 5xx/network failure exits 1. A 4xx is a server
|
|
13
16
|
* REFUSAL (`CliRefusal`): the `{ error }` envelope is carried verbatim for the command to print
|
|
14
17
|
* as JSON on stderr with exit code 2.
|
|
15
18
|
*/
|
|
16
19
|
|
|
20
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
21
|
+
|
|
17
22
|
export const API_KEY_PREFIX = "blcf_live_";
|
|
18
23
|
export const DEFAULT_API_URL = "https://app.blocofy.com";
|
|
19
24
|
|
|
@@ -36,14 +41,19 @@ function mediaUsesUrl(apiUrl, page) {
|
|
|
36
41
|
return `${String(apiUrl).replace(/\/+$/, "")}/api/v1/pages/${encodeURIComponent(page)}/media-uses`;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
/** True when a replay of this decision batch is idempotent on the server. */
|
|
45
|
+
export function everyDecisionKeyed(decisions) {
|
|
46
|
+
return Array.isArray(decisions) && decisions.length > 0 && decisions.every((d) => d && typeof d === "object" && typeof d.idempotency_key === "string" && d.idempotency_key !== "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function request(method, { apiUrl, apiKey, page, onRetry = null, retry = true }, body) {
|
|
40
50
|
const headers = { authorization: `Bearer ${apiKey}`, accept: "application/json" };
|
|
41
51
|
if (body !== undefined) headers["content-type"] = "application/json";
|
|
42
|
-
const res = await
|
|
43
|
-
|
|
44
|
-
headers,
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
const res = await fetchWithRetry(
|
|
53
|
+
mediaUsesUrl(apiUrl, page),
|
|
54
|
+
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body) },
|
|
55
|
+
{ onRetry, retries: retry ? undefined : 0 },
|
|
56
|
+
);
|
|
47
57
|
const text = await res.text();
|
|
48
58
|
let json;
|
|
49
59
|
try {
|
|
@@ -59,24 +69,25 @@ async function request(method, { apiUrl, apiKey, page }, body) {
|
|
|
59
69
|
const error = json?.error && typeof json.error === "object" ? json.error : { code: `http_${res.status}`, message: text.slice(0, 200) || `HTTP ${res.status}` };
|
|
60
70
|
throw new CliRefusal(res.status, error);
|
|
61
71
|
}
|
|
62
|
-
// 5xx (
|
|
72
|
+
// 5xx (after any retries) — the message never includes the request headers.
|
|
63
73
|
const detail = json?.error?.message ?? (text ? text.slice(0, 200) : "");
|
|
64
|
-
throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`);
|
|
74
|
+
throw Object.assign(new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ""}`), { status: res.status });
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** GET the page's media-use view (the newest draft's `revision{id,version}` is inside). */
|
|
68
|
-
export function fetchPageMediaUses({ apiUrl, apiKey, page }) {
|
|
69
|
-
return request("GET", { apiUrl, apiKey, page });
|
|
78
|
+
export function fetchPageMediaUses({ apiUrl, apiKey, page, onRetry = null }) {
|
|
79
|
+
return request("GET", { apiUrl, apiKey, page, onRetry });
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/**
|
|
73
83
|
* POST a decision batch. The body is exactly `{ expected_revision_id, expected_version, decisions }`
|
|
74
|
-
* — items are sent as given (idempotency keys are the caller's responsibility).
|
|
84
|
+
* — items are sent as given (idempotency keys are the caller's responsibility). Retried only when
|
|
85
|
+
* `everyDecisionKeyed(decisions)`.
|
|
75
86
|
*/
|
|
76
|
-
export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions }) {
|
|
87
|
+
export function decidePageMediaUses({ apiUrl, apiKey, page, expectedRevisionId, expectedVersion, decisions, onRetry = null }) {
|
|
77
88
|
return request(
|
|
78
89
|
"POST",
|
|
79
|
-
{ apiUrl, apiKey, page },
|
|
90
|
+
{ apiUrl, apiKey, page, onRetry, retry: everyDecisionKeyed(decisions) },
|
|
80
91
|
{ expected_revision_id: expectedRevisionId, expected_version: expectedVersion, decisions },
|
|
81
92
|
);
|
|
82
93
|
}
|
package/lib/messages.mjs
CHANGED
|
@@ -36,9 +36,11 @@ export function statusLine(session) {
|
|
|
36
36
|
* (ve `theme dev` taslak senkronu) sessizce retry ETMESİN; stderr'e kısa bir satır
|
|
37
37
|
* yazılır. `onRetry({ attempt, retries, reason })` payload'ından string üretir.
|
|
38
38
|
*/
|
|
39
|
-
export function retryNotice({ attempt, retries, reason }) {
|
|
39
|
+
export function retryNotice({ attempt, retries, reason, waitMs }) {
|
|
40
40
|
const why = reason ? ` (${reason})` : "";
|
|
41
|
-
|
|
41
|
+
const what = typeof reason === "string" && reason.startsWith("HTTP ") ? "Server temporarily unavailable" : "Network error";
|
|
42
|
+
const wait = Number.isFinite(waitMs) ? ` in ${Math.round(waitMs / 100) / 10}s` : "";
|
|
43
|
+
return `${what}${why} — retrying${wait} (${attempt}/${retries})…`;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -57,3 +59,41 @@ export function githubNote(session) {
|
|
|
57
59
|
(session.githubConnected === false ? " Connect a GitHub repo for two-way sync." : "")
|
|
58
60
|
);
|
|
59
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* CF-T9 — `blocofy status` health advice (pure). Never an imperative one-line fix: making another theme live
|
|
65
|
+
* replaces what every visitor sees, so the advice names the themes involved, the missing pages, why it happens,
|
|
66
|
+
* and SAFE next steps (preview first); `theme publish` is only mentioned as a separate, deliberate decision.
|
|
67
|
+
* Returns stderr lines ([] when healthy). Tolerates older servers without pages_by_instance/orphan_missing_slugs.
|
|
68
|
+
*/
|
|
69
|
+
export function healthAdvice(s) {
|
|
70
|
+
if (!s || (s.health !== "pages_split" && s.health !== "live_instance_empty")) return [];
|
|
71
|
+
const liveId = s.live_theme_instance?.id ?? null;
|
|
72
|
+
const names = new Map((Array.isArray(s.drafts) ? s.drafts : []).map((d) => [String(d.id), d.name]));
|
|
73
|
+
const holders = (Array.isArray(s.pages_by_instance) ? s.pages_by_instance : [])
|
|
74
|
+
.filter((p) => p && p.theme_instance != null && String(p.theme_instance) !== String(liveId) && p.count > 0)
|
|
75
|
+
.map((p) => ({ id: p.theme_instance, name: names.get(String(p.theme_instance)) ?? null, count: p.count }));
|
|
76
|
+
const label = (h) => `${h.id}${h.name ? ` "${h.name}"` : ""} (${h.count} published page${h.count === 1 ? "" : "s"})`;
|
|
77
|
+
const missing = Array.isArray(s.orphan_missing_slugs) ? s.orphan_missing_slugs : [];
|
|
78
|
+
const handle = holders.length === 1 ? String(holders[0].id) : "<handle>";
|
|
79
|
+
|
|
80
|
+
const out = [];
|
|
81
|
+
if (s.health === "live_instance_empty") {
|
|
82
|
+
out.push(" ⚠ The live theme has no published pages, so visitors get \"page not found\".");
|
|
83
|
+
} else {
|
|
84
|
+
out.push(` ⚠ ${missing.length || s.orphaned_pages || "Some"} published page(s) cannot be reached by visitors.`);
|
|
85
|
+
if (missing.length) out.push(` Missing on the live theme: ${missing.join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
if (holders.length) {
|
|
88
|
+
out.push(` Published pages are held by ${holders.length === 1 ? "a theme that is" : "themes that are"} not live: ${holders.map(label).join(", ")}`);
|
|
89
|
+
} else {
|
|
90
|
+
out.push(" Published pages are held by a theme that is not live (this server does not say which; see the panel's theme library).");
|
|
91
|
+
}
|
|
92
|
+
out.push(" Why: pages belong to a theme. Pages published on a theme that is not live are not shown on the site.");
|
|
93
|
+
out.push(" Safe next steps:");
|
|
94
|
+
out.push(` 1. Preview that theme without changing the site: blocofy theme pull <empty-dir> --instance ${handle}`);
|
|
95
|
+
out.push(" or in the panel: Theme → Theme library → \"Open in editor\".");
|
|
96
|
+
out.push(" 2. Then decide: recreate the missing pages on the live theme, or make that theme live.");
|
|
97
|
+
out.push(` Note: \`blocofy theme publish --instance ${handle}\` REPLACES the live theme for every visitor — a separate, deliberate decision.`);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
package/lib/output.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF-T2 (contract C2) — shared output for target display and refusals. Everything here goes to stderr (stdout
|
|
3
|
+
* stays the command's own result, e.g. `--json` payloads). Known secrets are registered once and redacted from
|
|
4
|
+
* every string this module prints, as a last line of defence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const secrets = new Set();
|
|
8
|
+
|
|
9
|
+
export function registerSecret(value) {
|
|
10
|
+
if (typeof value === "string" && value.length >= 8) secrets.add(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function redact(text) {
|
|
14
|
+
let out = String(text);
|
|
15
|
+
for (const s of secrets) out = out.split(s).join("[redacted]");
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The target block as data (used for `--json` and for `blocofy target`). */
|
|
20
|
+
export function targetData({ site, url, contextName, bindingLabel, operation }) {
|
|
21
|
+
return {
|
|
22
|
+
site: { id: site.id, slug: site.slug ?? null, name: site.name ?? null, domain: site.domain ?? null },
|
|
23
|
+
url: url ?? null,
|
|
24
|
+
context: contextName,
|
|
25
|
+
binding: bindingLabel,
|
|
26
|
+
operation,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatTargetBlock(t) {
|
|
31
|
+
const name = t.site.name ?? t.site.slug ?? String(t.site.id);
|
|
32
|
+
return [
|
|
33
|
+
`Target: ${name} · ${t.site.id} · ${t.site.domain ?? t.url ?? "?"}`,
|
|
34
|
+
`Context: ${t.context}`,
|
|
35
|
+
`Binding: ${t.binding}`,
|
|
36
|
+
`Operation: ${t.operation}`,
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function printTarget(t, { json = false, stream = process.stderr } = {}) {
|
|
41
|
+
stream.write(redact(json ? JSON.stringify({ target: t }) : formatTargetBlock(t)) + "\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `{"error":{code,message,details}}` under --json, `error [CODE]: message` otherwise. */
|
|
45
|
+
export function printError(error, { json = false, stream = process.stderr } = {}) {
|
|
46
|
+
const envelope = { error: { code: error.code ?? "ERROR", message: error.message ?? String(error), details: error.details ?? {} } };
|
|
47
|
+
stream.write(redact(json ? JSON.stringify(envelope) : `error [${envelope.error.code}]: ${envelope.error.message}`) + "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One warning line: `{"warning":{code,message}}` under --json, `warning [CODE]: message` otherwise. */
|
|
51
|
+
export function printWarning(warning, { json = false, stream = process.stderr } = {}) {
|
|
52
|
+
stream.write(redact(json ? JSON.stringify({ warning: { code: warning.code, message: warning.message } }) : `warning [${warning.code}]: ${warning.message}`) + "\n");
|
|
53
|
+
}
|