create-ailk 0.28.1 → 0.29.1

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/dist/topology.js CHANGED
@@ -949,6 +949,13 @@ export function renderSpokeHardFloors(opts) {
949
949
  " # Stripe webhook route, the checkout route, its price map and its e2e",
950
950
  " # workflow, and the sign-in paths are floored so their arrival here is a",
951
951
  " # human merge.",
952
+ " # Provenance (claude-setup#11353, claude-setup PR #11367): a `paths:` floor",
953
+ " # below fires only when the touched file is site-edited or not template-owned.",
954
+ " # A file whose PR-head bytes match its `.ailk-template-files.json` row AND the",
955
+ " # published create-ailk template at that row's version is exempt, so a",
956
+ " # `create-ailk update` that moves a template-owned file merges unattended.",
957
+ " # Never for a high-cost floor (security_settings, payments), never to an older",
958
+ " # template version, and only while the record already exists on main.",
952
959
  " - kind: pricing",
953
960
  " - kind: stripe_live_mode",
954
961
  " - paths:",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.28.1",
3
+ "version": "0.29.1",
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.52.0",
32
+ "@working-theory/templates": "0.52.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -93,7 +93,7 @@ Routes are versioned under `/v1/*`:
93
93
  - `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
94
94
  - `GET /v1/workspaces/:workspaceId/knowledge` — the workspace knowledge read (#9683, spec `docs/specs/9683-agent-session-audit-knowledge-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/knowledge.ts`, `// oss: false`): what the business offers, runs and serves, as `workspaceKnowledgeSchema` — `offerings` (the shown offering set, only while the commerce claim is held), `magnets` (the newest flows page's `live` rows), `meetingTypes` (the enabled slugs minus `privateSlugs`, titled by slug), `sitesServed` (the installed hub's live spokes for this workspace, read only when the hub's own gate answers `claimed`, through the optional `HubRegistry.listLiveSpokesForWorkspace`) and `strategyDocs` (`lib/strategy-docs.ts`, `[]` until a store exists) — every key always present, each entry an id, a title and a summary (`null` when it fails its check; an entry whose id or title fails is skipped and counted). Takes no query. Gate: `allows(people-read) || allows(people-memory-propose)` — a person member holds both; an agent only by grant. Every authenticated call writes one `workspace.knowledge.read` audit row (counts only; an agent's carries its session, thread `workspace:<workspaceId>`); `Cache-Control: private, no-store`. Same registration scope as the routes above (`workspace || multi-tenant`, `tenant:settings`). MCP twin: `get_workspace_knowledge` on `/mcp/workspace`.
95
95
  - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of nine permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, `member-pieces-manage` since #7878, and `people-memory-propose` and `inbox-act` since #9679 (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the nine check `allows(tenant, "<permission>")`, which reads the grants — except that the six existing inbox `inbox-act` checks still read `can(tenant.role, …)` until #9686 moves them, and `people-memory-propose` is read today only by the workspace knowledge read (#9683, beside `people-read`); every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
96
- - `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `If-Match`), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
96
+ - `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `X-Draft-Version`; `If-Match` still accepted for one release, #10264), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
97
97
  - `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling. **A client's file is never in the library's public life (#7877 D18):** the default `GET …/assets` listing leaves out every asset tagged `private` — only `?tag=private` lists them (the Admin's Compose) — and the public read answers its uniform 404 for an asset tagged `private`, attached to any member-area message, or (#8171 D3) named by a delivered piece version (one `pieceVersion.count`, since the tag can be removed after delivery), so such a file's bytes are served only by its owner's member reads.
98
98
  - `/v1/subscription/me` (GET) — `?include=billing` (#8171 D7), from a cookie session only, adds the optional `planName` and `paymentMethod` (brand, last four, expiry) read from Stripe through `lib/stripe.ts`'s `readCustomerBillingSummary` for the caller's own resolved customer: cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure leaves both keys absent, a value outside the schema's bounds is `null`, and no id is served or logged. A bearer token, another value or none answers exactly as before.
99
99
  - `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation. The read stays open to every member (the Admin's plan gate reads it); the portal needs `billing-manage` — the owner alone (`@working-theory/validation`'s permission table (#7636 D2); an admin is refused before any Stripe call). `/v1/billing/usage` (GET) needs `billing-read` (owner, admin). **The read's `commerce` field (#8907 D14)** is the deployment's commerce claim, `claimed | unclaimed | undetermined` (`resolveCommerceClaim()`; a rejected read is `undetermined` and the answer stays `200`) — what the Admin's Billing card reads (#8908 D10). **`?include=billing` (#9067 D1)** — exactly that string, never an array — adds `details` only for a caller holding `billing-manage` (the owner; an agent never qualifies): `licenseExpiresAt` (`checkLicense().expiresAt`, `null` on a failed read) always, and for an Organization with a Stripe customer `renewsAt` (the current subscription's first item's `current_period_end`, only while it will renew), `sites` (its quantity) and `paymentMethod` (brand, last four, expiry), read through `lib/stripe.ts`'s `readCustomerBillingSummary` by `services/entitlements.ts`'s `readOrgBillingSummary` — cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure answers `details: { licenseExpiresAt }` and still `200`, a value outside `orgBillingDetailsSchema`'s bounds is `null`, no id is served or logged, and the reply carries `Cache-Control: private, no-store`. Every other request is answered exactly as before, with no Stripe or licence read. **`POST /v1/billing/plan-checkout` (#9067 D3, `routes/billing/plan-checkout.ts`)** is the Billing tab's Purchase plan: a subscription Checkout for the caller's own Organization's plan (`planPurchaseLookupKey` — `ailk-pro_monthly` / `ailk-pro-plus_monthly`, `PLAN_PURCHASE_LOOKUP_KEYS` built by the Offerings lookup-key grammar, #9338 DD1), sandbox-only like the portal. **It sells only an Offerings plan (#9338 DD4):** `createCheckoutSession` gets a `refuseProduct` that refuses every Product whose `metadata.ailk_group` is not exactly `plan` (`isOfferingsPlanProduct`, `services/offerings/catalog.ts`), checked on the same expanded Price it sells; a refused or unexpanded Product answers `500 plan_price_missing`. Check order: tenant → `billing-manage` (403 before any lookup) → write budget (`chargeWriteBudget` scope `plancheckout`, 10 an hour per Organization and per person, `429` with `Retry-After`) → the commerce claim (`409 purchase_unavailable`, `503 license_undetermined`) → a strict `{ returnUrl }` (`bodyLimit` 4 KiB, `400 validation_failed`) → the return URL (canonical, no userinfo, query, fragment or `//` path, `https:` or a local `http:`, on `config.TRUSTED_ORIGINS`; `400 return_url_refused`, `500 config-error` when none is trusted) → no customer yet (`409 billing_customer_exists`) and a purchasable tier (`409 plan_not_purchasable`) → `STRIPE_SECRET_KEY` and `assertSandboxKey` → one payable session (an open earlier one is expired first, a completed one is `409 purchase_pending`) → `createCheckoutSession` with `mode: "subscription"`, 1–50 sites, `metadata` and `subscriptionMetadata` `{ organizationId, ...siteStamp() }` and `PLATFORM_TERMS_OF_SERVICE`, never a `buyer` → a Checkout URL only → the session id recorded on `Organization.planCheckoutSessionId` by compare-and-set (a lost race or a failed write expires the new session). It answers `{ url }` with `Cache-Control: private, no-store`; no body or log line carries the return URL, an id or a Stripe message. **The webhook's Organization arm binds only that recorded session (#9067 D13):** a `checkout.session.completed` with `metadata.organizationId` links `session.customer` through one guarded `updateMany` on `{ id, planCheckoutSessionId: session.id, billingCustomerId: null }` (clearing the session); anything else — an unrecorded session, a bound customer, every `customer.created` — writes nothing and logs `org_bind_unproven`. No MCP twin for the purchase — owner-only, recorded in `tests/parity/README.md`.
@@ -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