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.
- package/README.md +124 -1
- package/dist/cli.js +92 -2
- package/dist/lib/import-mdx.d.ts +59 -0
- package/dist/lib/import-mdx.js +452 -0
- package/dist/migrate-content.d.ts +45 -0
- package/dist/migrate-content.js +215 -0
- package/dist/seed-workspace.d.ts +71 -0
- package/dist/seed-workspace.js +242 -0
- package/package.json +3 -3
- package/templates/apps/api/CLAUDE.md +1 -1
- package/templates/apps/mcp/CLAUDE.md +16 -12
- package/templates/apps/web/CLAUDE.md +11 -1
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +4 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/careers/page.tsx +23 -15
- package/templates/apps/web/app/[locale]/clients/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/glossary/page.tsx +3 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +6 -0
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/lib/hub-children.tsx +38 -9
- package/templates/apps/web/messages/en.json +22 -2
- package/versions.json +48 -48
|
@@ -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.
|
|
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.
|
|
32
|
-
"@working-theory/templates": "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
|
|
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
|
|
129
|
+
### Why the parity test counts 22 and a keyless server registers 20 (21 over stdio once Free is released)
|
|
129
130
|
|
|
130
|
-
- **
|
|
131
|
-
`packages/metadata/src/mcp-tool-catalog.ts` defines
|
|
132
|
-
parity test counts that full array. It also holds the thirty-
|
|
133
|
-
workspace tools (#3591
|
|
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
|
-
- **
|
|
136
|
-
while the Free level is held or over HTTP —
|
|
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
|
-
- **
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|