@blocofy/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +483 -52
- package/bin/blocofy.mjs +1348 -276
- package/lib/args.mjs +2 -1
- package/lib/content-sync.mjs +129 -30
- package/lib/credentials.mjs +216 -60
- package/lib/http.mjs +63 -0
- package/lib/local-theme.mjs +7 -2
- package/lib/media-uses.mjs +27 -16
- package/lib/messages.mjs +42 -2
- package/lib/output.mjs +72 -0
- package/lib/page-files.mjs +12 -2
- package/lib/secret-store.mjs +133 -0
- package/lib/site-migrate.mjs +132 -0
- package/lib/site-state-client.mjs +92 -0
- package/lib/site-state-fs.mjs +137 -0
- package/lib/site-state.mjs +705 -0
- package/lib/target.mjs +485 -0
- package/lib/theme-sync.mjs +272 -139
- package/package.json +1 -1
package/lib/theme-sync.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
|
3
3
|
|
|
4
4
|
import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs";
|
|
5
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
6
|
+
import { PagesCliError, stagedWrite } from "./page-files.mjs";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* `blocofy theme pull/push`. Talks to the platform's `/api/dev/theme` endpoint
|
|
8
10
|
* (Bearer token). pull = GET → write to disk (re-adding `.liquid`); push =
|
|
9
11
|
* readLocalTemplates → POST (create/update; no delete).
|
|
12
|
+
*
|
|
13
|
+
* CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs — 429/502/503/504 + network errors, Retry-After).
|
|
14
|
+
* All are safe to resend: reads, a keyed (or naturally idempotent upsert) theme POST, publish (sets a pointer),
|
|
15
|
+
* rename (sets a label). `onRetry` surfaces each retry on stderr.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
/**
|
|
@@ -18,6 +24,13 @@ import { THEME_DIRS, localPathFor, readLocalTemplates } from "./local-theme.mjs"
|
|
|
18
24
|
const CANONICAL_PROTOCOL = "1";
|
|
19
25
|
const CANONICAL_CAPABILITIES = "validate,dry-run,diff,idempotency-key,target-instance";
|
|
20
26
|
|
|
27
|
+
/**
|
|
28
|
+
* CF-T5 review I1 — OPTIONAL capabilities (never part of the required fence above). `theme-locales`: this CLI sends the
|
|
29
|
+
* workspace's `locales/*` files, so a canonical deploy may replace `locales/` like any other theme folder (a server
|
|
30
|
+
* that sees no declaration keeps stored locale rows). Sent on every theme POST; GETs don't need it.
|
|
31
|
+
*/
|
|
32
|
+
const OPTIONAL_CAPABILITIES = "theme-locales";
|
|
33
|
+
|
|
21
34
|
function canonicalHeaders(extra = {}) {
|
|
22
35
|
return { "x-blocofy-protocol": CANONICAL_PROTOCOL, "x-blocofy-capabilities": CANONICAL_CAPABILITIES, ...extra };
|
|
23
36
|
}
|
|
@@ -35,6 +48,16 @@ async function errorText(res) {
|
|
|
35
48
|
return text || `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/** `errorText` as an Error that keeps the HTTP status (exit codes: 4xx → 2, 5xx → 1). */
|
|
52
|
+
async function responseError(res) {
|
|
53
|
+
const detail = await errorText(res);
|
|
54
|
+
const err = new Error(typeof detail === "string" ? detail : detail?.message ?? JSON.stringify(detail));
|
|
55
|
+
err.status = res.status;
|
|
56
|
+
if (typeof detail === "object" && detail && typeof detail.code === "string") err.code = detail.code;
|
|
57
|
+
else if (typeof detail === "string" && /^[a-z][a-z0-9_]*$/.test(detail)) err.code = detail;
|
|
58
|
+
return err;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
/**
|
|
39
62
|
* Yapılandırılmış HTTP hatası (0.5.0): mesajın yanında `code` (sunucunun `error` alanı), `status` ve tam
|
|
40
63
|
* `body` taşınır — 426 `cli_upgrade_required` (fence.missing/requiredVersion) ve 409 `idempotency_conflict`
|
|
@@ -61,57 +84,57 @@ async function httpError(res) {
|
|
|
61
84
|
* alanını hiç dönmez → false → `--dry-run` reddedilir; aksi hâlde eski sunucu `dryRun`'ı yok sayıp GERÇEK
|
|
62
85
|
* yazım yapardı (0.5.0 öncesi sessiz-yazım regresyonu).
|
|
63
86
|
*/
|
|
64
|
-
export async function fetchCanonicalSupport({ url, token }) {
|
|
87
|
+
export async function fetchCanonicalSupport({ url, token, onRetry = null }) {
|
|
65
88
|
const base = url.replace(/\/+$/, "");
|
|
66
|
-
const res = await
|
|
89
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme`, {
|
|
67
90
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
68
|
-
});
|
|
91
|
+
}, { onRetry });
|
|
69
92
|
if (!res.ok) throw await httpError(res);
|
|
70
93
|
const body = await res.json();
|
|
71
94
|
return { supported: body?.protocol === 1 };
|
|
72
95
|
}
|
|
73
96
|
|
|
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
97
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
98
|
+
* CF-T1/T2 review I1 — a pulled path is accepted only if `readLocalTemplates` would read it back: a top-level theme dir
|
|
99
|
+
* (exact case) with a file below it, or `config/settings_schema.json`. Any `.blocofy`/staging segment (compared
|
|
100
|
+
* case-insensitively: APFS/NTFS fold case, so `.BLOCOFY/project.json` IS the binding) and any dot-directory (`.git`…)
|
|
101
|
+
* is refused. Returns a reason string, or null when the path is acceptable.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* The flat root files a theme actually HAS. The platform's starter themes ship exactly these two, so exactly
|
|
105
|
+
* these two come down.
|
|
87
106
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
107
|
+
* This is an allowlist on purpose. It was briefly a denylist of tooling filenames, and that inverted the rule
|
|
108
|
+
* the rest of this function is built on: anything the server named that was not a lockfile - `CLAUDE.md`,
|
|
109
|
+
* `AGENTS.md`, `next.config.js` - was written straight into the developer's project root by an ordinary pull.
|
|
110
|
+
* A theme download must never be able to place a file the developer's own tooling then trusts. If a theme
|
|
111
|
+
* legitimately grows another root file, this set is where it is added, deliberately.
|
|
90
112
|
*/
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return
|
|
113
|
+
const ROOT_FILES_ALLOWED = new Set(["readme.md", "blueprint.json"]);
|
|
114
|
+
|
|
115
|
+
function pullPathProblem(rel) {
|
|
116
|
+
const segs = rel.split("/");
|
|
117
|
+
if (segs.some((seg) => { const l = seg.toLowerCase(); return l === ".blocofy" || l.startsWith(".blocofy-staging-"); })) return "reserved CLI path";
|
|
118
|
+
if (segs.slice(0, -1).some((seg) => seg.startsWith("."))) return "hidden directory";
|
|
119
|
+
// `config/` — the theme's own configuration rows. `settings_schema.json` is the one a push sends back; the
|
|
120
|
+
// platform serves the others (a starter theme ships `config/theme.json`) and a pull that refused them
|
|
121
|
+
// refused the WHOLE download, so a freshly provisioned site could not be pulled at all. They are written
|
|
122
|
+
// read-only: the push gate accepts only `settings_schema.json` under `config/`, and `pages`/`settings`
|
|
123
|
+
// have their own commands. One flat level, no nesting.
|
|
124
|
+
if (segs[0] === "config") {
|
|
125
|
+
if (segs.length !== 2) return "config files are one level deep";
|
|
126
|
+
// `settings pull` owns this name; a theme row must not shadow the file that command writes.
|
|
127
|
+
return segs[1] === "settings.json" ? "config/settings.json belongs to `settings pull`" : null;
|
|
128
|
+
}
|
|
129
|
+
if (segs.length === 1) {
|
|
130
|
+
// A FLAT FILE AT THE THEME ROOT. The platform stores rows a push does not send (the starter themes ship
|
|
131
|
+
// `README.md` and `blueprint.json`) and serves them on pull; refusing them refused the whole download.
|
|
132
|
+
// Case-folded, because APFS and NTFS fold case: `Readme.MD` and `README.md` are one file on disk.
|
|
133
|
+
if (segs[0].startsWith(".")) return "hidden file";
|
|
134
|
+
return ROOT_FILES_ALLOWED.has(segs[0].toLowerCase()) ? null : "not a theme file at the theme root";
|
|
112
135
|
}
|
|
113
|
-
|
|
114
|
-
|
|
136
|
+
if (!THEME_DIRS.has(segs[0])) return "not a theme file (would not be pushed back)";
|
|
137
|
+
return null;
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
/**
|
|
@@ -119,22 +142,32 @@ export async function fetchWithRetry(input, init, { retries = 2, onRetry, backof
|
|
|
119
142
|
* With `draft`, pulls the "CLI Draft" instance (what `theme dev` syncs into)
|
|
120
143
|
* instead of the live theme — symmetric with `push --draft`.
|
|
121
144
|
*/
|
|
122
|
-
export async function pullTheme({ dir, url, token, draft = false, instance = null }) {
|
|
145
|
+
export async function pullTheme({ dir, url, token, draft = false, instance = null, onRetry = null }) {
|
|
123
146
|
const base = url.replace(/\/+$/, "");
|
|
124
147
|
const query = instance ? `?instance=${encodeURIComponent(instance)}` : draft ? "?draft=1" : "";
|
|
125
|
-
const res = await
|
|
148
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
|
|
126
149
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
127
|
-
});
|
|
128
|
-
if (!res.ok) throw
|
|
150
|
+
}, { onRetry });
|
|
151
|
+
if (!res.ok) throw await responseError(res);
|
|
129
152
|
const { files } = await res.json();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
153
|
+
// CF-T2: staged + contained (all-or-nothing). Every path is validated before the first byte is written; a
|
|
154
|
+
// server path that escapes the directory, targets the CLI's own `.blocofy/` binding, or would not be read back by
|
|
155
|
+
// the next push refuses the whole pull.
|
|
156
|
+
const entries = Object.entries(files ?? {}).map(([key, content]) => [localPathFor(key), content]);
|
|
157
|
+
const rejected = entries.map(([rel]) => [rel, pullPathProblem(rel)]).filter(([, problem]) => problem);
|
|
158
|
+
if (rejected.length) {
|
|
159
|
+
throw new PagesCliError("PAGES_PATH_ESCAPE", "The server sent a path this CLI does not write; nothing was written.", {
|
|
160
|
+
diagnostics: rejected.map(([rel, problem]) => ({ level: "error", code: "PAGES_PATH_ESCAPE", message: problem, path: rel })),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const created = !existsSync(dir);
|
|
164
|
+
mkdirSync(dir, { recursive: true });
|
|
165
|
+
try {
|
|
166
|
+
return { count: stagedWrite(realpathSync(dir), entries) };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (created) rmSync(dir, { recursive: true, force: true }); // no empty directory left behind
|
|
169
|
+
throw error;
|
|
136
170
|
}
|
|
137
|
-
return { count };
|
|
138
171
|
}
|
|
139
172
|
|
|
140
173
|
/**
|
|
@@ -143,12 +176,12 @@ export async function pullTheme({ dir, url, token, draft = false, instance = nul
|
|
|
143
176
|
* öncesi (hedef tenant'ı yaz) çağırır; yanlış-tenant'a yazımı görünür kılar.
|
|
144
177
|
* `{ site: { id, slug, name }, liveThemeId }`.
|
|
145
178
|
*/
|
|
146
|
-
export async function fetchWhoami({ url, token }) {
|
|
179
|
+
export async function fetchWhoami({ url, token, onRetry = null }) {
|
|
147
180
|
const base = url.replace(/\/+$/, "");
|
|
148
|
-
const res = await
|
|
181
|
+
const res = await fetchWithRetry(`${base}/api/dev/whoami`, {
|
|
149
182
|
headers: { authorization: `Bearer ${token}` },
|
|
150
|
-
});
|
|
151
|
-
if (!res.ok) throw
|
|
183
|
+
}, { onRetry });
|
|
184
|
+
if (!res.ok) throw await responseError(res);
|
|
152
185
|
return res.json();
|
|
153
186
|
}
|
|
154
187
|
|
|
@@ -156,12 +189,12 @@ export async function fetchWhoami({ url, token }) {
|
|
|
156
189
|
* Dev session bilgisi (#119 `theme dev`): platform draft instance'ı hazırlar ve
|
|
157
190
|
* 3 görünümün URL'lerini döner — `{ draftInstanceId, previewUrl, editorUrl, site }`.
|
|
158
191
|
*/
|
|
159
|
-
export async function fetchDevSession({ url, token, name = null }) {
|
|
192
|
+
export async function fetchDevSession({ url, token, name = null, onRetry = null }) {
|
|
160
193
|
const base = url.replace(/\/+$/, "");
|
|
161
194
|
const query = name ? `?name=${encodeURIComponent(name)}` : "";
|
|
162
|
-
const res = await
|
|
195
|
+
const res = await fetchWithRetry(`${base}/api/dev/session${query}`, {
|
|
163
196
|
headers: { authorization: `Bearer ${token}` },
|
|
164
|
-
});
|
|
197
|
+
}, { onRetry });
|
|
165
198
|
if (!res.ok) {
|
|
166
199
|
const detail = await errorText(res);
|
|
167
200
|
// Yeni sunucular 410'da açıklayıcı bir gövde döndürür — onu olduğu gibi göster, o otoritedir.
|
|
@@ -173,11 +206,79 @@ export async function fetchDevSession({ url, token, name = null }) {
|
|
|
173
206
|
"(HTTP 410). Draft sync and `theme publish` are unaffected — they use different endpoints.",
|
|
174
207
|
);
|
|
175
208
|
}
|
|
176
|
-
throw new Error(detail);
|
|
209
|
+
throw Object.assign(new Error(detail), { status: res.status });
|
|
177
210
|
}
|
|
178
211
|
return res.json();
|
|
179
212
|
}
|
|
180
213
|
|
|
214
|
+
/**
|
|
215
|
+
* TPUSH-5 (6.5) — what a dry run binds the apply to. A server with the 6.5 contract answers a keyed dry run with the
|
|
216
|
+
* `manifestHash` of the files it checked and the `pointerVersion` its plan ran from (null: no deployment yet); the
|
|
217
|
+
* apply sends both back (`manifestHash`, `expectedPointerVersion`) so it is refused unless it writes exactly those
|
|
218
|
+
* bytes over exactly that state. An older server answers neither and gets the 0.10 body unchanged.
|
|
219
|
+
* The pointer version is per instance, so the apply also names the instance the plan ran against: a server that
|
|
220
|
+
* answers `targetInstance` (an opaque theme handle, or null for "a new draft") gets it back as
|
|
221
|
+
* `expectedTargetInstance` and refuses 409 `target_changed` when the apply would write anywhere else.
|
|
222
|
+
*/
|
|
223
|
+
function pushBindings(plan) {
|
|
224
|
+
if (typeof plan?.manifestHash !== "string" || !/^[0-9a-f]{64}$/.test(plan.manifestHash)) return {};
|
|
225
|
+
const v = plan.pointerVersion;
|
|
226
|
+
const t = plan.targetInstance;
|
|
227
|
+
return {
|
|
228
|
+
manifestHash: plan.manifestHash,
|
|
229
|
+
...(v === null || (Number.isInteger(v) && v >= 0) ? { expectedPointerVersion: v } : {}),
|
|
230
|
+
...(t === null || (typeof t === "string" && t !== "") ? { expectedTargetInstance: t } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The paths a dry-run plan says the push would remove. `config/settings.json` is left out: a stored row there is the
|
|
236
|
+
* anomaly every canonical deploy cleans up (the settings themselves live in site_themes), not a theme file.
|
|
237
|
+
*/
|
|
238
|
+
function plannedRemovals(plan) {
|
|
239
|
+
if (!Array.isArray(plan?.files)) return [];
|
|
240
|
+
return plan.files.filter((f) => f?.outcome === "removed" && typeof f.path === "string" && f.path !== "config/settings.json").map((f) => f.path);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function isReadbackUnverified(res) {
|
|
244
|
+
try {
|
|
245
|
+
return (await res.json())?.error === "readback_unverified";
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The paths a push can send: the mirror of the server's gate — multi-segment paths under THEME_DIRS plus the one
|
|
253
|
+
* config exception (a slash-less remote key such as a bare `layout` would be refused 422 by the server).
|
|
254
|
+
*/
|
|
255
|
+
function sendable(key) {
|
|
256
|
+
return (key.includes("/") && THEME_DIRS.has(key.split("/")[0] ?? "")) || key === "config/settings_schema.json";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* THEME_PUSH_TARGET_CHANGED — the plan would remove files this push did not carry, and says only what is true of them.
|
|
261
|
+
* A path the push cannot send (outside the merge mirror, e.g. a bare `layout`) is never kept by a re-run, nor by a
|
|
262
|
+
* local file at that path: only --prune gets past it. That includes every path the merge probe returned, since a
|
|
263
|
+
* sendable one would have been merged. Any other path was either added while the push ran (a re-run reads and keeps
|
|
264
|
+
* it) or is a row the probe cannot list (it lists published rows only), which no re-run keeps either.
|
|
265
|
+
*/
|
|
266
|
+
function targetChangedError(paths, remote) {
|
|
267
|
+
const notCarryable = paths.filter((p) => Object.hasOwn(remote, p) || !sendable(p));
|
|
268
|
+
const unseen = paths.filter((p) => !notCarryable.includes(p));
|
|
269
|
+
const parts = [`The dry run would remove ${paths.length} file(s) this push does not have locally (${paths.join(", ")}). Nothing was written.`];
|
|
270
|
+
if (notCarryable.length) {
|
|
271
|
+
parts.push(`${notCarryable.join(", ")}: the push cannot carry these paths, so running it again will not keep them; it can go ahead only with --prune, which removes them.`);
|
|
272
|
+
}
|
|
273
|
+
if (unseen.length) {
|
|
274
|
+
parts.push(
|
|
275
|
+
`${unseen.join(", ")}: not on the target when this push read it. If they were added while this push was running, run the push again and it keeps them. ` +
|
|
276
|
+
"If the same files stop the push again, the push cannot read them (for example a theme file that is not published) and cannot keep them: pass --prune to remove them, or add a local file at that path to replace it.",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return Object.assign(new Error(parts.join(" ")), { code: "THEME_PUSH_TARGET_CHANGED", details: { paths, notCarryable } });
|
|
280
|
+
}
|
|
281
|
+
|
|
181
282
|
/**
|
|
182
283
|
* Write the local theme to the site (create/update; no delete). With `draft`,
|
|
183
284
|
* writes to a draft theme instance instead of the live theme — preview & publish
|
|
@@ -187,78 +288,110 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
187
288
|
const base = url.replace(/\/+$/, "");
|
|
188
289
|
const files = readLocalTemplates(dir);
|
|
189
290
|
const headersFor = (extra = {}) => canonicalHeaders({ authorization: `Bearer ${token}`, ...extra });
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
// 1) PREFLIGHT dry-run POST (yalnız yerel küme): 422/426 sınıfı hatalar SUNUCUDA HİÇBİR ŞEY
|
|
195
|
-
// provizyonlanmadan yakalanır — probe GET'i taslak yaratabildiği için başarısız bir push
|
|
196
|
-
// arkada instance + sayfa klonu bırakıyordu.
|
|
197
|
-
// 2) MERGE PROBE GET: "push silmez" garantisi — uzakta olup yerelde olmayan, kapı-kabul-eden
|
|
198
|
-
// dosyalar payload'a aynen eklenir; kapı-dışı satırları (README.md vb.) sunucunun
|
|
199
|
-
// retained-rows kuralı korur. `--prune` bu dosyaları EKLEMEZ (kanonik deploy klasörleri
|
|
200
|
-
// atomik değiştirir → silinirler) ve yazımdan ÖNCE `confirmPrune(liste)`'ye bildirir.
|
|
201
|
-
// PS-09: taslak hedefte taslak-bayraklı GET KULLANILMAZ — sunucuda taslak provizyonlar (yan
|
|
202
|
-
// etki; yarıda kalırsa boş taslak bırakırdı). Mevcut CLI taslağı `/api/dev/site`'tan
|
|
203
|
-
// (`source === "import"`, sunucunun ensureDraftInstance seçimiyle aynı: id sıralı ilk) bulunup
|
|
204
|
-
// `?instance=` ile yoklanır; yoksa yoklanacak uzak dosya da yoktur → probe atlanır.
|
|
205
|
-
// 3) Gerçek POST. Dev-sync key göndermediği için bu aşamaların hiçbirini ödemez.
|
|
206
|
-
const pre = await fetchWithRetry(
|
|
291
|
+
const postHeaders = (extra = {}) => headersFor({ "content-type": "application/json", "x-blocofy-optional-capabilities": OPTIONAL_CAPABILITIES, ...extra });
|
|
292
|
+
const target = instance ? { files, instance } : name ? { files, draft, name } : { files, draft };
|
|
293
|
+
const post = (body, key, retry = {}) =>
|
|
294
|
+
fetchWithRetry(
|
|
207
295
|
`${base}/api/dev/theme`,
|
|
208
|
-
{ method: "POST", headers:
|
|
209
|
-
{ onRetry },
|
|
296
|
+
{ method: "POST", headers: postHeaders(key ? { "x-idempotency-key": key } : {}), body: JSON.stringify(body) },
|
|
297
|
+
{ onRetry, ...retry },
|
|
210
298
|
);
|
|
211
|
-
if (!pre.ok) throw await httpError(pre);
|
|
212
299
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
300
|
+
// Keyless (theme dev's draft sync): one POST to the per-file writer — no probe, no plan (deliberate legacy path).
|
|
301
|
+
if (!idempotencyKey) {
|
|
302
|
+
const res = await post(dryRun ? { ...target, dryRun: true } : target, null);
|
|
303
|
+
if (!res.ok) throw await httpError(res);
|
|
304
|
+
return res.json();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Keyed canonical push (and its --dry-run), TPUSH-5: ONE manifest for the dry run and the apply.
|
|
308
|
+
// 1) MERGE PROBE GET — "push does not delete": remote files missing locally that the server's gate accepts are
|
|
309
|
+
// added to the payload verbatim; rows outside that set (README.md…) are kept by the server's retained-rows
|
|
310
|
+
// rule. `--prune` leaves them out instead (the canonical deploy replaces the folders, so they are removed).
|
|
311
|
+
// PS-09: a draft target is never probed with a draft-flagged GET (that provisions a draft server-side). The
|
|
312
|
+
// existing CLI draft is found via `/api/dev/site` (`source === "import"`, the server's own pick: the first by
|
|
313
|
+
// id) and probed by handle; with no draft there is nothing remote to merge, so there is no probe.
|
|
314
|
+
// 2) DRY RUN of that MERGED payload. On a 6.5 server this is the control plane's rolled-back plan of the very
|
|
315
|
+
// operation the apply runs: refused by what the apply is refused by, with per-file outcomes, the manifest hash
|
|
316
|
+
// and the pointer version it ran from. It carries its OWN throwaway key: the control plane fingerprints the
|
|
317
|
+
// action, so a plan under a key an earlier deploy already spent is refused 409 — a same-key retry of a
|
|
318
|
+
// committed push could never converge. A plan removing a file the probe did not see means the target changed
|
|
319
|
+
// under this push; without --prune that is refused (push does not delete), with --prune it is confirmed.
|
|
320
|
+
// 3) APPLY of the same payload under the push key, bound by `manifestHash` + `expectedPointerVersion`.
|
|
321
|
+
// theme dev's sync sends no key and pays none of this.
|
|
322
|
+
const remoteOnlyKept = [];
|
|
323
|
+
const remoteOnlyRemoved = [];
|
|
324
|
+
let probeInstance = instance;
|
|
325
|
+
if (!instance && draft) {
|
|
326
|
+
const status = await fetchSiteStatus({ url, token, onRetry });
|
|
327
|
+
probeInstance = (status?.drafts ?? []).find((d) => d.source === "import")?.id ?? null;
|
|
328
|
+
}
|
|
329
|
+
let remote = {};
|
|
330
|
+
if (probeInstance || !draft) {
|
|
331
|
+
const query = probeInstance ? `?instance=${encodeURIComponent(probeInstance)}` : "";
|
|
332
|
+
const probe = await fetchWithRetry(`${base}/api/dev/theme${query}`, { headers: headersFor() }, { onRetry });
|
|
333
|
+
if (!probe.ok) throw await httpError(probe);
|
|
334
|
+
remote = (await probe.json()).files ?? {};
|
|
335
|
+
}
|
|
336
|
+
for (const [key, content] of Object.entries(remote)) {
|
|
337
|
+
if (key in files) continue;
|
|
338
|
+
if (sendable(key)) {
|
|
339
|
+
if (prune) {
|
|
340
|
+
remoteOnlyRemoved.push(key);
|
|
341
|
+
} else {
|
|
342
|
+
files[key] = typeof content === "string" ? content : "";
|
|
343
|
+
remoteOnlyKept.push(key);
|
|
237
344
|
}
|
|
238
345
|
}
|
|
239
|
-
remoteOnlyKept.sort();
|
|
240
|
-
remoteOnlyRemoved.sort();
|
|
241
|
-
if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
|
|
242
|
-
return { aborted: true, remoteOnlyRemoved };
|
|
243
|
-
}
|
|
244
346
|
}
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
const json = await res.json();
|
|
257
|
-
return {
|
|
258
|
-
...json,
|
|
347
|
+
|
|
348
|
+
// A plan's 502 readback_unverified is a verdict on a rolled-back plan, not a transient failure: not resent.
|
|
349
|
+
const pre = await post({ ...target, dryRun: true }, `cli-plan-${randomUUID()}`, { isFinal: isReadbackUnverified });
|
|
350
|
+
if (!pre.ok) throw Object.assign(await httpError(pre), { phase: "plan" });
|
|
351
|
+
const plan = await pre.json();
|
|
352
|
+
const unexpected = plannedRemovals(plan).filter((p) => !remoteOnlyRemoved.includes(p)).sort();
|
|
353
|
+
if (unexpected.length && !prune) throw targetChangedError(unexpected, remote);
|
|
354
|
+
remoteOnlyRemoved.push(...unexpected);
|
|
355
|
+
remoteOnlyKept.sort();
|
|
356
|
+
remoteOnlyRemoved.sort();
|
|
357
|
+
const extras = {
|
|
259
358
|
...(remoteOnlyKept.length ? { remoteOnlyKept } : {}),
|
|
260
359
|
...(remoteOnlyRemoved.length ? { remoteOnlyRemoved } : {}),
|
|
261
360
|
};
|
|
361
|
+
if (dryRun) return { ...plan, ...extras };
|
|
362
|
+
if (remoteOnlyRemoved.length && confirmPrune && !(await confirmPrune(remoteOnlyRemoved))) {
|
|
363
|
+
return { aborted: true, remoteOnlyRemoved };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const bindings = pushBindings(plan);
|
|
367
|
+
// An apply attempt without a certain answer (network error, 429/502/503/504) is resent under the same key. A
|
|
368
|
+
// refusal of the resend says nothing about that earlier attempt: "unknown", or "committed" when its answer said so.
|
|
369
|
+
let earlierAttempt = null;
|
|
370
|
+
const applyError = (err) => {
|
|
371
|
+
Object.assign(err, { phase: "apply" });
|
|
372
|
+
// target_changed names the current target; the one this push planned against is known only here.
|
|
373
|
+
if ("expectedTargetInstance" in bindings) err.expectedTargetInstance = bindings.expectedTargetInstance;
|
|
374
|
+
if (earlierAttempt) err.earlierAttempt = earlierAttempt;
|
|
375
|
+
return err;
|
|
376
|
+
};
|
|
377
|
+
let res;
|
|
378
|
+
try {
|
|
379
|
+
res = await post({ ...target, ...bindings }, idempotencyKey, {
|
|
380
|
+
onRetry: (info) => {
|
|
381
|
+
earlierAttempt ??= "unknown";
|
|
382
|
+
onRetry?.(info);
|
|
383
|
+
},
|
|
384
|
+
isFinal: async (attempt) => {
|
|
385
|
+
if ((await attempt.json().catch(() => null))?.committed === true) earlierAttempt = "committed";
|
|
386
|
+
return false;
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
// No answer to the last attempt either (network error, timeout): the write's outcome is unknown.
|
|
391
|
+
throw applyError(error);
|
|
392
|
+
}
|
|
393
|
+
if (!res.ok) throw applyError(await httpError(res));
|
|
394
|
+
return { ...(await res.json()), ...extras };
|
|
262
395
|
}
|
|
263
396
|
|
|
264
397
|
/**
|
|
@@ -267,13 +400,13 @@ export async function pushTheme({ dir, url, token, draft = false, instance = nul
|
|
|
267
400
|
* (sayfa klonları dahil), read-only bir komut mutasyon tetikleyemez (0.5.0 denetim düzeltmesi). Çağıran
|
|
268
401
|
* çıktıyı "canlıya göre fark" diye etiketler. Returns `{ added, changed, removed }` (stripped keys).
|
|
269
402
|
*/
|
|
270
|
-
export async function diffTheme({ dir, url, token, instance = null }) {
|
|
403
|
+
export async function diffTheme({ dir, url, token, instance = null, onRetry = null }) {
|
|
271
404
|
const base = url.replace(/\/+$/, "");
|
|
272
405
|
const query = instance ? `?instance=${encodeURIComponent(instance)}` : "";
|
|
273
|
-
const res = await
|
|
406
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme${query}`, {
|
|
274
407
|
headers: canonicalHeaders({ authorization: `Bearer ${token}` }),
|
|
275
|
-
});
|
|
276
|
-
if (!res.ok) throw
|
|
408
|
+
}, { onRetry });
|
|
409
|
+
if (!res.ok) throw await responseError(res);
|
|
277
410
|
const { files: remote = {} } = await res.json();
|
|
278
411
|
const local = readLocalTemplates(dir);
|
|
279
412
|
const added = [];
|
|
@@ -291,14 +424,14 @@ export async function diffTheme({ dir, url, token, instance = null }) {
|
|
|
291
424
|
* içi-sayfasız bir instance'ı reddeder ya da canlının sayfalarını klonlar (#431) —
|
|
292
425
|
* yayın sonrası site asla 404'e düşmez. `{ ok, published, cloned }` döner.
|
|
293
426
|
*/
|
|
294
|
-
export async function publishInstance({ url, token, instanceId }) {
|
|
427
|
+
export async function publishInstance({ url, token, instanceId, onRetry = null }) {
|
|
295
428
|
const base = url.replace(/\/+$/, "");
|
|
296
|
-
const res = await
|
|
429
|
+
const res = await fetchWithRetry(`${base}/api/dev/publish`, {
|
|
297
430
|
method: "POST",
|
|
298
431
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
299
432
|
body: JSON.stringify({ instanceId }),
|
|
300
|
-
});
|
|
301
|
-
if (!res.ok) throw
|
|
433
|
+
}, { onRetry });
|
|
434
|
+
if (!res.ok) throw await responseError(res);
|
|
302
435
|
return res.json();
|
|
303
436
|
}
|
|
304
437
|
|
|
@@ -307,14 +440,14 @@ export async function publishInstance({ url, token, instanceId }) {
|
|
|
307
440
|
* bir etiket — canlı instance dahil sahip olunan her instance yeniden adlandırılabilir.
|
|
308
441
|
* `{ ok, id, name }` döner.
|
|
309
442
|
*/
|
|
310
|
-
export async function renameInstance({ url, token, instance, name }) {
|
|
443
|
+
export async function renameInstance({ url, token, instance, name, onRetry = null }) {
|
|
311
444
|
const base = url.replace(/\/+$/, "");
|
|
312
|
-
const res = await
|
|
445
|
+
const res = await fetchWithRetry(`${base}/api/dev/theme/rename`, {
|
|
313
446
|
method: "POST",
|
|
314
447
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
315
448
|
body: JSON.stringify({ instance, name }),
|
|
316
|
-
});
|
|
317
|
-
if (!res.ok) throw
|
|
449
|
+
}, { onRetry });
|
|
450
|
+
if (!res.ok) throw await responseError(res);
|
|
318
451
|
return res.json();
|
|
319
452
|
}
|
|
320
453
|
|
|
@@ -322,9 +455,9 @@ export async function renameInstance({ url, token, instance, name }) {
|
|
|
322
455
|
* Site sağlık/durum özeti (`GET /api/dev/site`) — `blocofy status`. Canlı tema instance'ı,
|
|
323
456
|
* instance-başına sayfa dağılımı, taslaklar ve health döner.
|
|
324
457
|
*/
|
|
325
|
-
export async function fetchSiteStatus({ url, token }) {
|
|
458
|
+
export async function fetchSiteStatus({ url, token, onRetry = null }) {
|
|
326
459
|
const base = url.replace(/\/+$/, "");
|
|
327
|
-
const res = await
|
|
328
|
-
if (!res.ok) throw
|
|
460
|
+
const res = await fetchWithRetry(`${base}/api/dev/site`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
461
|
+
if (!res.ok) throw await responseError(res);
|
|
329
462
|
return res.json();
|
|
330
463
|
}
|