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,215 @@
1
+ /**
2
+ * `create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>]
3
+ * [--workspace <id>]` — import an existing site's MDX pages into its
4
+ * workspace (#10207).
5
+ *
6
+ * The same import as `seed-workspace` — `importMdx` over `<dir>/content` with
7
+ * the site's own installed adapter — for the site `seed-workspace` refuses: one
8
+ * with no `.ailk-site-manifest.json`, or a spoke, whose workspace lives on its
9
+ * hub (D1). No manifest and no topology is read; the operator names the API
10
+ * origin and the workspace, which is all the topology would decide.
11
+ *
12
+ * A dry run is the default (D3): the importer runs over `readOnlyFetch`, which
13
+ * passes each `GET` through and answers a `POST` or `PATCH` locally without
14
+ * sending it, so the plan — create · update · unchanged · skipped · failed — is
15
+ * decided by the importer itself against the live workspace, and no write can
16
+ * leave the process. `--apply` writes; a second `--apply` reports every page
17
+ * `unchanged`.
18
+ *
19
+ * Credentials follow `seed-workspace` (#10204 D2): the origin from
20
+ * `--api-url`, else `AILK_API_URL`; the workspace id from `--workspace`, else
21
+ * `AILK_WORKSPACE_ID`; the agent key from `AILK_AGENT_KEY` and nowhere else.
22
+ * A missing or malformed credential is refused before the adapter is loaded;
23
+ * every refusal, the importer's included, is thrown before any request.
24
+ *
25
+ * Pure module: no `import.meta.url`, so jest can import it directly. Spec:
26
+ * docs/specs/10207-create-ailk-migrate-content-import-spec.md.
27
+ */
28
+ import { join, resolve } from "node:path";
29
+ import { parseArgs } from "node:util";
30
+ import { formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
31
+ import { ParseError } from "./parse-args.js";
32
+ import { AGENT_KEY_ENV_VAR, WORKSPACE_ID_ENV_VAR } from "./seed-workspace.js";
33
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, } from "./topology.js";
34
+ /** The locale imported: the importer's default. */
35
+ const LOCALE = "en";
36
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
37
+ export class MigrateContentRefusedError extends Error {
38
+ exitCode = 2;
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "MigrateContentRefusedError";
42
+ }
43
+ }
44
+ function refuse(message) {
45
+ throw new MigrateContentRefusedError(`migrate-content: ${message} Nothing was sent.`);
46
+ }
47
+ /** A value given, or `undefined` for an absent or empty one. */
48
+ function given(value) {
49
+ const trimmed = value?.trim();
50
+ return trimmed === undefined || trimmed === "" ? undefined : trimmed;
51
+ }
52
+ // ── The dry run's transport (D3) ────────────────────────────────────────────
53
+ /**
54
+ * A fetch that sends `GET`s through `inner` and never sends a write: a `POST`
55
+ * is answered `201` and a `PATCH` `200` locally, with the one member of a
56
+ * write's answer `importMdx` reads (`data.locale`). Any other method throws.
57
+ */
58
+ export function readOnlyFetch(inner, locale) {
59
+ return (async (input, init) => {
60
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
61
+ if (method === "GET")
62
+ return inner(input, init);
63
+ if (method !== "POST" && method !== "PATCH") {
64
+ throw new Error(`a dry run sends no ${method} request`);
65
+ }
66
+ return new Response(JSON.stringify({ ok: true, data: { locale } }), {
67
+ status: method === "POST" ? 201 : 200,
68
+ headers: { "content-type": "application/json" },
69
+ });
70
+ });
71
+ }
72
+ // ── The plan (D4) ───────────────────────────────────────────────────────────
73
+ /** A dry run's verb for what `--apply` would do. */
74
+ const PLAN_VERB = {
75
+ created: "create",
76
+ updated: "update",
77
+ unchanged: "unchanged",
78
+ skipped: "skipped",
79
+ failed: "failed",
80
+ };
81
+ function planLine(outcome) {
82
+ const verb = PLAN_VERB[outcome.result];
83
+ return "reason" in outcome
84
+ ? `${outcome.path}: ${verb} — ${outcome.reason}`
85
+ : `${outcome.path}: ${verb}`;
86
+ }
87
+ // ── The command (D1–D4) ─────────────────────────────────────────────────────
88
+ export async function migrateContent(opts) {
89
+ const targetDir = resolve(opts.targetDir);
90
+ const env = opts.env ?? process.env;
91
+ const onLine = opts.onLine ?? (() => undefined);
92
+ const apply = opts.apply === true;
93
+ const apiKey = given(env[AGENT_KEY_ENV_VAR]);
94
+ if (apiKey === undefined) {
95
+ refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
96
+ }
97
+ const workspaceId = given(opts.workspaceId) ?? given(env[WORKSPACE_ID_ENV_VAR]);
98
+ if (workspaceId === undefined) {
99
+ refuse(`no workspace id was given. Pass --workspace <wksp_id>, or set ${WORKSPACE_ID_ENV_VAR}.`);
100
+ }
101
+ const flagOrigin = given(opts.apiUrl);
102
+ const source = flagOrigin !== undefined ? "--api-url" : HUB_ORIGIN_ENV_VAR;
103
+ const rawOrigin = flagOrigin ?? given(env[HUB_ORIGIN_ENV_VAR]);
104
+ if (rawOrigin === undefined) {
105
+ refuse(`no API origin was given. Pass --api-url <origin>, or set ${HUB_ORIGIN_ENV_VAR}.`);
106
+ }
107
+ let origin;
108
+ try {
109
+ origin = normalizeHubUrl(rawOrigin);
110
+ }
111
+ catch (err) {
112
+ if (!(err instanceof TopologyError))
113
+ throw err;
114
+ refuse(`the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials (from ${source}).`);
115
+ }
116
+ const at = `workspace ${workspaceId} at ${origin} (from ${source}).`;
117
+ onLine(apply
118
+ ? `Migrating ${opts.targetDir} into ${at}`
119
+ : `Planning ${opts.targetDir} against ${at} Nothing is written without --apply.`);
120
+ const adapters = opts.adapters ?? (await loadSiteMdxAdapter(targetDir));
121
+ const fetch = opts.fetch ?? globalThis.fetch;
122
+ const result = await importMdx({
123
+ contentRoot: join(targetDir, "content"),
124
+ adapters,
125
+ apiOrigin: origin,
126
+ workspaceId,
127
+ apiKey,
128
+ defaultLocale: LOCALE,
129
+ fetch: apply ? fetch : readOnlyFetch(fetch, LOCALE),
130
+ ...(opts.sleep !== undefined && { sleep: opts.sleep }),
131
+ // A dry run's importer lines say `created`/`updated`, which nothing was.
132
+ ...(apply && { onLine }),
133
+ });
134
+ const { failed } = result.counts;
135
+ if (apply) {
136
+ onLine(formatImportMdxReport({ pages: [], counts: result.counts }).trimEnd());
137
+ if (failed > 0) {
138
+ onLine(`${failed} page(s) were not migrated. Fix them and re-run: create-ailk migrate-content ${opts.targetDir} --apply`);
139
+ }
140
+ }
141
+ else {
142
+ const { created, updated, unchanged, skipped } = result.counts;
143
+ for (const outcome of result.pages)
144
+ onLine(planLine(outcome));
145
+ onLine(`create ${created} · update ${updated} · unchanged ${unchanged} · skipped ${skipped} · failed ${failed}`);
146
+ onLine("Dry run: nothing was written. Re-run with --apply to write these pages.");
147
+ if (failed > 0) {
148
+ onLine(`${failed} page(s) would fail. Fix them before --apply.`);
149
+ }
150
+ }
151
+ return { result, exitCode: failed > 0 ? 1 : 0 };
152
+ }
153
+ export const MIGRATE_CONTENT_HELP = `create-ailk migrate-content — import an existing site's pages into its workspace.
154
+
155
+ Usage:
156
+ AILK_AGENT_KEY=<key> create-ailk migrate-content [<dir>] [--apply] [--api-url <origin>] [--workspace <wksp_id>]
157
+
158
+ Arguments:
159
+ <dir> The site. Defaults to the current dir.
160
+
161
+ Options:
162
+ --apply Write the pages. Without it the command reads the
163
+ workspace, prints what it would do, and writes
164
+ nothing.
165
+ --api-url <origin> The API origin that holds the workspace — on a
166
+ spoke, its hub's. Else AILK_API_URL. https: only
167
+ (http: on localhost or 127.0.0.1).
168
+ --workspace <wksp_id> The workspace the pages are written to. Else
169
+ AILK_WORKSPACE_ID.
170
+ -h, --help Show this help.
171
+
172
+ Each content/en/<Type>/<slug>.mdx page (and each legacy
173
+ content/<Type>/<slug>.mdx) is written to the workspace as a draft through
174
+ the API's content routes, with the agent key, which is read from
175
+ AILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,
176
+ so the live site does not change. The site needs no .ailk-site-manifest.json
177
+ and may be a spoke; its dependencies must be installed.
178
+
179
+ A dry run prints create, update, unchanged, skipped or failed per page. It
180
+ cannot see a write the API would refuse at --apply (a conflict or a server
181
+ error), and a page whose content fails its page type is planned and saved
182
+ with errors the editor shows, not failed. --apply prints created, updated,
183
+ unchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no
184
+ page failed, 1 when any did, 2 on a refusal (nothing is sent).
185
+ `;
186
+ export function parseMigrateContentArgs(argv) {
187
+ let parsed;
188
+ try {
189
+ parsed = parseArgs({
190
+ args: argv,
191
+ strict: true,
192
+ allowPositionals: true,
193
+ options: {
194
+ apply: { type: "boolean", default: false },
195
+ "api-url": { type: "string" },
196
+ workspace: { type: "string" },
197
+ help: { type: "boolean", default: false, short: "h" },
198
+ },
199
+ });
200
+ }
201
+ catch (err) {
202
+ throw new ParseError(`migrate-content: ${err.message}`);
203
+ }
204
+ const [target, ...rest] = parsed.positionals;
205
+ if (rest.length > 0) {
206
+ throw new ParseError(`migrate-content takes at most one target directory, got ${parsed.positionals.length} arguments`);
207
+ }
208
+ return {
209
+ target,
210
+ apply: parsed.values.apply === true,
211
+ apiUrl: parsed.values["api-url"],
212
+ workspaceId: parsed.values.workspace,
213
+ help: parsed.values.help === true,
214
+ };
215
+ }
@@ -0,0 +1,71 @@
1
+ import { type ImportMdxResult, type MdxAdapterModule } from "./lib/import-mdx.js";
2
+ import { type Topology } from "./topology.js";
3
+ /** The environment variable the agent key is read from — and nowhere else. */
4
+ export declare const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
5
+ /** The environment variable the workspace id is read from when no flag names one. */
6
+ export declare const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
7
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
8
+ export declare class SeedWorkspaceRefusedError extends Error {
9
+ readonly exitCode = 2;
10
+ constructor(message: string);
11
+ }
12
+ export interface SeedWorkspaceOptions {
13
+ /** The site. */
14
+ targetDir: string;
15
+ /** `--api-url`. */
16
+ apiUrl?: string | undefined;
17
+ /** `--workspace`. */
18
+ workspaceId?: string | undefined;
19
+ /** Injectable for tests; defaults to `process.env`. */
20
+ env?: NodeJS.ProcessEnv;
21
+ /** Injectable for tests; defaults to the site's installed adapter. */
22
+ adapters?: MdxAdapterModule;
23
+ /** Injectable for tests; defaults to `globalThis.fetch`. */
24
+ fetch?: typeof globalThis.fetch;
25
+ /** Injectable for tests; the wait before a rate-limited retry. */
26
+ sleep?: (ms: number) => Promise<void>;
27
+ /** Each report line as it happens; defaults to nothing. */
28
+ onLine?: (line: string) => void;
29
+ }
30
+ export declare function seedWorkspace(opts: SeedWorkspaceOptions): Promise<{
31
+ result: ImportMdxResult;
32
+ exitCode: 0 | 1;
33
+ }>;
34
+ export type ScaffoldSeedPlan = {
35
+ kind: "not-applicable";
36
+ } | {
37
+ kind: "skip";
38
+ reason: string;
39
+ } | {
40
+ kind: "seed";
41
+ };
42
+ export interface ScaffoldSeedPlanInput {
43
+ topology: Topology | undefined;
44
+ /** The scaffold's effective module set. */
45
+ modules: readonly string[];
46
+ /** Whether `--install` ran. */
47
+ installed: boolean;
48
+ env: NodeJS.ProcessEnv;
49
+ }
50
+ /** What the scaffold does about seeding, decided in D3's order. */
51
+ export declare function scaffoldSeedPlan(input: ScaffoldSeedPlanInput): ScaffoldSeedPlan;
52
+ export interface RunScaffoldSeedOptions extends ScaffoldSeedPlanInput {
53
+ targetDir: string;
54
+ onLine: (line: string) => void;
55
+ adapters?: MdxAdapterModule;
56
+ fetch?: typeof globalThis.fetch;
57
+ sleep?: (ms: number) => Promise<void>;
58
+ }
59
+ /**
60
+ * The whole scaffold-time step: plan, then print the skip or run the seed. A
61
+ * refusal is printed, never thrown, and sets no exit code (D6).
62
+ */
63
+ export declare function runScaffoldSeed(opts: RunScaffoldSeedOptions): Promise<void>;
64
+ export interface SeedWorkspaceArgs {
65
+ target: string | undefined;
66
+ apiUrl: string | undefined;
67
+ workspaceId: string | undefined;
68
+ help: boolean;
69
+ }
70
+ export declare const SEED_WORKSPACE_HELP = "create-ailk seed-workspace \u2014 import a starter's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --api-url <origin> The site's API origin. Else AILK_API_URL. https:\n only (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 is written to the workspace as a\ndraft through the API's content routes, with the agent key, which is read\nfrom AILK_AGENT_KEY only and needs the content-edit grant. Each page prints\ncreated, updated, unchanged, skipped or failed, so a re-run is safe. The\nsite's dependencies must be installed. Exit 0 when every page was seeded, 1\nwhen any failed, 2 on a refusal (nothing is sent).\n";
71
+ export declare function parseSeedWorkspaceArgs(argv: string[]): SeedWorkspaceArgs;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <id>]`
3
+ * — import a starter scaffold's pages into its workspace (#10204), and the
4
+ * scaffold-time step that runs it at the end of `pnpm create ailk`.
5
+ *
6
+ * A fresh scaffold's `content/` tree is exactly its roster (spec §1), so the
7
+ * seed is `importMdx` over `<dir>/content` with the site's own installed
8
+ * adapter (D1): one `text/page` Artifact per page, and a re-run writes
9
+ * nothing and reports every page `unchanged`.
10
+ *
11
+ * The API origin comes from `--api-url`, else `AILK_API_URL`; the workspace
12
+ * id from `--workspace`, else `AILK_WORKSPACE_ID`; the agent key from
13
+ * `AILK_AGENT_KEY` and nowhere else — no flag and no file decides where the
14
+ * key goes (D2, the `seed-flows` rule, #9969 D8). Every refusal is thrown
15
+ * before the adapter reads a file and before any request; the origin and its
16
+ * source are printed before the first one. The importer owns the transport
17
+ * (#10203 D5): bearer header only, no redirect, the key never printed.
18
+ *
19
+ * The scaffold-time step (D3, D6) runs only on a hub with an Admin
20
+ * (`workspace` or `multi-tenant`), after `--install`, with all three
21
+ * variables set; otherwise it prints why and the command that seeds later,
22
+ * or nothing on a site with no Admin. It never fails the scaffold.
23
+ *
24
+ * Pure module: no `import.meta.url`, so jest can import it directly. `fetch`,
25
+ * `sleep`, `env` and the adapter are injectable. Spec:
26
+ * docs/specs/10204-seed-the-starter-s-roster-spec.md.
27
+ */
28
+ import { join, resolve } from "node:path";
29
+ import { parseArgs } from "node:util";
30
+ import { AddModuleRefusedError } from "./add-module-core.js";
31
+ import { ImportMdxRefusedError, formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
32
+ import { ParseError } from "./parse-args.js";
33
+ import { readSiteRoster } from "./site-roster.js";
34
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
35
+ /** The environment variable the agent key is read from — and nowhere else. */
36
+ export const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
37
+ /** The environment variable the workspace id is read from when no flag names one. */
38
+ export const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
39
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
40
+ export class SeedWorkspaceRefusedError extends Error {
41
+ exitCode = 2;
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "SeedWorkspaceRefusedError";
45
+ }
46
+ }
47
+ function refuse(message) {
48
+ throw new SeedWorkspaceRefusedError(`seed-workspace: ${message} Nothing was sent.`);
49
+ }
50
+ /** A value given, or `undefined` for an absent or empty one (D2). */
51
+ function given(value) {
52
+ const trimmed = value?.trim();
53
+ return trimmed === undefined || trimmed === "" ? undefined : trimmed;
54
+ }
55
+ // ── The command (D4) ────────────────────────────────────────────────────────
56
+ export async function seedWorkspace(opts) {
57
+ const targetDir = resolve(opts.targetDir);
58
+ const env = opts.env ?? process.env;
59
+ const onLine = opts.onLine ?? (() => undefined);
60
+ let roster;
61
+ try {
62
+ roster = readSiteRoster(targetDir);
63
+ }
64
+ catch (err) {
65
+ if (!(err instanceof AddModuleRefusedError))
66
+ throw err;
67
+ refuse(`${err.message}.`);
68
+ }
69
+ if (roster === undefined) {
70
+ refuse(`${opts.targetDir} has no .ailk-site-manifest.json, so it is not a starter scaffold.`);
71
+ }
72
+ let topology;
73
+ try {
74
+ topology = readSiteTopology(targetDir);
75
+ }
76
+ catch (err) {
77
+ if (!(err instanceof TopologyError))
78
+ throw err;
79
+ refuse(`${err.message}.`);
80
+ }
81
+ if (topology === "spoke") {
82
+ refuse(`${opts.targetDir} is a spoke: its pages are its own MDX files, and its hub owns the workspace.`);
83
+ }
84
+ const apiKey = given(env[AGENT_KEY_ENV_VAR]);
85
+ if (apiKey === undefined) {
86
+ refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
87
+ }
88
+ const workspaceId = given(opts.workspaceId) ?? given(env[WORKSPACE_ID_ENV_VAR]);
89
+ if (workspaceId === undefined) {
90
+ refuse(`no workspace id was given. Pass --workspace <wksp_id>, or set ${WORKSPACE_ID_ENV_VAR}.`);
91
+ }
92
+ const flagOrigin = given(opts.apiUrl);
93
+ const source = flagOrigin !== undefined ? "--api-url" : HUB_ORIGIN_ENV_VAR;
94
+ const rawOrigin = flagOrigin ?? given(env[HUB_ORIGIN_ENV_VAR]);
95
+ if (rawOrigin === undefined) {
96
+ refuse(`no API origin was given. Pass --api-url <origin>, or set ${HUB_ORIGIN_ENV_VAR}.`);
97
+ }
98
+ let origin;
99
+ try {
100
+ origin = normalizeHubUrl(rawOrigin);
101
+ }
102
+ catch (err) {
103
+ if (!(err instanceof TopologyError))
104
+ throw err;
105
+ refuse(`the API origin must be an https: origin (http: only on a loopback host) with no path, query, fragment or credentials (from ${source}).`);
106
+ }
107
+ onLine(`Seeding ${opts.targetDir} into workspace ${workspaceId} at ${origin} (from ${source}).`);
108
+ const adapters = opts.adapters ?? (await loadSiteMdxAdapter(targetDir));
109
+ const result = await importMdx({
110
+ contentRoot: join(targetDir, "content"),
111
+ adapters,
112
+ apiOrigin: origin,
113
+ workspaceId,
114
+ apiKey,
115
+ ...(opts.fetch !== undefined && { fetch: opts.fetch }),
116
+ ...(opts.sleep !== undefined && { sleep: opts.sleep }),
117
+ onLine,
118
+ });
119
+ // The report's count line alone: no page rows.
120
+ onLine(formatImportMdxReport({ pages: [], counts: result.counts }).trimEnd());
121
+ const { failed } = result.counts;
122
+ if (failed > 0) {
123
+ onLine(`${failed} page(s) were not seeded. Fix them and re-run: create-ailk seed-workspace ${opts.targetDir}`);
124
+ }
125
+ return { result, exitCode: failed > 0 ? 1 : 0 };
126
+ }
127
+ const SEED_CREDENTIALS = [
128
+ HUB_ORIGIN_ENV_VAR,
129
+ WORKSPACE_ID_ENV_VAR,
130
+ AGENT_KEY_ENV_VAR,
131
+ ];
132
+ /** `A`, `A and B`, `A, B and C`. */
133
+ function joinNames(names) {
134
+ return names.length <= 1
135
+ ? names.join("")
136
+ : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
137
+ }
138
+ /** What the scaffold does about seeding, decided in D3's order. */
139
+ export function scaffoldSeedPlan(input) {
140
+ if (input.topology === "spoke")
141
+ return { kind: "not-applicable" };
142
+ if (!input.modules.includes("workspace") &&
143
+ !input.modules.includes("multi-tenant")) {
144
+ return { kind: "not-applicable" };
145
+ }
146
+ const unset = SEED_CREDENTIALS.filter((name) => given(input.env[name]) === undefined);
147
+ if (unset.length > 0) {
148
+ return {
149
+ kind: "skip",
150
+ reason: `Workspace seeding skipped: ${joinNames(unset)} ${unset.length === 1 ? "is" : "are"} not set.`,
151
+ };
152
+ }
153
+ if (!input.installed) {
154
+ return {
155
+ kind: "skip",
156
+ reason: "Workspace seeding skipped: the site's dependencies are not installed (pass --install, or run pnpm install first).",
157
+ };
158
+ }
159
+ return { kind: "seed" };
160
+ }
161
+ /**
162
+ * The whole scaffold-time step: plan, then print the skip or run the seed. A
163
+ * refusal is printed, never thrown, and sets no exit code (D6).
164
+ */
165
+ export async function runScaffoldSeed(opts) {
166
+ const plan = scaffoldSeedPlan(opts);
167
+ if (plan.kind === "not-applicable")
168
+ return;
169
+ if (plan.kind === "skip") {
170
+ opts.onLine(plan.reason);
171
+ opts.onLine(` To seed it later: ${AGENT_KEY_ENV_VAR}=<key> create-ailk seed-workspace ${opts.targetDir} --api-url <origin> --workspace <wksp_id>`);
172
+ return;
173
+ }
174
+ opts.onLine("Seeding the workspace:");
175
+ try {
176
+ await seedWorkspace({
177
+ targetDir: opts.targetDir,
178
+ env: opts.env,
179
+ onLine: opts.onLine,
180
+ ...(opts.adapters !== undefined && { adapters: opts.adapters }),
181
+ ...(opts.fetch !== undefined && { fetch: opts.fetch }),
182
+ ...(opts.sleep !== undefined && { sleep: opts.sleep }),
183
+ });
184
+ }
185
+ catch (err) {
186
+ if (!(err instanceof SeedWorkspaceRefusedError) &&
187
+ !(err instanceof ImportMdxRefusedError)) {
188
+ throw err;
189
+ }
190
+ opts.onLine(err.message);
191
+ }
192
+ }
193
+ export const SEED_WORKSPACE_HELP = `create-ailk seed-workspace — import a starter's pages into its workspace.
194
+
195
+ Usage:
196
+ AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]
197
+
198
+ Arguments:
199
+ <dir> The site. Defaults to the current dir.
200
+
201
+ Options:
202
+ --api-url <origin> The site's API origin. Else AILK_API_URL. https:
203
+ only (http: on localhost or 127.0.0.1).
204
+ --workspace <wksp_id> The workspace the pages are written to. Else
205
+ AILK_WORKSPACE_ID.
206
+ -h, --help Show this help.
207
+
208
+ Each content/en/<Type>/<slug>.mdx page is written to the workspace as a
209
+ draft through the API's content routes, with the agent key, which is read
210
+ from AILK_AGENT_KEY only and needs the content-edit grant. Each page prints
211
+ created, updated, unchanged, skipped or failed, so a re-run is safe. The
212
+ site's dependencies must be installed. Exit 0 when every page was seeded, 1
213
+ when any failed, 2 on a refusal (nothing is sent).
214
+ `;
215
+ export function parseSeedWorkspaceArgs(argv) {
216
+ let parsed;
217
+ try {
218
+ parsed = parseArgs({
219
+ args: argv,
220
+ strict: true,
221
+ allowPositionals: true,
222
+ options: {
223
+ "api-url": { type: "string" },
224
+ workspace: { type: "string" },
225
+ help: { type: "boolean", default: false, short: "h" },
226
+ },
227
+ });
228
+ }
229
+ catch (err) {
230
+ throw new ParseError(`seed-workspace: ${err.message}`);
231
+ }
232
+ const [target, ...rest] = parsed.positionals;
233
+ if (rest.length > 0) {
234
+ throw new ParseError(`seed-workspace takes at most one target directory, got ${parsed.positionals.length} arguments`);
235
+ }
236
+ return {
237
+ target,
238
+ apiUrl: parsed.values["api-url"],
239
+ workspaceId: parsed.values.workspace,
240
+ help: parsed.values.help === true,
241
+ };
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.50.0",
32
- "@working-theory/templates": "0.50.0"
31
+ "@working-theory/schema": "0.51.0",
32
+ "@working-theory/templates": "0.51.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -119,7 +119,7 @@ Routes are versioned under `/v1/*`:
119
119
 
120
120
  **`AILK_OPERATOR_EMAIL` is REQUIRED for a `workspace`-module deployment** (#5515 item 1), exactly as it already is for the review dashboard. On a build with the `workspace` module and WITHOUT `multi-tenant`, `server.ts`'s `tenantPreHandlerForBuild()` selects the base guard (`src/middleware/default-tenant.ts` → `resolveDefaultTenantForOperator`), which fails CLOSED when the pin is unset: every `/v1/workspaces/*` route answers 403 to everyone — so the module's Admin pages (`apps/web/app/[locale]/(authed)/admin*`, renamed from `workspace` by #5637) render their shared `ForbiddenState` — and — the reason this matters — the TOFU operator bind (`services/default-tenant.ts#bindOperatorMembership`, #3631) is never reached, so the first account to load the settings page cannot seat ITSELF as the sole owner off a plain GET. With `multi-tenant` present, a session that has activated a workspace resolves from it and its Membership, unchanged; a session with NO active workspace (every `apps/web` request) goes through the same `resolveDefaultTenantForOperator`, so on a one-workspace deployment the pin is REQUIRED there too — unset, nobody is seated and those requests answer 403 (#7523). The `kind: "all"` waitlist read likewise needs the caller to BE the pin, not merely a sentinel-org owner. `.env.example` § Single-tenant operator pin carries the operator-facing copy; `src/middleware/__tests__/default-tenant.test.ts` § "#5515 item 1" is the objective record.
121
121
 
122
- The workspace site-config routes are **identity-bound tenant routes** (guarded by the tenant preHandler; the write scope is the caller's active workspace, never a body-supplied id). Like the sibling `/v1/workspaces/*` routes they have **no anonymous MCP twin** — the OSS MCP surface is identity-blind (`apps/mcp/CLAUDE.md`), so the "1:1 MCP tool" rule above does not apply to them; the tenant-scoped authed MCP surface is tracked in #3591. The `/v1/billing/*` and `/v1/waitlist-experiments/*` routes are the same shape — identity-bound tenant routes, admin/settings-facing rather than agent-facing, no MCP twin.
122
+ The workspace site-config routes are **identity-bound tenant routes** (guarded by the tenant preHandler; the scope is the workspace the request names — the path's `:workspaceId`, or on a route with none the required `x-ailk-workspace-scope` header — checked against the caller's Membership on every request, never a body-supplied id and never the session's old active-workspace pointer, #10165). Like the sibling `/v1/workspaces/*` routes they have **no anonymous MCP twin** — the OSS MCP surface is identity-blind (`apps/mcp/CLAUDE.md`), so the "1:1 MCP tool" rule above does not apply to them; the tenant-scoped authed MCP surface is tracked in #3591. The `/v1/billing/*` and `/v1/waitlist-experiments/*` routes are the same shape — identity-bound tenant routes, admin/settings-facing rather than agent-facing, no MCP twin.
123
123
 
124
124
  AEO Layer 1 scoring runs both ways: locally as a CLI command (`ailk audit`, filesystem content-root input) AND behind the anonymous `/v1/aeo/score` HTTP endpoint above (external-URL input, #4666) — the CLI's content-root audit and the route's crawler-fed score are two different input paths over the same underlying scorer, not a contradiction.
125
125
 
@@ -124,16 +124,18 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
124
124
  **Maintenance (2):** `audit`, `create_project` (stdio only, #9965 D13)
125
125
  **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has. `submit_flow_step` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP`, default 50 — #8071)
126
126
  **Booking (4):** `list_availability`, `get_event_meta`, `list_meetings`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`. `create_booking` takes the HTTP write branch (its own daily cap, `MCP_ABUSE_CREATE_BOOKING_DAILY_CAP`, default 20, and an `attendee.email` format check — #8071). `list_meetings` (#8843 D5) twins `GET /v1/schedule/meetings`: it takes no argument and returns the site's Live meetings — slug, visibility, position and whether each can be booked, plus a bookable one's calLink, title, length, description and price; `private` means unlisted, not secret
127
+ **Redirect (1):** `get_redirect` (#10202 D8) — module-gated (`module: "content-write"`) and licence-feature-tagged (`feature: "workspace"`, the content-write claim), gated on the deployment's `workspace` licence feature under `MCP_FEATURE_GATE_MODE`. It twins `GET /v1/redirects`: given an old site path (and an optional locale) it returns where the site permanently redirects it — `{ from, to, permanent: true }` — or the route's not-found body. Its handler rides `@working-theory/mcp-workspace`'s anonymous `tools` (`redirect.ts`), the pack the `workspace` and `multi-tenant` modules carry, and calls the module-level `apiClient` with no agent key
127
128
 
128
- ### Why the parity test counts 21 and a keyless server registers 19 (20 over stdio once Free is released)
129
+ ### Why the parity test counts 22 and a keyless server registers 20 (21 over stdio once Free is released)
129
130
 
130
- - **21 in the `TOOLS` array / parity test**: the `TOOLS` array in
131
- `packages/metadata/src/mcp-tool-catalog.ts` defines 21 anonymous tools; the
132
- parity test counts that full array. It also holds the thirty-one signed-in
133
- workspace tools (#3591), which no anonymous view returns — see § The
131
+ - **22 in the `TOOLS` array / parity test**: the `TOOLS` array in
132
+ `packages/metadata/src/mcp-tool-catalog.ts` defines 22 anonymous tools; the
133
+ parity test counts that full array. It also holds the thirty-three signed-in
134
+ workspace tools (#3591; the content-suggestion pair `draft_page_suggestions`
135
+ and `decide_page_suggestion` since #10218), which no anonymous view returns — see § The
134
136
  signed-in workspace surface below.
135
- - **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one,
136
- while the Free level is held or over HTTP — 20 and 21 over stdio once it is
137
+ - **20 registered on a KEYLESS deployment, 21 on a commerce-claimed one,
138
+ while the Free level is held or over HTTP — 21 and 22 over stdio once it is
137
139
  released** (#5971, #9534, #9965 D13): `createMcpServer` (`apps/mcp/src/server.ts`) registers
138
140
  `getMcpToolRegistrations()` minus the topology seam's `UNSERVED_MCP_TOOLS`,
139
141
  minus `create_project` while create-ailk's `release-scope.json` holds
@@ -143,16 +145,16 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
143
145
  `addon`-tagged tool whose claim this deployment does not hold.
144
146
  `start_purchase` carries `addon: "commerce"`, so it is the one entry whose
145
147
  presence depends on the licence — one fewer without the claim.
146
- - **19 free anonymous tools while Free is held or over HTTP (20 over stdio once released)**: since #5853 retired the three content-write
148
+ - **20 anonymous tools while Free is held or over HTTP (21 over stdio once released)**: since #5853 retired the three content-write
147
149
  tools, every registered tool remains anonymous — call-time gating checks a
148
- licence claim, never an identity. The six carrying `tier: "pro"`
150
+ licence claim, never an identity. The seven carrying `tier: "pro"`
149
151
  (`get_flow`, `submit_flow_step`, `list_availability`, `get_event_meta`,
150
- `list_meetings`, `create_booking`) are gated at REGISTRATION **and at call time** (#7315):
152
+ `list_meetings`, `create_booking`, `get_redirect`) are gated at REGISTRATION **and at call time** (#7315):
151
153
  each needs its module, and each catalog row names the licence `feature:`
152
154
  that entitles it. `start_purchase` is gated at call time by both this
153
155
  server-wide gate and its own Gate 2 in `tools/purchase.ts`.
154
156
  `MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
155
- feature does. In `"warn"` mode (this release) the six register on module
157
+ feature does. In `"warn"` mode (this release) the seven register on module
156
158
  presence alone, and the boot (`bootMcp()`) logs one warning (stderr) per tool
157
159
  served without its feature. In `"refuse"` mode (the next release) they
158
160
  also need the deployment's licence to hold the feature, and
@@ -283,7 +285,9 @@ Example (lead routing):
283
285
  - Don't add a tool without the matching `apps/api` endpoint.
284
286
  - Don't put business logic in tool handlers — they're adapters.
285
287
 
286
- Last updated: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
288
+ Last updated: 2026-10-29 — #10202 (D8): `get_redirect` (`mcp-workspace`'s `redirect.ts`, the pack's first anonymous handler) twins `GET /v1/redirects` under `module: "content-write"` and `feature: "workspace"` — 22 in `TOOLS`, 20 registered keyless and 21 commerce-claimed while Free is held, seven `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
289
+
290
+ Prior: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
287
291
 
288
292
  Prior: 2026-10-24 — #9749: `get_slack_connection_status` (`mcp-workspace`'s `slack_connection.ts`) and `get_prospect_channel_settings` / `set_prospect_channel_settings` (`mcp-scheduling`'s `prospect_channel_settings.ts`, the first signed-in handlers outside `mcp-workspace`) twin the Slack status read and the prospect-channel settings pair — the signed-in workspace surface is thirty-one tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/mcp-scheduling/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
289
293
 
@@ -594,6 +594,14 @@ A site serves its services at `/services` unless `content/_site.mdx` sets `servi
594
594
  - **Surfaces.** The canonical, hreflang and `BreadcrumbList` (the base's title-cased last segment, then the category's title), `/sitemap.xml` and `/llms.txt` (`pagePathFor`), every nav and footer entry at `/services` (mapped in the locale layout and `components/signed-in-nav.tsx`), the hub's own section links, and route-family chrome (`lib/page-chrome.ts`) all follow it. A site that sets neither `services.path` nor any `parent` serves every URL exactly as before.
595
595
  - **Moving it.** `/ailk:growth-ops page rename` moves the base (`--from <current base> --to <new base>`): it writes `services.path` and keeps a previous moved base redirecting.
596
596
 
597
+ ## Nav-only lists (`navOnly`)
598
+
599
+ A list page whose frontmatter sets `navOnly: true` (valid on a registry list type only, #10200) has no page of its own: `honourNavOnly` (`lib/hub-children.tsx`) answers `permanentRedirect` (308) to the first child in the route's own child order (a hub's is slug order after `includeChild`, not the order of links its authored sections carry), at the card's public path with the locale prefix kept, and `notFound()` when it lists none. Only an exact `true` acts; anything else renders as before.
600
+
601
+ - **Where.** The thirteen hubs inside `hubPageInput` (slug order after `includeChild`; `/services` at `servicePublicPath`), `/alternatives` (`/vs/<slug>`, slug-read order), `/templates`, `/careers` (newest first) and `/reports` (newest first) with one call each, and the childless `/faq`, `/glossary`, `/clients`, `/testimonials` and the `[slug]` `BlogList` teaser, which 404.
602
+ - **Not honoured.** The docs-blog module's Fumadocs `/blog` and `/docs`; `DownloadsList` (`/downloads` already redirects to `/reports`); `LocationsList` (no route). `/sitemap.xml` and `/llms.txt` still list a Nav-only list's URL.
603
+ - **The nav is untouched.** `lib/nav-config.ts` is generated from route surfaces, not from list pages, so the heading and its children stay in the menu.
604
+
597
605
  ## AEO & performance
598
606
 
599
607
  - Static rendering is the default. Don't introduce client rendering without a reason.
@@ -610,7 +618,9 @@ A site serves its services at `/services` unless `content/_site.mdx` sets `servi
610
618
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
611
619
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
612
620
 
613
- Last updated: 2026-10-27 — § Where the services live (`services.path`): the services base, one category level, the 308 from `/services` and the `update-page` base move (#10043) — verified against `lib/services-path.ts`, `lib/service-listing.ts`, `middleware.ts`, `next.config.mjs` and `app/[locale]/services/`.
621
+ Last updated: 2026-10-28 — § Nav-only lists (`navOnly`): a Nav-only list's route answers 308 to its first child and 404 with none (#10200) — verified against `lib/hub-children.tsx` and the nine list routes it is called from beside the hubs.
622
+
623
+ Prior: 2026-10-27 — § Where the services live (`services.path`): the services base, one category level, the 308 from `/services` and the `update-page` base move (#10043) — verified against `lib/services-path.ts`, `lib/service-listing.ts`, `middleware.ts`, `next.config.mjs` and `app/[locale]/services/`.
614
624
 
615
625
  Prior: 2026-10-26 — § The same-origin relay: the bot check moves to `lib/bot-check.ts` and covers the relay's three lead routes and the seven public lead, flow and checkout Server Actions, with `BotIdClient` protecting every same-origin POST and the `AILK_BOT_CHECK` switch (#9255) — verified against `lib/bot-check.ts`, `lib/relay.ts`, `app/[locale]/layout.tsx` and the seven actions' files.
616
626