create-ailk 0.14.0 → 0.15.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.
Files changed (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -49,6 +49,8 @@ export interface ExtractModuleBundleOptions {
49
49
  existingFilePolicy?: ExistingFilePolicy;
50
50
  /** A caller check on the manifest's `patches` (#6653 D3). See `InstallBundleTreeOptions`. */
51
51
  checkPatches?: (patches: readonly ModulePatch[]) => void;
52
+ /** Destinations this scaffold does not carry (#7426 D4). See `InstallBundleTreeOptions`. */
53
+ skip?: (destination: string) => boolean;
52
54
  }
53
55
  /**
54
56
  * Extract a fetched module bundle into `targetDir`, merging its files onto
@@ -131,6 +133,15 @@ export interface InstallBundleTreeOptions {
131
133
  * bundle's patches to the plus-config allowlist.
132
134
  */
133
135
  checkPatches?: (patches: readonly ModulePatch[]) => void;
136
+ /**
137
+ * Scaffold-relative destinations this scaffold does not carry (#7426 D4) —
138
+ * a spoke passes `topology.ts` `isSpokeExcluded`. A skipped destination is
139
+ * never written: a plain file or an `installMap` destination it names is
140
+ * dropped, and so is a `patches` entry whose target it names. It is NOT an
141
+ * ownership claim, and a skipped patch target is not a missing one. Every
142
+ * entry is still validated as before. Absent ⟹ nothing is skipped.
143
+ */
144
+ skip?: (destination: string) => boolean;
134
145
  }
135
146
  /** See `InstallBundleTreeOptions.existingFilePolicy`. */
136
147
  export type ExistingFilePolicy = "overwrite" | "skip-existing";
@@ -385,6 +385,24 @@ function validateLayerOwnership(destinations, ownership, subject, isTemplate, pa
385
385
  }
386
386
  }
387
387
  }
388
+ /** Every regular file under `srcRoot`, as a `/`-separated relative path. */
389
+ function stagedFiles(srcRoot) {
390
+ const out = [];
391
+ const stack = [srcRoot];
392
+ while (stack.length > 0) {
393
+ const dir = stack.pop();
394
+ if (dir === undefined)
395
+ break;
396
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
397
+ const srcPath = join(dir, entry.name);
398
+ if (entry.isDirectory())
399
+ stack.push(srcPath);
400
+ else if (entry.isFile())
401
+ out.push(relative(srcRoot, srcPath).split(sep).join("/"));
402
+ }
403
+ }
404
+ return out;
405
+ }
388
406
  /**
389
407
  * Every scaffold-relative destination this tree will write: each unmapped
390
408
  * staged file at its own path (exactly the set `copyTree` lands, same walk,
@@ -457,6 +475,7 @@ export async function extractModuleBundle(options) {
457
475
  ...(options.checkPatches !== undefined
458
476
  ? { checkPatches: options.checkPatches }
459
477
  : {}),
478
+ ...(options.skip !== undefined ? { skip: options.skip } : {}),
460
479
  });
461
480
  }
462
481
  finally {
@@ -484,13 +503,18 @@ export function installBundleTree(options) {
484
503
  const manifest = isTemplate
485
504
  ? readAilkTemplateManifest(sourceRoot, name, templateRequires, templateExtends)
486
505
  : readAilkModuleManifest(sourceRoot);
487
- const installMap = manifest?.installMap ?? {};
488
- validateInstallMap(installMap, sourceRoot, targetAbs, subject, isTemplate);
506
+ const declaredInstallMap = manifest?.installMap ?? {};
507
+ validateInstallMap(declaredInstallMap, sourceRoot, targetAbs, subject, isTemplate);
508
+ // #7426 D4 — a destination the scaffold does not carry is dropped here,
509
+ // after validation, so a spoke never receives it and never records it.
510
+ const skip = options.skip;
511
+ const isSkipped = (destination) => skip !== undefined && skip(normalize(destination).split(sep).join("/"));
512
+ const installMap = Object.fromEntries(Object.entries(declaredInstallMap).filter(([, destination]) => !isSkipped(destination)));
489
513
  // patches validation (spec 5418 § 3 D6) runs at the same point, before
490
514
  // any copy — a bad target/kind/op rejects the whole tree with nothing
491
515
  // written to targetDir, same posture as installMap and the tar-entry
492
516
  // validation above.
493
- const patches = manifest?.patches ?? [];
517
+ const declaredPatches = manifest?.patches ?? [];
494
518
  // #5514 (Medium) — content, never code (spec 5462 D9/D16): every `patches`
495
519
  // kind either edits a code file directly (`next-config`, `jest-config`)
496
520
  // or a manifest a template has no legitimate reason to touch
@@ -498,10 +522,13 @@ export function installBundleTree(options) {
498
522
  // content template) — no shipped template uses it, so the simplest
499
523
  // fail-closed reading refuses it outright rather than enumerating an
500
524
  // allowed subset that has no current user.
501
- if (isTemplate && patches.length > 0) {
525
+ if (isTemplate && declaredPatches.length > 0) {
502
526
  throw new Error(`${subject}: a template must not carry patches — content, never code (spec 5462 D9)`);
503
527
  }
504
- validatePatches(patches, targetAbs, subject);
528
+ // Every declared patch is validated, skipped or not (#7426 D4), exactly as
529
+ // the installMap is above; only then are the skipped ones dropped.
530
+ validatePatches(declaredPatches, targetAbs, subject);
531
+ const patches = declaredPatches.filter((patch) => !isSkipped(patch.target));
505
532
  options.checkPatches?.(patches);
506
533
  // The exclusion set copyTree/the content-only guard both use — built from
507
534
  // NORMALIZED installMap keys (#5514) so a "./x" key excludes the same
@@ -512,7 +539,10 @@ export function installBundleTree(options) {
512
539
  const excluded = new Set([
513
540
  AILK_TEMPLATE_JSON,
514
541
  AILK_MODULE_JSON,
515
- ...Object.keys(installMap).map((key) => normalize(key).split(sep).join("/")),
542
+ ...Object.keys(declaredInstallMap).map((key) => normalize(key).split(sep).join("/")),
543
+ // A plain file lands at its own staged path, so that path IS its
544
+ // destination: a skipped one is excluded from the copy like a mapped source.
545
+ ...(skip !== undefined ? stagedFiles(sourceRoot).filter(isSkipped) : []),
516
546
  ]);
517
547
  if (isTemplate) {
518
548
  // #6658 D7 — a template's roster of record lands as JSON, through its
@@ -20,5 +20,10 @@ export declare function appendLine(text: string, line: string): string;
20
20
  * `apps/api/.env` line (0600, other lines kept), and a `.gitignore` entry for
21
21
  * `ailk.key` when no existing line covers it. Idempotent — a second call with
22
22
  * the same key changes no byte.
23
+ *
24
+ * `apiEnv: false` skips the `apps/api/.env` line — a spoke (#7426 D6) has no
25
+ * `apps/api`, so it keeps the key in `ailk.key` alone.
23
26
  */
24
- export declare function writeLicenseKeyFiles(dir: string, key: string): void;
27
+ export declare function writeLicenseKeyFiles(dir: string, key: string, opts?: {
28
+ apiEnv?: boolean;
29
+ }): void;
@@ -85,13 +85,18 @@ function upsertEnvLine(existing, key) {
85
85
  * `apps/api/.env` line (0600, other lines kept), and a `.gitignore` entry for
86
86
  * `ailk.key` when no existing line covers it. Idempotent — a second call with
87
87
  * the same key changes no byte.
88
+ *
89
+ * `apiEnv: false` skips the `apps/api/.env` line — a spoke (#7426 D6) has no
90
+ * `apps/api`, so it keeps the key in `ailk.key` alone.
88
91
  */
89
- export function writeLicenseKeyFiles(dir, key) {
92
+ export function writeLicenseKeyFiles(dir, key, opts = {}) {
90
93
  assertLicenseKeyShape(key);
91
94
  writeOwnerOnly(join(dir, LICENSE_KEY_FILE), `${key}\n`);
92
- const envPath = join(dir, LICENSE_ENV_FILE);
93
- const env = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
94
- writeOwnerOnly(envPath, upsertEnvLine(env, key));
95
+ if (opts.apiEnv !== false) {
96
+ const envPath = join(dir, LICENSE_ENV_FILE);
97
+ const env = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
98
+ writeOwnerOnly(envPath, upsertEnvLine(env, key));
99
+ }
95
100
  const ignorePath = join(dir, ".gitignore");
96
101
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
97
102
  const covered = ignore
@@ -83,6 +83,15 @@ export interface PageRosterRow {
83
83
  * scaffold carries (#6940).
84
84
  */
85
85
  export interface ModuleSlice {
86
+ /**
87
+ * Admits the module on a spoke (`create-ailk --topology spoke`, #7426 D7).
88
+ * `true` → a spoke may carry it, and its bundle installs through the spoke
89
+ * filter (`topology.ts` `isSpokeExcluded`), so any `apps/api` or
90
+ * `database` part it carries is dropped. Unset → refused on a spoke.
91
+ * `topology.ts` `spokeModules()` reads it; a sibling issue admits its
92
+ * module by setting it here, never in a second list.
93
+ */
94
+ spoke?: true;
86
95
  /**
87
96
  * The paid UI package base-tree code loads through
88
97
  * `apps/web/lib/paid-sections.generated.ts` (#7536). Present → one row in the
@@ -206,6 +206,7 @@ function payloadPaths(kind, name, destRoot) {
206
206
  export const MODULE_SLICES = {
207
207
  // ── marketing — thin presentation module (the default) ───────────────────
208
208
  marketing: {
209
+ spoke: true,
209
210
  webImports: [
210
211
  WEB_AUTH_ADAPTER_IMPORT,
211
212
  "packages/content-adapters",
@@ -287,6 +288,7 @@ export const MODULE_SLICES = {
287
288
  },
288
289
  // ── docs-blog — Pro module: both fumadocs surfaces (D-46) ────────────────
289
290
  "docs-blog": {
291
+ spoke: true,
290
292
  webImports: [WEB_AUTH_ADAPTER_IMPORT, "packages/fumadocs-schema"],
291
293
  packages: {
292
294
  "fumadocs-schema": {
@@ -1388,6 +1390,10 @@ export const MODULE_SLICES = {
1388
1390
  "apps/api/src/bin/deliverable-upload.ts",
1389
1391
  "apps/api/src/bin/session-retention-sweep.ts",
1390
1392
  "apps/api/src/bin/seed-presets.ts",
1393
+ // #7844 D7 — the operator flow-copy CLI and its service: flows code by
1394
+ // subject, and a Free scaffold carries no flows.
1395
+ "apps/api/src/bin/flows-copy.ts",
1396
+ "apps/api/src/services/flow-copy.ts",
1391
1397
  "apps/api/src/bin/listing-csv-sweep.ts",
1392
1398
  "apps/api/src/bin/followup-sweep.ts",
1393
1399
  "apps/api/src/bin/seed-waitlist-experiments.ts",
@@ -26,6 +26,15 @@ export declare const RESERVED_SHAPES: readonly ["local-business-product"];
26
26
  /** The `--design` vocabulary (#6754 D1) — `STARTER_DESIGNS`, pinned by test. */
27
27
  export declare const RECOGNIZED_DESIGNS: readonly ["standard", "minimalism"];
28
28
  export type DesignName = (typeof RECOGNIZED_DESIGNS)[number];
29
+ /**
30
+ * The `--topology` vocabulary (#7426 D1) — orthogonal to `--shape`: any shape
31
+ * can be scaffolded as either. Declared here rather than imported from
32
+ * `topology.ts` for the reason `RECOGNIZED_MODULES` is: that module reads
33
+ * `MODULE_SLICES`, and importing it here closes a runtime cycle through
34
+ * `lib/module-tier.ts`. `spoke-topology.test.ts` pins the two lists equal.
35
+ */
36
+ export declare const RECOGNIZED_TOPOLOGIES: readonly ["hub", "spoke"];
37
+ export type TopologyName = (typeof RECOGNIZED_TOPOLOGIES)[number];
29
38
  export interface CliOptions {
30
39
  /** Positional target directory (the dir to scaffold into). Optional only
31
40
  * when `help` or `version` is set. */
@@ -94,6 +103,19 @@ export interface CliOptions {
94
103
  * flag is.
95
104
  */
96
105
  license?: string;
106
+ /**
107
+ * The scaffold's topology (`--topology`, #7426 D1). `hub` — the default,
108
+ * and the only value before #7426 — unless `--topology spoke`.
109
+ */
110
+ topology: TopologyName;
111
+ /**
112
+ * The raw `--hub-url` value (#7426 D2), present only on a spoke run that
113
+ * passed it. `scaffold()` validates and normalizes it, falling back to
114
+ * `AILK_API_URL`.
115
+ */
116
+ hubUrl?: string;
117
+ /** Spoke only: `false` with `--without-mcp` (#7426 D3), else `true`. */
118
+ mcp?: boolean;
97
119
  }
98
120
  export declare class ParseError extends Error {
99
121
  constructor(message: string);
@@ -59,6 +59,14 @@ const SHAPE_DESCRIPTIONS = {
59
59
  "person-service": "a person selling their own service",
60
60
  };
61
61
  const SHAPE_LIST = RECOGNIZED_SHAPES.join(", ");
62
+ /**
63
+ * The `--topology` vocabulary (#7426 D1) — orthogonal to `--shape`: any shape
64
+ * can be scaffolded as either. Declared here rather than imported from
65
+ * `topology.ts` for the reason `RECOGNIZED_MODULES` is: that module reads
66
+ * `MODULE_SLICES`, and importing it here closes a runtime cycle through
67
+ * `lib/module-tier.ts`. `spoke-topology.test.ts` pins the two lists equal.
68
+ */
69
+ export const RECOGNIZED_TOPOLOGIES = ["hub", "spoke"];
62
70
  export class ParseError extends Error {
63
71
  constructor(message) {
64
72
  super(message);
@@ -158,6 +166,18 @@ Options:
158
166
  --license <key> Your licence key: fetch paid modules with it and
159
167
  write it into the scaffold (apps/api/.env and a
160
168
  gitignored ailk.key, both owner-only).
169
+ --topology <hub|spoke> hub (the default) scaffolds the whole site: web,
170
+ API, MCP and database. spoke scaffolds a web-only
171
+ site that sends its forms, flows and listings to a
172
+ hub — no apps/api, no database, no sign-in, and
173
+ apps/mcp kept as a stateless front. Composes with
174
+ any --shape or --template. A spoke carries
175
+ marketing and docs-blog.
176
+ --hub-url <origin> A spoke's hub, e.g. https://hub.example.com (https:,
177
+ or http: on localhost). Defaults to AILK_API_URL;
178
+ a spoke with neither is refused. Written to
179
+ apps/web/.env.local — the spoke key never is.
180
+ --without-mcp A spoke without apps/mcp.
161
181
  --install After scaffolding, run \`pnpm install\` in the
162
182
  target directory. Off by default.
163
183
  --help, -h Print this message.
@@ -194,6 +214,9 @@ export function parseCliArgs(argv, env = { interactive: false }) {
194
214
  shape: { type: "string" },
195
215
  design: { type: "string" },
196
216
  license: { type: "string" },
217
+ topology: { type: "string" },
218
+ "hub-url": { type: "string" },
219
+ "without-mcp": { type: "boolean", default: false },
197
220
  surfaces: { type: "string" },
198
221
  "sync-architecture": { type: "string" },
199
222
  "sync-catalog": { type: "boolean", default: false },
@@ -224,6 +247,10 @@ export function parseCliArgs(argv, env = { interactive: false }) {
224
247
  const syncClaudeMd = parsed.values["sync-claude-md"] === true;
225
248
  const withCatalog = parsed.values["with-catalog"] === true;
226
249
  const license = parseLicense(parsed.values.license);
250
+ const rawTopology = parsed.values.topology;
251
+ const topology = parseTopology(rawTopology);
252
+ const hubUrl = parsed.values["hub-url"];
253
+ const withoutMcp = parsed.values["without-mcp"] === true;
227
254
  // The sync modes each exit after rewriting their one file, so asking for
228
255
  // more than one in one invocation has no coherent meaning.
229
256
  const syncFlags = [
@@ -234,6 +261,21 @@ export function parseCliArgs(argv, env = { interactive: false }) {
234
261
  if (syncFlags.length > 1) {
235
262
  throw new ParseError(`${syncFlags.join(" and ")} cannot be combined; run them one at a time`);
236
263
  }
264
+ // #7426 D1 — a sync reads the topology from the site's manifest; naming one
265
+ // here has no coherent meaning.
266
+ if (rawTopology !== undefined && syncFlags.length > 0) {
267
+ throw new ParseError(`--topology cannot be combined with ${syncFlags[0]}: a sync reads the site's topology from its .ailk-site-manifest.json`);
268
+ }
269
+ // #7426 D2/D3 — the spoke-only flags.
270
+ if (topology !== "spoke") {
271
+ const spokeOnly = [
272
+ hubUrl !== undefined ? "--hub-url" : undefined,
273
+ withoutMcp ? "--without-mcp" : undefined,
274
+ ].filter((f) => f !== undefined);
275
+ if (spokeOnly.length > 0) {
276
+ throw new ParseError(`${spokeOnly.join(" and ")} only apply${spokeOnly.length === 1 ? "s" : ""} to a spoke. Pass --topology spoke, or drop ${spokeOnly.length === 1 ? "it" : "them"}.`);
277
+ }
278
+ }
237
279
  // Validate the sync-architecture value now, through the SAME parseModules
238
280
  // path `--modules` uses, so an unknown module throws ParseError at parse time
239
281
  // (cli.ts re-parses the raw value to the module list in sync mode).
@@ -255,6 +297,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
255
297
  syncClaudeMd: false,
256
298
  withCatalog: false,
257
299
  syncArchitecture: undefined,
300
+ topology: "hub",
258
301
  };
259
302
  }
260
303
  // Sync modes: the target dir is optional (positional if given, else cwd).
@@ -275,6 +318,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
275
318
  syncClaudeMd,
276
319
  withCatalog,
277
320
  syncArchitecture,
321
+ topology: "hub",
278
322
  };
279
323
  }
280
324
  if (parsed.positionals.length === 0) {
@@ -292,6 +336,13 @@ export function parseCliArgs(argv, env = { interactive: false }) {
292
336
  : "--design";
293
337
  throw new ParseError(`--template cannot be combined with ${flags}: a template already fixes its shape and design. Pass --template alone, or --shape with an optional --design.`);
294
338
  }
339
+ // #7426 D2 — a spoke needs its hub's origin. Presence only, here: the value
340
+ // is validated and normalized by `scaffold()` (`topology.ts`
341
+ // `normalizeHubUrl`), still before the target directory exists.
342
+ if (topology === "spoke" &&
343
+ (hubUrl ?? process.env["AILK_API_URL"] ?? "").trim() === "") {
344
+ throw new ParseError("a spoke needs its hub's origin, and none was given. Pass --hub-url <origin>, or set AILK_API_URL (e.g. https://hub.example.com).");
345
+ }
295
346
  // R1 — no silent default: with neither a template nor a shape, ask on a
296
347
  // terminal and refuse without one.
297
348
  const needsShapePrompt = template === undefined && shape === undefined;
@@ -314,8 +365,22 @@ export function parseCliArgs(argv, env = { interactive: false }) {
314
365
  withCatalog,
315
366
  syncArchitecture: undefined,
316
367
  ...(license !== undefined ? { license } : {}),
368
+ topology,
369
+ ...(topology === "spoke"
370
+ ? { ...(hubUrl !== undefined ? { hubUrl } : {}), mcp: !withoutMcp }
371
+ : {}),
317
372
  };
318
373
  }
374
+ /** Validate `--topology` (#7426 D1): `hub` when absent, trimmed and lowercased. */
375
+ function parseTopology(raw) {
376
+ if (raw === undefined)
377
+ return "hub";
378
+ const name = raw.trim().toLowerCase();
379
+ if (!RECOGNIZED_TOPOLOGIES.includes(name)) {
380
+ throw new ParseError(`unknown topology "${raw}". Recognized: ${RECOGNIZED_TOPOLOGIES.join(", ")}`);
381
+ }
382
+ return name;
383
+ }
319
384
  /**
320
385
  * Validate `--shape` (#6754 D2 step 2): trimmed and lowercased. A reserved
321
386
  * shape is refusal R2; any other unrecognized value names the recognized set.
@@ -467,6 +532,14 @@ export function toScaffoldOptions(opts) {
467
532
  ...(opts.shape !== undefined ? { shape: opts.shape } : {}),
468
533
  ...(opts.design !== undefined ? { design: opts.design } : {}),
469
534
  ...(opts.license !== undefined ? { license: opts.license } : {}),
535
+ // #7426 — a hub run hands `scaffold()` the object it did before.
536
+ ...(opts.topology === "spoke"
537
+ ? {
538
+ topology: "spoke",
539
+ ...(opts.hubUrl !== undefined ? { hubUrl: opts.hubUrl } : {}),
540
+ mcp: opts.mcp !== false,
541
+ }
542
+ : {}),
470
543
  };
471
544
  }
472
545
  /**
@@ -22,6 +22,7 @@ import { type ModuleName } from "./parse-args.js";
22
22
  import { type TemplateLayout } from "./site-chrome.js";
23
23
  import { type SyncRoutesResult } from "./sync-routes.js";
24
24
  import { type StarterShape } from "./template-manifest.js";
25
+ import { type Topology } from "./topology.js";
25
26
  export interface ScaffoldOptions {
26
27
  /** Absolute or relative path to the target directory. Must not exist or be empty. */
27
28
  targetDir: string;
@@ -136,6 +137,18 @@ export interface ScaffoldOptions {
136
137
  * code registry that has since been deleted (#6638's per-name table).
137
138
  */
138
139
  designLayout?: TemplateLayout;
140
+ /**
141
+ * The scaffold's topology (`--topology`, #7426 D1). `"hub"` — the default
142
+ * — is the whole site. `"spoke"` is the same scaffold with nodes removed
143
+ * (`topology.ts`): no `apps/api`, no `database/`, no sign-in, `apps/mcp`
144
+ * a stateless front unless `mcp: false`. A spoke needs a `template` or a
145
+ * `shape` (the manifest records the topology) and a hub origin.
146
+ */
147
+ topology?: Topology;
148
+ /** A spoke's hub origin (`--hub-url`), else `AILK_API_URL` (#7426 D2). */
149
+ hubUrl?: string;
150
+ /** Spoke only: `false` drops `apps/mcp` (`--without-mcp`, #7426 D3). Default true. */
151
+ mcp?: boolean;
139
152
  }
140
153
  export interface ScaffoldResult {
141
154
  /** Resolved absolute path to the scaffolded directory. */
@@ -161,6 +174,13 @@ export interface ScaffoldResult {
161
174
  * a keyed run with no `--modules` carries the key's derived level here.
162
175
  */
163
176
  modules: ModuleName[];
177
+ /**
178
+ * A spoke only (#7426 D7): modules the key's default set carried that a
179
+ * spoke does not admit, dropped from this scaffold. Absent when none were.
180
+ */
181
+ droppedModules?: ModuleName[];
182
+ /** A spoke only (#7426 D2): the normalized hub origin written to apps/web/.env.local. */
183
+ hubOrigin?: string;
164
184
  }
165
185
  /**
166
186
  * Scaffold an AI Launch Kit starter project into targetDir (synchronous).
@@ -18,7 +18,7 @@
18
18
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
19
19
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
- import { AddModuleRefusedError, applyModuleToScaffold, } from "./add-module.js";
21
+ import { AddModuleRefusedError, addModuleSiteTopology, applyModuleToScaffold, } from "./add-module.js";
22
22
  import { planDesignMd } from "./apply-design-md.js";
23
23
  import { renderClaudeMd, syncClaudeMd, } from "./claude-md.js";
24
24
  import { CATALOG_TARGET_PATH, writeComponentCatalog, } from "./component-catalog.js";
@@ -44,6 +44,7 @@ import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoste
44
44
  import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
45
45
  import { syncRoutes } from "./sync-routes.js";
46
46
  import { BASE_LAYER_DIR, SHAPE_STARTERS, TEMPLATE_MANIFESTS, TemplateError, mergeRoster, resolveTemplate, resolveTemplateLineage, rosterDeclares, templateDelivery, templateTreeDir, } from "./template-manifest.js";
47
+ import { TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeModuleRefusal, spokeModules, writeHubOriginEnv, } from "./topology.js";
47
48
  import { validateTarget } from "./validate.js";
48
49
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
49
50
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
@@ -151,14 +152,23 @@ export function scaffoldSync(opts) {
151
152
  // The declared route surface (#4154). Resolved BEFORE the copy: it decides
152
153
  // both which route directories are skipped and what architecture.yaml records.
153
154
  const enabledSurfaces = resolveSurfaces(opts.modules ?? ["marketing"], opts.surfaces ?? []);
155
+ // #7426 D4 — a spoke is this same copy with the spoke's exclusions added:
156
+ // one registry over the one template tree, never a second tree.
157
+ const spoke = opts.topology === "spoke" ? { mcp: opts.mcp !== false } : undefined;
154
158
  const versions = readVersionMap();
155
159
  copyTemplate({
156
160
  templatesDir: TEMPLATES_DIR,
157
161
  targetDir: targetAbs,
158
162
  versions,
159
- excludePaths: excludedRoutePaths(enabledSurfaces),
163
+ excludePaths: [
164
+ ...excludedRoutePaths(enabledSurfaces),
165
+ ...(spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : []),
166
+ ],
160
167
  markers: buildScaffoldMarkers(projectName),
161
168
  });
169
+ // #7426 D5/D6 — the root files a spoke rewrites, and the spoke seams.
170
+ if (spoke !== undefined)
171
+ applySpokeTransforms(targetAbs, spoke);
162
172
  // Stamp the copied CLAUDE.md's template regions with their hashes (#6904
163
173
  // D2). No later layer writes a root CLAUDE.md, so this is the final file.
164
174
  writeFileSync(join(targetAbs, "CLAUDE.md"), renderClaudeMd(readFileSync(join(TEMPLATES_DIR, "CLAUDE.md"), "utf8"), projectName));
@@ -167,7 +177,7 @@ export function scaffoldSync(opts) {
167
177
  // closes the dangling `architecture_rules: architecture.yaml` reference in
168
178
  // the scaffolded project.yaml. Written AFTER the template copy, at the target
169
179
  // root — a deterministic function of the selected module set.
170
- writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces));
180
+ writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
171
181
  // Regenerate the site nav from the same surface set (#4154). The nav is a
172
182
  // static list the layout renders into the header AND the footer, so leaving
173
183
  // the template's copy in place would link /docs and /blog sitewide in a
@@ -252,6 +262,9 @@ export async function scaffold(opts) {
252
262
  ? resolveShapeTemplate(opts.shape)
253
263
  : undefined;
254
264
  assertReleased("modules", [...(opts.modules ?? []), ...(template?.requires ?? [])]);
265
+ // #7426 D1-D3/D7 — a spoke's refusals, before anything is fetched or
266
+ // written: the target directory never exists for a refused spoke.
267
+ const spoke = resolveSpoke(opts, template);
255
268
  // #6813 D8 step 3 — the design this scaffold renders, resolved from its
256
269
  // package ONCE, before any fetch or write: the caller's override, else the
257
270
  // template's own. Unset for a scaffold naming neither. Replaces the
@@ -308,6 +321,8 @@ export async function scaffold(opts) {
308
321
  // list.
309
322
  let modules;
310
323
  let bundles;
324
+ // #7426 D7 — the key's default modules a spoke does not admit.
325
+ let dropped = [];
311
326
  // The run's derived level (#6759), for `assertDesignAllowed` below.
312
327
  // Undefined on the alias branch and on a `--modules`-keep run (D2/D4 in
313
328
  // module-license-gate.ts — the level plays no part in composing either),
@@ -360,6 +375,15 @@ export async function scaffold(opts) {
360
375
  });
361
376
  bundles = chosen.bundles;
362
377
  level = chosen.level;
378
+ // #7426 D7 — a key-derived default set keeps only what a spoke admits;
379
+ // the run names what it dropped (`droppedModules`).
380
+ if (spoke !== undefined && opts.modules === undefined) {
381
+ const admitted = new Set(spokeModules());
382
+ dropped = chosen.modules.filter((m) => !admitted.has(m));
383
+ chosen.modules = chosen.modules.filter((m) => admitted.has(m));
384
+ for (const name of dropped)
385
+ bundles.delete(name);
386
+ }
363
387
  // #7010 — a `--modules`-keep run composes no level (NFR-2), so a chosen
364
388
  // non-Free design derives one from the key here, as the addition branch
365
389
  // does; a plain keep run makes no extra network call.
@@ -453,6 +477,7 @@ export async function scaffold(opts) {
453
477
  const result = scaffoldSync({
454
478
  ...opts,
455
479
  modules,
480
+ ...(spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}),
456
481
  ...(surfaces !== undefined ? { surfaces } : {}),
457
482
  // #5612 D1/D2 — the code registry's own `layout` block, known
458
483
  // synchronously (unlike a bundle's `ailk-template.json`, only readable
@@ -486,6 +511,14 @@ export async function scaffold(opts) {
486
511
  // anything. Starts empty at the base-layer boundary: the `templates/`
487
512
  // snapshot (layer 0) is deliberately outside it.
488
513
  const ownership = new Map();
514
+ // #7426 D4 — every layer below lands through the spoke filter: a module
515
+ // bundle's or the template's destination a spoke does not carry is dropped.
516
+ const exclusions = spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : [];
517
+ const spokeSkip = spoke !== undefined
518
+ ? {
519
+ skip: (destination) => isSpokeExcluded(destination, { mcp: spoke.mcp, exclusions }),
520
+ }
521
+ : {};
489
522
  // The BASE LAYER lands first (#5618 D1) — the seed files every template
490
523
  // reaches by `extends` (today the three module-less base pages), read off
491
524
  // the `site-templates/_base/` tree this package ships, through the same
@@ -505,6 +538,7 @@ export async function scaffold(opts) {
505
538
  versions,
506
539
  markers,
507
540
  ownership,
541
+ ...spokeSkip,
508
542
  });
509
543
  }
510
544
  for (const [moduleName, bytes] of bundles) {
@@ -515,6 +549,7 @@ export async function scaffold(opts) {
515
549
  versions,
516
550
  markers,
517
551
  ownership,
552
+ ...spokeSkip,
518
553
  });
519
554
  }
520
555
  // The template lands LAST — after `scaffoldSync` and after every module
@@ -550,6 +585,7 @@ export async function scaffold(opts) {
550
585
  versions,
551
586
  markers,
552
587
  ownership,
588
+ ...spokeSkip,
553
589
  })
554
590
  : (() => {
555
591
  const sourceRoot = join(SITE_TEMPLATES_DIR, templateTreeDir(template.name));
@@ -565,6 +601,7 @@ export async function scaffold(opts) {
565
601
  versions,
566
602
  markers,
567
603
  ownership,
604
+ ...spokeSkip,
568
605
  });
569
606
  })();
570
607
  installedLayout = installed.layout;
@@ -696,6 +733,8 @@ export async function scaffold(opts) {
696
733
  roster,
697
734
  modules: result.modules,
698
735
  level,
736
+ topology: spoke !== undefined ? "spoke" : "hub",
737
+ patternVersion: markers["CREATE_AILK_VERSION"] ?? "",
699
738
  siteTemplatesDir: SITE_TEMPLATES_DIR,
700
739
  templatesDir: TEMPLATES_DIR,
701
740
  markers,
@@ -707,20 +746,70 @@ export async function scaffold(opts) {
707
746
  writePaidSections(result.targetDir);
708
747
  // #6107 D2 — after every layer landed, so nothing later overwrites it: a
709
748
  // served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
710
- writeAuthSecretFile(result.targetDir);
749
+ // A spoke has no sign-in (#7426 D4), so it gets its hub's origin instead
750
+ // (D2) — never the spoke key, which the hub issues.
751
+ if (spoke !== undefined)
752
+ writeHubOriginEnv(result.targetDir, spoke.hubOrigin);
753
+ else
754
+ writeAuthSecretFile(result.targetDir);
711
755
  // #6683 D9 — persisted only after every layer landed, and only when the
712
- // caller passed `license`: a scaffold without it writes nothing new.
756
+ // caller passed `license`: a scaffold without it writes nothing new. A
757
+ // spoke has no apps/api, so it keeps the key in ailk.key alone (#7426 D6).
713
758
  if (opts.license !== undefined) {
714
- writeLicenseKeyFiles(result.targetDir, opts.license);
759
+ writeLicenseKeyFiles(result.targetDir, opts.license, {
760
+ apiEnv: spoke === undefined,
761
+ });
715
762
  }
716
763
  // #6754 D4/D5 — the resolved starter, so the CLI names what it built.
764
+ const withDropped = {
765
+ ...result,
766
+ ...(dropped.length > 0 ? { droppedModules: dropped } : {}),
767
+ ...(spoke !== undefined ? { hubOrigin: spoke.hubOrigin } : {}),
768
+ };
717
769
  if (template !== undefined && resolvedDesign !== undefined) {
718
770
  return {
719
- ...result,
771
+ ...withDropped,
720
772
  starter: { template: template.name, shape: template.shape, design: resolvedDesign.recorded },
721
773
  };
722
774
  }
723
- return result;
775
+ return withDropped;
776
+ }
777
+ /**
778
+ * A spoke's resolved inputs (#7426 D1-D3/D7), or undefined for a hub. Every
779
+ * refusal is a `TemplateError` thrown before anything is fetched or written.
780
+ */
781
+ function resolveSpoke(opts, template) {
782
+ if (opts.topology !== "spoke") {
783
+ const spokeOnly = [
784
+ opts.hubUrl !== undefined ? "hubUrl" : undefined,
785
+ opts.mcp !== undefined ? "mcp" : undefined,
786
+ ].filter((f) => f !== undefined);
787
+ if (spokeOnly.length > 0) {
788
+ throw new TemplateError(`${spokeOnly.join(" and ")} only apply to a spoke — pass topology: "spoke"`);
789
+ }
790
+ return undefined;
791
+ }
792
+ if (template === undefined) {
793
+ throw new TemplateError("a spoke needs a starter — pass --shape <shape> or --template <name>: the site manifest is where the topology is recorded");
794
+ }
795
+ let hubOrigin;
796
+ try {
797
+ hubOrigin = normalizeHubUrl(opts.hubUrl ?? process.env["AILK_API_URL"]);
798
+ }
799
+ catch (err) {
800
+ if (err instanceof TopologyError)
801
+ throw new TemplateError(err.message);
802
+ throw err;
803
+ }
804
+ for (const name of normalizeModules([...(opts.modules ?? []), ...template.requires])) {
805
+ const refusal = spokeModuleRefusal(name);
806
+ if (refusal !== undefined) {
807
+ throw new TemplateError(template.requires.includes(name) && !(opts.modules ?? []).includes(name)
808
+ ? `template "${template.name}" requires ${refusal}`
809
+ : refusal);
810
+ }
811
+ }
812
+ return { mcp: opts.mcp !== false, hubOrigin };
724
813
  }
725
814
  /**
726
815
  * The per-row install of the two SHARED seed layers (#6646 D1, disposition
@@ -842,7 +931,12 @@ export function syncArchitectureSync(opts) {
842
931
  throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
843
932
  }
844
933
  const enabledSurfaces = resolveSurfaces(opts.modules, opts.surfaces ?? []);
845
- writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces));
934
+ // #7426 D13 — a spoke stays a spoke: its topology is read from its
935
+ // manifest, and `mcp` from whether it still carries apps/mcp.
936
+ const topology = readSiteTopology(targetAbs);
937
+ writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces, topology === "spoke"
938
+ ? { topology, mcp: existsSync(join(targetAbs, "apps", "mcp")) }
939
+ : {}));
846
940
  return {
847
941
  targetDir: targetAbs,
848
942
  surfaces: enabledSurfaces,
@@ -960,6 +1054,13 @@ export async function addModule(opts) {
960
1054
  if (!existsSync(join(targetAbs, "architecture.yaml"))) {
961
1055
  throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
962
1056
  }
1057
+ // #7426 D13 — a spoke refuses a module it does not admit before any key
1058
+ // read or network call (`applyModuleToScaffold` refuses it again).
1059
+ if (addModuleSiteTopology(targetAbs) === "spoke") {
1060
+ const refusal = spokeModuleRefusal(opts.moduleName);
1061
+ if (refusal !== undefined)
1062
+ throw new AddModuleRefusedError(refusal);
1063
+ }
963
1064
  const { paid } = classifyModules([opts.moduleName]);
964
1065
  if (paid.length === 0) {
965
1066
  throw new AddModuleRefusedError(`"${opts.moduleName}" ships with every scaffold — there is no module bundle to add`);