create-ailk 0.28.1 → 0.29.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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * `importMdx` — one MDX→Artifact importer, idempotent on re-run (#10203).
3
+ *
4
+ * Reads a site's default-locale MDX pages through the site's own MDX adapter
5
+ * and writes each one as a `text/page` Artifact through the tenant-guarded
6
+ * workspace content routes. Per page (D3): read the draft; create on 404;
7
+ * PATCH under `If-Match` when the content differs; write nothing when it is
8
+ * the same — so a second run over the same files creates nothing and reports
9
+ * every page `unchanged`. A file the adapter rejects is `failed` with its path
10
+ * and reason and the run goes on. The seed step (U6b) and `migrate-content`
11
+ * (U6c) both call it; this module adds no command.
12
+ *
13
+ * create-ailk ships one runtime dependency, so the adapter is never imported:
14
+ * the caller injects it, and `loadSiteMdxAdapter` resolves the SITE's
15
+ * installed copy from its `apps/web` at run time (the #6937 precedent).
16
+ *
17
+ * The key travels only as `authorization: Bearer <key>`, never across a
18
+ * redirect (`redirect: "error"`), and is never printed. Of an API answer only
19
+ * a status and a `[a-z0-9_]` error code reach output (SEC-7876-S1).
20
+ *
21
+ * Spec: docs/specs/10203-import-mdx-one-mdx-artifact-spec.md.
22
+ */
23
+ import { existsSync, readdirSync, statSync } from "node:fs";
24
+ import { createRequire } from "node:module";
25
+ import { dirname, join, resolve } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { TopologyError, normalizeHubUrl } from "../topology.js";
28
+ /**
29
+ * The package the adapter comes from. Interpolated, not written inline, so no
30
+ * import specifier of a package create-ailk does not depend on appears here.
31
+ */
32
+ const CONTENT_ADAPTERS_PACKAGE = "@working-theory/content-adapters";
33
+ /** The adapter's locale shape (`BCP47_RE` in its `mdx/adapter.ts`). */
34
+ const LOCALE = /^[a-z]{2}(-[A-Z]{2})?$/;
35
+ /** `wksp_<uuidv7>` — the `@working-theory/ids` shape, restated: no runtime dep. */
36
+ const WORKSPACE_ID = /^wksp_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
37
+ /** A key is visible ASCII only: what an HTTP header can carry, with no space. */
38
+ const KEY_SHAPE = /^[\x21-\x7e]+$/;
39
+ /** The only API-supplied text printed: a code of this shape, else `unknown`. */
40
+ const SAFE_CODE = /^[a-z0-9_]{1,64}$/;
41
+ const REQUEST_TIMEOUT_MS = 15_000;
42
+ const ADAPTER_REASON_MAX = 300;
43
+ /** A 429 is retried this many times; the next one fails the page. */
44
+ const RATE_LIMIT_RETRIES = 3;
45
+ const RATE_LIMIT_WAIT_MAX_S = 60;
46
+ /** A refusal the CLI exits 2 on, thrown before any file is read or request sent. */
47
+ export class ImportMdxRefusedError extends Error {
48
+ exitCode = 2;
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "ImportMdxRefusedError";
52
+ }
53
+ }
54
+ // ── Refusals ────────────────────────────────────────────────────────────────
55
+ function refuse(message) {
56
+ throw new ImportMdxRefusedError(`import-mdx: ${message} Nothing was sent.`);
57
+ }
58
+ function checkOptions(opts, defaultLocale) {
59
+ let isDir;
60
+ try {
61
+ isDir = statSync(opts.contentRoot).isDirectory();
62
+ }
63
+ catch {
64
+ isDir = false;
65
+ }
66
+ if (!isDir)
67
+ refuse(`the content root ${opts.contentRoot} is not a directory.`);
68
+ if (!KEY_SHAPE.test(opts.apiKey)) {
69
+ refuse("the API key is empty or carries a space, a control character or a non-ASCII character.");
70
+ }
71
+ if (!WORKSPACE_ID.test(opts.workspaceId)) {
72
+ refuse("the workspace id is not a workspace id (wksp_<uuidv7>).");
73
+ }
74
+ if (!LOCALE.test(defaultLocale)) {
75
+ refuse("the default locale is not a locale such as en or pt-BR.");
76
+ }
77
+ try {
78
+ return normalizeHubUrl(opts.apiOrigin);
79
+ }
80
+ catch (err) {
81
+ if (!(err instanceof TopologyError))
82
+ throw err;
83
+ refuse("the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials.");
84
+ }
85
+ }
86
+ /** Every `.mdx` file and every link to one or to a directory below `root`; `_` entries unseen. */
87
+ function mdxFiles(root, prefix = []) {
88
+ return readdirSync(join(root, ...prefix), { withFileTypes: true }).flatMap((entry) => {
89
+ if (entry.name.startsWith("_"))
90
+ return [];
91
+ const segments = [...prefix, entry.name];
92
+ if (entry.isDirectory())
93
+ return mdxFiles(root, segments);
94
+ if (entry.isSymbolicLink()) {
95
+ let dir = false;
96
+ try {
97
+ dir = statSync(join(root, ...segments)).isDirectory();
98
+ }
99
+ catch {
100
+ // A dangling link: reported only when it is named like a page.
101
+ }
102
+ return dir || entry.name.endsWith(".mdx")
103
+ ? [{ segments, link: true }]
104
+ : [];
105
+ }
106
+ return entry.isFile() && entry.name.endsWith(".mdx")
107
+ ? [{ segments, link: false }]
108
+ : [];
109
+ });
110
+ }
111
+ function byTypeThenSlug(a, b) {
112
+ if (a.type !== b.type)
113
+ return a.type < b.type ? -1 : 1;
114
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
115
+ }
116
+ function plan(contentRoot, types, defaultLocale) {
117
+ const localePages = [];
118
+ const legacyPages = [];
119
+ const skipped = [];
120
+ for (const { segments, link } of mdxFiles(contentRoot)) {
121
+ const path = segments.join("/");
122
+ const slugOf = (file) => file.slice(0, -".mdx".length);
123
+ const [first, second, third] = segments;
124
+ if (link) {
125
+ skipped.push({
126
+ path,
127
+ result: "skipped",
128
+ reason: "a symbolic link is not followed",
129
+ });
130
+ }
131
+ else if (segments.length === 3 &&
132
+ first === defaultLocale &&
133
+ types.includes(second)) {
134
+ localePages.push({
135
+ path,
136
+ type: second,
137
+ slug: slugOf(third),
138
+ localeTree: true,
139
+ });
140
+ }
141
+ else if (segments.length === 2 && types.includes(first)) {
142
+ legacyPages.push({
143
+ path,
144
+ type: first,
145
+ slug: slugOf(second),
146
+ localeTree: false,
147
+ });
148
+ }
149
+ else if (segments.length > 1 &&
150
+ LOCALE.test(first) &&
151
+ first !== defaultLocale) {
152
+ skipped.push({
153
+ path,
154
+ result: "skipped",
155
+ reason: `locale ${first} is not imported (only ${defaultLocale})`,
156
+ });
157
+ }
158
+ else {
159
+ skipped.push({
160
+ path,
161
+ result: "skipped",
162
+ reason: "not a page: the adapter reads <locale>/<Type>/<slug>.mdx",
163
+ });
164
+ }
165
+ }
166
+ skipped.sort((a, b) => (a.path < b.path ? -1 : 1));
167
+ return {
168
+ pages: [
169
+ ...localePages.sort(byTypeThenSlug),
170
+ ...legacyPages.sort(byTypeThenSlug),
171
+ ],
172
+ skipped,
173
+ };
174
+ }
175
+ // ── "Unchanged" (D4) ────────────────────────────────────────────────────────
176
+ function sortKeys(value) {
177
+ if (Array.isArray(value))
178
+ return value.map(sortKeys);
179
+ if (value === null || typeof value !== "object")
180
+ return value;
181
+ return Object.fromEntries(Object.keys(value)
182
+ .sort()
183
+ .map((key) => [key, sortKeys(value[key])]));
184
+ }
185
+ /** JSON with keys sorted at every depth; `undefined` members and Date objects as JSON has them. */
186
+ function canonical(value) {
187
+ return JSON.stringify(sortKeys(JSON.parse(JSON.stringify(value ?? null))));
188
+ }
189
+ function safeCode(value) {
190
+ return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
191
+ }
192
+ function retryAfterSeconds(header) {
193
+ const seconds = header !== null && /^\d+$/.test(header) ? Number(header) : 0;
194
+ return seconds >= 1 && seconds <= RATE_LIMIT_WAIT_MAX_S
195
+ ? seconds
196
+ : RATE_LIMIT_WAIT_MAX_S;
197
+ }
198
+ async function call(t, method, url, body, ifMatch) {
199
+ for (let retry = 0;; retry++) {
200
+ let response;
201
+ try {
202
+ response = await t.fetch(url, {
203
+ method,
204
+ headers: {
205
+ authorization: `Bearer ${t.key}`,
206
+ ...(body !== undefined && { "content-type": "application/json" }),
207
+ ...(ifMatch !== undefined && { "if-match": ifMatch }),
208
+ },
209
+ ...(body !== undefined && { body: JSON.stringify(body) }),
210
+ // A redirect would re-send the key and the body to another URL.
211
+ redirect: "error",
212
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
213
+ });
214
+ }
215
+ catch (err) {
216
+ const name = err instanceof Error ? err.name.toLowerCase() : undefined;
217
+ return { network: `network ${safeCode(name)}` };
218
+ }
219
+ if (response.status === 429 && retry < RATE_LIMIT_RETRIES) {
220
+ const seconds = retryAfterSeconds(response.headers.get("retry-after"));
221
+ t.onLine(`rate limited — waiting ${seconds}s`);
222
+ await t.sleep(seconds * 1000);
223
+ continue;
224
+ }
225
+ let envelope = {};
226
+ try {
227
+ envelope = ((await response.json()) ?? {});
228
+ }
229
+ catch {
230
+ // No JSON body: the status alone is reported.
231
+ }
232
+ const data = envelope.data;
233
+ return {
234
+ status: response.status,
235
+ code: safeCode(envelope.error?.code),
236
+ data: data !== null && typeof data === "object"
237
+ ? data
238
+ : {},
239
+ };
240
+ }
241
+ }
242
+ function failed(path, reason, halt) {
243
+ return {
244
+ outcome: { path, result: "failed", reason },
245
+ ...(halt !== undefined && { halt }),
246
+ };
247
+ }
248
+ /** A non-success answer: 401/403 stops the run, anything else fails the page. */
249
+ function refusedAnswer(path, answer) {
250
+ if ("network" in answer)
251
+ return failed(path, answer.network);
252
+ const reason = `${answer.status} ${answer.code}`;
253
+ return answer.status === 401 || answer.status === 403
254
+ ? failed(path, reason, `not sent: the API refused the key (${reason})`)
255
+ : failed(path, reason);
256
+ }
257
+ /**
258
+ * A success answered for another locale: the API does not serve `locale`. A
259
+ * success with no locale at all is not the API's answer shape: that page fails.
260
+ */
261
+ function wrongLocale(path, answer, locale) {
262
+ if ("network" in answer || answer.data["locale"] === locale)
263
+ return undefined;
264
+ if (typeof answer.data["locale"] !== "string") {
265
+ return failed(path, `${answer.status} unknown`);
266
+ }
267
+ return failed(path, `the API does not serve locale ${locale}`, `not sent: the API does not serve locale ${locale}`);
268
+ }
269
+ async function writePage(t, page, locale, content) {
270
+ const { path, type, slug } = page;
271
+ const address = `${t.base}/content/${encodeURIComponent(type)}/${encodeURIComponent(slug)}?locale=${encodeURIComponent(locale)}`;
272
+ const done = (result) => ({
273
+ outcome: { path, result, type, slug },
274
+ });
275
+ for (let reread = false;; reread = true) {
276
+ const read = await call(t, "GET", address);
277
+ if ("network" in read)
278
+ return refusedAnswer(path, read);
279
+ if (read.status === 200) {
280
+ const mismatch = wrongLocale(path, read, locale);
281
+ if (mismatch !== undefined)
282
+ return mismatch;
283
+ const version = read.data["version"];
284
+ if (typeof version !== "string")
285
+ return failed(path, "200 unknown");
286
+ if (canonical(read.data["content"]) === canonical(content)) {
287
+ return done("unchanged");
288
+ }
289
+ // The token is the version just compared against, so a draft saved
290
+ // since the read is a 409, never a silent overwrite.
291
+ const saved = await call(t, "PATCH", address, content, version);
292
+ if ("network" in saved || saved.status !== 200)
293
+ return refusedAnswer(path, saved);
294
+ return wrongLocale(path, saved, locale) ?? done("updated");
295
+ }
296
+ if (read.status !== 404 || reread)
297
+ return refusedAnswer(path, read);
298
+ const created = await call(t, "POST", `${t.base}/content`, {
299
+ ...content,
300
+ locale,
301
+ });
302
+ if ("network" in created)
303
+ return refusedAnswer(path, created);
304
+ if (created.status === 201) {
305
+ return wrongLocale(path, created, locale) ?? done("created");
306
+ }
307
+ // 409: created by someone else since the read — read it once more.
308
+ if (created.status !== 409)
309
+ return refusedAnswer(path, created);
310
+ }
311
+ }
312
+ // ── The report (D6) ─────────────────────────────────────────────────────────
313
+ function outcomeLine(outcome) {
314
+ return "reason" in outcome
315
+ ? `${outcome.path}: ${outcome.result} — ${outcome.reason}`
316
+ : `${outcome.path}: ${outcome.result}`;
317
+ }
318
+ /** The whole report, one line per page and a final count line. */
319
+ export function formatImportMdxReport(result) {
320
+ const { created, updated, unchanged, skipped, failed: failures, } = result.counts;
321
+ return `${[
322
+ ...result.pages.map(outcomeLine),
323
+ `created ${created} · updated ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failures}`,
324
+ ].join("\n")}\n`;
325
+ }
326
+ // ── The importer ────────────────────────────────────────────────────────────
327
+ /** One whitespace-collapsed line of at most `ADAPTER_REASON_MAX` characters. */
328
+ function adapterReason(err) {
329
+ const message = err instanceof Error ? err.message : String(err);
330
+ return `adapter: ${message.replace(/\s+/g, " ").trim().slice(0, ADAPTER_REASON_MAX)}`;
331
+ }
332
+ export async function importMdx(opts) {
333
+ const defaultLocale = opts.defaultLocale ?? "en";
334
+ const origin = checkOptions(opts, defaultLocale);
335
+ const onLine = opts.onLine ?? (() => undefined);
336
+ const t = {
337
+ fetch: opts.fetch ?? globalThis.fetch,
338
+ base: `${origin}/v1/workspaces/${encodeURIComponent(opts.workspaceId)}`,
339
+ key: opts.apiKey,
340
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
341
+ onLine,
342
+ };
343
+ const contentRoot = resolve(opts.contentRoot);
344
+ const { pages, skipped } = plan(contentRoot, opts.adapters.SCHEMA_ORG_TYPES, defaultLocale);
345
+ const reader = opts.adapters.createMdxAdapter({ contentRoot, defaultLocale });
346
+ const outcomes = [];
347
+ const record = (outcome) => {
348
+ outcomes.push(outcome);
349
+ onLine(outcomeLine(outcome));
350
+ };
351
+ // Two pages at one (locale, slug) address would overwrite each other on
352
+ // every run, so the first in processing order wins and the rest fail.
353
+ const addresses = new Map();
354
+ let halt;
355
+ for (const page of pages) {
356
+ const holder = addresses.get(page.slug);
357
+ if (holder !== undefined) {
358
+ record({
359
+ path: page.path,
360
+ result: "failed",
361
+ reason: `same address as ${holder} (a page is addressed by locale and slug)`,
362
+ });
363
+ continue;
364
+ }
365
+ addresses.set(page.slug, page.path);
366
+ if (halt !== undefined) {
367
+ record({ path: page.path, result: "failed", reason: halt });
368
+ continue;
369
+ }
370
+ let source;
371
+ try {
372
+ source = page.localeTree
373
+ ? await reader.getPageSource(page.type, page.slug, defaultLocale)
374
+ : await reader.getPageSource(page.type, page.slug);
375
+ }
376
+ catch (err) {
377
+ record({ path: page.path, result: "failed", reason: adapterReason(err) });
378
+ continue;
379
+ }
380
+ if (source === null) {
381
+ record({
382
+ path: page.path,
383
+ result: "failed",
384
+ reason: "adapter: not read",
385
+ });
386
+ continue;
387
+ }
388
+ const result = await writePage(t, page, defaultLocale, {
389
+ ...source.frontmatter,
390
+ body: source.body,
391
+ });
392
+ halt = result.halt;
393
+ record(result.outcome);
394
+ }
395
+ skipped.forEach(record);
396
+ const counts = {
397
+ created: 0,
398
+ updated: 0,
399
+ unchanged: 0,
400
+ skipped: 0,
401
+ failed: 0,
402
+ };
403
+ for (const outcome of outcomes)
404
+ counts[outcome.result] += 1;
405
+ return { pages: outcomes, counts };
406
+ }
407
+ // ── The site's own adapter (D1) ─────────────────────────────────────────────
408
+ /** Whether a `node_modules` at or above `dir` holds the adapter package. */
409
+ function installedAbove(dir) {
410
+ const pkg = join("node_modules", ...CONTENT_ADAPTERS_PACKAGE.split("/"));
411
+ for (let at = dir;; at = dirname(at)) {
412
+ if (existsSync(join(at, pkg, "package.json")))
413
+ return true;
414
+ if (dirname(at) === at)
415
+ return false;
416
+ }
417
+ }
418
+ /**
419
+ * The site's installed adapter package, resolved from its `apps/web` — never
420
+ * create-ailk's own `node_modules`.
421
+ */
422
+ export async function loadSiteMdxAdapter(siteRoot) {
423
+ const web = join(resolve(siteRoot), "apps", "web");
424
+ let entry;
425
+ try {
426
+ // `resolve` falls back to NODE_PATH (which `pnpm exec` and `npx` set) once
427
+ // the site's own node_modules come up empty, so the package must first be
428
+ // found in one of those.
429
+ if (!installedAbove(web))
430
+ throw new Error("not installed");
431
+ entry = createRequire(join(web, "package.json")).resolve(CONTENT_ADAPTERS_PACKAGE);
432
+ }
433
+ catch {
434
+ throw new ImportMdxRefusedError(`import-mdx: ${CONTENT_ADAPTERS_PACKAGE} is not installed in ${web}. Install the site's dependencies (pnpm install) first. Nothing was sent.`);
435
+ }
436
+ let mod;
437
+ try {
438
+ mod = (await import(pathToFileURL(entry).href));
439
+ }
440
+ catch (err) {
441
+ const code = err?.code;
442
+ const name = err instanceof Error ? err.name : "error";
443
+ throw new ImportMdxRefusedError(`import-mdx: ${CONTENT_ADAPTERS_PACKAGE} in ${web} is installed but failed to load (${typeof code === "string" ? code : name}). Nothing was sent.`);
444
+ }
445
+ const types = mod.SCHEMA_ORG_TYPES;
446
+ if (typeof mod.createMdxAdapter !== "function" ||
447
+ !Array.isArray(types) ||
448
+ !types.every((type) => typeof type === "string")) {
449
+ throw new ImportMdxRefusedError(`import-mdx: ${CONTENT_ADAPTERS_PACKAGE} in ${web} does not export createMdxAdapter and SCHEMA_ORG_TYPES. Install the site's dependencies (pnpm install) first. Nothing was sent.`);
450
+ }
451
+ return mod;
452
+ }
@@ -0,0 +1,45 @@
1
+ import { type ImportMdxResult, type MdxAdapterModule } from "./lib/import-mdx.js";
2
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
3
+ export declare class MigrateContentRefusedError extends Error {
4
+ readonly exitCode = 2;
5
+ constructor(message: string);
6
+ }
7
+ export interface MigrateContentOptions {
8
+ /** The site. */
9
+ targetDir: string;
10
+ /** `--apply`: write. Without it nothing is written. */
11
+ apply?: boolean;
12
+ /** `--api-url`. */
13
+ apiUrl?: string | undefined;
14
+ /** `--workspace`. */
15
+ workspaceId?: string | undefined;
16
+ /** Injectable for tests; defaults to `process.env`. */
17
+ env?: NodeJS.ProcessEnv;
18
+ /** Injectable for tests; defaults to the site's installed adapter. */
19
+ adapters?: MdxAdapterModule;
20
+ /** Injectable for tests; defaults to `globalThis.fetch`. */
21
+ fetch?: typeof globalThis.fetch;
22
+ /** Injectable for tests; the wait before a rate-limited retry. */
23
+ sleep?: (ms: number) => Promise<void>;
24
+ /** Each report line as it happens; defaults to nothing. */
25
+ onLine?: (line: string) => void;
26
+ }
27
+ /**
28
+ * A fetch that sends `GET`s through `inner` and never sends a write: a `POST`
29
+ * is answered `201` and a `PATCH` `200` locally, with the one member of a
30
+ * write's answer `importMdx` reads (`data.locale`). Any other method throws.
31
+ */
32
+ export declare function readOnlyFetch(inner: typeof globalThis.fetch, locale: string): typeof globalThis.fetch;
33
+ export declare function migrateContent(opts: MigrateContentOptions): Promise<{
34
+ result: ImportMdxResult;
35
+ exitCode: 0 | 1;
36
+ }>;
37
+ export interface MigrateContentArgs {
38
+ target: string | undefined;
39
+ apply: boolean;
40
+ apiUrl: string | undefined;
41
+ workspaceId: string | undefined;
42
+ help: boolean;
43
+ }
44
+ export declare const MIGRATE_CONTENT_HELP = "create-ailk migrate-content \u2014 import an existing site's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --apply Write the pages. Without it the command reads the\n workspace, prints what it would do, and writes\n nothing.\n --api-url <origin> The API origin that holds the workspace \u2014 on a\n spoke, its hub's. Else AILK_API_URL. https: only\n (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page (and each legacy\ncontent/<Type>/<slug>.mdx) is written to the workspace as a draft through\nthe API's content routes, with the agent key, which is read from\nAILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,\nso the live site does not change. The site needs no .ailk-site-manifest.json\nand may be a spoke; its dependencies must be installed.\n\nA dry run prints create, update, unchanged, skipped or failed per page. It\ncannot see a write the API would refuse at --apply (a conflict or a server\nerror), and a page whose content fails its page type is planned and saved\nwith errors the editor shows, not failed. --apply prints created, updated,\nunchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no\npage failed, 1 when any did, 2 on a refusal (nothing is sent).\n";
45
+ export declare function parseMigrateContentArgs(argv: string[]): MigrateContentArgs;