create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -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,24 @@ 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.
181
+ --hub-sign-in <origin> A spoke whose /account signs visitors in through
182
+ its hub's own sign-in page (the hub's web origin,
183
+ https: or http: on localhost). No database: the
184
+ session is a sealed cookie. Writes the origin and
185
+ a session secret to apps/web/.env.local — never the
186
+ client secret, which the hub issues.
161
187
  --install After scaffolding, run \`pnpm install\` in the
162
188
  target directory. Off by default.
163
189
  --help, -h Print this message.
@@ -194,6 +220,10 @@ export function parseCliArgs(argv, env = { interactive: false }) {
194
220
  shape: { type: "string" },
195
221
  design: { type: "string" },
196
222
  license: { type: "string" },
223
+ topology: { type: "string" },
224
+ "hub-url": { type: "string" },
225
+ "without-mcp": { type: "boolean", default: false },
226
+ "hub-sign-in": { type: "string" },
197
227
  surfaces: { type: "string" },
198
228
  "sync-architecture": { type: "string" },
199
229
  "sync-catalog": { type: "boolean", default: false },
@@ -224,6 +254,11 @@ export function parseCliArgs(argv, env = { interactive: false }) {
224
254
  const syncClaudeMd = parsed.values["sync-claude-md"] === true;
225
255
  const withCatalog = parsed.values["with-catalog"] === true;
226
256
  const license = parseLicense(parsed.values.license);
257
+ const rawTopology = parsed.values.topology;
258
+ const topology = parseTopology(rawTopology);
259
+ const hubUrl = parsed.values["hub-url"];
260
+ const withoutMcp = parsed.values["without-mcp"] === true;
261
+ const hubSignIn = parsed.values["hub-sign-in"];
227
262
  // The sync modes each exit after rewriting their one file, so asking for
228
263
  // more than one in one invocation has no coherent meaning.
229
264
  const syncFlags = [
@@ -234,6 +269,22 @@ export function parseCliArgs(argv, env = { interactive: false }) {
234
269
  if (syncFlags.length > 1) {
235
270
  throw new ParseError(`${syncFlags.join(" and ")} cannot be combined; run them one at a time`);
236
271
  }
272
+ // #7426 D1 — a sync reads the topology from the site's manifest; naming one
273
+ // here has no coherent meaning.
274
+ if (rawTopology !== undefined && syncFlags.length > 0) {
275
+ throw new ParseError(`--topology cannot be combined with ${syncFlags[0]}: a sync reads the site's topology from its .ailk-site-manifest.json`);
276
+ }
277
+ // #7426 D2/D3 — the spoke-only flags.
278
+ if (topology !== "spoke") {
279
+ const spokeOnly = [
280
+ hubUrl !== undefined ? "--hub-url" : undefined,
281
+ withoutMcp ? "--without-mcp" : undefined,
282
+ hubSignIn !== undefined ? "--hub-sign-in" : undefined,
283
+ ].filter((f) => f !== undefined);
284
+ if (spokeOnly.length > 0) {
285
+ 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"}.`);
286
+ }
287
+ }
237
288
  // Validate the sync-architecture value now, through the SAME parseModules
238
289
  // path `--modules` uses, so an unknown module throws ParseError at parse time
239
290
  // (cli.ts re-parses the raw value to the module list in sync mode).
@@ -255,6 +306,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
255
306
  syncClaudeMd: false,
256
307
  withCatalog: false,
257
308
  syncArchitecture: undefined,
309
+ topology: "hub",
258
310
  };
259
311
  }
260
312
  // Sync modes: the target dir is optional (positional if given, else cwd).
@@ -275,6 +327,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
275
327
  syncClaudeMd,
276
328
  withCatalog,
277
329
  syncArchitecture,
330
+ topology: "hub",
278
331
  };
279
332
  }
280
333
  if (parsed.positionals.length === 0) {
@@ -292,6 +345,13 @@ export function parseCliArgs(argv, env = { interactive: false }) {
292
345
  : "--design";
293
346
  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
347
  }
348
+ // #7426 D2 — a spoke needs its hub's origin. Presence only, here: the value
349
+ // is validated and normalized by `scaffold()` (`topology.ts`
350
+ // `normalizeHubUrl`), still before the target directory exists.
351
+ if (topology === "spoke" &&
352
+ (hubUrl ?? process.env["AILK_API_URL"] ?? "").trim() === "") {
353
+ 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).");
354
+ }
295
355
  // R1 — no silent default: with neither a template nor a shape, ask on a
296
356
  // terminal and refuse without one.
297
357
  const needsShapePrompt = template === undefined && shape === undefined;
@@ -314,8 +374,26 @@ export function parseCliArgs(argv, env = { interactive: false }) {
314
374
  withCatalog,
315
375
  syncArchitecture: undefined,
316
376
  ...(license !== undefined ? { license } : {}),
377
+ topology,
378
+ ...(topology === "spoke"
379
+ ? {
380
+ ...(hubUrl !== undefined ? { hubUrl } : {}),
381
+ mcp: !withoutMcp,
382
+ ...(hubSignIn !== undefined ? { hubSignIn } : {}),
383
+ }
384
+ : {}),
317
385
  };
318
386
  }
387
+ /** Validate `--topology` (#7426 D1): `hub` when absent, trimmed and lowercased. */
388
+ function parseTopology(raw) {
389
+ if (raw === undefined)
390
+ return "hub";
391
+ const name = raw.trim().toLowerCase();
392
+ if (!RECOGNIZED_TOPOLOGIES.includes(name)) {
393
+ throw new ParseError(`unknown topology "${raw}". Recognized: ${RECOGNIZED_TOPOLOGIES.join(", ")}`);
394
+ }
395
+ return name;
396
+ }
319
397
  /**
320
398
  * Validate `--shape` (#6754 D2 step 2): trimmed and lowercased. A reserved
321
399
  * shape is refusal R2; any other unrecognized value names the recognized set.
@@ -467,6 +545,17 @@ export function toScaffoldOptions(opts) {
467
545
  ...(opts.shape !== undefined ? { shape: opts.shape } : {}),
468
546
  ...(opts.design !== undefined ? { design: opts.design } : {}),
469
547
  ...(opts.license !== undefined ? { license: opts.license } : {}),
548
+ // #7426 — a hub run hands `scaffold()` the object it did before.
549
+ ...(opts.topology === "spoke"
550
+ ? {
551
+ topology: "spoke",
552
+ ...(opts.hubUrl !== undefined ? { hubUrl: opts.hubUrl } : {}),
553
+ mcp: opts.mcp !== false,
554
+ ...(opts.hubSignIn !== undefined
555
+ ? { hubSignInUrl: opts.hubSignIn }
556
+ : {}),
557
+ }
558
+ : {}),
470
559
  };
471
560
  }
472
561
  /**
@@ -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,24 @@ 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;
152
+ /**
153
+ * Spoke only: the hub's web origin this spoke signs visitors in at
154
+ * (`--hub-sign-in`, #7894 D1). Set, the spoke is scaffolded in the hub
155
+ * sign-in mode (`SpokeShapeOptions.signIn: "hub"`).
156
+ */
157
+ hubSignInUrl?: string;
139
158
  }
140
159
  export interface ScaffoldResult {
141
160
  /** Resolved absolute path to the scaffolded directory. */
@@ -161,6 +180,15 @@ export interface ScaffoldResult {
161
180
  * a keyed run with no `--modules` carries the key's derived level here.
162
181
  */
163
182
  modules: ModuleName[];
183
+ /**
184
+ * A spoke only (#7426 D7): modules the key's default set carried that a
185
+ * spoke does not admit, dropped from this scaffold. Absent when none were.
186
+ */
187
+ droppedModules?: ModuleName[];
188
+ /** A spoke only (#7426 D2): the normalized hub origin written to apps/web/.env.local. */
189
+ hubOrigin?: string;
190
+ /** A hub sign-in spoke only (#7894 D11): the hub web origin written to apps/web/.env.local. */
191
+ hubSignInOrigin?: string;
164
192
  }
165
193
  /**
166
194
  * 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";
@@ -29,21 +29,26 @@ import { HOME_PAGE_PATH, designHomeSeeds, planDesignHome, shapeHomePath, shipped
29
29
  import { applyDesignPresets, readSectionLookCatalog, shippedPageTypes, validateDesignPresets, } from "./design-presets.js";
30
30
  import { resolveDesign, spliceThemeBlock, } from "./design-scaffold.js";
31
31
  import { writeAuthSecretFile } from "./lib/auth-secret-file.js";
32
+ import { appendEnvRows } from "./lib/declared-env.js";
33
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
32
34
  import { extractModuleBundle, installBundleTree, } from "./lib/extract-module-bundle.js";
33
35
  import { fetchTemplate } from "./lib/fetch-template.js";
34
36
  import { assertLicenseKeyShape, writeLicenseKeyFiles, } from "./lib/license-key-files.js";
35
37
  import { assertDesignAllowed, assertReleased, deriveDesignLevel, deriveLevel, resolveModuleSet, resolvePaidModules, } from "./lib/module-license-gate.js";
36
- import { classifyModules } from "./lib/module-tier.js";
38
+ import { classifyModules, isAddition } from "./lib/module-tier.js";
37
39
  import { normalizeModules } from "./module-architecture.js";
38
40
  import { writePaidSections } from "./paid-sections.js";
39
41
  import { RECOGNIZED_SHAPES, RESERVED_SHAPES, } from "./parse-args.js";
42
+ import { writeSpokeFlowsReadme } from "./seed-flows.js";
40
43
  import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
41
44
  import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
42
45
  import { planSiteManifest } from "./site-manifest.js";
43
46
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, withMeetPlaceholderSeeds, } from "./site-roster.js";
47
+ import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
44
48
  import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
45
49
  import { syncRoutes } from "./sync-routes.js";
46
50
  import { BASE_LAYER_DIR, SHAPE_STARTERS, TEMPLATE_MANIFESTS, TemplateError, mergeRoster, resolveTemplate, resolveTemplateLineage, rosterDeclares, templateDelivery, templateTreeDir, } from "./template-manifest.js";
51
+ import { SPOKE_ONLY_FILES, TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubSignInUrl, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeDestination, spokeModuleRefusal, spokeModules, writeHubOriginEnv, writeHubSignInEnv, } from "./topology.js";
47
52
  import { validateTarget } from "./validate.js";
48
53
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
49
54
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
@@ -151,14 +156,31 @@ export function scaffoldSync(opts) {
151
156
  // The declared route surface (#4154). Resolved BEFORE the copy: it decides
152
157
  // both which route directories are skipped and what architecture.yaml records.
153
158
  const enabledSurfaces = resolveSurfaces(opts.modules ?? ["marketing"], opts.surfaces ?? []);
159
+ // #7426 D4 — a spoke is this same copy with the spoke's exclusions added:
160
+ // one registry over the one template tree, never a second tree.
161
+ const spoke = opts.topology === "spoke"
162
+ ? {
163
+ mcp: opts.mcp !== false,
164
+ ...(opts.hubSignInUrl !== undefined ? { signIn: "hub" } : {}),
165
+ }
166
+ : undefined;
154
167
  const versions = readVersionMap();
155
168
  copyTemplate({
156
169
  templatesDir: TEMPLATES_DIR,
157
170
  targetDir: targetAbs,
158
171
  versions,
159
- excludePaths: excludedRoutePaths(enabledSurfaces),
172
+ excludePaths: [
173
+ ...excludedRoutePaths(enabledSurfaces),
174
+ // #7874 D11 — the declared-env check is a spoke's alone.
175
+ ...(spoke !== undefined
176
+ ? spokeExclusions(TEMPLATES_DIR, spoke)
177
+ : SPOKE_ONLY_FILES),
178
+ ],
160
179
  markers: buildScaffoldMarkers(projectName),
161
180
  });
181
+ // #7426 D5/D6 — the root files a spoke rewrites, and the spoke seams.
182
+ if (spoke !== undefined)
183
+ applySpokeTransforms(targetAbs, spoke);
162
184
  // Stamp the copied CLAUDE.md's template regions with their hashes (#6904
163
185
  // D2). No later layer writes a root CLAUDE.md, so this is the final file.
164
186
  writeFileSync(join(targetAbs, "CLAUDE.md"), renderClaudeMd(readFileSync(join(TEMPLATES_DIR, "CLAUDE.md"), "utf8"), projectName));
@@ -167,7 +189,7 @@ export function scaffoldSync(opts) {
167
189
  // closes the dangling `architecture_rules: architecture.yaml` reference in
168
190
  // the scaffolded project.yaml. Written AFTER the template copy, at the target
169
191
  // root — a deterministic function of the selected module set.
170
- writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces));
192
+ writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
171
193
  // Regenerate the site nav from the same surface set (#4154). The nav is a
172
194
  // static list the layout renders into the header AND the footer, so leaving
173
195
  // the template's copy in place would link /docs and /blog sitewide in a
@@ -187,6 +209,11 @@ export function scaffoldSync(opts) {
187
209
  // scaffold with `withCatalog` unset touches exactly the files it did before.
188
210
  if (opts.withCatalog === true)
189
211
  writeComponentCatalog(targetAbs);
212
+ // #7874 D9 — a spoke's env_contract rows, as its last write, so every file
213
+ // above is in the scan. A hub's project.yaml is never touched.
214
+ if (spoke !== undefined) {
215
+ appendEnvRows(targetAbs, { topology: "spoke", catalog: ENV_CATALOG });
216
+ }
190
217
  const filesCreated = countFiles(targetAbs);
191
218
  return {
192
219
  targetDir: targetAbs,
@@ -252,6 +279,9 @@ export async function scaffold(opts) {
252
279
  ? resolveShapeTemplate(opts.shape)
253
280
  : undefined;
254
281
  assertReleased("modules", [...(opts.modules ?? []), ...(template?.requires ?? [])]);
282
+ // #7426 D1-D3/D7 — a spoke's refusals, before anything is fetched or
283
+ // written: the target directory never exists for a refused spoke.
284
+ const spoke = resolveSpoke(opts, template);
255
285
  // #6813 D8 step 3 — the design this scaffold renders, resolved from its
256
286
  // package ONCE, before any fetch or write: the caller's override, else the
257
287
  // template's own. Unset for a scaffold naming neither. Replaces the
@@ -308,6 +338,8 @@ export async function scaffold(opts) {
308
338
  // list.
309
339
  let modules;
310
340
  let bundles;
341
+ // #7426 D7 — the key's default modules a spoke does not admit.
342
+ let dropped = [];
311
343
  // The run's derived level (#6759), for `assertDesignAllowed` below.
312
344
  // Undefined on the alias branch and on a `--modules`-keep run (D2/D4 in
313
345
  // module-license-gate.ts — the level plays no part in composing either),
@@ -360,6 +392,19 @@ export async function scaffold(opts) {
360
392
  });
361
393
  bundles = chosen.bundles;
362
394
  level = chosen.level;
395
+ // #7426 D7 — a key-derived default set keeps only what a spoke admits;
396
+ // the run names what it dropped (`droppedModules`). Naming only
397
+ // additions (`--modules commerce`) keeps the level default too, so it is
398
+ // filtered the same way (#7838 D12); a named keep set is never touched.
399
+ if (spoke !== undefined &&
400
+ (opts.modules === undefined ||
401
+ opts.modules.every(isAddition))) {
402
+ const admitted = new Set(spokeModules());
403
+ dropped = chosen.modules.filter((m) => !admitted.has(m));
404
+ chosen.modules = chosen.modules.filter((m) => admitted.has(m));
405
+ for (const name of dropped)
406
+ bundles.delete(name);
407
+ }
363
408
  // #7010 — a `--modules`-keep run composes no level (NFR-2), so a chosen
364
409
  // non-Free design derives one from the key here, as the addition branch
365
410
  // does; a plain keep run makes no extra network call.
@@ -453,6 +498,7 @@ export async function scaffold(opts) {
453
498
  const result = scaffoldSync({
454
499
  ...opts,
455
500
  modules,
501
+ ...(spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}),
456
502
  ...(surfaces !== undefined ? { surfaces } : {}),
457
503
  // #5612 D1/D2 — the code registry's own `layout` block, known
458
504
  // synchronously (unlike a bundle's `ailk-template.json`, only readable
@@ -476,7 +522,8 @@ export async function scaffold(opts) {
476
522
  // it a `{{MARKER}}` authored in a base-layer or site-template file would
477
523
  // survive into the scaffold verbatim — the state before this card, and the
478
524
  // reason a template file could not carry the version stamp.
479
- const markers = buildScaffoldMarkers(opts.projectName ?? basename(result.targetDir));
525
+ const projectName = opts.projectName ?? basename(result.targetDir);
526
+ const markers = buildScaffoldMarkers(projectName);
480
527
  // #5618 D4 — the file-grain ownership registry for THIS scaffold: which
481
528
  // layer wrote which destination. Call-scoped (a fresh Map per scaffold(),
482
529
  // never module-level), threaded through every layer install below in the
@@ -486,6 +533,16 @@ export async function scaffold(opts) {
486
533
  // anything. Starts empty at the base-layer boundary: the `templates/`
487
534
  // snapshot (layer 0) is deliberately outside it.
488
535
  const ownership = new Map();
536
+ // #7426 D4 — every layer below lands through the spoke filter: a module
537
+ // bundle's or the template's destination a spoke does not carry is dropped.
538
+ // #7876 D10 — and a module flow file lands in `flows/` as declared data.
539
+ const exclusions = spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : [];
540
+ const spokeSkip = spoke !== undefined
541
+ ? {
542
+ skip: (destination) => isSpokeExcluded(destination, { ...spoke, exclusions }),
543
+ remapDestination: spokeDestination,
544
+ }
545
+ : {};
489
546
  // The BASE LAYER lands first (#5618 D1) — the seed files every template
490
547
  // reaches by `extends` (today the three module-less base pages), read off
491
548
  // the `site-templates/_base/` tree this package ships, through the same
@@ -505,6 +562,7 @@ export async function scaffold(opts) {
505
562
  versions,
506
563
  markers,
507
564
  ownership,
565
+ ...spokeSkip,
508
566
  });
509
567
  }
510
568
  for (const [moduleName, bytes] of bundles) {
@@ -515,6 +573,7 @@ export async function scaffold(opts) {
515
573
  versions,
516
574
  markers,
517
575
  ownership,
576
+ ...spokeSkip,
518
577
  });
519
578
  }
520
579
  // The template lands LAST — after `scaffoldSync` and after every module
@@ -550,6 +609,7 @@ export async function scaffold(opts) {
550
609
  versions,
551
610
  markers,
552
611
  ownership,
612
+ ...spokeSkip,
553
613
  })
554
614
  : (() => {
555
615
  const sourceRoot = join(SITE_TEMPLATES_DIR, templateTreeDir(template.name));
@@ -565,6 +625,7 @@ export async function scaffold(opts) {
565
625
  versions,
566
626
  markers,
567
627
  ownership,
628
+ ...spokeSkip,
568
629
  });
569
630
  })();
570
631
  installedLayout = installed.layout;
@@ -682,6 +743,17 @@ export async function scaffold(opts) {
682
743
  writeFileSync(rosterPath, nextManifest);
683
744
  }
684
745
  }
746
+ // #7838 D8 — a commerce spoke's own checkout: two routes, its config and
747
+ // two return pages, once every layer has landed (so the spoke filter has
748
+ // already dropped the hub-checkout files) and before the manifest, whose
749
+ // code-route rows then record /checkout/thank-you and /checkout/unavailable.
750
+ const spokeCommerce = spoke !== undefined && result.modules.includes("commerce");
751
+ if (spokeCommerce) {
752
+ writeSpokeCheckoutSlice(result.targetDir, {
753
+ site: spokeCheckoutSite(projectName),
754
+ versions,
755
+ });
756
+ }
685
757
  // #6944 — the manifest records what this scaffold IS, once every layer has
686
758
  // landed: the effective module set, a row for every route on disk, and the
687
759
  // level only when the key path above derived one — `resolveModuleSet`, or
@@ -696,6 +768,9 @@ export async function scaffold(opts) {
696
768
  roster,
697
769
  modules: result.modules,
698
770
  level,
771
+ topology: spoke !== undefined ? "spoke" : "hub",
772
+ ...(spoke?.signIn === "hub" ? { signIn: "hub" } : {}),
773
+ patternVersion: markers["CREATE_AILK_VERSION"] ?? "",
699
774
  siteTemplatesDir: SITE_TEMPLATES_DIR,
700
775
  templatesDir: TEMPLATES_DIR,
701
776
  markers,
@@ -705,22 +780,105 @@ export async function scaffold(opts) {
705
780
  // #7536 D3 — the registry follows `apps/web/package.json`, which a module
706
781
  // bundle's `patches` (and the template layer) just finished editing.
707
782
  writePaidSections(result.targetDir);
783
+ // #7874 D9 — a spoke's rows again, now the module bundles and the template
784
+ // layer are in the scan; it appends only what those layers added. A
785
+ // commerce spoke also declares what the checkout package reads (#7838 D9).
786
+ if (spoke !== undefined) {
787
+ appendEnvRows(result.targetDir, {
788
+ topology: "spoke",
789
+ ...spokeEnvRowOptions(spokeCommerce),
790
+ });
791
+ }
708
792
  // #6107 D2 — after every layer landed, so nothing later overwrites it: a
709
793
  // served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
710
- writeAuthSecretFile(result.targetDir);
794
+ // A spoke has no sign-in (#7426 D4), so it gets its hub's origin instead
795
+ // (D2) — never the spoke key, which the hub issues.
796
+ // #7894 D11 — a spoke signing in through its hub also gets the hub's web
797
+ // origin and its own session secret; never the client secret.
798
+ if (spoke !== undefined)
799
+ writeHubOriginEnv(result.targetDir, spoke.hubOrigin);
800
+ else
801
+ writeAuthSecretFile(result.targetDir);
802
+ if (spoke?.hubSignInOrigin !== undefined) {
803
+ writeHubSignInEnv(result.targetDir, spoke.hubSignInOrigin);
804
+ }
805
+ // #7876 D9 — a spoke carrying the flow stepper says what its `flows/` is
806
+ // and how its rollout seeds it. A hub, and a spoke without flows, get none.
807
+ if (spoke !== undefined && modules.includes("signal-collectors")) {
808
+ writeSpokeFlowsReadme(result.targetDir);
809
+ }
711
810
  // #6683 D9 — persisted only after every layer landed, and only when the
712
- // caller passed `license`: a scaffold without it writes nothing new.
811
+ // caller passed `license`: a scaffold without it writes nothing new. A
812
+ // spoke has no apps/api, so it keeps the key in ailk.key alone (#7426 D6).
713
813
  if (opts.license !== undefined) {
714
- writeLicenseKeyFiles(result.targetDir, opts.license);
814
+ writeLicenseKeyFiles(result.targetDir, opts.license, {
815
+ apiEnv: spoke === undefined,
816
+ });
715
817
  }
716
818
  // #6754 D4/D5 — the resolved starter, so the CLI names what it built.
819
+ const withDropped = {
820
+ ...result,
821
+ ...(dropped.length > 0 ? { droppedModules: dropped } : {}),
822
+ ...(spoke !== undefined ? { hubOrigin: spoke.hubOrigin } : {}),
823
+ ...(spoke?.hubSignInOrigin !== undefined
824
+ ? { hubSignInOrigin: spoke.hubSignInOrigin }
825
+ : {}),
826
+ };
717
827
  if (template !== undefined && resolvedDesign !== undefined) {
718
828
  return {
719
- ...result,
829
+ ...withDropped,
720
830
  starter: { template: template.name, shape: template.shape, design: resolvedDesign.recorded },
721
831
  };
722
832
  }
723
- return result;
833
+ return withDropped;
834
+ }
835
+ /**
836
+ * A spoke's resolved inputs (#7426 D1-D3/D7), or undefined for a hub. Every
837
+ * refusal is a `TemplateError` thrown before anything is fetched or written.
838
+ */
839
+ function resolveSpoke(opts, template) {
840
+ if (opts.topology !== "spoke") {
841
+ const spokeOnly = [
842
+ opts.hubUrl !== undefined ? "hubUrl" : undefined,
843
+ opts.mcp !== undefined ? "mcp" : undefined,
844
+ opts.hubSignInUrl !== undefined ? "hubSignInUrl" : undefined,
845
+ ].filter((f) => f !== undefined);
846
+ if (spokeOnly.length > 0) {
847
+ throw new TemplateError(`${spokeOnly.join(" and ")} only apply to a spoke — pass topology: "spoke"`);
848
+ }
849
+ return undefined;
850
+ }
851
+ if (template === undefined) {
852
+ throw new TemplateError("a spoke needs a starter — pass --shape <shape> or --template <name>: the site manifest is where the topology is recorded");
853
+ }
854
+ let hubOrigin;
855
+ let hubSignInOrigin;
856
+ try {
857
+ hubOrigin = normalizeHubUrl(opts.hubUrl ?? process.env["AILK_API_URL"]);
858
+ if (opts.hubSignInUrl !== undefined) {
859
+ hubSignInOrigin = normalizeHubSignInUrl(opts.hubSignInUrl);
860
+ }
861
+ }
862
+ catch (err) {
863
+ if (err instanceof TopologyError)
864
+ throw new TemplateError(err.message);
865
+ throw err;
866
+ }
867
+ for (const name of normalizeModules([...(opts.modules ?? []), ...template.requires])) {
868
+ const refusal = spokeModuleRefusal(name);
869
+ if (refusal !== undefined) {
870
+ throw new TemplateError(template.requires.includes(name) && !(opts.modules ?? []).includes(name)
871
+ ? `template "${template.name}" requires ${refusal}`
872
+ : refusal);
873
+ }
874
+ }
875
+ return {
876
+ mcp: opts.mcp !== false,
877
+ hubOrigin,
878
+ ...(hubSignInOrigin !== undefined
879
+ ? { signIn: "hub", hubSignInOrigin }
880
+ : {}),
881
+ };
724
882
  }
725
883
  /**
726
884
  * The per-row install of the two SHARED seed layers (#6646 D1, disposition
@@ -842,7 +1000,12 @@ export function syncArchitectureSync(opts) {
842
1000
  throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
843
1001
  }
844
1002
  const enabledSurfaces = resolveSurfaces(opts.modules, opts.surfaces ?? []);
845
- writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces));
1003
+ // #7426 D13 — a spoke stays a spoke: its topology is read from its
1004
+ // manifest, and `mcp` from whether it still carries apps/mcp.
1005
+ const topology = readSiteTopology(targetAbs);
1006
+ writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces, topology === "spoke"
1007
+ ? { topology, mcp: existsSync(join(targetAbs, "apps", "mcp")) }
1008
+ : {}));
846
1009
  return {
847
1010
  targetDir: targetAbs,
848
1011
  surfaces: enabledSurfaces,
@@ -960,6 +1123,13 @@ export async function addModule(opts) {
960
1123
  if (!existsSync(join(targetAbs, "architecture.yaml"))) {
961
1124
  throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
962
1125
  }
1126
+ // #7426 D13 — a spoke refuses a module it does not admit before any key
1127
+ // read or network call (`applyModuleToScaffold` refuses it again).
1128
+ if (addModuleSiteTopology(targetAbs) === "spoke") {
1129
+ const refusal = spokeModuleRefusal(opts.moduleName);
1130
+ if (refusal !== undefined)
1131
+ throw new AddModuleRefusedError(refusal);
1132
+ }
963
1133
  const { paid } = classifyModules([opts.moduleName]);
964
1134
  if (paid.length === 0) {
965
1135
  throw new AddModuleRefusedError(`"${opts.moduleName}" ships with every scaffold — there is no module bundle to add`);
@@ -0,0 +1,65 @@
1
+ /** The environment variable the spoke key is read from — and nowhere else. */
2
+ export declare const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
3
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
4
+ export declare class SeedFlowsRefusedError extends Error {
5
+ readonly exitCode = 2;
6
+ constructor(message: string);
7
+ }
8
+ export interface SeedFlowsOptions {
9
+ /** The spoke site. */
10
+ targetDir: string;
11
+ /** `--hub-url`. */
12
+ hubUrl?: string | undefined;
13
+ /** `--dry-run` — read and check the files, send nothing, read no key. */
14
+ dryRun?: boolean;
15
+ /** Injectable for tests; defaults to `globalThis.fetch`. */
16
+ fetch?: typeof globalThis.fetch;
17
+ /** Injectable for tests; defaults to `process.env`. */
18
+ env?: NodeJS.ProcessEnv;
19
+ /** Each report line as it happens; defaults to nothing. */
20
+ onLine?: (line: string) => void;
21
+ }
22
+ /** Where the hub origin came from. */
23
+ export type HubSource = "--hub-url" | "AILK_API_URL" | "apps/web/.env.local";
24
+ export type SeedFlowOutcome = {
25
+ slug: string;
26
+ ok: true;
27
+ result: string;
28
+ status: string;
29
+ } | {
30
+ slug: string;
31
+ ok: false;
32
+ failure: string;
33
+ } | {
34
+ slug: string;
35
+ ok: "dry-run";
36
+ };
37
+ export interface SeedFlowsResult {
38
+ /** The hub origin, when one resolved (a dry run may have none). */
39
+ hub?: {
40
+ origin: string;
41
+ source: HubSource;
42
+ };
43
+ /** Set when there were no declared flows to send. */
44
+ empty?: string;
45
+ flows: SeedFlowOutcome[];
46
+ /** 0 every flow answered 200 (or a dry run); 1 any failed. */
47
+ exitCode: 0 | 1;
48
+ }
49
+ /** The whole report, one line per row — what `seedFlows` printed as it went. */
50
+ export declare function formatSeedFlowsReport(result: SeedFlowsResult): string;
51
+ export declare function seedFlows(opts: SeedFlowsOptions): Promise<SeedFlowsResult>;
52
+ export declare const SPOKE_FLOWS_README = "# flows/\n\nEach file here is one **declared flow**: `flows/<slug>.json` holds one flow\nconfig, exactly as the hub stores it. This repo owns these configs \u2014 the hub\nruns them for this site.\n\n## What a spoke may declare\n\nA spoke key may seed only what this site's own flow pages show:\n`slug`, `defaultLocale`, `steps`, `thankYou` and `flags`. Anything the\nhub acts on outside these pages \u2014 emails, deliverables, pricing, registers,\nlistings \u2014 is refused (`field_not_seedable`). A flow that needs one of those\nis authored in the hub's Admin instead.\n\nThe file name must be the config's `slug` plus `.json`.\n\n## Seeding them into the hub\n\nRun this as part of each rollout, from the site's root, with the spoke key\nyour hub issued set as `AILK_SPOKE_KEY` in the environment (a shell or CI\nsecret \u2014 never a file in this repo):\n\n npx create-ailk seed-flows .\n\nThe hub origin comes from `--hub-url`, else `AILK_API_URL`, else the\n`AILK_API_URL=` line of `apps/web/.env.local`. The key is read from the\nenvironment only. Each flow answers `created`, `updated` or `unchanged`,\nso running it again is safe. `--dry-run` checks the files and sends nothing.\n\nThe hub's operator must first turn seeding on (`AILK_SPOKE_FLOW_SEED=true`\non the hub). Until it is on and this command has run, links to these flows\n(`/flows/<slug>`) reach no flow.\n\n## Who owns what\n\nThe next seed that differs overwrites the hub's copy of a flow's config, so\nedit flows here, not in the Admin. A flow's status (live or draft) is the\nhub's: switching a seeded flow to draft in the Admin keeps it draft through\nevery later seed. Removing a file here does not delete the hub's flow. If\nthe Admin adds a block only it can author (an email, a deliverable, an\noffer), the hub refuses the next seed of that flow (`slug_owned`) rather\nthan delete the block.\n";
53
+ /**
54
+ * Write `SPOKE_FLOWS_README` to `<targetDir>/flows/README.md` unless that
55
+ * file exists. Returns whether it wrote.
56
+ */
57
+ export declare function writeSpokeFlowsReadme(targetDir: string): boolean;
58
+ export interface SeedFlowsArgs {
59
+ target: string | undefined;
60
+ hubUrl: string | undefined;
61
+ dryRun: boolean;
62
+ help: boolean;
63
+ }
64
+ export declare const SEED_FLOWS_HELP = "create-ailk seed-flows \u2014 send a spoke's declared flows to its hub.\n\nUsage:\n AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]\n\nArguments:\n <dir> The spoke site. Defaults to the current dir.\n\nOptions:\n --hub-url <origin> The hub's origin. Else AILK_API_URL, else the\n AILK_API_URL= line of apps/web/.env.local. https:\n only (http: on localhost or 127.0.0.1).\n --dry-run Check every flows/*.json and print what would be sent.\n Sends nothing and reads no key.\n -h, --help Show this help.\n\nEach flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with\nthe spoke key, which is read from AILK_SPOKE_KEY only. The hub must have\nAILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any\nfailed, 2 on a refusal (nothing is sent).\n";
65
+ export declare function parseSeedFlowsArgs(argv: string[]): SeedFlowsArgs;