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
package/dist/adopt.js ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `create-ailk adopt [<target-dir>]` — record, once, what an existing site
3
+ * is (#7875).
4
+ *
5
+ * A site scaffolded before #7426, or not scaffolded by create-ailk at all,
6
+ * has no `topology` in its `.ailk-site-manifest.json`, so every create-ailk
7
+ * command that asks `readSiteTopology` treats it as a hub — wrong for a site
8
+ * trimmed to a spoke by hand. Adopt writes the two fields the keystone added
9
+ * (D10 of spec 7426), and nothing else:
10
+ *
11
+ * - `topology` is derived from the tree against `SPOKE_REMOVED_ROOTS`
12
+ * (D1) — all removed roots absent is a spoke, all present a hub, a mix
13
+ * is refused. Never a flag: the tree is the evidence;
14
+ * - `patternVersion` is the running create-ailk version, the value a
15
+ * scaffold records (D8 — on an adopted site, the version it was adopted
16
+ * against);
17
+ * - an existing manifest gains the two fields, every other byte kept
18
+ * (`planTopologyRecord`, D3); a site with none gets one built by the
19
+ * scaffold's own writer from `--template` and the module set
20
+ * (`planAdoptedSiteManifest`, D4/D5);
21
+ * - a manifest already recording either field is refused (D2): adopt runs
22
+ * once. A recorded module a spoke cannot carry is a warning, never a
23
+ * refusal (D6) — `add-module` is the admission gate, not adopt.
24
+ *
25
+ * It writes the manifest — never a seam file, `architecture.yaml`, anything
26
+ * under `apps/api`, and never another subcommand (`sync-routes --apply`
27
+ * included). The one exception is a spoke's `project.yaml` (#7874 D9): on a
28
+ * site it derives as a spoke, adopt also appends an `env_contract` row for
29
+ * every env var the site's code reads that has none, so its `pnpm
30
+ * env:declared` check has a contract to read. A hub's `project.yaml` is never
31
+ * touched. Every refusal throws before any write.
32
+ *
33
+ * Pure module: no `import.meta.url`, so jest can import it directly — the
34
+ * package-rooted directories are supplied by the CLI, the `add-route.ts`
35
+ * split. Spec: docs/specs/7875-create-ailk-adopt-spec.md.
36
+ */
37
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
38
+ import { basename, join, resolve } from "node:path";
39
+ import { parseArgs } from "node:util";
40
+ import { readArchitectureHeader } from "./add-module-core.js";
41
+ import { buildScaffoldMarkers } from "./copy.js";
42
+ import { EnvContractError, planEnvRows } from "./lib/declared-env.js";
43
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
44
+ import { normalizeModules } from "./module-architecture.js";
45
+ import { ParseError, RECOGNIZED_MODULES, } from "./parse-args.js";
46
+ import { planAdoptedSiteManifest, planTopologyRecord, } from "./site-manifest.js";
47
+ import { LEGACY_SITE_ROSTER_PATH, SITE_ROSTER_PATH, readSiteRoster, resolveRosterTemplate, } from "./site-roster.js";
48
+ import { SHAPE_STARTERS } from "./template-manifest.js";
49
+ import { MCP_SEAM_PATH, SPOKE_REMOVED_ROOTS, WEB_SEAM_PATH, renderMcpSeam, renderWebSeam, spokeModuleRefusal, } from "./topology.js";
50
+ /** A refusal the CLI exits 2 on, thrown before anything is written. */
51
+ export class AdoptRefusedError extends Error {
52
+ exitCode = 2;
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = "AdoptRefusedError";
56
+ }
57
+ }
58
+ /** The generated seam files, by the app directory each lives in, with their renders. */
59
+ const SEAMS = [
60
+ { app: "apps/web", path: WEB_SEAM_PATH, render: renderWebSeam },
61
+ { app: "apps/mcp", path: MCP_SEAM_PATH, render: renderMcpSeam },
62
+ ];
63
+ function isDirectory(path) {
64
+ return existsSync(path) && statSync(path).isDirectory();
65
+ }
66
+ /**
67
+ * D1 — the topology the tree shows: every `SPOKE_REMOVED_ROOTS` entry absent
68
+ * is a spoke, every one present a hub. Anything else is refused.
69
+ */
70
+ function deriveTopology(targetDir) {
71
+ const present = SPOKE_REMOVED_ROOTS.filter((root) => isDirectory(join(targetDir, root)));
72
+ if (present.length === 0)
73
+ return "spoke";
74
+ if (present.length === SPOKE_REMOVED_ROOTS.length)
75
+ return "hub";
76
+ const absent = SPOKE_REMOVED_ROOTS.filter((root) => !present.includes(root));
77
+ throw new AdoptRefusedError(`adopt: ${targetDir} has ${present.join(", ")} but not ${absent.join(", ")}, so it is neither a hub nor a spoke`);
78
+ }
79
+ /** D5 — the module set for a site with no manifest: `--modules`, else the header. */
80
+ function moduleSet(targetDir, flag) {
81
+ if (flag !== undefined) {
82
+ try {
83
+ return normalizeModules(flag.split(","));
84
+ }
85
+ catch (err) {
86
+ throw new AdoptRefusedError(`--modules is not a module list: ${err.message}`);
87
+ }
88
+ }
89
+ const archPath = join(targetDir, "architecture.yaml");
90
+ if (!existsSync(archPath)) {
91
+ throw new AdoptRefusedError(`${targetDir} has no architecture.yaml to read its module set from; name it with --modules <a,b>`);
92
+ }
93
+ try {
94
+ return readArchitectureHeader(readFileSync(archPath, "utf8")).modules;
95
+ }
96
+ catch (err) {
97
+ throw new AdoptRefusedError(`${err.message.replace(/\.$/, "")}. Name the module set with --modules <a,b> instead.`);
98
+ }
99
+ }
100
+ /** The recorded module names this package recognizes; anything else is ignored. */
101
+ function recordedModules(value) {
102
+ if (!Array.isArray(value))
103
+ return [];
104
+ return value.filter((m) => RECOGNIZED_MODULES.includes(m));
105
+ }
106
+ /**
107
+ * Adopt the site at `opts.targetDir`: write the manifest recording its
108
+ * topology and pattern version. Every refusal throws before the one write.
109
+ *
110
+ * @throws AdoptRefusedError (exit 2) on a refusal this command names, and
111
+ * the `AddModuleRefusedError` (exit 2) the roster readers and manifest
112
+ * writer throw on a manifest they cannot read or extend.
113
+ */
114
+ export function adoptSite(opts) {
115
+ const targetDir = resolve(opts.targetDir);
116
+ if (!isDirectory(targetDir)) {
117
+ throw new Error(`adopt target is not a directory: ${targetDir}. Point adopt at an existing site.`);
118
+ }
119
+ const topology = deriveTopology(targetDir);
120
+ const markers = buildScaffoldMarkers(basename(targetDir));
121
+ const patternVersion = markers["CREATE_AILK_VERSION"];
122
+ if (patternVersion === undefined || patternVersion === "") {
123
+ // A once-only field: never record an empty version a second run cannot fix.
124
+ throw new Error("adopt: this create-ailk has no version to record");
125
+ }
126
+ const record = { topology, patternVersion };
127
+ const roster = readSiteRoster(targetDir);
128
+ let text;
129
+ let modules;
130
+ if (roster !== undefined) {
131
+ if (roster.path === LEGACY_SITE_ROSTER_PATH) {
132
+ throw new AdoptRefusedError(`${LEGACY_SITE_ROSTER_PATH} is the pre-#6658 roster name; rename it to ${SITE_ROSTER_PATH}, then re-run adopt.`);
133
+ }
134
+ for (const key of ["topology", "patternVersion"]) {
135
+ if (key in roster.doc) {
136
+ throw new AdoptRefusedError(`${roster.path} already records "${key}", so this site is already adopted. To move it to a new create-ailk version, see the README § Keeping a spoke current.`);
137
+ }
138
+ }
139
+ for (const [flag, value] of [
140
+ ["--template", opts.template],
141
+ ["--modules", opts.modules],
142
+ ]) {
143
+ if (value !== undefined) {
144
+ throw new AdoptRefusedError(`${roster.path} already records the site's template and modules; ${flag} would contradict it. Re-run adopt without ${flag}.`);
145
+ }
146
+ }
147
+ text = planTopologyRecord(roster.text, record, roster.path);
148
+ modules = recordedModules(roster.doc["modules"]);
149
+ }
150
+ else {
151
+ if (opts.template === undefined) {
152
+ throw new AdoptRefusedError(`${targetDir} has no ${SITE_ROSTER_PATH}; name the template it was built from with --template <name> (one of: ${Object.keys(SHAPE_STARTERS).join(", ")}).`);
153
+ }
154
+ try {
155
+ resolveRosterTemplate(opts.template);
156
+ }
157
+ catch (err) {
158
+ throw new AdoptRefusedError(`--template "${opts.template}" names no declared template: ${err.message}`);
159
+ }
160
+ modules = moduleSet(targetDir, opts.modules);
161
+ text = planAdoptedSiteManifest({
162
+ targetDir,
163
+ template: opts.template,
164
+ modules,
165
+ ...record,
166
+ siteTemplatesDir: opts.siteTemplatesDir,
167
+ templatesDir: opts.templatesDir,
168
+ markers,
169
+ });
170
+ }
171
+ const warnings = topology === "spoke"
172
+ ? modules.flatMap((m) => spokeModuleRefusal(m) ?? [])
173
+ : [];
174
+ const seams = SEAMS.filter(({ app }) => isDirectory(join(targetDir, app))).map(({ path, render }) => {
175
+ const abs = join(targetDir, path);
176
+ if (!existsSync(abs))
177
+ return { path, state: "absent" };
178
+ return {
179
+ path,
180
+ state: readFileSync(abs, "utf8") === render(topology) ? "present" : "differs",
181
+ };
182
+ });
183
+ // #7874 D9 — a spoke's env_contract rows, planned before any write: a
184
+ // block adopt cannot read is a refusal, and nothing is written.
185
+ const yamlPath = join(targetDir, "project.yaml");
186
+ let env = { state: "hub" };
187
+ let yamlNext;
188
+ if (topology === "spoke") {
189
+ if (!existsSync(yamlPath)) {
190
+ env = { state: "no-project-yaml" };
191
+ }
192
+ else {
193
+ const yamlText = readFileSync(yamlPath, "utf8");
194
+ try {
195
+ const plan = planEnvRows(targetDir, yamlText, {
196
+ topology,
197
+ catalog: ENV_CATALOG,
198
+ });
199
+ if (plan.text === yamlText) {
200
+ env = { state: "unchanged" };
201
+ }
202
+ else {
203
+ env = { state: "written", added: plan.added };
204
+ yamlNext = plan.text;
205
+ }
206
+ }
207
+ catch (err) {
208
+ if (err instanceof EnvContractError) {
209
+ throw new AdoptRefusedError(`adopt: ${err.message}`);
210
+ }
211
+ throw err;
212
+ }
213
+ }
214
+ }
215
+ writeFileSync(join(targetDir, SITE_ROSTER_PATH), text);
216
+ if (yamlNext !== undefined)
217
+ writeFileSync(yamlPath, yamlNext);
218
+ return {
219
+ targetDir,
220
+ path: SITE_ROSTER_PATH,
221
+ topology,
222
+ patternVersion,
223
+ created: roster === undefined,
224
+ seams,
225
+ warnings,
226
+ env,
227
+ };
228
+ }
229
+ /** The printed report: the path written, the record, the seams, the warnings. */
230
+ export function formatAdoptReport(result) {
231
+ const lines = [
232
+ `[create-ailk] adopt → ${result.targetDir}`,
233
+ `${result.created ? "Wrote" : "Updated"} ${result.path}`,
234
+ ` topology: ${result.topology}`,
235
+ ` patternVersion: ${result.patternVersion}`,
236
+ ];
237
+ if (result.seams.length > 0) {
238
+ lines.push(`Seam files (against the ${result.topology} render):`);
239
+ const width = Math.max(...result.seams.map((s) => s.path.length));
240
+ lines.push(...result.seams.map((s) => ` ${s.path.padEnd(width)} ${s.state}`));
241
+ }
242
+ const env = formatAdoptEnv(result.env);
243
+ if (env !== undefined)
244
+ lines.push(env);
245
+ lines.push(...result.warnings.map((w) => `warning: ${w}`));
246
+ return lines.join("\n") + "\n";
247
+ }
248
+ /** The report's one env_contract line (#7874 D9); none on a hub. */
249
+ function formatAdoptEnv(env) {
250
+ switch (env.state) {
251
+ case "written":
252
+ return `env_contract: ${env.added.length} rows added (${env.added.join(", ")})`;
253
+ case "unchanged":
254
+ return "env_contract: every read already declared";
255
+ case "no-project-yaml":
256
+ return "env_contract: no project.yaml, nothing written";
257
+ case "hub":
258
+ return undefined;
259
+ }
260
+ }
261
+ export const ADOPT_HELP = `create-ailk adopt — record what an existing site is, once.
262
+
263
+ Usage:
264
+ create-ailk adopt [<target-dir>] [--template <name>] [--modules <a,b>]
265
+
266
+ Arguments:
267
+ <target-dir> The existing site. Defaults to the current dir.
268
+
269
+ Options:
270
+ --template <name> The template the site was built from. Required
271
+ when the site has no .ailk-site-manifest.json;
272
+ refused when it has one.
273
+ --modules <a,b> The site's module set, when it has no manifest.
274
+ Overrides architecture.yaml's "# Modules:" header.
275
+ --help, -h Print this message.
276
+
277
+ Writes "topology" and "patternVersion" into .ailk-site-manifest.json. On a
278
+ spoke it also appends an env_contract row to project.yaml for every
279
+ environment variable the site's code reads that has none, so pnpm
280
+ env:declared has a contract to check; it never edits or removes a row, and
281
+ it never touches a hub's project.yaml. Nothing else is written. The
282
+ topology is read from the tree: no apps/api and no database/ is a spoke,
283
+ both is a hub, one without the other is refused.
284
+ patternVersion is this create-ailk's version. A site with no manifest gets
285
+ one, built the way a scaffold builds it. A manifest that already records
286
+ either field is refused: adopt runs once. A recorded module a spoke cannot
287
+ carry is a warning, not a refusal.
288
+ `;
289
+ /**
290
+ * Parse the argv AFTER the `adopt` word. Strict `node:util` parseArgs, like
291
+ * `parseAddRouteArgs`; the values themselves are validated by `adoptSite`.
292
+ */
293
+ export function parseAdoptArgs(argv) {
294
+ const parsed = parseArgs({
295
+ args: argv,
296
+ strict: true,
297
+ allowPositionals: true,
298
+ options: {
299
+ template: { type: "string" },
300
+ modules: { type: "string" },
301
+ help: { type: "boolean", default: false, short: "h" },
302
+ },
303
+ });
304
+ const [target, ...rest] = parsed.positionals;
305
+ if (rest.length > 0) {
306
+ throw new ParseError(`adopt takes at most one target directory, got ${parsed.positionals.length} arguments`);
307
+ }
308
+ return {
309
+ target,
310
+ template: parsed.values.template,
311
+ modules: parsed.values.modules,
312
+ help: parsed.values.help === true,
313
+ };
314
+ }
@@ -1,4 +1,5 @@
1
1
  import { type HomeSeedOutcome } from "./design-home.js";
2
+ import { type UnreadChromeKnob } from "./lib/chrome-readers.js";
2
3
  export interface ApplyDesignOptions {
3
4
  /** The design: a shipped name, or a directory holding a DESIGN.md. */
4
5
  design: string;
@@ -10,6 +11,8 @@ export interface ApplyDesignOptions {
10
11
  /** The overlay's `templates/` (#7743 D7) — where a treeless shape's home lives. Default: `templates` beside `siteTemplatesDir`. */
11
12
  templatesDir?: string;
12
13
  catalogPath: string;
14
+ /** #7781 — apply the design even when a reader file of the site ignores a chrome knob it sets; the pairs are reported instead of refused. */
15
+ allowUnreadChrome?: boolean;
13
16
  /** Where a relative design path resolves from. Default process.cwd(). */
14
17
  cwd?: string;
15
18
  /** Read for AILK_LICENSE_KEY only (D6). Default process.env. */
@@ -60,13 +63,21 @@ export interface ApplyDesignResult {
60
63
  fonts?: string[];
61
64
  /** #7720 D4.2 — faces were written, but the site's layout.tsx does not read lib/site-fonts. */
62
65
  fontsUnwired?: true;
66
+ /** #7781 — chrome knobs the design set that a reader file of the site does not read; present only when non-empty, which only `allowUnreadChrome` lets through. */
67
+ unreadChrome?: UnreadChromeKnob[];
68
+ }
69
+ /** #7781 — thrown before any write when the design sets a chrome knob that a reader file of the site does not read. */
70
+ export declare class UnreadChromeError extends Error {
71
+ readonly unread: readonly UnreadChromeKnob[];
72
+ constructor(unread: readonly UnreadChromeKnob[]);
63
73
  }
64
74
  export interface ApplyDesignArgs {
65
75
  design: string | undefined;
66
76
  target: string | undefined;
77
+ allowUnreadChrome: boolean;
67
78
  help: boolean;
68
79
  }
69
- export declare const APPLY_DESIGN_HELP = "create-ailk apply-design \u2014 make an existing site wear a design.\n\nUsage:\n create-ailk apply-design <name> [--target <site-dir>]\n create-ailk apply-design <path> [--target <site-dir>]\n\nArguments:\n <name|path> A shipped design (standard, minimalism), or a\n directory holding a DESIGN.md. A relative path\n resolves from the current directory.\n\nOptions:\n --target <site-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n --help, -h Print this message.\n\nWrites only the look: DESIGN.md (replaced whole by the design's, keeping the\nsite's Source template and Version lines), the theme: block of\ncontent/_site.mdx, the generated apps/web/lib/site-chrome.ts, the generated\napps/web/lib/site-fonts.ts, the look fields of sections the site's pages\nalready place, and the design/layout/theme record in .ailk-site-manifest.json.\nNever adds, removes or renames a route or page, never touches a content field\n(except a placeholder home's sections, below) or the brand: block, and leaves\nevery other page's structured data unchanged.\n\nA design may seed the placeholder home's sections \u2014 its home arc \u2014 while they\nare still a placeholder: the shape's own, or a design's arc of them. It never\nchanges a home whose sections were edited; it reports it and leaves it. A\nseeded home takes the design's looks; look values on the sections it replaced\nare not kept. A design that ships no arc restores the shape's own sections on\na home that still wears another design's untouched arc.\n\nA design other than standard needs a Pro or Pro Plus key: the site's own\nailk.key (put one there with create-ailk license set <key>), else\nAILK_LICENSE_KEY. A malformed package, a preset the validator refuses or a\nmissing key is refused before any file is written.\n\nA preset sets the look fields it names and never unsets one: a field it does\nnot name keeps whatever value the page has. Running it again changes nothing.\n";
80
+ export declare const APPLY_DESIGN_HELP = "create-ailk apply-design \u2014 make an existing site wear a design.\n\nUsage:\n create-ailk apply-design <name> [--target <site-dir>]\n create-ailk apply-design <path> [--target <site-dir>]\n\nArguments:\n <name|path> A shipped design (standard, minimalism), or a\n directory holding a DESIGN.md. A relative path\n resolves from the current directory.\n\nOptions:\n --target <site-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n --allow-unread-chrome Apply the design even when a file of the site does\n not read a chrome setting the design sets (a site\n scaffolded before the setting existed). Without it\n that is refused; with it each such file is reported.\n --help, -h Print this message.\n\nWrites only the look: DESIGN.md (replaced whole by the design's, keeping the\nsite's Source template and Version lines), the theme: block of\ncontent/_site.mdx, the generated apps/web/lib/site-chrome.ts, the generated\napps/web/lib/site-fonts.ts, the look fields of sections the site's pages\nalready place, and the design/layout/theme record in .ailk-site-manifest.json.\nNever adds, removes or renames a route or page, never touches a content field\n(except a placeholder home's sections, below) or the brand: block, and leaves\nevery other page's structured data unchanged.\n\nA design may seed the placeholder home's sections \u2014 its home arc \u2014 while they\nare still a placeholder: the shape's own, or a design's arc of them. It never\nchanges a home whose sections were edited; it reports it and leaves it. A\nseeded home takes the design's looks; look values on the sections it replaced\nare not kept. A design that ships no arc restores the shape's own sections on\na home that still wears another design's untouched arc.\n\nA design other than standard needs a Pro or Pro Plus key: the site's own\nailk.key (put one there with create-ailk license set <key>), else\nAILK_LICENSE_KEY. A malformed package, a preset the validator refuses or a\nmissing key is refused before any file is written.\n\nA preset sets the look fields it names and never unsets one: a field it does\nnot name keeps whatever value the page has. Running it again changes nothing.\n";
70
81
  /**
71
82
  * Parse the argv AFTER the `apply-design` word (D1). Strict `node:util`
72
83
  * parseArgs, like `parseSyncRoutesArgs`: exactly one positional, the design
@@ -22,6 +22,7 @@ import { compileDesignFonts, compileDesignTheme } from "./design-compile.js";
22
22
  import { HOME_PAGE_PATH, designHomeSeeds, planDesignHome, shapeHomePath, shippedHomeSeeds, } from "./design-home.js";
23
23
  import { applyDesignPresets, readSectionLookCatalog, shippedPageTypes, validateDesignPresets, } from "./design-presets.js";
24
24
  import { resolveDesign, spliceThemeBlock } from "./design-scaffold.js";
25
+ import { chromeKnobReaders, unreadChromeKnobs } from "./lib/chrome-readers.js";
25
26
  import { LICENSE_KEY_FILE } from "./lib/license-key-files.js";
26
27
  import { CONVERSION_HINT_URL, LicenseRequiredError, assertDesignAllowed, deriveLevel, } from "./lib/module-license-gate.js";
27
28
  import { LEVEL_LABEL } from "./lib/module-tier.js";
@@ -29,6 +30,17 @@ import { ParseError } from "./parse-args.js";
29
30
  import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
30
31
  import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
31
32
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, } from "./site-roster.js";
33
+ /** #7781 — thrown before any write when the design sets a chrome knob that a reader file of the site does not read. */
34
+ export class UnreadChromeError extends Error {
35
+ unread;
36
+ constructor(unread) {
37
+ super(`design chrome would not render: the site's own copy of these files does not read the knob the design sets (update them from the current create-ailk overlay, or pass --allow-unread-chrome to apply anyway):\n${unread
38
+ .map(({ knob, file }) => ` ${knob} — ${file}`)
39
+ .join("\n")}`);
40
+ this.unread = unread;
41
+ this.name = "UnreadChromeError";
42
+ }
43
+ }
32
44
  /** The site's locale layout — the one reader of the generated site-fonts.ts. */
33
45
  const SITE_LAYOUT_PATH = "apps/web/app/[locale]/layout.tsx";
34
46
  export const APPLY_DESIGN_HELP = `create-ailk apply-design — make an existing site wear a design.
@@ -45,6 +57,10 @@ Arguments:
45
57
  Options:
46
58
  --target <site-dir> An already-scaffolded repo (contains
47
59
  architecture.yaml). Defaults to the current dir.
60
+ --allow-unread-chrome Apply the design even when a file of the site does
61
+ not read a chrome setting the design sets (a site
62
+ scaffolded before the setting existed). Without it
63
+ that is refused; with it each such file is reported.
48
64
  --help, -h Print this message.
49
65
 
50
66
  Writes only the look: DESIGN.md (replaced whole by the design's, keeping the
@@ -83,6 +99,7 @@ export function parseApplyDesignArgs(argv) {
83
99
  allowPositionals: true,
84
100
  options: {
85
101
  target: { type: "string" },
102
+ "allow-unread-chrome": { type: "boolean", default: false },
86
103
  help: { type: "boolean", default: false, short: "h" },
87
104
  },
88
105
  });
@@ -94,7 +111,12 @@ export function parseApplyDesignArgs(argv) {
94
111
  if (design === undefined && !help) {
95
112
  throw new ParseError("apply-design needs a design. Usage: create-ailk apply-design <name|path> [--target <site-dir>]");
96
113
  }
97
- return { design, target: parsed.values.target, help };
114
+ return {
115
+ design,
116
+ target: parsed.values.target,
117
+ allowUnreadChrome: parsed.values["allow-unread-chrome"] === true,
118
+ help,
119
+ };
98
120
  }
99
121
  /** `abs` relative to `root`, with `/` separators. */
100
122
  function siteRelative(root, abs) {
@@ -189,12 +211,18 @@ export async function applyDesign(opts) {
189
211
  throw new LicenseRequiredError(`design "${resolved.display}" is a Pro feature — this site's licence level is ${LEVEL_LABEL[level]}; put a Pro or Pro Plus key in the site with create-ailk license set <key>, or set AILK_LICENSE_KEY → ${CONVERSION_HINT_URL}`);
190
212
  }
191
213
  }
214
+ // #7781 — after the licence gate, before any plan: every chrome knob the design sets must be read by
215
+ // the site's own copy of each overlay file that reads it. The reader map is derived from the overlay.
216
+ const roster = readSiteRoster(targetAbs);
217
+ const chrome = resolveSiteChrome(roster?.template.layout, pkg.layout);
218
+ const templatesDir = opts.templatesDir ?? join(dirname(opts.siteTemplatesDir), "templates");
219
+ const unreadChrome = unreadChromeKnobs(chrome, chromeKnobReaders(templatesDir), targetAbs);
220
+ if (unreadChrome.length > 0 && opts.allowUnreadChrome !== true)
221
+ throw new UnreadChromeError(unreadChrome);
192
222
  // C6 / D4 — plan every write in memory.
193
223
  const theme = compileDesignTheme(pkg);
194
224
  const siteMdxCurrent = readFileSync(siteMdxPath, "utf8");
195
225
  const siteMdxNext = spliceThemeBlock(siteMdxCurrent, theme, siteMdxPath);
196
- const roster = readSiteRoster(targetAbs);
197
- const chrome = resolveSiteChrome(roster?.template.layout, pkg.layout);
198
226
  const chromePath = join(targetAbs, SITE_CHROME_PATH);
199
227
  const chromeCurrent = existsSync(chromePath) ? readFileSync(chromePath, "utf8") : undefined;
200
228
  const chromeNext = renderSiteChrome(chrome);
@@ -229,7 +257,7 @@ export async function applyDesign(opts) {
229
257
  ? ""
230
258
  : readFileSync(shapeHomePath(shape, {
231
259
  siteTemplatesDir: opts.siteTemplatesDir,
232
- templatesDir: opts.templatesDir ?? join(dirname(opts.siteTemplatesDir), "templates"),
260
+ templatesDir,
233
261
  }), "utf8"),
234
262
  shippedSeeds,
235
263
  catalog: catalog(),
@@ -328,6 +356,7 @@ export async function applyDesign(opts) {
328
356
  ...(roster !== undefined ? { rosterPath: roster.path } : {}),
329
357
  ...(fontFamilies !== undefined ? { fonts: fontFamilies } : {}),
330
358
  ...(fontsUnwired ? { fontsUnwired: true } : {}),
359
+ ...(unreadChrome.length > 0 ? { unreadChrome } : {}),
331
360
  };
332
361
  }
333
362
  /** The changed-file report (D9). */
@@ -364,6 +393,9 @@ export function formatApplyDesignReport(result) {
364
393
  if (result.fontsUnwired === true) {
365
394
  lines.push(`fonts not loaded: ${SITE_LAYOUT_PATH} predates lib/site-fonts — update the layout from the current create-ailk overlay`);
366
395
  }
396
+ for (const { knob, file } of result.unreadChrome ?? []) {
397
+ lines.push(`chrome not rendered: ${knob} — ${file} does not read it`);
398
+ }
367
399
  if (result.manifest === "legacy-roster") {
368
400
  lines.push(`manifest not recorded: ${result.rosterPath} predates the design record`);
369
401
  }
package/dist/cli.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * addModule(), print the report, exit (2 on a refusal it names)
8
8
  * on an `add-route` subcommand: add one route surface to an existing
9
9
  * site, print the report, exit (2 on a refusal it names) (#6947)
10
+ * on an `adopt` subcommand: record an existing site's topology and
11
+ * pattern version in its manifest, print the report, exit (2 on a
12
+ * refusal it names) (#7875)
13
+ * on a `seed-flows` subcommand: send a spoke's declared flows/*.json to
14
+ * its hub with the key from AILK_SPOKE_KEY, print one line per flow,
15
+ * exit 1 if any failed (2 on a refusal it names, nothing sent) (#7876)
10
16
  * on a `sync-routes` subcommand: parse its own argv, run syncRoutesSync(),
11
17
  * print the report, exit (1 iff routes are missing in check mode)
12
18
  * on a `license set` subcommand: write the key into the scaffold, push it
@@ -40,6 +46,7 @@ import { basename, dirname, resolve } from "node:path";
40
46
  import { fileURLToPath } from "node:url";
41
47
  import { ADD_MODULE_HELP, AddModuleRefusedError, formatAddModuleReport, parseAddModuleArgs, } from "./add-module.js";
42
48
  import { ADD_ROUTE_HELP, AddRouteRefusedError, addRouteToScaffold, formatAddRouteReport, parseAddRouteArgs, } from "./add-route.js";
49
+ import { ADOPT_HELP, AdoptRefusedError, adoptSite, formatAdoptReport, parseAdoptArgs, } from "./adopt.js";
43
50
  import { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
44
51
  import { CLAUDE_MD_CHECK_PATH } from "./claude-md.js";
45
52
  import { buildScaffoldMarkers } from "./copy.js";
@@ -48,6 +55,7 @@ import { FREE_PREREQUISITES_NOTE, LICENSED_PREREQUISITES_NOTE, isLicensedInstall
48
55
  import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
49
56
  import { PLUGIN_INSTALL_LINES, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
50
57
  import { addModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
58
+ import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
51
59
  import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
52
60
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
53
61
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -161,6 +169,62 @@ async function main() {
161
169
  }
162
170
  process.exit(0);
163
171
  }
172
+ // `adopt [dir]` (#7875): record an existing site's topology and pattern
173
+ // version in its manifest, once. Writes that one file and nothing else. A
174
+ // refusal it names (a half-trimmed tree, a second run, a missing or
175
+ // contradicting --template / --modules, a legacy roster) exits 2.
176
+ if (argv[0] === "adopt") {
177
+ const args = parseAdoptArgs(argv.slice(1));
178
+ if (args.help) {
179
+ process.stdout.write(ADOPT_HELP);
180
+ process.exit(0);
181
+ }
182
+ try {
183
+ const result = adoptSite({
184
+ targetDir: args.target ?? process.cwd(),
185
+ templatesDir: resolve(PKG_ROOT, "templates"),
186
+ siteTemplatesDir: resolve(PKG_ROOT, "site-templates"),
187
+ template: args.template,
188
+ modules: args.modules,
189
+ });
190
+ process.stdout.write(formatAdoptReport(result));
191
+ }
192
+ catch (err) {
193
+ if (!(err instanceof AdoptRefusedError) &&
194
+ !(err instanceof AddModuleRefusedError)) {
195
+ throw err;
196
+ }
197
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
198
+ process.exit(err.exitCode);
199
+ }
200
+ process.exit(0);
201
+ }
202
+ // `seed-flows [dir]` (#7876): send a spoke's declared flows/*.json to its
203
+ // hub with the spoke key from AILK_SPOKE_KEY. Exit 1 when any flow failed;
204
+ // a refusal it names (a hub, a bad file, no key, no valid hub origin)
205
+ // exits 2 before anything is sent.
206
+ if (argv[0] === "seed-flows") {
207
+ const args = parseSeedFlowsArgs(argv.slice(1));
208
+ if (args.help) {
209
+ process.stdout.write(SEED_FLOWS_HELP);
210
+ process.exit(0);
211
+ }
212
+ try {
213
+ const result = await seedFlows({
214
+ targetDir: args.target ?? process.cwd(),
215
+ hubUrl: args.hubUrl,
216
+ dryRun: args.dryRun,
217
+ onLine: (line) => process.stdout.write(`${line}\n`),
218
+ });
219
+ process.exit(result.exitCode);
220
+ }
221
+ catch (err) {
222
+ if (!(err instanceof SeedFlowsRefusedError))
223
+ throw err;
224
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
225
+ process.exit(err.exitCode);
226
+ }
227
+ }
164
228
  // Subcommand dispatch, ahead of the flag parser: `sync-routes` has its own
165
229
  // argv shape (#4986). Check mode exits 1 iff a template route is missing;
166
230
  // locally modified routes are reported but do not fail; --apply exits 0.
@@ -203,6 +267,7 @@ async function main() {
203
267
  siteTemplatesDir: resolve(PKG_ROOT, "site-templates"),
204
268
  templatesDir: resolve(PKG_ROOT, "templates"),
205
269
  catalogPath: resolve(PKG_ROOT, "component-catalog.json"),
270
+ allowUnreadChrome: args.allowUnreadChrome,
206
271
  });
207
272
  process.stdout.write(formatApplyDesignReport(result));
208
273
  process.exit(0);
@@ -246,6 +311,10 @@ async function main() {
246
311
  "\n" +
247
312
  ADD_ROUTE_HELP +
248
313
  "\n" +
314
+ ADOPT_HELP +
315
+ "\n" +
316
+ SEED_FLOWS_HELP +
317
+ "\n" +
249
318
  APPLY_DESIGN_HELP +
250
319
  "\n" +
251
320
  LICENSE_SET_HELP);
@@ -318,7 +387,8 @@ async function main() {
318
387
  // marketing-site-free` still rides the no-key case (record D11): its
319
388
  // `requires` is all-free, so it installs from the shipped
320
389
  // `site-templates/` tree with no key and no request.
321
- const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, } = await scaffold(toScaffoldOptions(opts));
390
+ const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, droppedModules, hubOrigin, hubSignInOrigin, } = await scaffold(toScaffoldOptions(opts));
391
+ const isSpoke = opts.topology === "spoke";
322
392
  // The starter scaffold() resolved — from --template, or from --shape (#6754 D4).
323
393
  const templateName = starter?.template ?? opts.template;
324
394
  if (opts.install) {
@@ -346,6 +416,34 @@ async function main() {
346
416
  ...(starter !== undefined
347
417
  ? [`Starter: shape ${starter.shape}, design ${starter.design}.`]
348
418
  : []),
419
+ // #7426 — a spoke says so, and names what its hosts must carry (D3).
420
+ ...(isSpoke
421
+ ? [
422
+ `Topology: spoke — no apps/api, no database, ${hubSignInOrigin !== undefined ? "sign-in through the hub" : "no sign-in"}${opts.mcp === false ? ", no apps/mcp" : "; apps/mcp is a stateless front"}.`,
423
+ ...(droppedModules !== undefined && droppedModules.length > 0
424
+ ? [
425
+ `Not carried on a spoke: ${droppedModules.join(", ")} (your licence includes ${droppedModules.length === 1 ? "it" : "them"}; a spoke does not admit ${droppedModules.length === 1 ? "it" : "them"} yet).`,
426
+ ]
427
+ : []),
428
+ "",
429
+ `Every host serving this spoke's ${opts.mcp === false ? "apps/web" : "apps/web and apps/mcp"} must carry:`,
430
+ ` AILK_API_URL=${hubOrigin ?? "<your hub origin>"} # written to apps/web/.env.local`,
431
+ " AILK_SPOKE_KEY # issued by the hub; never written here",
432
+ " AILK_VISITOR_FORWARD_KEY # issued by the hub; never written here",
433
+ // #7894 D11 — the hub sign-in mode's client, issued on the hub.
434
+ ...(hubSignInOrigin !== undefined
435
+ ? [
436
+ ` AILK_HUB_SIGN_IN_URL=${hubSignInOrigin} # written to apps/web/.env.local`,
437
+ " AILK_HUB_SESSION_SECRET # generated into apps/web/.env.local; keep it secret",
438
+ " AILK_HUB_CLIENT_ID # this spoke's id on the hub; never written here",
439
+ " AILK_HUB_CLIENT_SECRET # issued by the hub; never written here",
440
+ "",
441
+ "On the hub, issue the client id and secret with:",
442
+ " pnpm --filter @working-theory/api spokes sign-in-client --id <spoke_id> --redirect-uri <this site's origin>/api/hub-session/callback",
443
+ ]
444
+ : []),
445
+ ]
446
+ : []),
349
447
  "",
350
448
  ];
351
449
  // Prerequisites (#6440 AC4) — every one the scaffold enforces, and nothing
@@ -374,13 +472,15 @@ async function main() {
374
472
  lines.push("Next steps:");
375
473
  lines.push(` cd ${targetAbs}`);
376
474
  if (!opts.install) {
377
- lines.push(" pnpm install # also generates the Prisma client");
475
+ lines.push(isSpoke ? " pnpm install" : " pnpm install # also generates the Prisma client");
378
476
  }
379
477
  lines.push(" pnpm dev # http://localhost:3000");
380
478
  lines.push(" pnpm build # production build, when you are ready to deploy");
381
479
  lines.push("");
382
480
  lines.push("Open http://localhost:3000 to see the home page.");
383
- lines.push("Adding auth, saved flows, checkout or database-backed content later? README.md \u00a7 \"When you need a database\" has the one-command setup.");
481
+ if (!isSpoke) {
482
+ lines.push("Adding auth, saved flows, checkout or database-backed content later? README.md \u00a7 \"When you need a database\" has the one-command setup.");
483
+ }
384
484
  lines.push("");
385
485
  lines.push("Claude Code: the AI Launch Kit skills come from the ailk plugin. Trust this folder and Claude Code loads it, or run in a session:");
386
486
  for (const line of PLUGIN_INSTALL_LINES)