@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/args.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* Sonraki token'ı değer olarak almayan flag'ler. 0.8.0: `api-key` de boolean'dır — `login --api-key`
|
|
9
9
|
* gizli prompt açar; `--api-key <değer>` sözdizimi bilerek YOKTUR (sır argv'ye ve shell geçmişine girmez).
|
|
10
|
+
* CF-T1: `--keychain` (login) ve `--adopt` (link) de değersizdir. CF-T3: `--force` (pages push) da; gerekçesi `--reason <text>`.
|
|
10
11
|
*/
|
|
11
|
-
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json"]);
|
|
12
|
+
const BOOLEAN_FLAGS = new Set(["draft", "live", "yes", "confirm", "no-sync", "dry", "dry-run", "validate", "diff", "help", "version", "api-key", "json", "strict", "write", "keychain", "adopt", "force"]);
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `known`: bu komutun kabul ettiği bayrak adları (Set). Verildiğinde bilinmeyen bir `--x`
|
package/lib/content-sync.mjs
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
PagesCliError,
|
|
7
|
+
checkLocalBatch,
|
|
8
|
+
folderConflicts,
|
|
9
|
+
classifyPageFile,
|
|
10
|
+
collectPageFiles,
|
|
11
|
+
pageFileSemanticKey,
|
|
12
|
+
pageFilePath,
|
|
13
|
+
resolveContained,
|
|
14
|
+
serializeV2PageFile,
|
|
15
|
+
stagedWrite,
|
|
16
|
+
} from "./page-files.mjs";
|
|
17
|
+
import { fetchWithRetry } from "./http.mjs";
|
|
18
|
+
import { checkRelativePagePath, parsePageFilePath } from "./page-path-codec.mjs";
|
|
3
19
|
|
|
4
20
|
/**
|
|
5
|
-
* `blocofy pages pull/push` + `settings pull/push` (#119).
|
|
6
|
-
*
|
|
7
|
-
* `/api/dev/content`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
21
|
+
* `blocofy pages pull/push/check/migrate-layout` + `settings pull/push` (#119, PS-19). Page files use the
|
|
22
|
+
* locale-aware layout (`pages/<locale>/index.json`, `pages/<locale>/routes/…/index.json`); the platform's
|
|
23
|
+
* `/api/dev/content` protocol v2 is required for every page operation. Tema KODU `theme pull/push`'a gider.
|
|
24
|
+
* Güvenlik platformda: push yalnız mevcut sayfayı günceller, yeni oluşturmaz, silmez.
|
|
25
|
+
*
|
|
26
|
+
* CF-T3: every request goes through `fetchWithRetry` (lib/http.mjs). Reads are safe to resend; a settings push is an
|
|
27
|
+
* upsert; a pages push carries one `x-idempotency-key` per push operation, resent unchanged on every retry, and the
|
|
28
|
+
* server treats a re-sent, already-applied page as `unchanged`.
|
|
10
29
|
*/
|
|
11
30
|
|
|
31
|
+
export const PAGES_PROTOCOL_VERSION = 2;
|
|
32
|
+
|
|
12
33
|
async function errorText(res) {
|
|
13
34
|
const text = await res.text();
|
|
14
35
|
try {
|
|
@@ -18,7 +39,9 @@ async function errorText(res) {
|
|
|
18
39
|
}
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
42
|
+
const baseOf = (url) => url.replace(/\/+$/, "");
|
|
43
|
+
|
|
44
|
+
/** scope "pages" → pages/**.json (lexical order, no symlinks); "settings" → config/settings.json. `{path: content}`. */
|
|
22
45
|
export function readContentFiles(dir, scope) {
|
|
23
46
|
const out = {};
|
|
24
47
|
if (scope === "settings") {
|
|
@@ -26,52 +49,356 @@ export function readContentFiles(dir, scope) {
|
|
|
26
49
|
if (existsSync(p)) out["config/settings.json"] = readFileSync(p, "utf8");
|
|
27
50
|
return out;
|
|
28
51
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
const { files, errors } = collectPageFiles(dir);
|
|
53
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
54
|
+
for (const [path, content] of files) out[path] = content;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The server's page capabilities, or `PAGES_SERVER_UPGRADE_REQUIRED`. An old server answers `scope=capabilities`
|
|
60
|
+
* as a full export without `protocol_version`; nothing is written either way.
|
|
61
|
+
*/
|
|
62
|
+
export async function probePagesServer({ url, token, onRetry = null }) {
|
|
63
|
+
const res = await fetchWithRetry(`${baseOf(url)}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
64
|
+
if (!res.ok) throw Object.assign(new Error(await errorText(res)), { status: res.status });
|
|
65
|
+
const body = await res.json().catch(() => null);
|
|
66
|
+
if (body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
67
|
+
throw new PagesCliError(
|
|
68
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
69
|
+
"This site's server does not support locale-aware page files yet. Nothing was changed; update the platform or use an older CLI.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
defaultLocale: body.default_locale,
|
|
74
|
+
supportedLocales: body.supported_locales ?? [],
|
|
75
|
+
// CF-T3 — base_revision / expected_plan_hash / force are only sent to a server that says it checks them.
|
|
76
|
+
revisionCas: body.page_revision_cas === 1 && body.plan_hash === 1,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Site içeriğini diske çek (pull). Pages require protocol v2; every path is contained and staged. */
|
|
81
|
+
export async function pullContent({ dir, url, token, scope, writeFile, onRetry = null }) {
|
|
82
|
+
const res = await fetchWithRetry(`${baseOf(url)}/api/dev/content?scope=${encodeURIComponent(scope)}`, {
|
|
83
|
+
headers: { authorization: `Bearer ${token}` },
|
|
84
|
+
}, { onRetry });
|
|
85
|
+
const body = await res.json().catch(() => null);
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
// Nothing is written on a non-2xx: an incomplete export (PAGES_EXPORT_INCOMPLETE) carries every finding and no files.
|
|
88
|
+
throw new PagesCliError(body?.code ?? "PAGES_PULL_FAILED", body?.error ?? `Pull failed (HTTP ${res.status}); nothing was written.`, {
|
|
89
|
+
status: res.status,
|
|
90
|
+
diagnostics: Array.isArray(body?.diagnostics) ? body.diagnostics : [],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const files = body?.files ?? {};
|
|
94
|
+
const wantsPages = scope !== "settings";
|
|
95
|
+
if (wantsPages && body?.protocol_version !== PAGES_PROTOCOL_VERSION) {
|
|
96
|
+
throw new PagesCliError(
|
|
97
|
+
"PAGES_SERVER_UPGRADE_REQUIRED",
|
|
98
|
+
"This site's server still uses the old page layout, which cannot keep languages apart. Nothing was written.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const diagnostics = [...(body?.diagnostics ?? [])];
|
|
103
|
+
const errors = [];
|
|
104
|
+
const entries = [];
|
|
105
|
+
for (const path of Object.keys(files).sort()) {
|
|
106
|
+
const content = files[path];
|
|
107
|
+
if (path === "config/settings.json" && scope !== "pages") {
|
|
108
|
+
entries.push([path, content]);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const unsafe = checkRelativePagePath(path);
|
|
112
|
+
const parsed = unsafe ? null : parsePageFilePath(path);
|
|
113
|
+
if (unsafe || !parsed || (parsed.kind !== "canonical" && parsed.kind !== "hashed") || !wantsPages) {
|
|
114
|
+
errors.push({ level: "error", code: unsafe?.code ?? "PAGES_INVALID_PATH", message: `server sent an unexpected path (${unsafe?.reason ?? "not a canonical page path"})`, path });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const c = classifyPageFile(path, typeof content === "string" ? content : "", {});
|
|
118
|
+
if (!c.ok || c.layout !== "v2") {
|
|
119
|
+
errors.push({ level: "error", code: c.ok ? "PAGES_INVALID_PATH" : c.code, message: `server sent a page file that does not match its path (${c.ok ? "legacy payload" : c.message})`, path });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
entries.push([path, content]);
|
|
123
|
+
}
|
|
124
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "The server response failed validation; nothing was written.", { diagnostics: errors });
|
|
125
|
+
|
|
126
|
+
// Local files this pull does not own: reported, never deleted or overwritten.
|
|
127
|
+
if (wantsPages && existsSync(dir)) {
|
|
128
|
+
const local = collectPageFiles(dir);
|
|
129
|
+
for (const e of local.errors) diagnostics.push({ ...e, level: "warning" });
|
|
130
|
+
const remote = new Set(Object.keys(files));
|
|
131
|
+
for (const [path, content] of local.files) {
|
|
132
|
+
if (remote.has(path)) continue;
|
|
133
|
+
const c = classifyPageFile(path, content, { defaultLocale: body.default_locale });
|
|
134
|
+
if (c.ok && c.layout === "legacy") {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
level: "warning",
|
|
137
|
+
code: "PAGES_STALE_LEGACY_FILE",
|
|
138
|
+
message: `${path} was not written by this pull. Canonical replacement: ${pageFilePath(c.locale, c.slug)}`,
|
|
139
|
+
path,
|
|
140
|
+
locale: c.locale,
|
|
141
|
+
slug: c.slug,
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
diagnostics.push({ level: "warning", code: "PAGES_STALE_FILE", message: `${path} is not on the site and was not written by this pull.`, path });
|
|
38
145
|
}
|
|
39
|
-
if (!name.endsWith(".json")) continue;
|
|
40
|
-
const key = "pages/" + relative(pagesDir, full).split(sep).join("/");
|
|
41
|
-
out[key] = readFileSync(full, "utf8");
|
|
42
146
|
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const count = stagedWrite(fsRoot(dir), entries, writeFile ? { writeFile } : {});
|
|
150
|
+
return { count, diagnostics, defaultLocale: body?.default_locale ?? null };
|
|
46
151
|
}
|
|
47
152
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
153
|
+
function fsRoot(dir) {
|
|
154
|
+
mkdirSync(dir, { recursive: true });
|
|
155
|
+
return collectPageFiles(dir).root;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** CF-T3 — the most a `--force` reason may be (the platform's FORCE_REASON_MAX). */
|
|
159
|
+
export const FORCE_REASON_MAX = 500;
|
|
160
|
+
|
|
161
|
+
/** CF-T3 — paths of `forced`, whether the server lists strings or `{path, base_revision, current_revision}` objects. */
|
|
162
|
+
export function forcedPaths(forced) {
|
|
163
|
+
return (Array.isArray(forced) ? forced : []).map((f) => (typeof f === "string" ? f : f?.path)).filter((p) => typeof p === "string");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function pushRefusal(res, body, fallback) {
|
|
167
|
+
return new PagesCliError(body?.code ?? "PAGES_PUSH_FAILED", body?.error ?? fallback, {
|
|
168
|
+
diagnostics: body?.diagnostics ?? [],
|
|
169
|
+
pages: body?.pages ?? null,
|
|
170
|
+
status: res.status,
|
|
56
171
|
});
|
|
57
|
-
if (!res.ok) throw new Error(await errorText(res));
|
|
58
|
-
return { ...(await res.json()), fileCount: Object.keys(files).length };
|
|
59
172
|
}
|
|
60
173
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Push. `settings` keeps the v1 body. `pages`: server capability probe → local preflight (nothing is sent on a
|
|
176
|
+
* local error) → `POST {protocol_version: 2, dry_run, files}`. The server preflights the whole batch again and
|
|
177
|
+
* writes nothing unless every file passes.
|
|
178
|
+
*
|
|
179
|
+
* CF-T3 (contract C4) — on a server advertising `page_revision_cas` a push is two requests: a dry run (the plan,
|
|
180
|
+
* handed to `onPlan` before anything is written), then the real push carrying the plan's `expected_plan_hash` and the
|
|
181
|
+
* push's `x-idempotency-key`. A refused dry run (a stale file, a file without base_revision) throws and nothing else is
|
|
182
|
+
* sent; a plan that no longer holds at push time is 409 `PAGES_PLAN_STALE`. `force` + `forceReason` travel on both
|
|
183
|
+
* requests. An older server gets exactly the old single request (no CAS fields); `onServer(server)` lets the caller warn.
|
|
184
|
+
*/
|
|
185
|
+
export async function pushContent({ dir, url, token, scope, instance = null, dryRun = false, idempotencyKey = null, onRetry = null, force = false, forceReason = null, onServer = null, onPlan = null }) {
|
|
186
|
+
const base = baseOf(url);
|
|
187
|
+
if (scope === "settings") {
|
|
188
|
+
const files = readContentFiles(dir, scope);
|
|
189
|
+
// T10.1 — an older server ignores `instance` and would write the LIVE theme: send it only to a server that says
|
|
190
|
+
// it honours it (scope=capabilities `settings_instance: 1`); otherwise refuse before anything is sent.
|
|
191
|
+
if (instance) {
|
|
192
|
+
const res = await fetchWithRetry(`${base}/api/dev/content?scope=capabilities`, { headers: { authorization: `Bearer ${token}` } }, { onRetry });
|
|
193
|
+
if (!res.ok) throw Object.assign(new Error(await errorText(res)), { status: res.status });
|
|
194
|
+
const caps = await res.json().catch(() => null);
|
|
195
|
+
if (caps?.protocol_version !== PAGES_PROTOCOL_VERSION || caps?.settings_instance !== 1) {
|
|
196
|
+
throw new PagesCliError(
|
|
197
|
+
"SETTINGS_INSTANCE_UNSUPPORTED",
|
|
198
|
+
"This site's server cannot target a theme with `settings push --instance` (it would write the live theme instead). Nothing was sent; update the platform or push with --live.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const res = await fetchWithRetry(`${base}/api/dev/content`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
205
|
+
body: JSON.stringify(instance ? { files, instance } : { files }),
|
|
206
|
+
}, { onRetry });
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
const text = await res.text();
|
|
209
|
+
let body = null;
|
|
210
|
+
try {
|
|
211
|
+
body = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
/* plain-text body */
|
|
214
|
+
}
|
|
215
|
+
const error = Object.assign(new Error(body?.error ?? text), { status: res.status });
|
|
216
|
+
if (typeof body?.code === "string") error.code = body.code;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
return { ...(await res.json()), fileCount: Object.keys(files).length };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const server = await probePagesServer({ url, token, onRetry });
|
|
223
|
+
onServer?.(server);
|
|
224
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
225
|
+
if (errors.length > 0) throw new PagesCliError(errors[0].code, "Local page files cannot be read safely; nothing was sent.", { diagnostics: errors });
|
|
226
|
+
const localDiagnostics = checkLocalBatch(local.map(([p, c]) => classifyPageFile(p, c, server)));
|
|
227
|
+
const localErrors = localDiagnostics.filter((d) => d.level === "error");
|
|
228
|
+
if (localErrors.length > 0) {
|
|
229
|
+
throw new PagesCliError(localErrors[0].code, "Preflight failed locally; no pages were sent or changed.", { diagnostics: localDiagnostics });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const files = {};
|
|
233
|
+
for (const [path, content] of local) files[path] = content;
|
|
234
|
+
// One key per push operation: every transport retry resends this exact request (same key, same body).
|
|
235
|
+
const key = idempotencyKey ?? `cli-${randomUUID()}`;
|
|
236
|
+
const post = (payload, withKey) =>
|
|
237
|
+
fetchWithRetry(`${base}/api/dev/content`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...(withKey ? { "x-idempotency-key": key } : {}) },
|
|
240
|
+
body: JSON.stringify({ protocol_version: PAGES_PROTOCOL_VERSION, ...payload, files }),
|
|
241
|
+
}, { onRetry });
|
|
242
|
+
|
|
243
|
+
if (!server.revisionCas) {
|
|
244
|
+
const res = await post({ dry_run: dryRun }, true);
|
|
245
|
+
const body = await res.json().catch(() => null);
|
|
246
|
+
if (!res.ok || body?.ok !== true) throw pushRefusal(res, body, `Push failed (HTTP ${res.status}).`);
|
|
247
|
+
return { ...body, fileCount: local.length, revisionCas: false };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const forceFields = force ? { force: true, force_reason: forceReason } : {};
|
|
251
|
+
const planRes = await post({ dry_run: true, ...forceFields }, false);
|
|
252
|
+
const plan = await planRes.json().catch(() => null);
|
|
253
|
+
if (!planRes.ok || plan?.ok !== true) {
|
|
254
|
+
const error = pushRefusal(planRes, plan, `Push plan failed (HTTP ${planRes.status}); nothing was changed.`);
|
|
255
|
+
if (error.code === "PAGES_REVISION_CONFLICT" || error.code === "PAGES_BASE_REVISION_REQUIRED") {
|
|
256
|
+
const stale = error.diagnostics.filter((d) => d.code === "PAGES_REVISION_CONFLICT" || d.code === "PAGES_BASE_REVISION_REQUIRED").length;
|
|
257
|
+
error.message =
|
|
258
|
+
`${stale} page file(s) no longer describe the page on the site (changed since you pulled, or no base_revision); nothing was pushed or changed. ` +
|
|
259
|
+
"Run `blocofy pages pull` to refresh, or `--force --reason <text>` to overwrite.";
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (typeof plan.plan_hash !== "string") {
|
|
264
|
+
throw new PagesCliError("PAGES_PUSH_FAILED", "The server's plan has no plan_hash; nothing was changed.", { status: planRes.status });
|
|
265
|
+
}
|
|
266
|
+
onPlan?.(plan);
|
|
267
|
+
if (dryRun) return { ...plan, fileCount: local.length, revisionCas: true };
|
|
268
|
+
|
|
269
|
+
const res = await post({ dry_run: false, expected_plan_hash: plan.plan_hash, ...forceFields }, true);
|
|
270
|
+
const body = await res.json().catch(() => null);
|
|
271
|
+
if (!res.ok || body?.ok !== true) {
|
|
272
|
+
const error = pushRefusal(res, body, `Push failed (HTTP ${res.status}).`);
|
|
273
|
+
if (error.code === "PAGES_PLAN_STALE") {
|
|
274
|
+
error.message = "The site changed between the plan and the push; no pages were changed. Run the push again to review the new plan.";
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
return { ...body, fileCount: local.length, revisionCas: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `pages check`. Offline: path, JSON, format, layout and local duplicates. With `server` credentials it also
|
|
283
|
+
* resolves the default/supported locales and asks the server for a dry-run preflight (targets, documents).
|
|
284
|
+
*/
|
|
285
|
+
export async function checkPages({ dir, url, token, onRetry = null }) {
|
|
286
|
+
const { files: local, errors } = collectPageFiles(dir);
|
|
287
|
+
const diagnostics = [...errors];
|
|
288
|
+
let server = null;
|
|
289
|
+
if (url && token) server = await probePagesServer({ url, token, onRetry });
|
|
290
|
+
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
291
|
+
diagnostics.push(...checkLocalBatch(classified));
|
|
292
|
+
// CF-T3 — a v2 file without base_revision will be refused by a revision-checking server; say so before the push.
|
|
293
|
+
// Not on an older server: it never stamps the field, so the warning could never be fixed there.
|
|
294
|
+
if (!server || server.revisionCas) {
|
|
295
|
+
for (const c of classified) {
|
|
296
|
+
if (c.ok && c.layout === "v2" && c.parsed.base_revision == null) {
|
|
297
|
+
diagnostics.push({ level: "warning", code: "PAGES_BASE_REVISION_MISSING", message: "no base_revision: the site cannot tell whether this page changed since you pulled it, so a push will be refused. Run `blocofy pages pull` to refresh the file (or push with --force --reason <text>).", path: c.path, locale: c.locale, slug: c.slug });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let plan = null;
|
|
303
|
+
if (server && !diagnostics.some((d) => d.level === "error")) {
|
|
304
|
+
try {
|
|
305
|
+
plan = await pushContent({ dir, url, token, scope: "pages", dryRun: true, onRetry });
|
|
306
|
+
for (const d of plan.diagnostics ?? []) {
|
|
307
|
+
if (!diagnostics.some((x) => x.code === d.code && x.path === d.path)) diagnostics.push(d);
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!(error instanceof PagesCliError)) throw error;
|
|
311
|
+
diagnostics.push(...(error.diagnostics.length ? error.diagnostics : [{ level: "error", code: error.code, message: error.message }]));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { fileCount: local.length, diagnostics, plan, online: Boolean(server) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `pages migrate-layout`. Plans a move for every legacy file whose (locale, slug) is proven, and refuses the
|
|
319
|
+
* whole migration — zero moves — on any error or ambiguity. `write: true` rewrites each file as v2 at its
|
|
320
|
+
* canonical path (contained, staged per file, verified) and only then removes the legacy file.
|
|
321
|
+
*/
|
|
322
|
+
export async function migrateLayout({ dir, url, token, write = false, onRetry = null }) {
|
|
323
|
+
const { root, files: local, errors } = collectPageFiles(dir);
|
|
324
|
+
const diagnostics = [...errors];
|
|
325
|
+
const needsDefault = local.some(([, c]) => {
|
|
326
|
+
try {
|
|
327
|
+
const j = JSON.parse(c);
|
|
328
|
+
return j && typeof j === "object" && !("format_version" in j) && !("locale" in j);
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
66
332
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
333
|
+
let server = null;
|
|
334
|
+
if (url && token) server = await probePagesServer({ url, token, onRetry });
|
|
335
|
+
|
|
336
|
+
const classified = local.map(([p, c]) => classifyPageFile(p, c, server ?? {}));
|
|
337
|
+
diagnostics.push(...checkLocalBatch(classified).filter((d) => d.code !== "PAGES_LEGACY_LAYOUT"));
|
|
338
|
+
if (needsDefault && !server) {
|
|
339
|
+
for (const c of classified) {
|
|
340
|
+
if (c.ok && c.localeSource === "unresolved") {
|
|
341
|
+
diagnostics.push({ level: "error", code: "PAGES_AMBIGUOUS_LAYOUT", message: "file has no locale and the site's default language is unknown offline; log in (blocofy login) or add \"locale\" to the file", path: c.path, slug: c.slug });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const byPath = new Map(classified.filter((c) => c.ok).map((c) => [c.path, c]));
|
|
347
|
+
const moves = [];
|
|
348
|
+
const claimed = new Map();
|
|
349
|
+
for (const c of classified) {
|
|
350
|
+
if (!c.ok || c.layout !== "legacy" || !c.locale) continue;
|
|
351
|
+
const to = pageFilePath(c.locale, c.slug);
|
|
352
|
+
const existing = byPath.get(to);
|
|
353
|
+
if (existing) {
|
|
354
|
+
if (pageFileSemanticKey(existing.parsed) === pageFileSemanticKey(c.parsed)) {
|
|
355
|
+
diagnostics.push({ level: "warning", code: "PAGES_DUPLICATE_EQUIVALENT", message: `${to} already holds the same content; ${c.path} was left in place (remove it yourself)`, path: c.path, locale: c.locale, slug: c.slug });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
// checkLocalBatch has already reported the conflicting pair as PAGES_DUPLICATE_TARGET.
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (claimed.has(to)) {
|
|
362
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: `${c.path} and ${claimed.get(to)} both move to ${to}`, path: c.path, locale: c.locale, slug: c.slug });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
claimed.set(to, c.path);
|
|
366
|
+
moves.push({ from: c.path, to, locale: c.locale, slug: c.slug, content: serializeV2PageFile(c.parsed, c.locale) });
|
|
367
|
+
}
|
|
368
|
+
moves.sort((a, b) => (a.from < b.from ? -1 : 1));
|
|
369
|
+
const remaining = local.map(([p]) => p).filter((p) => !moves.some((m) => m.from === p));
|
|
370
|
+
diagnostics.push(...folderConflicts([...remaining, ...moves.map((m) => m.to)]));
|
|
371
|
+
|
|
372
|
+
const refused = diagnostics.some((d) => d.level === "error");
|
|
373
|
+
if (!write || refused) return { moves, moved: 0, diagnostics, refused };
|
|
374
|
+
|
|
375
|
+
// Every target validated before the first move.
|
|
376
|
+
for (const m of moves) {
|
|
377
|
+
const r = resolveContained(root, m.to);
|
|
378
|
+
if (r.error) {
|
|
379
|
+
diagnostics.push({ level: "error", code: r.error.code, message: r.error.reason, path: m.to });
|
|
380
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
381
|
+
}
|
|
382
|
+
if (existsSync(r.target)) {
|
|
383
|
+
diagnostics.push({ level: "error", code: "PAGES_DUPLICATE_TARGET", message: "target appeared during migration", path: m.to });
|
|
384
|
+
return { moves, moved: 0, diagnostics, refused: true };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let moved = 0;
|
|
388
|
+
for (const m of moves) {
|
|
389
|
+
const verify = classifyPageFile(m.to, m.content, { defaultLocale: m.locale });
|
|
390
|
+
if (!verify.ok || verify.layout !== "v2") throw new PagesCliError("PAGES_INVALID_PATH", `internal: ${m.to} would not be a valid v2 file; stopped after ${moved} move(s).`);
|
|
391
|
+
const resolved = resolveContained(root, m.to);
|
|
392
|
+
if (resolved.error) throw new PagesCliError(resolved.error.code, `${m.to}: ${resolved.error.reason}; stopped after ${moved} move(s).`);
|
|
393
|
+
const { target } = resolved;
|
|
394
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
395
|
+
const temp = `${target}.blocofy-tmp`;
|
|
396
|
+
writeFileSync(temp, m.content, { flag: "wx" });
|
|
397
|
+
renameSync(temp, target);
|
|
398
|
+
const legacy = resolveContained(root, m.from);
|
|
399
|
+
if (legacy.error) throw new PagesCliError(legacy.error.code, `${m.from}: ${legacy.error.reason}; stopped after ${moved} move(s).`);
|
|
400
|
+
rmSync(legacy.target);
|
|
401
|
+
moved += 1;
|
|
402
|
+
}
|
|
403
|
+
return { moves, moved, diagnostics, refused: false };
|
|
77
404
|
}
|