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
@@ -15,6 +15,13 @@
15
15
  * (`resolveModuleSet` / `deriveLevel`). Never inferred from `modules`:
16
16
  * the level comes only from the key. An alias or `--modules` keep run
17
17
  * derives none and records none;
18
+ * - `topology` and `patternVersion` (#7426 D10) — `"hub"` or `"spoke"`,
19
+ * and the create-ailk version that wrote the files, on every scaffold.
20
+ * A site with no `topology` predates the option and reads as a hub
21
+ * (`topology.ts` `readSiteTopology`);
22
+ * - `signIn` (#7894 D1) — `"hub"` on a spoke that signs in through its
23
+ * hub, written after `topology` on such a spoke only; absent reads as
24
+ * `"off"` (`topology.ts` `readSpokeShape`);
18
25
  * - a row for every route present on disk — each module's contributed rows
19
26
  * through `planRosterAppend` (add-module's own append), the sample pages
20
27
  * a module's bundle installs (`sampleRows`), and a code-route row for any
@@ -31,7 +38,7 @@ import { join, relative, sep } from "node:path";
31
38
  import { isDeepStrictEqual } from "node:util";
32
39
  import { AddModuleRefusedError } from "./add-module-core.js";
33
40
  import { MODULE_SLICES, normalizeModules } from "./module-architecture.js";
34
- import { moduleRosterRows, planRosterAppend, } from "./site-roster.js";
41
+ import { SITE_ROSTER_PATH, moduleRosterRows, parseSiteRoster, planRosterAppend, resolveRosterTemplate, } from "./site-roster.js";
35
42
  import { TEMPLATE_MANIFESTS, rosterDeclares, templateTreeDir, } from "./template-manifest.js";
36
43
  /** Prettier's default print width — the generator formats the manifest with it. */
37
44
  const PRINT_WIDTH = 80;
@@ -51,16 +58,24 @@ const CODE_ROUTE_ROW = {
51
58
  sections: [],
52
59
  };
53
60
  /**
54
- * The manifest's next text: `modules` (and `level`, when derived) after
55
- * `requires`, and a row for every route present on disk.
61
+ * The manifest's next text: `modules` (and `level`, when derived), then
62
+ * `topology` and `patternVersion`, after `requires`, and a row for every
63
+ * route present on disk.
56
64
  *
57
- * @throws AddModuleRefusedError when the file already records `modules` or
58
- * `level`, or its layout is not the generator's (`rows` last, `requires`
59
- * at the top level) — guessing would silently mis-record the site.
65
+ * @throws AddModuleRefusedError when the file already records `modules`,
66
+ * `level`, `topology` or `patternVersion`, or its layout is not the
67
+ * generator's (`rows` last, `requires` at the top level) — guessing would
68
+ * silently mis-record the site.
60
69
  */
61
70
  export function planSiteManifest(opts) {
62
71
  const { roster } = opts;
63
- for (const key of ["modules", "level"]) {
72
+ for (const key of [
73
+ "modules",
74
+ "level",
75
+ "topology",
76
+ "signIn",
77
+ "patternVersion",
78
+ ]) {
64
79
  if (key in roster.doc) {
65
80
  throw new AddModuleRefusedError(`${roster.path} already records "${key}"`);
66
81
  }
@@ -81,7 +96,91 @@ export function planSiteManifest(opts) {
81
96
  ...codeRouteRows(opts.targetDir, declared),
82
97
  ];
83
98
  const withRows = appendRows(appended, extra, roster);
84
- return insertRecord(withRows, opts.modules, opts.level, roster.path);
99
+ return insertRecord(withRows, opts, roster.path);
100
+ }
101
+ /** The top-level keys `planSiteManifest` writes before the topology record, in order. */
102
+ const RECORD_ANCHORS = ["requires", "modules", "level"];
103
+ /**
104
+ * `text` with `topology` and `patternVersion` inserted directly after the
105
+ * last of `requires`, `modules`, `level` the file records — the position
106
+ * `planSiteManifest` gives them (#7875 D3). Every other byte is the input's.
107
+ *
108
+ * @throws AddModuleRefusedError when the file already records either field,
109
+ * records none of the anchor keys, records one more than once at the top
110
+ * level, or the result does not read back the two values exactly.
111
+ */
112
+ export function planTopologyRecord(text, record, label) {
113
+ const doc = JSON.parse(text);
114
+ for (const key of ["topology", "patternVersion"]) {
115
+ if (key in doc) {
116
+ throw new AddModuleRefusedError(`${label} already records "${key}"`);
117
+ }
118
+ }
119
+ let end = -1;
120
+ for (const key of RECORD_ANCHORS) {
121
+ if (!(key in doc))
122
+ continue;
123
+ const anchor = `\n ${JSON.stringify(key)}: `;
124
+ const at = text.indexOf(anchor);
125
+ if (at === -1 || text.indexOf(anchor, at + 1) !== -1) {
126
+ throw new AddModuleRefusedError(`${label} has no single top-level "${key}" key to record the topology beside`);
127
+ }
128
+ const start = at + anchor.length;
129
+ const close = text[start] === "[" ? "]" : '"';
130
+ end = Math.max(end, text.indexOf(close, start + 1) + 1);
131
+ }
132
+ if (end === -1) {
133
+ throw new AddModuleRefusedError(`${label} has no top-level "requires" key to record the topology beside`);
134
+ }
135
+ const next = text.slice(0, end) +
136
+ `,\n "topology": ${JSON.stringify(record.topology)}` +
137
+ `,\n "patternVersion": ${JSON.stringify(record.patternVersion)}` +
138
+ text.slice(end);
139
+ let after;
140
+ try {
141
+ after = JSON.parse(next);
142
+ }
143
+ catch {
144
+ after = {};
145
+ }
146
+ if (after["topology"] !== record.topology ||
147
+ after["patternVersion"] !== record.patternVersion) {
148
+ throw new AddModuleRefusedError(`${label}: the topology could not be recorded after its last top-level key`);
149
+ }
150
+ return next;
151
+ }
152
+ /**
153
+ * The manifest for a site that has none (#7875 D4): a seed naming the
154
+ * template, its `requires` and no rows, parsed the way `readSiteRoster`
155
+ * reads a manifest, then `planSiteManifest` over it — so module rows, sample
156
+ * rows and a code-route row per page route on disk arrive exactly as a
157
+ * scaffold's would. No `level` (it comes only from a licence key), and none
158
+ * of the template's `shape`, `design`, `categories`, `layout` or `theme`:
159
+ * they would claim a look the site may not wear.
160
+ *
161
+ * @throws AddModuleRefusedError when the template is undeclared, or as
162
+ * `planSiteManifest` does.
163
+ */
164
+ export function planAdoptedSiteManifest(opts) {
165
+ let requires;
166
+ try {
167
+ requires = resolveRosterTemplate(opts.template).requires;
168
+ }
169
+ catch (err) {
170
+ throw new AddModuleRefusedError(`"${opts.template}" names no declared template (${err.message})`);
171
+ }
172
+ const seed = `${renderJson({ template: opts.template, requires: [...requires], rows: [] }, 0)}\n`;
173
+ return planSiteManifest({
174
+ targetDir: opts.targetDir,
175
+ roster: parseSiteRoster(seed, SITE_ROSTER_PATH),
176
+ modules: opts.modules,
177
+ level: undefined,
178
+ topology: opts.topology,
179
+ patternVersion: opts.patternVersion,
180
+ siteTemplatesDir: opts.siteTemplatesDir,
181
+ templatesDir: opts.templatesDir,
182
+ markers: opts.markers,
183
+ });
85
184
  }
86
185
  /**
87
186
  * The rows for the sample pages a module's bundle installs through its
@@ -233,8 +332,12 @@ function appendRows(text, rows, roster) {
233
332
  ? text.slice(0, end) + rendered + roster.trail + text.slice(close)
234
333
  : text.slice(0, end) + "," + rendered + text.slice(end);
235
334
  }
236
- /** `text` with `modules` (and `level`, when derived) inserted after the top-level `requires`. */
237
- function insertRecord(text, modules, level, label) {
335
+ /**
336
+ * `text` with `modules` (and `level`, when derived), `topology` and
337
+ * `patternVersion` inserted after the top-level `requires`.
338
+ */
339
+ function insertRecord(text, opts, label) {
340
+ const { modules, level, topology, signIn, patternVersion } = opts;
238
341
  const anchor = '\n "requires": ';
239
342
  const at = text.indexOf(anchor);
240
343
  if (at === -1 || text.indexOf(anchor, at + 1) !== -1) {
@@ -243,11 +346,17 @@ function insertRecord(text, modules, level, label) {
243
346
  const end = text.indexOf("]", at) + 1;
244
347
  const name = '"modules": ';
245
348
  const record = `,\n ${name}${renderJson([...modules], 2, name.length, 1)}` +
246
- (level !== undefined ? `,\n "level": ${JSON.stringify(level)}` : "");
349
+ (level !== undefined ? `,\n "level": ${JSON.stringify(level)}` : "") +
350
+ `,\n "topology": ${JSON.stringify(topology)}` +
351
+ (signIn !== undefined ? `,\n "signIn": ${JSON.stringify(signIn)}` : "") +
352
+ `,\n "patternVersion": ${JSON.stringify(patternVersion)}`;
247
353
  const next = text.slice(0, end) + record + text.slice(end);
248
354
  const doc = JSON.parse(next);
249
355
  if (!isDeepStrictEqual(doc["modules"], [...modules]) ||
250
- doc["level"] !== level) {
356
+ doc["level"] !== level ||
357
+ doc["topology"] !== topology ||
358
+ doc["signIn"] !== signIn ||
359
+ doc["patternVersion"] !== patternVersion) {
251
360
  throw new AddModuleRefusedError(`${label}: the module set could not be recorded beside "requires"`);
252
361
  }
253
362
  return next;
@@ -58,6 +58,19 @@ export interface ModuleRosterRow {
58
58
  * not declare is refused: guessing would silently mis-roster the site.
59
59
  */
60
60
  export declare function readSiteRoster(targetDir: string): SiteRoster | undefined;
61
+ /**
62
+ * The roster `text` holds, read as `readSiteRoster` reads the file at the
63
+ * site-relative `path` (#7875 D10) — the one parse, so a manifest `adopt`
64
+ * builds goes through the same template resolution a read one does.
65
+ */
66
+ export declare function parseSiteRoster(text: string, path: string): SiteRoster;
67
+ /**
68
+ * The registry entry a manifest's `template` names. #6757 D6 — a bare-shape
69
+ * starter's name (the shape itself) resolves through SHAPE_STARTERS first;
70
+ * `resolveTemplate` alone never sees one (NFR-3).
71
+ * @throws Error from `resolveTemplate` when the name is undeclared.
72
+ */
73
+ export declare function resolveRosterTemplate(name: string): TemplateManifest;
61
74
  /** What `planSiteManifestDesign` writes when it rewrites the manifest. */
62
75
  export interface SiteManifestDesignNext {
63
76
  design: string;
@@ -11,6 +11,7 @@
11
11
  * does (#6646). This module owns every read and write of that file:
12
12
  *
13
13
  * - `readSiteRoster` — the file, or `undefined` for a template-less site;
14
+ * `parseSiteRoster` is its parse of given text (#7875 D10);
14
15
  * - `moduleRosterRows` — a module's rows, read from the generator's own
15
16
  * output in the shipped template manifests (never re-derived: the
16
17
  * derivation needs a YAML parser the package does not ship, and would be
@@ -167,22 +168,35 @@ export function readSiteRoster(targetDir) {
167
168
  const path = [SITE_ROSTER_PATH, LEGACY_SITE_ROSTER_PATH].find((name) => existsSync(join(targetDir, name)));
168
169
  if (path === undefined)
169
170
  return undefined;
170
- const file = parseRosterFile(readFileSync(join(targetDir, path), "utf8"), path);
171
- const name = String(file.doc["template"]);
171
+ return parseSiteRoster(readFileSync(join(targetDir, path), "utf8"), path);
172
+ }
173
+ /**
174
+ * The roster `text` holds, read as `readSiteRoster` reads the file at the
175
+ * site-relative `path` (#7875 D10) — the one parse, so a manifest `adopt`
176
+ * builds goes through the same template resolution a read one does.
177
+ */
178
+ export function parseSiteRoster(text, path) {
179
+ const file = parseRosterFile(text, path);
172
180
  let template;
173
181
  try {
174
- // #6757 D6 — a bare-shape starter's name (the shape itself) resolves
175
- // through SHAPE_STARTERS first; `resolveTemplate` alone never sees one
176
- // (NFR-3).
177
- template = Object.prototype.hasOwnProperty.call(SHAPE_STARTERS, name)
178
- ? SHAPE_STARTERS[name]
179
- : resolveTemplate(name);
182
+ template = resolveRosterTemplate(String(file.doc["template"]));
180
183
  }
181
184
  catch (err) {
182
185
  throw new AddModuleRefusedError(`${path} names no declared template (${err.message}), so the page roster is unknown`);
183
186
  }
184
187
  return { ...file, template, path };
185
188
  }
189
+ /**
190
+ * The registry entry a manifest's `template` names. #6757 D6 — a bare-shape
191
+ * starter's name (the shape itself) resolves through SHAPE_STARTERS first;
192
+ * `resolveTemplate` alone never sees one (NFR-3).
193
+ * @throws Error from `resolveTemplate` when the name is undeclared.
194
+ */
195
+ export function resolveRosterTemplate(name) {
196
+ return Object.prototype.hasOwnProperty.call(SHAPE_STARTERS, name)
197
+ ? SHAPE_STARTERS[name]
198
+ : resolveTemplate(name);
199
+ }
186
200
  /** `value`, rendered the way prettier prints an object-only JSON value nested one level under the manifest's root object. */
187
201
  function renderManifestValue(value) {
188
202
  return JSON.stringify(value, null, 2)
@@ -0,0 +1,42 @@
1
+ import type { EnvCatalogEntry, PlanEnvRowsOptions } from "./lib/declared-env.js";
2
+ import { type InstallReportRow } from "./lib/extract-module-bundle.js";
3
+ import { type WorkspaceVersionMap } from "./substitute.js";
4
+ /** The checkout package a spoke's two route files import. */
5
+ export declare const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
6
+ /** The site's checkout data — written once, the site's from then on. */
7
+ export declare const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
8
+ /** The two return pages: Stripe's success URL and every refused buy's target. */
9
+ export declare const SPOKE_CHECKOUT_PAGE_FILES: readonly string[];
10
+ /** The variables a commerce spoke depends on (D9). The package reads three of them. */
11
+ export declare const SPOKE_CHECKOUT_ENV_CATALOG: Readonly<Record<string, EnvCatalogEntry>>;
12
+ /** The names a commerce spoke declares although only the package reads them (D9). */
13
+ export declare const SPOKE_CHECKOUT_DECLARED_ENV: readonly string[];
14
+ /**
15
+ * The catalog (and declared names) a spoke's `appendEnvRows` call passes: a
16
+ * commerce spoke adds the checkout's four rows (D9). `ENV_CATALOG` itself is
17
+ * unchanged — its test fails on an entry the monorepo's apps never read.
18
+ */
19
+ export declare function spokeEnvRowOptions(commerce: boolean): Pick<PlanEnvRowsOptions, "catalog" | "declare">;
20
+ /**
21
+ * The site stamp for a project name (D6): lower-cased, every run outside
22
+ * `a-z0-9` collapsed to `-`, trimmed, cut to 40. A name that leaves nothing
23
+ * usable — or `default`, which is also an unset hub's own stamp — gets
24
+ * `site-` and the first 8 hex characters of its SHA-256. Never `AILK_SITE_ID`.
25
+ */
26
+ export declare function spokeCheckoutSite(projectName: string): string;
27
+ /**
28
+ * The slice for `site`, in order: the two route files, the config, the two
29
+ * pages (D3). Throws `TopologyError` for an invalid stamp (SEC-7838-10).
30
+ */
31
+ export declare function renderSpokeCheckoutFiles(site: string): [string, string][];
32
+ /**
33
+ * Write the slice into `targetDir` (D3). A file that already exists is left
34
+ * byte-for-byte and reported `skipped` — the config is the site's once
35
+ * written. Adds the package to `apps/web/package.json` only when absent.
36
+ * Every refusal (an invalid stamp, a version map without the package, a
37
+ * missing or malformed `apps/web/package.json`) throws before the first write.
38
+ */
39
+ export declare function writeSpokeCheckoutSlice(targetDir: string, opts: {
40
+ site: string;
41
+ versions: WorkspaceVersionMap;
42
+ }): InstallReportRow[];
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The spoke checkout slice (#7838) — what a spoke carrying `commerce` gets in
3
+ * `apps/web`: the buy route and the Stripe webhook route (the only two files
4
+ * allowed to import the checkout package, #7873), the site's own
5
+ * `lib/checkout-config.ts`, and the thank-you and unavailable return pages.
6
+ *
7
+ * Every guarantee lives in `@working-theory/spoke-checkout` and arrives by a
8
+ * package bump; these files are thin and, apart from the config's one stamp
9
+ * line, byte-identical across spokes at one create-ailk version (roadmap D3,
10
+ * AC-SPOKE-02). A hub never gets them: its checkout is `apps/api`'s.
11
+ *
12
+ * Spec: docs/specs/7838-spoke-web-checkout-slice-spec.md (D3–D7, D9).
13
+ *
14
+ * Pure module: no `import.meta.url`, so jest imports it directly. Every
15
+ * package specifier in a rendered file is interpolated from a constant, so the
16
+ * monorepo's own architecture lint never reads a rendered line as an import
17
+ * create-ailk makes (the `CONTENT_ADAPTERS_PACKAGE` idiom in topology.ts).
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { SPOKE_CHECKOUT_ROUTE_FILES } from "./derive-architecture.js";
23
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
24
+ import { diffTwoStrings, } from "./lib/extract-module-bundle.js";
25
+ import { substituteWorkspaceVersions, } from "./substitute.js";
26
+ import { TopologyError } from "./topology.js";
27
+ /** The checkout package a spoke's two route files import. */
28
+ export const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
29
+ const API_CLIENT_PACKAGE = "@working-theory/api-client";
30
+ const UI_SECTIONS_PACKAGE = "@working-theory/ui/sections";
31
+ const VALIDATION_PACKAGE = "@working-theory/validation";
32
+ /** The site's checkout data — written once, the site's from then on. */
33
+ export const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
34
+ /** The two return pages: Stripe's success URL and every refused buy's target. */
35
+ export const SPOKE_CHECKOUT_PAGE_FILES = Object.freeze([
36
+ "apps/web/app/[locale]/checkout/thank-you/page.tsx",
37
+ "apps/web/app/[locale]/checkout/unavailable/page.tsx",
38
+ ]);
39
+ /** The variables a commerce spoke depends on (D9). The package reads three of them. */
40
+ export const SPOKE_CHECKOUT_ENV_CATALOG = Object.freeze({
41
+ STRIPE_SECRET_KEY: {
42
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: a test key (sk_test_ or rk_test_), or a restricted live key the operator sets with STRIPE_MODE=live.`,
43
+ secret: true,
44
+ },
45
+ STRIPE_WEBHOOK_SECRET: {
46
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: the signing secret of this site's Stripe webhook endpoint.`,
47
+ secret: true,
48
+ },
49
+ STRIPE_MODE: {
50
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: test (the default) or live. Live is the operator's switch, per site.`,
51
+ secret: false,
52
+ },
53
+ AILK_SPOKE_KEY: {
54
+ description: "The hub-issued key this site sends as x-ailk-spoke-key; the checkout fulfilment refuses to run without it. Never committed.",
55
+ secret: true,
56
+ },
57
+ });
58
+ /** The names a commerce spoke declares although only the package reads them (D9). */
59
+ export const SPOKE_CHECKOUT_DECLARED_ENV = Object.freeze([
60
+ "STRIPE_SECRET_KEY",
61
+ "STRIPE_WEBHOOK_SECRET",
62
+ "STRIPE_MODE",
63
+ ]);
64
+ /**
65
+ * The catalog (and declared names) a spoke's `appendEnvRows` call passes: a
66
+ * commerce spoke adds the checkout's four rows (D9). `ENV_CATALOG` itself is
67
+ * unchanged — its test fails on an entry the monorepo's apps never read.
68
+ */
69
+ export function spokeEnvRowOptions(commerce) {
70
+ return commerce
71
+ ? {
72
+ catalog: { ...ENV_CATALOG, ...SPOKE_CHECKOUT_ENV_CATALOG },
73
+ declare: SPOKE_CHECKOUT_DECLARED_ENV,
74
+ }
75
+ : { catalog: ENV_CATALOG };
76
+ }
77
+ /** `metadata.site` / an offering slug — the package's own pattern. */
78
+ const SITE_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
79
+ /** A stamp a renderer may interpolate: the package's pattern, never `default` (D6). */
80
+ function isValidSite(site) {
81
+ return SITE_PATTERN.test(site) && site !== "default";
82
+ }
83
+ function assertSite(site) {
84
+ if (!isValidSite(site)) {
85
+ throw new TopologyError(`spoke checkout: site stamp ${JSON.stringify(site)} must match ^[a-z0-9][a-z0-9-]{0,39}$ and must not be "default"`);
86
+ }
87
+ }
88
+ /**
89
+ * The site stamp for a project name (D6): lower-cased, every run outside
90
+ * `a-z0-9` collapsed to `-`, trimmed, cut to 40. A name that leaves nothing
91
+ * usable — or `default`, which is also an unset hub's own stamp — gets
92
+ * `site-` and the first 8 hex characters of its SHA-256. Never `AILK_SITE_ID`.
93
+ */
94
+ export function spokeCheckoutSite(projectName) {
95
+ const slug = projectName
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, "-")
98
+ .replace(/^-+|-+$/g, "")
99
+ .slice(0, 40)
100
+ .replace(/-+$/, "");
101
+ if (isValidSite(slug))
102
+ return slug;
103
+ const digest = createHash("sha256").update(projectName).digest("hex");
104
+ return `site-${digest.slice(0, 8)}`;
105
+ }
106
+ /** One route file (D4): three lines of wiring, every guarantee in the package. */
107
+ function renderRoute(opts) {
108
+ return [
109
+ `// ${opts.title} — GENERATED by create-ailk (#7838). Do not edit: every`,
110
+ "// guarantee lives in the spoke-checkout package and arrives by a package",
111
+ "// bump. This site's data is lib/checkout-config.ts.",
112
+ `import { createSpokeCheckout } from "${SPOKE_CHECKOUT_PACKAGE}";`,
113
+ "",
114
+ `import { checkoutConfig } from "../../../../lib/checkout-config";`,
115
+ "",
116
+ `export const runtime = "nodejs";`,
117
+ `export const dynamic = "force-dynamic";`,
118
+ "",
119
+ `// platform-decisions-waiver: api-versioning — ${opts.waiver} (spec 7838 §7)`,
120
+ opts.exportLine,
121
+ "",
122
+ ].join("\n");
123
+ }
124
+ const BUY_ROUTE = renderRoute({
125
+ title: "The buy route",
126
+ waiver: "a browser buy link that redirects to Stripe Checkout, not a versioned API contract",
127
+ exportLine: "export const { GET, POST } = createSpokeCheckout(checkoutConfig).buy;",
128
+ });
129
+ const WEBHOOK_ROUTE = renderRoute({
130
+ title: "The Stripe webhook route",
131
+ waiver: "the endpoint URL registered in Stripe, not a versioned API contract",
132
+ exportLine: "export const { POST } = createSpokeCheckout(checkoutConfig).webhook;",
133
+ });
134
+ /** The site's checkout data (D5). Only the `const SITE` line varies by site. */
135
+ function renderConfig(site) {
136
+ return [
137
+ "// checkout-config.ts — this spoke's checkout, as data (#7838).",
138
+ "//",
139
+ "// Written once by create-ailk and yours from then on: create-ailk never",
140
+ "// rewrites it. The buy route and the Stripe webhook route build their",
141
+ "// handlers from it. Those two are the only files allowed to import the",
142
+ "// checkout package (architecture.yaml), so this file never imports it,",
143
+ "// stripe-client or the stripe SDK. Keep it to data and the fulfil callback.",
144
+ "//",
145
+ "// site Stamped on every Checkout Session as metadata.site. This site's",
146
+ "// webhook acts only on its own stamp, and the hub ignores any stamp",
147
+ "// that is not its own, so the stamp must be unique on the Stripe",
148
+ '// account this site shares. Never "default". Change it only before',
149
+ "// any price exists: every lookup key starts with it.",
150
+ "// offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>.",
151
+ "// scaffold-commerce replaces the placeholder below.",
152
+ "// fulfil Runs for each paid session, at least once. It posts one lead to",
153
+ "// the hub with the session id as its Idempotency-Key, so a repeat",
154
+ "// writes nothing new. The hub dedupes only an identical body: change",
155
+ "// the payload's shape only when no webhook is failing.",
156
+ `import { leads } from "${API_CLIENT_PACKAGE}";`,
157
+ "",
158
+ `import { resolveBaseUrl } from "../src/lib/routes";`,
159
+ "",
160
+ `const SITE = "${site}";`,
161
+ "",
162
+ "/** What this site's fulfil callback reads from a completed checkout. */",
163
+ "interface CompletedCheckout {",
164
+ " idempotencyKey: string;",
165
+ " sessionId: string;",
166
+ " offering: string;",
167
+ ' kind: "fixed" | "quoted";',
168
+ ' mode: "test" | "live";',
169
+ " amountTotal: number | null;",
170
+ " currency: string | null;",
171
+ " email: string | null;",
172
+ " customerId: string | null;",
173
+ "}",
174
+ "",
175
+ "export const checkoutConfig = {",
176
+ " site: SITE,",
177
+ " siteBaseUrl: resolveBaseUrl(),",
178
+ " offerings: {",
179
+ " // A placeholder until scaffold-commerce creates this site's prices.",
180
+ ' example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },',
181
+ " },",
182
+ " async fulfil(checkout: CompletedCheckout): Promise<void> {",
183
+ " if (checkout.email === null) return;",
184
+ " // No spoke key: the hub cannot place the lead in this site's workspace.",
185
+ " // Fail, so Stripe retries and the dashboard shows a failed webhook.",
186
+ " if (!process.env.AILK_SPOKE_KEY)",
187
+ ' throw new Error("AILK_SPOKE_KEY is not set");',
188
+ " const result = await leads.submit(",
189
+ " {",
190
+ " siteId: SITE,",
191
+ ' source: "web",',
192
+ " email: checkout.email,",
193
+ " payload: {",
194
+ " site: SITE,",
195
+ ' page: "checkout",',
196
+ " offering: checkout.offering,",
197
+ " checkoutKind: checkout.kind,",
198
+ " mode: checkout.mode,",
199
+ " amountTotal: checkout.amountTotal,",
200
+ " currency: checkout.currency,",
201
+ " stripeCustomerId: checkout.customerId,",
202
+ " checkoutSession: checkout.sessionId,",
203
+ " },",
204
+ " },",
205
+ ' { headers: { "Idempotency-Key": checkout.idempotencyKey } },',
206
+ " );",
207
+ " // A replay under the same key answers 201 with the first lead; any",
208
+ " // failure (422 idempotency_key_reused included) makes Stripe retry.",
209
+ " if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);",
210
+ " },",
211
+ "};",
212
+ "",
213
+ ].join("\n");
214
+ }
215
+ /** One return page (D7): a server component that reads nothing but its copy. */
216
+ function renderPage(opts) {
217
+ return [
218
+ `// ${opts.title} — GENERATED by create-ailk (#7838).`,
219
+ ...opts.about.map((line) => `// ${line}`),
220
+ `import { SectionRenderer } from "${UI_SECTIONS_PACKAGE}";`,
221
+ `import type { PageSection } from "${VALIDATION_PACKAGE}";`,
222
+ `import type { Metadata } from "next";`,
223
+ `import { getTranslations } from "next-intl/server";`,
224
+ `import type { ReactElement } from "react";`,
225
+ "",
226
+ "interface Props {",
227
+ " params: Promise<{ locale: string }>;",
228
+ "}",
229
+ "",
230
+ "export async function generateMetadata({ params }: Props): Promise<Metadata> {",
231
+ " const { locale } = await params;",
232
+ ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
233
+ ` return { title: t("title"), robots: { index: false, follow: false } };`,
234
+ "}",
235
+ "",
236
+ "export default async function Page({ params }: Props): Promise<ReactElement> {",
237
+ " const { locale } = await params;",
238
+ ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
239
+ " const sections: PageSection[] = [",
240
+ ...opts.sections.map((line) => ` ${line}`),
241
+ " ];",
242
+ " return (",
243
+ ' <main id="main">',
244
+ " <SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />",
245
+ " </main>",
246
+ " );",
247
+ "}",
248
+ "",
249
+ ].join("\n");
250
+ }
251
+ const THANK_YOU_PAGE = renderPage({
252
+ title: "The checkout thank-you page",
253
+ about: [
254
+ "Stripe returns a buyer here after paying. It reads no search parameter and",
255
+ "calls neither Stripe nor the hub: the webhook is what fulfils the order.",
256
+ ],
257
+ namespace: "checkoutThankYou",
258
+ sections: [
259
+ "{",
260
+ ' id: "checkout-thank-you",',
261
+ ' type: "content-block",',
262
+ ' data: { headline: t("title"), body: t("body") },',
263
+ "},",
264
+ ],
265
+ });
266
+ const UNAVAILABLE_PAGE = renderPage({
267
+ title: "The checkout unavailable page",
268
+ about: [
269
+ "Every refused buy lands here. It never says why: a reason is a probe's",
270
+ "answer, and the buyer needs only that nothing was charged.",
271
+ ],
272
+ namespace: "checkoutUnavailable",
273
+ sections: [
274
+ "{",
275
+ ' id: "checkout-unavailable",',
276
+ ' type: "content-block",',
277
+ ' data: { headline: t("title"), body: t("body") },',
278
+ "},",
279
+ "{",
280
+ ' id: "checkout-unavailable-cta",',
281
+ ' type: "cta",',
282
+ " data: {",
283
+ ' headline: t("ctaHeadline"),',
284
+ ' primaryCta: { label: t("ctaLabel"), href: "/contact" },',
285
+ " },",
286
+ "},",
287
+ ],
288
+ });
289
+ /**
290
+ * The slice for `site`, in order: the two route files, the config, the two
291
+ * pages (D3). Throws `TopologyError` for an invalid stamp (SEC-7838-10).
292
+ */
293
+ export function renderSpokeCheckoutFiles(site) {
294
+ assertSite(site);
295
+ const [buyPath, webhookPath] = SPOKE_CHECKOUT_ROUTE_FILES;
296
+ const [thankYouPath, unavailablePath] = SPOKE_CHECKOUT_PAGE_FILES;
297
+ return [
298
+ [buyPath, BUY_ROUTE],
299
+ [webhookPath, WEBHOOK_ROUTE],
300
+ [SPOKE_CHECKOUT_CONFIG_PATH, renderConfig(site)],
301
+ [thankYouPath, THANK_YOU_PAGE],
302
+ [unavailablePath, UNAVAILABLE_PAGE],
303
+ ];
304
+ }
305
+ const WEB_PACKAGE_JSON = "apps/web/package.json";
306
+ /** `deps` with `name: version` added in sorted position, every other key kept in place. */
307
+ function withDependency(deps, name, version) {
308
+ const out = {};
309
+ let placed = false;
310
+ for (const [key, value] of Object.entries(deps)) {
311
+ // Code-unit order, as package managers sort — never the host's locale.
312
+ if (!placed && key > name) {
313
+ out[name] = version;
314
+ placed = true;
315
+ }
316
+ out[key] = value;
317
+ }
318
+ if (!placed)
319
+ out[name] = version;
320
+ return out;
321
+ }
322
+ /**
323
+ * Write the slice into `targetDir` (D3). A file that already exists is left
324
+ * byte-for-byte and reported `skipped` — the config is the site's once
325
+ * written. Adds the package to `apps/web/package.json` only when absent.
326
+ * Every refusal (an invalid stamp, a version map without the package, a
327
+ * missing or malformed `apps/web/package.json`) throws before the first write.
328
+ */
329
+ export function writeSpokeCheckoutSlice(targetDir, opts) {
330
+ const files = renderSpokeCheckoutFiles(opts.site);
331
+ const range = JSON.parse(substituteWorkspaceVersions(`{"${SPOKE_CHECKOUT_PACKAGE}": "workspace:*"}`, opts.versions));
332
+ const version = range[SPOKE_CHECKOUT_PACKAGE];
333
+ const pkgPath = join(targetDir, WEB_PACKAGE_JSON);
334
+ const before = readFileSync(pkgPath, "utf8");
335
+ const pkg = JSON.parse(before);
336
+ const rows = [];
337
+ for (const [path, text] of files) {
338
+ const abs = join(targetDir, path);
339
+ if (existsSync(abs)) {
340
+ rows.push({ path, action: "skipped", wanted: text });
341
+ continue;
342
+ }
343
+ mkdirSync(dirname(abs), { recursive: true });
344
+ writeFileSync(abs, text);
345
+ rows.push({ path, action: "written" });
346
+ }
347
+ const deps = pkg.dependencies ?? {};
348
+ if (!Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)) {
349
+ pkg.dependencies = withDependency(deps, SPOKE_CHECKOUT_PACKAGE, version);
350
+ const after = `${JSON.stringify(pkg, null, 2)}${before.endsWith("\n") ? "\n" : ""}`;
351
+ writeFileSync(pkgPath, after);
352
+ rows.push({
353
+ path: WEB_PACKAGE_JSON,
354
+ action: "patched",
355
+ change: diffTwoStrings(WEB_PACKAGE_JSON, before, after, true),
356
+ });
357
+ }
358
+ return rows;
359
+ }