@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/theme-sync.mjs
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { mkdirSync,
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
|
3
2
|
|
|
4
3
|
import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
4
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
5
|
+
import { PagesCliError, stagedWrite } from "./page-files.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
|
|
8
9
|
* (Bearer token). pull = GET → write to disk (re-adding `.liquid`); push =
|
|
9
10
|
* readLocalTemplates → POST (create/update; no delete).
|
|
11
|
+
*
|
|
12
|
+
* CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs — 429/502/503/504 + network errors, Retry-After).
|
|
13
|
+
* All are safe to resend: reads, a keyed (or naturally idempotent upsert) theme POST, publish (sets a pointer),
|
|
14
|
+
* rename (sets a label). `onRetry` surfaces each retry on stderr.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
/**
|
|
@@ -18,6 +23,13 @@ import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs"
|
|
|
18
23
|
const CANONICAL_PROTOCOL = "1";
|
|
19
24
|
const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* CF-T5 review I1 — OPTIONAL capabilities (never part of the required fence above). `theme-locales`: this CLI sends the
|
|
28
|
+
* workspace's `locales/*` files, so a canonical deploy may replace `locales/` like any other theme folder (a server
|
|
29
|
+
* that sees no declaration keeps stored locale rows). Sent on every theme POST; GETs don't need it.
|
|
30
|
+
*/
|
|
31
|
+
const OPTIONAL_CAPABILITIES = "theme-locales";
|
|
32
|
+
|
|
21
33
|
function canonicalHeaders(extra = {}) {
|
|
22
34
|
return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
|
|
23
35
|
}
|
|
@@ -35,6 +47,16 @@ async function errorText(res) {
|
|
|
35
47
|
return text || `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
/** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
|
|
51
|
+
async function responseError(res) {
|
|
52
|
+
const detail = await errorText(res);
|
|
53
|
+
const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
|
|
54
|
+
err.status = res.status;
|
|
55
|
+
if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
|
|
56
|
+
else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
|
|
57
|
+
return err;
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
61
|
* Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
|
|
40
62
|
* `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
|
|
@@ -61,57 +83,57 @@ async function httpError(res) {
|
|
|
61
83
|
* alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
|
|
62
84
|
* yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
|
|
63
85
|
*/
|
|
64
|
-
export async function fetchCanonicalSupport({ url, token }) {
|
|
86
|
+
export async function fetchCanonicalSupport({ url, token, onRetry = null }) {
|
|
65
87
|
const base = url.replace(/\/+$/, "");
|
|
66
|
-
const res = await
|
|
88
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme`, {
|
|
67
89
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
68
|
-
});
|
|
90
|
+
}, { onRetry });
|
|
69
91
|
if (!res.ok) throw await httpError(res);
|
|
70
92
|
const body = await res.json();
|
|
71
93
|
return { supported: body?.protocol === 1 };
|
|
72
94
|
}
|
|
73
95
|
|
|
74
|
-
/** Backoff delays (ms) between attempts — index 0 = after the first failure. */
|
|
75
|
-
const RETRY_BACKOFF_MS = [300, 900];
|
|
76
|
-
|
|
77
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
-
|
|
79
96
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
97
|
+
* CF-T1/T2 review I1 — a pulled path is accepted only if `readLocalTemplates` would read it back: a top-level theme dir
|
|
98
|
+
* (exact case) with a file below it, or `config/settings_schema.json`. Any `.blocofy`/staging segment (compared
|
|
99
|
+
* case-insensitively: APFS/NTFS fold case, so `.BLOCOFY/project.json` IS the binding) and any dot-directory (`.git`…)
|
|
100
|
+
* is refused. Returns a reason string, or null when the path is acceptable.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* The flat root files a theme actually HAS. The platform's starter themes ship exactly these two, so exactly
|
|
104
|
+
* these two come down.
|
|
87
105
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
106
|
+
* This is an allowlist on purpose. It was briefly a denylist of tooling filenames, and that inverted the rule
|
|
107
|
+
* the rest of this function is built on: anything the server named that was not a lockfile - `CLAUDE.md`,
|
|
108
|
+
* `AGENTS.md`, `next.config.js` - was written straight into the developer's project root by an ordinary pull.
|
|
109
|
+
* A theme download must never be able to place a file the developer's own tooling then trusts. If a theme
|
|
110
|
+
* legitimately grows another root file, this set is where it is added, deliberately.
|
|
90
111
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
112
|
+
const ROOT_FILES_ALLOWED = new Set(["readme.md", "blueprint.json"]);
|
|
113
|
+
|
|
114
|
+
function pullPathProblem(rel) {
|
|
115
|
+
const segs = rel.split("/");
|
|
116
|
+
if (segs.some((seg) => { const l = seg.toLowerCase(); return l === ".blocofy" || l.startsWith(".blocofy-staging-"); })) return "reserved CLI path";
|
|
117
|
+
if (segs.slice(0, -1).some((seg) => seg.startsWith("."))) return "hidden directory";
|
|
118
|
+
// `config/` — the theme's own configuration rows. `settings_schema.json` is the one a push sends back; the
|
|
119
|
+
// platform serves the others (a starter theme ships `config/theme.json`) and a pull that refused them
|
|
120
|
+
// refused the WHOLE download, so a freshly provisioned site could not be pulled at all. They are written
|
|
121
|
+
// read-only: the push gate accepts only `settings_schema.json` under `config/`, and `pages`/`settings`
|
|
122
|
+
// have their own commands. One flat level, no nesting.
|
|
123
|
+
if (segs[0] === "config") {
|
|
124
|
+
if (segs.length !== 2) return "config files are one level deep";
|
|
125
|
+
// `settings pull` owns this name; a theme row must not shadow the file that command writes.
|
|
126
|
+
return segs[1] === "settings.json" ? "config/settings.json belongs to `settings pull`" : null;
|
|
127
|
+
}
|
|
128
|
+
if (segs.length === 1) {
|
|
129
|
+
// A FLAT FILE AT THE THEME ROOT. The platform stores rows a push does not send (the starter themes ship
|
|
130
|
+
// `README.md` and `blueprint.json`) and serves them on pull; refusing them refused the whole download.
|
|
131
|
+
// Case-folded, because APFS and NTFS fold case: `Readme.MD` and `README.md` are one file on disk.
|
|
132
|
+
if (segs[0].startsWith(".")) return "hidden file";
|
|
133
|
+
return ROOT_FILES_ALLOWED.has(segs[0].toLowerCase()) ? null : "not a theme file at the theme root";
|
|
112
134
|
}
|
|
113
|
-
|
|
114
|
-
|
|
135
|
+
if (!THEME_DIRS.has(segs[0])) return "not a theme file (would not be pushed back)";
|
|
136
|
+
return null;
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
/**
|
|
@@ -119,22 +141,32 @@ export async function fetchWithRetry(input, init, { retries = 2, onRetry, backof
|
|
|
119
141
|
* With `draft`, pulls the "CLI Draft" instance (what `theme dev` syncs into)
|
|
120
142
|
* instead of the live theme — symmetric with `push --draft`.
|
|
121
143
|
*/
|
|
122
|
-
export async function pullTheme({ dir, url, token, draft = false, instance = null }) {
|
|
144
|
+
export async function pullTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
|
|
123
145
|
const base = url.replace(/\/+$/, "");
|
|
124
146
|
const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
|
|
125
|
-
const res = await
|
|
147
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
|
|
126
148
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
127
|
-
});
|
|
128
|
-
if (!res.ok) throw
|
|
149
|
+
}, { onRetry });
|
|
150
|
+
if (!res.ok) throw await responseError(res);
|
|
129
151
|
const { files } = await res.json();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
// CF-T2: staged + contained (all-or-nothing). Every path is validated before the first byte is written; a
|
|
153
|
+
// server path that escapes the directory, targets the CLI's own `.blocofy/` binding, or would not be read back by
|
|
154
|
+
// the next push refuses the whole pull.
|
|
155
|
+
const entries = Object.entries(files ?? {}).map(([key, content]) => [localPathFor(key), content]);
|
|
156
|
+
const rejected = entries.map(([rel]) => [rel, pullPathProblem(rel)]).filter(([, problem]) => problem);
|
|
157
|
+
if (rejected.length) {
|
|
158
|
+
throw new PagesCliError("PAGES_PATH_ESCAPE", "The server sent a path this CLI does not write; nothing was written.", {
|
|
159
|
+
diagnostics: rejected.map(([rel, problem]) => ({ level: "error", code: "PAGES_PATH_ESCAPE", message: problem, path: rel })),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const created = !existsSync(dir);
|
|
163
|
+
mkdirSync(dir, { recursive: true });
|
|
164
|
+
try {
|
|
165
|
+
return { count: stagedWrite(realpathSync(dir), entries) };
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (created) rmSync(dir, { recursive: true, force: true }); // no empty directory left behind
|
|
168
|
+
throw error;
|
|
136
169
|
}
|
|
137
|
-
return { count };
|
|
138
170
|
}
|
|
139
171
|
|
|
140
172
|
/**
|
|
@@ -143,12 +175,12 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
|
|
|
143
175
|
* öncesi (hedef tenant'ı yaz) çağırır; yanlış-tenant'a yazımı görünür kılar.
|
|
144
176
|
* `{ site: { id, slug, name }, liveThemeId }`.
|
|
145
177
|
*/
|
|
146
|
-
export async function fetchWhoami({ url, token }) {
|
|
178
|
+
export async function fetchWhoami({ url, token, onRetry = null }) {
|
|
147
179
|
const base = url.replace(/\/+$/, "");
|
|
148
|
-
const res = await
|
|
180
|
+
const res = await fetchWithRetry(`${base}/api/dev/whoami`, {
|
|
149
181
|
headers: { authorization: `Bearer ${token}` },
|
|
150
|
-
});
|
|
151
|
-
if (!res.ok) throw
|
|
182
|
+
}, { onRetry });
|
|
183
|
+
if (!res.ok) throw await responseError(res);
|
|
152
184
|
return res.json();
|
|
153
185
|
}
|
|
154
186
|
|
|
@@ -156,12 +188,12 @@ export async function fetchWhoami({ url, token }) {
|
|
|
156
188
|
* Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
|
|
157
189
|
* 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
|
|
158
190
|
*/
|
|
159
|
-
export async function fetchDevSession({ url, token, name = null }) {
|
|
191
|
+
export async function fetchDevSession({ url, token, name = null, onRetry = null }) {
|
|
160
192
|
const base = url.replace(/\/+$/, "");
|
|
161
193
|
const query = name ? `?name=${encodeURIComponent(name)}` : "";
|
|
162
|
-
const res = await
|
|
194
|
+
const res = await fetchWithRetry(`${base}/api/dev/session${query}`, {
|
|
163
195
|
headers: { authorization: `Bearer ${token}` },
|
|
164
|
-
});
|
|
196
|
+
}, { onRetry });
|
|
165
197
|
if (!res.ok) {
|
|
166
198
|
const detail = await errorText(res);
|
|
167
199
|
// Yeni sunucular 410'da açıklayıcı bir gövde döndürür — onu olduğu gibi göster, o otoritedir.
|
|
@@ -173,7 +205,7 @@ export async function fetchDevSession({ url, token, name = null }) {
|
|
|
173
205
|
"(HTTP 410). Draft sync and `theme publish` are unaffected — they use different endpoints.",
|
|
174
206
|
);
|
|
175
207
|
}
|
|
176
|
-
throw new Error(detail);
|
|
208
|
+
throw Object.assign(new Error(detail), { status: res.status });
|
|
177
209
|
}
|
|
178
210
|
return res.json();
|
|
179
211
|
}
|
|
@@ -187,6 +219,7 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
187
219
|
const base = url.replace(/\/+$/, "");
|
|
188
220
|
const files = readLocalTemplates(dir);
|
|
189
221
|
const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
|
|
222
|
+
const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
|
|
190
223
|
const remoteOnlyKept = [];
|
|
191
224
|
const remoteOnlyRemoved = [];
|
|
192
225
|
if (idempotencyKey && !dryRun) {
|
|
@@ -205,20 +238,20 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
205
238
|
// 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
|
|
206
239
|
const pre = await fetchWithRetry(
|
|
207
240
|
`${base}/api/dev/theme`,
|
|
208
|
-
{ method: "POST", headers:
|
|
241
|
+
{ method: "POST", headers: postHeaders({ "x-idempotency-key": idempotencyKey }), body: JSON.stringify({ ...(instance ? { files, instance } : { files, draft, ...(name ? { name } : {}) }), dryRun: true }) },
|
|
209
242
|
{ onRetry },
|
|
210
243
|
);
|
|
211
244
|
if (!pre.ok) throw await httpError(pre);
|
|
212
245
|
|
|
213
246
|
let probeInstance = instance;
|
|
214
247
|
if (!instance && draft) {
|
|
215
|
-
const status = await fetchSiteStatus({ url, token });
|
|
248
|
+
const status = await fetchSiteStatus({ url, token, onRetry });
|
|
216
249
|
probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
|
|
217
250
|
}
|
|
218
251
|
let remote = {};
|
|
219
252
|
if (probeInstance || !draft) {
|
|
220
253
|
const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
|
|
221
|
-
const probe = await
|
|
254
|
+
const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
|
|
222
255
|
if (!probe.ok) throw await httpError(probe);
|
|
223
256
|
remote = (await probe.json()).files ?? {};
|
|
224
257
|
}
|
|
@@ -245,7 +278,7 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
245
278
|
const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
|
|
246
279
|
// dryRun asks the server to validate (auth + snapshot + hostile-Liquid worker) WITHOUT writing.
|
|
247
280
|
const payload = dryRun ? { ...target, dryRun: true } : target;
|
|
248
|
-
const headers =
|
|
281
|
+
const headers = postHeaders();
|
|
249
282
|
if (idempotencyKey) headers["x-idempotency-key"] = idempotencyKey;
|
|
250
283
|
const res = await fetchWithRetry(
|
|
251
284
|
`${base}/api/dev/theme`,
|
|
@@ -267,13 +300,13 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
267
300
|
* (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
|
|
268
301
|
* çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
|
|
269
302
|
*/
|
|
270
|
-
export async function diffTheme({ dir, url, token, instance = null }) {
|
|
303
|
+
export async function diffTheme({ dir, url, token, instance = null, onRetry = null }) {
|
|
271
304
|
const base = url.replace(/\/+$/, "");
|
|
272
305
|
const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
|
|
273
|
-
const res = await
|
|
306
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
|
|
274
307
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
275
|
-
});
|
|
276
|
-
if (!res.ok) throw
|
|
308
|
+
}, { onRetry });
|
|
309
|
+
if (!res.ok) throw await responseError(res);
|
|
277
310
|
const { files: remote = {} } = await res.json();
|
|
278
311
|
const local = readLocalTemplates(dir);
|
|
279
312
|
const added = [];
|
|
@@ -291,14 +324,14 @@ export async function diffTheme({ dir, url, token, instance = null }) {
|
|
|
291
324
|
* içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
|
|
292
325
|
* yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
|
|
293
326
|
*/
|
|
294
|
-
export async function publishInstance({ url, token, instanceId }) {
|
|
327
|
+
export async function publishInstance({ url, token, instanceId, onRetry = null }) {
|
|
295
328
|
const base = url.replace(/\/+$/, "");
|
|
296
|
-
const res = await
|
|
329
|
+
const res = await fetchWithRetry(`${base}/api/dev/publish`, {
|
|
297
330
|
method: "POST",
|
|
298
331
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
299
332
|
body: JSON.stringify({ instanceId }),
|
|
300
|
-
});
|
|
301
|
-
if (!res.ok) throw
|
|
333
|
+
}, { onRetry });
|
|
334
|
+
if (!res.ok) throw await responseError(res);
|
|
302
335
|
return res.json();
|
|
303
336
|
}
|
|
304
337
|
|
|
@@ -307,14 +340,14 @@ export async function publishInstance({ url, token, instanceId }) {
|
|
|
307
340
|
* bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
|
|
308
341
|
* `{ ok, id, name }` döner.
|
|
309
342
|
*/
|
|
310
|
-
export async function renameInstance({ url, token, instance, name }) {
|
|
343
|
+
export async function renameInstance({ url, token, instance, name, onRetry = null }) {
|
|
311
344
|
const base = url.replace(/\/+$/, "");
|
|
312
|
-
const res = await
|
|
345
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme/rename`, {
|
|
313
346
|
method: "POST",
|
|
314
347
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
315
348
|
body: JSON.stringify({ instance, name }),
|
|
316
|
-
});
|
|
317
|
-
if (!res.ok) throw
|
|
349
|
+
}, { onRetry });
|
|
350
|
+
if (!res.ok) throw await responseError(res);
|
|
318
351
|
return res.json();
|
|
319
352
|
}
|
|
320
353
|
|
|
@@ -322,9 +355,9 @@ export async function renameInstance({ url, token, instance, name }) {
|
|
|
322
355
|
* Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
|
|
323
356
|
* instance-başına sayfa dağılımı, taslaklar ve health döner.
|
|
324
357
|
*/
|
|
325
|
-
export async function fetchSiteStatus({ url, token }) {
|
|
358
|
+
export async function fetchSiteStatus({ url, token, onRetry = null }) {
|
|
326
359
|
const base = url.replace(/\/+$/, "");
|
|
327
|
-
const res = await
|
|
328
|
-
if (!res.ok) throw
|
|
360
|
+
const res = await fetchWithRetry(`${base}/api/dev/site`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
361
|
+
if (!res.ok) throw await responseError(res);
|
|
329
362
|
return res.json();
|
|
330
363
|
}
|