create-ailk 0.30.0 → 0.31.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 +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
package/dist/cutover.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-ailk cutover <dir> --before <origin> --after <origin> [--apply]` —
|
|
3
|
+
* move a site from its page files to its database, one checked step at a time
|
|
4
|
+
* (#10648, spec docs/specs/10648-the-cutover-kit-frozen-inventory-spec.md D7,
|
|
5
|
+
* D8; roadmap D15, D16).
|
|
6
|
+
*
|
|
7
|
+
* Eight steps, in this order only: import → publish → inventory → comparison →
|
|
8
|
+
* set env → deploy → verify → remove files. With `--apply` they run, and the
|
|
9
|
+
* first that fails ends the run with exit 1 and every later step recorded
|
|
10
|
+
* `not-run`. Each step's command and evidence go into
|
|
11
|
+
* `.ailk-cutover/runs/<UTC stamp>/run.json`, beside the comparison's
|
|
12
|
+
* `comparison.json`, `comparison.md` and screenshots (`runs/` is gitignored:
|
|
13
|
+
* screenshots do not belong in the site's history).
|
|
14
|
+
*
|
|
15
|
+
* Without `--apply` it is a dry run: the import is `migrate-content`'s own dry
|
|
16
|
+
* run (over `readOnlyFetch`), the other steps are planned and printed, only
|
|
17
|
+
* `GET`s are sent, no `vercel` or `git` command is spawned and no file is
|
|
18
|
+
* written anywhere.
|
|
19
|
+
*
|
|
20
|
+
* The kit never publishes — publishing is a human act (#10345 D7) — so the
|
|
21
|
+
* publish step checks that every page the import read is `published` and,
|
|
22
|
+
* on the first `--apply`, names the pages to publish in Admin and stops. A
|
|
23
|
+
* re-run continues. The rollback is `export-content` (the runbook,
|
|
24
|
+
* docs/internal/runbooks/site-cutover.md).
|
|
25
|
+
*
|
|
26
|
+
* Pure module: no `import.meta.url`, so jest imports it directly; every step's
|
|
27
|
+
* collaborator is injectable.
|
|
28
|
+
*/
|
|
29
|
+
import { spawnSync } from "node:child_process";
|
|
30
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join, resolve } from "node:path";
|
|
32
|
+
import { parseArgs } from "node:util";
|
|
33
|
+
import { CUTOVER_DIR, INVENTORY_FILE, formatAuditCounts, given, inventory, listWorkspacePages, readInventory, readSiteLocales, resolveWorkspaceAccess, siteOrigin, } from "./inventory.js";
|
|
34
|
+
import { languageCountsLine, renderChangeLine, } from "./language-import.js";
|
|
35
|
+
import { PLAYWRIGHT_INSTALL_HINT, loadChromium, runComparison, } from "./lib/render-comparison.js";
|
|
36
|
+
import { migrateContent } from "./migrate-content.js";
|
|
37
|
+
import { ParseError } from "./parse-args.js";
|
|
38
|
+
export const CUTOVER_STEPS = [
|
|
39
|
+
"import",
|
|
40
|
+
"publish",
|
|
41
|
+
"inventory",
|
|
42
|
+
"comparison",
|
|
43
|
+
"set-env",
|
|
44
|
+
"deploy",
|
|
45
|
+
"verify",
|
|
46
|
+
"remove-files",
|
|
47
|
+
];
|
|
48
|
+
export const ENV_TARGETS = ["preview", "production"];
|
|
49
|
+
/** The env var the site's web app switches its content reads on. */
|
|
50
|
+
const CONTENT_ADAPTER_VAR = "CONTENT_ADAPTER";
|
|
51
|
+
/** A Vercel preview's protection bypass, sent only to `--after` and the deployment. */
|
|
52
|
+
const BYPASS_ENV_VAR = "VERCEL_AUTOMATION_BYPASS_SECRET";
|
|
53
|
+
/** A refusal the CLI exits 2 on, raised before step 1. */
|
|
54
|
+
export class CutoverRefusedError extends Error {
|
|
55
|
+
exitCode = 2;
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "CutoverRefusedError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** The default runner: `spawnSync` with no shell, stdout piped, stderr shown. */
|
|
62
|
+
export const spawnRunner = (command, args, options) => {
|
|
63
|
+
const result = spawnSync(command, args, {
|
|
64
|
+
cwd: options.cwd,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
shell: false,
|
|
67
|
+
...(options.input !== undefined && { input: options.input }),
|
|
68
|
+
stdio: [options.input !== undefined ? "pipe" : "ignore", "pipe", "inherit"],
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
status: result.status,
|
|
72
|
+
stdout: result.stdout ?? "",
|
|
73
|
+
...(result.error !== undefined && {
|
|
74
|
+
error: result.error.code ?? result.error.name,
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/** `20261103T100000Z`. */
|
|
79
|
+
function stamp(date) {
|
|
80
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Step 8's paths: each imported file under the root it came from, repo-relative
|
|
84
|
+
* (D8). An outcome's path is relative to the content root the importer walked.
|
|
85
|
+
*/
|
|
86
|
+
export function removablePaths(pages) {
|
|
87
|
+
return pages
|
|
88
|
+
.filter((p) => p.result === "created" || p.result === "updated" || p.result === "unchanged")
|
|
89
|
+
.map((p) => `${p.root === "editorial" ? "apps/web/content" : "content"}/${p.path}`);
|
|
90
|
+
}
|
|
91
|
+
const imported = (pages) => pages.flatMap((p) => ("type" in p ? [`${p.type}/${p.slug}`] : []));
|
|
92
|
+
const vercelEnvArgs = (target) => ({
|
|
93
|
+
rm: ["env", "rm", CONTENT_ADAPTER_VAR, target, "--yes"],
|
|
94
|
+
add: ["env", "add", CONTENT_ADAPTER_VAR, target],
|
|
95
|
+
ls: ["env", "ls", target],
|
|
96
|
+
});
|
|
97
|
+
const deployArgs = (target) => (target === "production" ? ["deploy", "--prod"] : ["deploy"]);
|
|
98
|
+
// ── The command (D7) ────────────────────────────────────────────────────────
|
|
99
|
+
export async function cutover(opts) {
|
|
100
|
+
const targetDir = resolve(opts.targetDir);
|
|
101
|
+
const env = opts.env ?? process.env;
|
|
102
|
+
const onLine = opts.onLine ?? (() => undefined);
|
|
103
|
+
const fetch = opts.fetch ?? globalThis.fetch;
|
|
104
|
+
const apply = opts.apply === true;
|
|
105
|
+
const refuse = (message) => {
|
|
106
|
+
throw new CutoverRefusedError(`cutover: ${message} Nothing was run.`);
|
|
107
|
+
};
|
|
108
|
+
// Refusals, before step 1.
|
|
109
|
+
const access = resolveWorkspaceAccess(targetDir, { apiUrl: opts.apiUrl, workspaceId: opts.workspaceId, env }, refuse);
|
|
110
|
+
const rawBefore = given(opts.before);
|
|
111
|
+
const rawAfter = given(opts.after);
|
|
112
|
+
if (rawBefore === undefined)
|
|
113
|
+
refuse("pass --before <origin>, the site served from its files.");
|
|
114
|
+
if (rawAfter === undefined)
|
|
115
|
+
refuse("pass --after <origin>, a deployment reading the database.");
|
|
116
|
+
const before = siteOrigin(rawBefore, "--before", refuse);
|
|
117
|
+
const after = siteOrigin(rawAfter, "--after", refuse);
|
|
118
|
+
const target = opts.envTarget ?? "preview";
|
|
119
|
+
if (!ENV_TARGETS.includes(target))
|
|
120
|
+
refuse("--env-target must be preview or production.");
|
|
121
|
+
const tolerance = opts.pixelTolerance ?? 0;
|
|
122
|
+
if (!(tolerance >= 0 && tolerance <= 1))
|
|
123
|
+
refuse("--pixel-tolerance must be a ratio from 0 to 1.");
|
|
124
|
+
const bypass = given(env[BYPASS_ENV_VAR]);
|
|
125
|
+
const afterHeaders = bypass === undefined ? undefined : { "x-vercel-protection-bypass": bypass };
|
|
126
|
+
const { defaultLocale } = readSiteLocales(targetDir);
|
|
127
|
+
const web = join(targetDir, "apps", "web");
|
|
128
|
+
const spoke = access.spokeKey !== undefined;
|
|
129
|
+
const deps = {
|
|
130
|
+
migrate: ({ apply: write }) => migrateContent({
|
|
131
|
+
targetDir,
|
|
132
|
+
apply: write,
|
|
133
|
+
apiUrl: opts.apiUrl,
|
|
134
|
+
workspaceId: opts.workspaceId,
|
|
135
|
+
env,
|
|
136
|
+
fetch,
|
|
137
|
+
...(opts.adapters !== undefined && { adapters: opts.adapters }),
|
|
138
|
+
}),
|
|
139
|
+
list: async () => (await listWorkspacePages(access, fetch, defaultLocale, refuse)).filter((r) => r.locale === defaultLocale),
|
|
140
|
+
inventory: async () => {
|
|
141
|
+
const result = await inventory({
|
|
142
|
+
targetDir,
|
|
143
|
+
origin: before,
|
|
144
|
+
docsPath: opts.docsPath,
|
|
145
|
+
apiUrl: opts.apiUrl,
|
|
146
|
+
workspaceId: opts.workspaceId,
|
|
147
|
+
env,
|
|
148
|
+
fetch,
|
|
149
|
+
...(opts.codec !== undefined && { codec: opts.codec }),
|
|
150
|
+
onLine: (line) => onLine(` ${line}`),
|
|
151
|
+
});
|
|
152
|
+
return { exitCode: result.exitCode, summary: formatAuditCounts(result.audit.counts) };
|
|
153
|
+
},
|
|
154
|
+
frozen: () => {
|
|
155
|
+
const inv = readInventory(targetDir);
|
|
156
|
+
if (inv === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
const images = inv.images.flatMap((i) => {
|
|
159
|
+
const url = new URL(i.url);
|
|
160
|
+
return url.origin === inv.origin ? [url.pathname + url.search] : [];
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
pages: inv.frozen.filter((e) => e.kind === "page").map((e) => e.path),
|
|
164
|
+
sweep: [...inv.frozen.map((e) => e.path), ...images],
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
compare: async (o) => {
|
|
168
|
+
const chromium = loadChromium([targetDir, process.cwd()]);
|
|
169
|
+
if (chromium === undefined) {
|
|
170
|
+
return { pass: false, summary: `no Playwright Chromium found — ${PLAYWRIGHT_INSTALL_HINT}` };
|
|
171
|
+
}
|
|
172
|
+
const report = await runComparison({
|
|
173
|
+
chromium,
|
|
174
|
+
before,
|
|
175
|
+
after: o.after,
|
|
176
|
+
...(afterHeaders !== undefined && { afterHeaders }),
|
|
177
|
+
pages: o.pages,
|
|
178
|
+
sweepPaths: o.sweep,
|
|
179
|
+
tolerance,
|
|
180
|
+
screenshots: o.screenshots,
|
|
181
|
+
dir: o.dir,
|
|
182
|
+
fetch,
|
|
183
|
+
onLine: (line) => onLine(` ${line}`),
|
|
184
|
+
});
|
|
185
|
+
const differing = report.pages.filter((p) => !p.pass).map((p) => p.path);
|
|
186
|
+
const not200 = report.sweep.results.filter((r) => r.status !== 200).length;
|
|
187
|
+
return {
|
|
188
|
+
pass: report.pass,
|
|
189
|
+
summary: `${report.pages.length - differing.length}/${report.pages.length} page(s) the same; sweep: ${not200} of ${report.sweep.results.length} not 200${differing.length > 0 ? `; differs: ${differing.join(", ")}` : ""}`,
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
run: spawnRunner,
|
|
193
|
+
git: spawnRunner,
|
|
194
|
+
...opts.deps,
|
|
195
|
+
};
|
|
196
|
+
const runDir = join(targetDir, CUTOVER_DIR, "runs", stamp((opts.now ?? (() => new Date()))()));
|
|
197
|
+
const steps = CUTOVER_STEPS.map((name, i) => ({
|
|
198
|
+
n: i + 1,
|
|
199
|
+
name,
|
|
200
|
+
status: "not-run",
|
|
201
|
+
command: "",
|
|
202
|
+
evidence: [],
|
|
203
|
+
}));
|
|
204
|
+
const record = () => {
|
|
205
|
+
if (!apply)
|
|
206
|
+
return;
|
|
207
|
+
const runs = join(targetDir, CUTOVER_DIR, "runs");
|
|
208
|
+
mkdirSync(runDir, { recursive: true });
|
|
209
|
+
if (!existsSync(join(runs, ".gitignore")))
|
|
210
|
+
writeFileSync(join(runs, ".gitignore"), "*\n");
|
|
211
|
+
writeFileSync(join(runDir, "run.json"), `${JSON.stringify({ apply, steps }, null, 2)}\n`);
|
|
212
|
+
};
|
|
213
|
+
onLine(apply
|
|
214
|
+
? `Cutting ${opts.targetDir} over: ${before} (files) → ${after} (database), env target ${target}.`
|
|
215
|
+
: `Planning the cutover of ${opts.targetDir}: ${before} (files) → ${after} (database), env target ${target}. Nothing is written or spawned without --apply.`);
|
|
216
|
+
// State the steps hand on.
|
|
217
|
+
let outcomes = [];
|
|
218
|
+
let deployed;
|
|
219
|
+
const STEP = {
|
|
220
|
+
import: async () => {
|
|
221
|
+
const command = `create-ailk migrate-content ${opts.targetDir}${apply ? " --apply" : ""}`;
|
|
222
|
+
// #10735 D8 — a Language render change stops here, before any write:
|
|
223
|
+
// `--apply` first plans, and imports only a plan with none.
|
|
224
|
+
const changes = (language) => language === undefined || language.renderChanges.length === 0
|
|
225
|
+
? undefined
|
|
226
|
+
: [
|
|
227
|
+
...language.renderChanges.map(renderChangeLine),
|
|
228
|
+
"make each listed copy match the words its line names (or reword the question), redeploy --before, and re-run",
|
|
229
|
+
];
|
|
230
|
+
if (apply) {
|
|
231
|
+
const planned = changes((await deps.migrate({ apply: false })).language);
|
|
232
|
+
if (planned !== undefined)
|
|
233
|
+
return { ok: false, command, evidence: planned };
|
|
234
|
+
}
|
|
235
|
+
const { result, language } = await deps.migrate({ apply });
|
|
236
|
+
outcomes = result.pages;
|
|
237
|
+
const { created, updated, unchanged, skipped, failed } = result.counts;
|
|
238
|
+
const counts = `created ${created} · updated ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failed}`;
|
|
239
|
+
const changed = changes(language);
|
|
240
|
+
return {
|
|
241
|
+
ok: failed === 0 && changed === undefined,
|
|
242
|
+
command,
|
|
243
|
+
evidence: [
|
|
244
|
+
...result.pages.filter((p) => p.result === "failed").map((p) => `${p.path}: failed`),
|
|
245
|
+
counts,
|
|
246
|
+
...(language === undefined ? [] : [languageCountsLine(language, apply)]),
|
|
247
|
+
...(changed ?? []),
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
publish: async () => {
|
|
252
|
+
const rows = new Map((await deps.list()).map((r) => [`${r.type}/${r.slug}`, r.status]));
|
|
253
|
+
const unpublished = imported(outcomes).filter((key) => rows.get(key) !== "published");
|
|
254
|
+
return {
|
|
255
|
+
ok: unpublished.length === 0,
|
|
256
|
+
command: "read the workspace list: every imported page is published",
|
|
257
|
+
evidence: unpublished.length === 0
|
|
258
|
+
? [`${imported(outcomes).length} imported page(s), all published`]
|
|
259
|
+
: [
|
|
260
|
+
...unpublished.map((key) => `${key}: ${rows.get(key) ?? "absent"} — publish it in Admin`),
|
|
261
|
+
"Publish these pages in Admin, then re-run. Publishing is a human act; the kit never publishes.",
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
inventory: async () => {
|
|
266
|
+
const command = `create-ailk inventory ${opts.targetDir} --origin ${before}`;
|
|
267
|
+
if (!apply) {
|
|
268
|
+
const frozen = deps.frozen();
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
command,
|
|
272
|
+
evidence: [
|
|
273
|
+
frozen === undefined
|
|
274
|
+
? `not frozen: ${before} would be crawled into ${CUTOVER_DIR}/${INVENTORY_FILE}`
|
|
275
|
+
: `frozen: ${frozen.sweep.length} URL(s), ${frozen.pages.length} page(s); the audit would be rewritten`,
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const { exitCode, summary } = await deps.inventory();
|
|
280
|
+
return { ok: exitCode === 0, command, evidence: [`${CUTOVER_DIR}/${INVENTORY_FILE} and audit.json: ${summary}`] };
|
|
281
|
+
},
|
|
282
|
+
comparison: async () => {
|
|
283
|
+
const command = `compare every frozen page on ${before} and ${after} (text, JSON-LD, screenshots), sweep for 200`;
|
|
284
|
+
const frozen = deps.frozen();
|
|
285
|
+
if (!apply) {
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
command,
|
|
289
|
+
evidence: [frozen === undefined ? "every URL the inventory freezes" : `${frozen.pages.length} page(s), ${frozen.sweep.length} URL(s) swept`],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (frozen === undefined)
|
|
293
|
+
return { ok: false, command, evidence: ["no frozen inventory"] };
|
|
294
|
+
const dir = join(runDir, "comparison");
|
|
295
|
+
const { pass, summary } = await deps.compare({ after, ...frozen, screenshots: true, dir });
|
|
296
|
+
return { ok: pass, command, evidence: [summary, `${dir}/comparison.md`] };
|
|
297
|
+
},
|
|
298
|
+
"set-env": async () => {
|
|
299
|
+
const args = vercelEnvArgs(target);
|
|
300
|
+
const command = `(cd apps/web && vercel ${args.rm.join(" ")}; echo database | vercel ${args.add.join(" ")})`;
|
|
301
|
+
if (!apply)
|
|
302
|
+
return { ok: true, command, evidence: [`${CONTENT_ADAPTER_VAR}=database on ${target}`] };
|
|
303
|
+
deps.run("vercel", args.rm, { cwd: web });
|
|
304
|
+
const added = deps.run("vercel", args.add, { cwd: web, input: "database" });
|
|
305
|
+
if (added.error === "ENOENT")
|
|
306
|
+
return { ok: false, command, evidence: ["vercel is not installed (npm i -g vercel)"] };
|
|
307
|
+
if (added.status !== 0)
|
|
308
|
+
return { ok: false, command, evidence: [`vercel env add exited ${added.status}`] };
|
|
309
|
+
if (spoke) {
|
|
310
|
+
const listed = deps.run("vercel", args.ls, { cwd: web });
|
|
311
|
+
if (listed.status !== 0 || !/\bAILK_SPOKE_KEY\b/.test(listed.stdout)) {
|
|
312
|
+
return { ok: false, command, evidence: [`AILK_SPOKE_KEY is not set on ${target}: a spoke's database read needs it`] };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, command, evidence: [`${CONTENT_ADAPTER_VAR}=database on ${target}`] };
|
|
316
|
+
},
|
|
317
|
+
deploy: async () => {
|
|
318
|
+
const args = deployArgs(target);
|
|
319
|
+
const command = `(cd apps/web && vercel ${args.join(" ")})`;
|
|
320
|
+
if (!apply)
|
|
321
|
+
return { ok: true, command, evidence: ["the deployment URL"] };
|
|
322
|
+
const result = deps.run("vercel", args, { cwd: web });
|
|
323
|
+
deployed = [...result.stdout.matchAll(/https:\/\/[^\s"'<>]+/g)].at(-1)?.[0];
|
|
324
|
+
if (result.status !== 0 || deployed === undefined) {
|
|
325
|
+
return { ok: false, command, evidence: [`vercel deploy exited ${result.status}${deployed === undefined ? " with no URL" : ""}`] };
|
|
326
|
+
}
|
|
327
|
+
deployed = new URL(deployed).origin;
|
|
328
|
+
return { ok: true, command, evidence: [deployed] };
|
|
329
|
+
},
|
|
330
|
+
verify: async () => {
|
|
331
|
+
const command = `compare every frozen page on ${before} and the deployment (text, JSON-LD), sweep for 200`;
|
|
332
|
+
if (!apply)
|
|
333
|
+
return { ok: true, command, evidence: ["the deployment from step 6"] };
|
|
334
|
+
const frozen = deps.frozen();
|
|
335
|
+
if (frozen === undefined)
|
|
336
|
+
return { ok: false, command, evidence: ["no frozen inventory"] };
|
|
337
|
+
const dir = join(runDir, "verify");
|
|
338
|
+
const { pass, summary } = await deps.compare({ after: deployed, ...frozen, screenshots: false, dir });
|
|
339
|
+
return { ok: pass, command, evidence: [summary, `${dir}/comparison.md`] };
|
|
340
|
+
},
|
|
341
|
+
"remove-files": async () => {
|
|
342
|
+
const paths = removablePaths(outcomes);
|
|
343
|
+
const command = `git rm --quiet -- ${paths.join(" ")}`;
|
|
344
|
+
if (!apply)
|
|
345
|
+
return { ok: true, command, evidence: [`${paths.length} file(s), staged, never committed`] };
|
|
346
|
+
const refused = paths.filter((p) => p.split("/").some((s) => s.startsWith("_")));
|
|
347
|
+
if (refused.length > 0)
|
|
348
|
+
return { ok: false, command, evidence: refused.map((p) => `${p}: never removed`) };
|
|
349
|
+
const missing = paths.filter((p) => !existsSync(join(targetDir, p)));
|
|
350
|
+
if (missing.length > 0)
|
|
351
|
+
return { ok: false, command, evidence: missing.map((p) => `${p}: not in the work tree`) };
|
|
352
|
+
if (paths.length === 0)
|
|
353
|
+
return { ok: true, command, evidence: ["no file to remove"] };
|
|
354
|
+
const status = deps.git("git", ["status", "--porcelain", "--", ...paths], { cwd: targetDir });
|
|
355
|
+
if (status.status !== 0 || status.stdout.trim() !== "") {
|
|
356
|
+
return { ok: false, command, evidence: ["uncommitted changes in the files to remove — commit or discard them first", status.stdout.trim()] };
|
|
357
|
+
}
|
|
358
|
+
const removed = deps.git("git", ["rm", "--quiet", "--", ...paths], { cwd: targetDir });
|
|
359
|
+
return removed.status === 0
|
|
360
|
+
? { ok: true, command, evidence: [`${paths.length} file(s) staged for removal; commit them`] }
|
|
361
|
+
: { ok: false, command, evidence: [`git rm exited ${removed.status}`] };
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
let exitCode = 0;
|
|
365
|
+
for (const step of steps) {
|
|
366
|
+
let result;
|
|
367
|
+
try {
|
|
368
|
+
result = await STEP[step.name]();
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
// A refusal raised by the import before it sent anything is the run's
|
|
372
|
+
// refusal (exit 2); anything else thrown in a step fails that step.
|
|
373
|
+
if (step.n === 1 && err.exitCode === 2)
|
|
374
|
+
throw err;
|
|
375
|
+
result = { ok: false, command: step.command, evidence: [`error: ${err.message}`] };
|
|
376
|
+
}
|
|
377
|
+
step.command = result.command;
|
|
378
|
+
step.evidence = result.evidence;
|
|
379
|
+
step.status = !apply ? "planned" : result.ok ? "passed" : "failed";
|
|
380
|
+
onLine(`[${step.n}/8] ${step.name}: ${step.status} — ${step.command}`);
|
|
381
|
+
for (const line of result.evidence)
|
|
382
|
+
onLine(` ${line}`);
|
|
383
|
+
record();
|
|
384
|
+
// A dry run fails only on its import plan; a later step is planned, not run.
|
|
385
|
+
if (!result.ok && (apply || step.name === "import"))
|
|
386
|
+
exitCode = 1;
|
|
387
|
+
if (!result.ok && apply)
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (apply) {
|
|
391
|
+
onLine(exitCode === 0
|
|
392
|
+
? `Cut over. Commit the removal and ${CUTOVER_DIR}/; the run record is ${runDir}.`
|
|
393
|
+
: `Stopped. The run record is ${runDir}. Rollback: docs/internal/runbooks/site-cutover.md.`);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
onLine("Dry run: nothing was written or spawned. Re-run with --apply to cut over.");
|
|
397
|
+
}
|
|
398
|
+
return { exitCode, steps };
|
|
399
|
+
}
|
|
400
|
+
export const CUTOVER_HELP = `create-ailk cutover — move a site from its page files to its database, one checked step at a time.
|
|
401
|
+
|
|
402
|
+
Usage:
|
|
403
|
+
AILK_AGENT_KEY=<key> create-ailk cutover [<dir>] --before <origin> --after <origin> [--apply] [--env-target preview|production] [--docs-path <path>] [--pixel-tolerance <ratio>] [--api-url <origin>] [--workspace <wksp_id>]
|
|
404
|
+
|
|
405
|
+
Arguments:
|
|
406
|
+
<dir> The site. Defaults to the current dir.
|
|
407
|
+
|
|
408
|
+
Options:
|
|
409
|
+
--before <origin> The site served from its files.
|
|
410
|
+
--after <origin> A deployment reading the database (a preview
|
|
411
|
+
with CONTENT_ADAPTER=database).
|
|
412
|
+
--apply Run the steps. Without it the command plans
|
|
413
|
+
them: it sends only reads, spawns nothing and
|
|
414
|
+
writes nothing.
|
|
415
|
+
--env-target <target> preview (the default) or production — where
|
|
416
|
+
step 5 sets CONTENT_ADAPTER and step 6 deploys.
|
|
417
|
+
--docs-path <path> Where the site serves its docs. Default /docs.
|
|
418
|
+
--pixel-tolerance <ratio> The share of a screenshot's pixels that may
|
|
419
|
+
differ. Default 0.
|
|
420
|
+
--api-url <origin> The API origin that holds the workspace — on a
|
|
421
|
+
spoke, its hub's. Else AILK_API_URL.
|
|
422
|
+
--workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.
|
|
423
|
+
-h, --help Show this help.
|
|
424
|
+
|
|
425
|
+
Steps, in this order; the first that fails stops the run:
|
|
426
|
+
1 import create-ailk migrate-content <dir> --apply; stops, before
|
|
427
|
+
writing, when the import would change a page's render
|
|
428
|
+
2 publish every imported page is published (publish in Admin)
|
|
429
|
+
3 inventory create-ailk inventory <dir> --origin <before>; audit passes
|
|
430
|
+
4 comparison text, JSON-LD and screenshots per frozen page, before and
|
|
431
|
+
after, at 1440 and 390 wide; every frozen URL answers 200
|
|
432
|
+
5 set env vercel env add CONTENT_ADAPTER <target> = database (apps/web)
|
|
433
|
+
6 deploy vercel deploy [--prod] (apps/web)
|
|
434
|
+
7 verify text and JSON-LD per frozen page on the deployment; 200s
|
|
435
|
+
8 remove files git rm --quiet -- <the imported files> (staged, not committed)
|
|
436
|
+
|
|
437
|
+
Each run writes .ailk-cutover/runs/<UTC time>/run.json and the comparison's
|
|
438
|
+
evidence beside it (gitignored). The comparison needs Playwright's Chromium:
|
|
439
|
+
${PLAYWRIGHT_INSTALL_HINT}. When VERCEL_AUTOMATION_BYPASS_SECRET is set,
|
|
440
|
+
requests to --after and the deployment carry it. The agent key needs the
|
|
441
|
+
content-edit grant; on a spoke, AILK_SPOKE_KEY is needed too. The rollback
|
|
442
|
+
is create-ailk export-content (docs/internal/runbooks/site-cutover.md).
|
|
443
|
+
Exit 0 when every step passed (a dry run: when the import plan has no
|
|
444
|
+
failed page), 1 when one failed, 2 on a refusal.
|
|
445
|
+
`;
|
|
446
|
+
export function parseCutoverArgs(argv) {
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = parseArgs({
|
|
450
|
+
args: argv,
|
|
451
|
+
strict: true,
|
|
452
|
+
allowPositionals: true,
|
|
453
|
+
options: {
|
|
454
|
+
before: { type: "string" },
|
|
455
|
+
after: { type: "string" },
|
|
456
|
+
apply: { type: "boolean", default: false },
|
|
457
|
+
"env-target": { type: "string" },
|
|
458
|
+
"docs-path": { type: "string" },
|
|
459
|
+
"pixel-tolerance": { type: "string" },
|
|
460
|
+
"api-url": { type: "string" },
|
|
461
|
+
workspace: { type: "string" },
|
|
462
|
+
help: { type: "boolean", default: false, short: "h" },
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
throw new ParseError(`cutover: ${err.message}`);
|
|
468
|
+
}
|
|
469
|
+
const [target, ...rest] = parsed.positionals;
|
|
470
|
+
if (rest.length > 0) {
|
|
471
|
+
throw new ParseError(`cutover takes at most one target directory, got ${parsed.positionals.length} arguments`);
|
|
472
|
+
}
|
|
473
|
+
const envTarget = parsed.values["env-target"];
|
|
474
|
+
if (envTarget !== undefined && !ENV_TARGETS.includes(envTarget)) {
|
|
475
|
+
throw new ParseError("cutover: --env-target must be preview or production");
|
|
476
|
+
}
|
|
477
|
+
const tolerance = parsed.values["pixel-tolerance"];
|
|
478
|
+
const pixelTolerance = tolerance === undefined ? undefined : Number(tolerance);
|
|
479
|
+
if (pixelTolerance !== undefined && (tolerance.trim() === "" || Number.isNaN(pixelTolerance))) {
|
|
480
|
+
throw new ParseError("cutover: --pixel-tolerance must be a number from 0 to 1");
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
target,
|
|
484
|
+
before: parsed.values.before,
|
|
485
|
+
after: parsed.values.after,
|
|
486
|
+
apply: parsed.values.apply === true,
|
|
487
|
+
envTarget: envTarget,
|
|
488
|
+
docsPath: parsed.values["docs-path"],
|
|
489
|
+
pixelTolerance,
|
|
490
|
+
apiUrl: parsed.values["api-url"],
|
|
491
|
+
workspaceId: parsed.values.workspace,
|
|
492
|
+
help: parsed.values.help === true,
|
|
493
|
+
};
|
|
494
|
+
}
|
package/dist/design-presets.js
CHANGED
|
@@ -94,11 +94,14 @@ export function shippedPageTypes(siteTemplatesDir) {
|
|
|
94
94
|
// ── D3: validateDesignPresets ──────────────────────────────────────────────
|
|
95
95
|
/** The JSON Schema keywords the value check evaluates (spec § 1.2); `default` is not a constraint. */
|
|
96
96
|
const SUPPORTED_KEYWORDS = new Set([
|
|
97
|
+
"additionalProperties",
|
|
97
98
|
"anyOf",
|
|
98
99
|
"const",
|
|
99
100
|
"default",
|
|
100
101
|
"enum",
|
|
101
102
|
"exclusiveMinimum",
|
|
103
|
+
"maximum",
|
|
104
|
+
"minimum",
|
|
102
105
|
"properties",
|
|
103
106
|
"type",
|
|
104
107
|
]);
|
|
@@ -173,13 +176,26 @@ function checkValue(schema, value, field, label) {
|
|
|
173
176
|
if (typeof min === "number" && !(typeof value === "number" && value > min)) {
|
|
174
177
|
return { field, reason: `must be greater than ${min}` };
|
|
175
178
|
}
|
|
176
|
-
|
|
177
|
-
|
|
179
|
+
const minimum = schema["minimum"];
|
|
180
|
+
if (typeof minimum === "number" && !(typeof value === "number" && value >= minimum)) {
|
|
181
|
+
return { field, reason: `must be at least ${minimum}` };
|
|
182
|
+
}
|
|
183
|
+
const maximum = schema["maximum"];
|
|
184
|
+
if (typeof maximum === "number" && !(typeof value === "number" && value <= maximum)) {
|
|
185
|
+
return { field, reason: `must be at most ${maximum}` };
|
|
186
|
+
}
|
|
187
|
+
if ((isPlainObject(schema["properties"]) || hasOwn(schema, "additionalProperties")) &&
|
|
188
|
+
isPlainObject(value)) {
|
|
189
|
+
const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
|
|
190
|
+
// An unlisted sub-key is refused unless `additionalProperties` admits it
|
|
191
|
+
// (`true`, or a schema it is then checked against); absent or `false` refuses.
|
|
192
|
+
const extra = schema["additionalProperties"];
|
|
178
193
|
for (const [sub, subValue] of Object.entries(value)) {
|
|
179
|
-
|
|
194
|
+
const listed = hasOwn(properties, sub);
|
|
195
|
+
if (!listed && extra !== true && !isPlainObject(extra)) {
|
|
180
196
|
return { field: `${field}.${sub}`, reason: `is not a field of ${label}` };
|
|
181
197
|
}
|
|
182
|
-
const failure = checkValue(properties[sub], subValue, `${field}.${sub}`, `${label}.${sub}`);
|
|
198
|
+
const failure = checkValue(listed ? properties[sub] : extra, subValue, `${field}.${sub}`, `${label}.${sub}`);
|
|
183
199
|
if (failure !== undefined)
|
|
184
200
|
return failure;
|
|
185
201
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type WordsCodec } from "./inventory.js";
|
|
2
|
+
import { type LanguageItemKind } from "./language-import.js";
|
|
3
|
+
/** A refusal the CLI exits 2 on, thrown before any request is sent. */
|
|
4
|
+
export declare class ExportContentRefusedError extends Error {
|
|
5
|
+
readonly exitCode = 2;
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare const SPOKE_HINT = "the site's read does not serve this workspace's published version \u2014 is the spoke at --store database?";
|
|
9
|
+
/** The site-relative file a stored page is written to, and whether it is editorial. */
|
|
10
|
+
export declare function fileFor(targetDir: string, type: string, slug: string, locale: string): {
|
|
11
|
+
path: string;
|
|
12
|
+
editorial: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The file whose import yields `content` again with `sections` as its layout,
|
|
16
|
+
* or why there is none.
|
|
17
|
+
*/
|
|
18
|
+
export declare function recombine(content: Record<string, unknown>, sections: readonly unknown[] | undefined, codec: WordsCodec, editorial: boolean, pinned?: Record<LanguageItemKind, Map<string, Record<string, string>>>): {
|
|
19
|
+
text: string;
|
|
20
|
+
} | {
|
|
21
|
+
reason: string;
|
|
22
|
+
};
|
|
23
|
+
export interface ExportContentOptions {
|
|
24
|
+
targetDir: string;
|
|
25
|
+
/** `--dry-run`: print what would be written, write nothing. */
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
apiUrl?: string | undefined;
|
|
28
|
+
workspaceId?: string | undefined;
|
|
29
|
+
env?: NodeJS.ProcessEnv;
|
|
30
|
+
fetch?: typeof globalThis.fetch;
|
|
31
|
+
/** Injectable for tests; defaults to the site's installed codec. */
|
|
32
|
+
codec?: WordsCodec;
|
|
33
|
+
onLine?: (line: string) => void;
|
|
34
|
+
}
|
|
35
|
+
export interface ExportedPage {
|
|
36
|
+
type: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
/** The site-relative file; `null` when the page failed before one was chosen. */
|
|
39
|
+
file: string | null;
|
|
40
|
+
result: "created" | "written" | "unchanged" | "failed";
|
|
41
|
+
reason?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function exportContent(opts: ExportContentOptions): Promise<{
|
|
44
|
+
exitCode: 0 | 1;
|
|
45
|
+
pages: ExportedPage[];
|
|
46
|
+
}>;
|
|
47
|
+
export interface ExportContentArgs {
|
|
48
|
+
target: string | undefined;
|
|
49
|
+
dryRun: boolean;
|
|
50
|
+
apiUrl: string | undefined;
|
|
51
|
+
workspaceId: string | undefined;
|
|
52
|
+
help: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare const EXPORT_CONTENT_HELP = "create-ailk export-content \u2014 write every published page back to its content file.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk export-content [<dir>] [--dry-run] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --dry-run Print what would be written, and write nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL.\n --workspace <wksp_id> The workspace. Else AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nThe rollback for a cutover: every page the workspace list reports published\nin the default locale is written to the file migrate-content reads it from\n\u2014 content/en/<Type>/<slug>.mdx (or the legacy content/<Type>/<slug>.mdx that\nexists), apps/web/content/en/blog/<slug>.mdx for a post and the same under\ndocs for a docs page \u2014 with its words moved back into its sections, so a\nre-run of migrate-content reports every page unchanged. Published edits are\nkept. A page with a draft ahead of its published version fails by name:\npublish or revert it in Admin, then re-run. The site's own API read must\nserve the workspace's published version (on a spoke, the hub's row at\n--store database). Each page prints created, written, unchanged or failed.\nThe agent key needs the content-edit grant; on a spoke, AILK_SPOKE_KEY is\nneeded too. Exit 0 when no page failed, 1 when any did, 2 on a refusal.\n";
|
|
55
|
+
export declare function parseExportContentArgs(argv: string[]): ExportContentArgs;
|