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
@@ -1,3 +1,33 @@
1
+ /**
2
+ * Derive a scaffolded site's `architecture.yaml` from its selected module set.
3
+ *
4
+ * `deriveArchitecture(modules)` composes the module-independent BASE
5
+ * (module-architecture.ts `ARCHITECTURE_BASE`) with the selected module slices
6
+ * into ONE deterministic `architecture.yaml` document string. The module set is
7
+ * the single source of truth for the result — same set → byte-identical output
8
+ * (no timestamps, stable key/array ordering), so the derivation doubles as the
9
+ * re-runnable sync path (add a module, re-derive, the file updates; re-deriving
10
+ * an unchanged set is a no-op).
11
+ *
12
+ * Zero runtime dependencies (create-ailk is a published, standalone CLI): the
13
+ * YAML is emitted by a small, purpose-built deterministic serializer over the
14
+ * fixed document shape this module produces — no `yaml`/`js-yaml` dependency.
15
+ */
16
+ import { type ForbiddenPattern } from "./module-architecture.js";
17
+ import type { Topology } from "./topology.js";
18
+ /** The topology a document is derived for (#7426 D8). */
19
+ export interface DeriveArchitectureOptions {
20
+ /** `"hub"` (the default) or `"spoke"`. */
21
+ topology?: Topology;
22
+ /** Spoke only: whether the spoke keeps `apps/mcp` (default true). */
23
+ mcp?: boolean;
24
+ }
25
+ /** The only spoke files that may import @working-theory/spoke-checkout (#7873). */
26
+ export declare const SPOKE_CHECKOUT_ROUTE_FILES: readonly string[];
27
+ /** The forbidden patterns a spoke's apps/web carries (#7873, brief Part 2 §2). */
28
+ export declare const SPOKE_WEB_FORBIDDEN_PATTERNS: readonly ForbiddenPattern[];
29
+ /** The same three for a spoke's kept apps/mcp — no MCP file may import the package (#7873). */
30
+ export declare const SPOKE_MCP_FORBIDDEN_PATTERNS: readonly ForbiddenPattern[];
1
31
  /**
2
32
  * Derive the `architecture.yaml` document for a module set.
3
33
  *
@@ -6,8 +36,13 @@
6
36
  * @param surfaces the enabled gateable route surfaces. Omitted ⟹ derived from
7
37
  * the module set alone (`resolveSurfaces`), so a caller that passes only
8
38
  * modules gets exactly what the modules imply.
39
+ * @param options the topology (#7426 D8). Absent or `"hub"` ⟹ the output is
40
+ * byte-identical to the two-argument form. `"spoke"` ⟹ only the spoke's own
41
+ * apps (`web`, and `mcp` unless `mcp: false`), no `namespaces`, no
42
+ * `database` root, a top-level `topology: spoke` and the hub-facing
43
+ * composition tiers.
9
44
  * @returns the complete `architecture.yaml` file content (a deterministic
10
- * function of the module SET and the surface SET).
45
+ * function of the module SET, the surface SET and the topology).
11
46
  * @throws Error on an unrecognized module (defensive; parse-args rejects first).
12
47
  */
13
- export declare function deriveArchitecture(modules?: string[], surfaces?: string[]): string;
48
+ export declare function deriveArchitecture(modules?: string[], surfaces?: string[], options?: DeriveArchitectureOptions): string;
@@ -15,6 +15,66 @@
15
15
  */
16
16
  import { ARCHITECTURE_BASE, MODULE_SLICES, normalizeModules, } from "./module-architecture.js";
17
17
  import { CORE_SURFACE_PATHS, RECOGNIZED_SURFACES, resolveSurfaces, } from "./surfaces.js";
18
+ /** A spoke's composition tiers (#7426 D8): its data and API are the hub's. */
19
+ const SPOKE_TIERS = {
20
+ data: "none — the hub's database",
21
+ api: "the hub at AILK_API_URL (apps/mcp is a stateless front)",
22
+ presentation: "apps/web",
23
+ };
24
+ // ── The spoke checkout boundary (#7873, brief Part 2 §2) ───────────────────
25
+ //
26
+ // A scaffold's lint (template-overlay/tools/lint-architecture.ts) resolves an
27
+ // import only to a workspace on disk, so the one way to ban an npm package is
28
+ // a line-level forbidden pattern. These go under the spoke's own `apps.web`
29
+ // and `apps.mcp` only — never top level, and never on a hub derivation.
30
+ /** The only spoke files that may import @working-theory/spoke-checkout (#7873). */
31
+ export const SPOKE_CHECKOUT_ROUTE_FILES = Object.freeze([
32
+ "apps/web/app/api/checkout/[offering]/route.ts",
33
+ "apps/web/app/api/webhooks/stripe/route.ts",
34
+ ]);
35
+ /**
36
+ * A line-level regex for an import of `specifier` or one of its subpaths, in
37
+ * every form the line can take: `from "…"` (a static or re-export, including
38
+ * the closing line of a multi-line import), `import "…"`, `import("…")` and
39
+ * `require("…")`. The leading `\b` keeps `myrequire(…)` / `datafrom(…)` out;
40
+ * the quote set takes a backtick so `import(\`…\`)` is not a way round it.
41
+ * A plain string (`"stripe"`) and a lookalike name (`…-extra`) do not match.
42
+ */
43
+ function specifierForm(specifier) {
44
+ const q = "['\"`]";
45
+ const literal = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ return `\\b(?:from|import|require)\\s*\\(?\\s*${q}${literal}(?:/[^'"\`]*)?${q}`;
47
+ }
48
+ /** P1 with no exclusions — a kept apps/mcp's form; apps/web adds the two routes. */
49
+ const SPOKE_CHECKOUT_PATTERN = Object.freeze({
50
+ pattern: specifierForm("@working-theory/spoke-checkout"),
51
+ reason: "Spoke checkout: only the buy route and the webhook route may import @working-theory/spoke-checkout (brief Part 2 §2, #7873)",
52
+ });
53
+ const STRIPE_CLIENT_PATTERN = Object.freeze({
54
+ pattern: specifierForm("@working-theory/stripe-client"),
55
+ reason: "A spoke never imports stripe-client directly — it reaches Stripe only through @working-theory/spoke-checkout (#7873)",
56
+ });
57
+ const STRIPE_SDK_PATTERN = Object.freeze({
58
+ pattern: specifierForm("stripe"),
59
+ reason: "A spoke never imports the stripe SDK directly — it reaches Stripe only through @working-theory/spoke-checkout (#7873)",
60
+ });
61
+ /** The forbidden patterns a spoke's apps/web carries (#7873, brief Part 2 §2). */
62
+ export const SPOKE_WEB_FORBIDDEN_PATTERNS = Object.freeze([
63
+ Object.freeze({ ...SPOKE_CHECKOUT_PATTERN, paths_excluded: [...SPOKE_CHECKOUT_ROUTE_FILES] }),
64
+ STRIPE_CLIENT_PATTERN,
65
+ STRIPE_SDK_PATTERN,
66
+ ]);
67
+ /** The same three for a spoke's kept apps/mcp — no MCP file may import the package (#7873). */
68
+ export const SPOKE_MCP_FORBIDDEN_PATTERNS = Object.freeze([
69
+ SPOKE_CHECKOUT_PATTERN,
70
+ STRIPE_CLIENT_PATTERN,
71
+ STRIPE_SDK_PATTERN,
72
+ ]);
73
+ /** Per-app extra patterns on the spoke branch only, keyed by the app's key. */
74
+ const SPOKE_APP_FORBIDDEN = {
75
+ web: SPOKE_WEB_FORBIDDEN_PATTERNS,
76
+ mcp: SPOKE_MCP_FORBIDDEN_PATTERNS,
77
+ };
18
78
  function isScalar(v) {
19
79
  return typeof v === "string" || typeof v === "number";
20
80
  }
@@ -150,11 +210,18 @@ function namespacesToYaml(ns) {
150
210
  * @param surfaces the enabled gateable route surfaces. Omitted ⟹ derived from
151
211
  * the module set alone (`resolveSurfaces`), so a caller that passes only
152
212
  * modules gets exactly what the modules imply.
213
+ * @param options the topology (#7426 D8). Absent or `"hub"` ⟹ the output is
214
+ * byte-identical to the two-argument form. `"spoke"` ⟹ only the spoke's own
215
+ * apps (`web`, and `mcp` unless `mcp: false`), no `namespaces`, no
216
+ * `database` root, a top-level `topology: spoke` and the hub-facing
217
+ * composition tiers.
153
218
  * @returns the complete `architecture.yaml` file content (a deterministic
154
- * function of the module SET and the surface SET).
219
+ * function of the module SET, the surface SET and the topology).
155
220
  * @throws Error on an unrecognized module (defensive; parse-args rejects first).
156
221
  */
157
- export function deriveArchitecture(modules = ["marketing"], surfaces) {
222
+ export function deriveArchitecture(modules = ["marketing"], surfaces, options = {}) {
223
+ const spoke = options.topology === "spoke";
224
+ const keepMcp = options.mcp !== false;
158
225
  const selected = normalizeModules(modules);
159
226
  const slices = selected.map((m) => MODULE_SLICES[m]);
160
227
  // The declared route surface (#4154). A scaffold's live-route set is a
@@ -180,12 +247,20 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
180
247
  };
181
248
  for (const s of slices)
182
249
  Object.assign(declared, s.apps ?? {});
250
+ // A spoke carries only its own apps (#7426 D8): web, and mcp unless
251
+ // `--without-mcp` — never api, never a module's admin.
252
+ if (spoke) {
253
+ for (const key of Object.keys(declared)) {
254
+ if (key !== "web" && !(key === "mcp" && keepMcp))
255
+ delete declared[key];
256
+ }
257
+ }
183
258
  // A scaffold carries no `packages/` tree — `@working-theory/*` install from
184
259
  // npm — so an entry naming a monorepo folder is dropped rather than shipped
185
260
  // as a rule the linter can never apply (#6940). What survives names a path
186
261
  // the scaffold has: a declared app, or `database/`.
187
262
  const roots = [
188
- "database",
263
+ ...(spoke ? [] : ["database"]),
189
264
  ...Object.values(declared).map((a) => a.path),
190
265
  ];
191
266
  const inScaffold = (p) => {
@@ -202,15 +277,21 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
202
277
  const a = declared[key];
203
278
  if (!a)
204
279
  continue;
280
+ // The checkout boundary is keyed on the topology the caller passed, never
281
+ // a module or a path glob alone, so a hub derivation is unchanged (#7873).
282
+ const extra = spoke ? (SPOKE_APP_FORBIDDEN[key] ?? []) : [];
205
283
  apps[key] = appToYaml({
206
284
  ...a,
207
285
  may_import_from: a.may_import_from.filter(inScaffold),
208
286
  may_not_import_from: a.may_not_import_from.filter(inScaffold),
209
- forbidden_patterns: mergeForbidden([...ARCHITECTURE_BASE.appForbiddenPatterns], a.forbidden_patterns ?? []).map(scoped),
287
+ forbidden_patterns: mergeForbidden([...ARCHITECTURE_BASE.appForbiddenPatterns], [...(a.forbidden_patterns ?? []), ...extra]).map(scoped),
210
288
  });
211
289
  }
212
- // namespaces — present only if a data-tier module contributes one.
213
- const nsSlice = slices.find((s) => s.namespaces)?.namespaces;
290
+ // namespaces — present only if a data-tier module contributes one, and
291
+ // never on a spoke, which has no database/ of its own.
292
+ const nsSlice = spoke
293
+ ? undefined
294
+ : slices.find((s) => s.namespaces)?.namespaces;
214
295
  // forbidden_patterns — base floor first, then the selected modules' patterns.
215
296
  const extraForbidden = [];
216
297
  for (const s of slices)
@@ -218,8 +299,11 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
218
299
  const forbidden = mergeForbidden([...ARCHITECTURE_BASE.forbiddenPatterns], extraForbidden).map(scoped);
219
300
  const doc = {
220
301
  version: ARCHITECTURE_BASE.version,
302
+ ...(spoke ? { topology: "spoke" } : {}),
221
303
  composition_model: {
222
- tiers: { ...ARCHITECTURE_BASE.composition_model.tiers },
304
+ tiers: spoke
305
+ ? { ...SPOKE_TIERS }
306
+ : { ...ARCHITECTURE_BASE.composition_model.tiers },
223
307
  },
224
308
  surfaces: {
225
309
  core: [...CORE_SURFACE_PATHS],
@@ -240,6 +324,7 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
240
324
  "#",
241
325
  `# Modules: ${selected.join(", ")}`,
242
326
  `# Surfaces: ${enabledSurfaces.length > 0 ? enabledSurfaces.join(", ") : "core only"}`,
327
+ ...(spoke ? ["# Topology: spoke"] : []),
243
328
  "",
244
329
  ].join("\n");
245
330
  return header + emitDocument(doc);
@@ -207,7 +207,7 @@ function parseScalar(raw, lineNo, file) {
207
207
  * The one dotted key path whose value may be a block sequence (#7719 D2) —
208
208
  * the deck's ordered ground list. Every other `- ` line is refused as before.
209
209
  */
210
- const SEQUENCE_PATHS = new Set(["layout.deckGrounds"]);
210
+ const SEQUENCE_PATHS = new Set(["layout.deckGrounds", "layout.closingFooterPaths"]);
211
211
  /**
212
212
  * Parse a block sequence of scalars whose `- ` items sit at `pos.i`'s indent
213
213
  * (#7719 D2). Each item's text is read by `parseScalar`, with every refusal it
@@ -487,7 +487,7 @@ function validateStringMap(raw, file, field) {
487
487
  const LAYOUT_KEYS = ["nav", "footer", "announcementBanner", "home"];
488
488
  /** Every key a package's `layout` may carry — #7719 D3 adds the optional `deckGrounds` (a deck home only),
489
489
  * #7721 D1 the optional `innerPages`, #7740 D1 the optional `deckPresentation` (a deck home only),
490
- * #7742 D1 the optional `colorMode` and `chromeStyle`. */
490
+ * #7742 D1 the optional `colorMode` and `chromeStyle`, #7797 D1 the optional `closingFooterPaths`. */
491
491
  const ALLOWED_LAYOUT_KEYS = new Set([
492
492
  ...LAYOUT_KEYS,
493
493
  "deckGrounds",
@@ -495,6 +495,7 @@ const ALLOWED_LAYOUT_KEYS = new Set([
495
495
  "deckPresentation",
496
496
  "colorMode",
497
497
  "chromeStyle",
498
+ "closingFooterPaths",
498
499
  ]);
499
500
  function validateLayout(raw, file) {
500
501
  const obj = requireObject(raw, file, "layout");
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, LicenseRequiredError, PAI
9
9
  export { DEFAULT_AILK_MODULE_REGISTRY_URL, FetchModuleError, fetchModule, type FetchModuleOptions, } from "./lib/fetch-module.js";
10
10
  export { extractModuleBundle, type ExistingFilePolicy, type ExtractModuleBundleOptions, type InstallReportRow, } from "./lib/extract-module-bundle.js";
11
11
  export { ADD_MODULE_HELP, AddModuleRefusedError, applyModuleToScaffold, formatAddModuleReport, parseAddModuleArgs, type AddModuleReportRow, type AddModuleResult, type ApplyModuleOptions, } from "./add-module.js";
12
- export { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, type ApplyDesignArgs, type ApplyDesignChange, type ApplyDesignOptions, type ApplyDesignResult, } from "./apply-design.js";
12
+ export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, type ApplyDesignArgs, type ApplyDesignChange, type ApplyDesignOptions, type ApplyDesignResult, } from "./apply-design.js";
13
13
  export type { HomeSeedOutcome } from "./design-home.js";
14
14
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
15
15
  export { SYNC_ROUTES_HELP, formatSyncRoutesReport, listTemplateRouteDirs, parseSyncRoutesArgs, syncRoutes, type RouteReport, type SyncRoutesOptions, type SyncRoutesResult, } from "./sync-routes.js";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { extractModuleBundle, } from "./lib/extract-module-bundle.js";
16
16
  export { ADD_MODULE_HELP, AddModuleRefusedError, applyModuleToScaffold, formatAddModuleReport, parseAddModuleArgs, } from "./add-module.js";
17
17
  // `create-ailk apply-design` (#6819) — make an existing site wear a design,
18
18
  // changing only its look. The pure core, beside `add-module`'s.
19
- export { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
19
+ export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
20
20
  // The site's page roster of record and its install-diff check (#6682).
21
21
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
22
22
  // `create-ailk sync-routes` (#4986) — the template-route drift check + copy.
@@ -0,0 +1,21 @@
1
+ import { type ResolvedSiteChrome } from "../site-chrome.js";
2
+ /** One knob a design sets that one of the site's reader files does not read. */
3
+ export interface UnreadChromeKnob {
4
+ knob: string;
5
+ /** Overlay-relative path of the reader file, with `/` separators. */
6
+ file: string;
7
+ }
8
+ /** The pattern that means "this text reads `knob`". */
9
+ export declare function chromeReadPattern(knob: string): RegExp;
10
+ /**
11
+ * knob → the overlay files that read it, sorted. Scans `<templatesDir>/apps/web`;
12
+ * fails closed (throws, naming the directory) when it is absent or unreadable,
13
+ * so a broken overlay can never read as "nothing reads any knob".
14
+ */
15
+ export declare function chromeKnobReaders(templatesDir: string): Map<string, string[]>;
16
+ /**
17
+ * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
18
+ * the shipped overlay's `file` reads it, the site has its own `file`, and the
19
+ * site's `file` does not read it. A reader file the site lacks is skipped.
20
+ */
21
+ export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly string[]>, siteDir: string): UnreadChromeKnob[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Which generated app files read which chrome knob (#7781, spec
3
+ * docs/specs/apply-design-unread-chrome-spec.md).
4
+ *
5
+ * `apply-design` writes a design's chrome knobs into the site's generated
6
+ * `apps/web/lib/site-chrome.ts`; other generated files read them. A site
7
+ * scaffolded before a knob existed has a reader file that ignores it, so the
8
+ * design is half-applied. These pure functions find that gap:
9
+ *
10
+ * - `chromeKnobReaders` derives, from the shipped overlay, the files that read
11
+ * each knob — nothing is hard-coded, so a new reader file is picked up.
12
+ * - `unreadChromeKnobs` compares the site's own copy of each reader file.
13
+ *
14
+ * "Reads `knob`" means the file's text matches `(?:siteChrome|chrome)\.<knob>\b`.
15
+ * A file that reads a knob under another name (a destructured alias) is not
16
+ * recognised — accepted (spec § Clarifications, detection).
17
+ */
18
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { DEFAULT_SITE_CHROME, } from "../site-chrome.js";
21
+ /** The overlay tree the generated app files live under. */
22
+ const OVERLAY_WEB_DIR = ["apps", "web"];
23
+ /** What precedes a knob name in a read: `siteChrome.` or `chrome.` — the one definition both the overlay scan and the per-site check use. */
24
+ const READ_HEAD = "(?:siteChrome|chrome)\\.";
25
+ /** The pattern that means "this text reads `knob`". */
26
+ export function chromeReadPattern(knob) {
27
+ return new RegExp(`${READ_HEAD}${knob.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
28
+ }
29
+ /** Every `.ts`/`.tsx` file under `dir` outside a `__tests__` segment, as `/`-joined paths relative to `root`. */
30
+ function listSources(root, dir, out) {
31
+ for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
32
+ const rel = `${dir}/${entry.name}`;
33
+ if (entry.isDirectory()) {
34
+ if (entry.name !== "__tests__")
35
+ listSources(root, rel, out);
36
+ }
37
+ else if (/\.tsx?$/.test(entry.name)) {
38
+ out.push(rel);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * knob → the overlay files that read it, sorted. Scans `<templatesDir>/apps/web`;
44
+ * fails closed (throws, naming the directory) when it is absent or unreadable,
45
+ * so a broken overlay can never read as "nothing reads any knob".
46
+ */
47
+ export function chromeKnobReaders(templatesDir) {
48
+ const webDir = join(templatesDir, ...OVERLAY_WEB_DIR);
49
+ const readers = new Map();
50
+ try {
51
+ if (!existsSync(webDir))
52
+ throw new Error("missing");
53
+ const files = [];
54
+ listSources(templatesDir, OVERLAY_WEB_DIR.join("/"), files);
55
+ for (const file of files.sort()) {
56
+ const text = readFileSync(join(templatesDir, file), "utf8");
57
+ for (const match of text.matchAll(new RegExp(`${READ_HEAD}([A-Za-z_$][\\w$]*)\\b`, "g"))) {
58
+ const knob = match[1];
59
+ const list = readers.get(knob) ?? [];
60
+ if (!list.includes(file))
61
+ list.push(file);
62
+ readers.set(knob, list);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ throw new Error(`cannot check chrome knobs: the overlay has no readable apps/web at ${webDir}. Reinstall create-ailk.`);
68
+ }
69
+ return readers;
70
+ }
71
+ /** A knob is set when it differs from its default; an optional knob (no default) when present. */
72
+ function isSet(resolved, knob) {
73
+ const value = resolved[knob];
74
+ if (value === undefined)
75
+ return false;
76
+ if (!(knob in DEFAULT_SITE_CHROME))
77
+ return true;
78
+ return (value !== DEFAULT_SITE_CHROME[knob]);
79
+ }
80
+ /**
81
+ * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
82
+ * the shipped overlay's `file` reads it, the site has its own `file`, and the
83
+ * site's `file` does not read it. A reader file the site lacks is skipped.
84
+ */
85
+ export function unreadChromeKnobs(resolved, readers, siteDir) {
86
+ const out = [];
87
+ for (const knob of Object.keys(resolved).sort()) {
88
+ if (!isSet(resolved, knob))
89
+ continue;
90
+ const pattern = chromeReadPattern(knob);
91
+ for (const file of readers.get(knob) ?? []) {
92
+ const abs = join(siteDir, file);
93
+ if (!existsSync(abs))
94
+ continue;
95
+ if (!pattern.test(readFileSync(abs, "utf8")))
96
+ out.push({ knob, file });
97
+ }
98
+ }
99
+ return out;
100
+ }
@@ -0,0 +1,142 @@
1
+ /** Names the platform sets, which need no row (D4). Fixed: a site cannot extend it. */
2
+ export declare const PLATFORM_ENV_NAMES: readonly string[];
3
+ /** Every name with one of these prefixes is a platform name too (D4). */
4
+ export declare const PLATFORM_ENV_PREFIXES: readonly string[];
5
+ /** Names a spoke may neither read nor declare (D5): its hub origin is server-only. */
6
+ export declare const SPOKE_REFUSED_ENV: readonly string[];
7
+ /** Whether `name` is set by the platform and so needs no row. */
8
+ export declare function isPlatformEnvName(name: string): boolean;
9
+ /** A site's topology. Restated from `topology.ts`: the scaffold copy cannot import it. */
10
+ export type SiteTopology = "hub" | "spoke";
11
+ /** One literal env read: the variable name and its 1-based line. */
12
+ export interface EnvRead {
13
+ name: string;
14
+ line: number;
15
+ }
16
+ /**
17
+ * Every literal env read in `source` (D3): `process.env.NAME`,
18
+ * `process.env["NAME"]` / `['NAME']`, either with `?.` in place of a `.`,
19
+ * and each key of an object pattern destructured from `process.env`. A
20
+ * computed `process.env[expr]` is skipped. In source order.
21
+ */
22
+ export declare function findEnvReads(source: string): EnvRead[];
23
+ /**
24
+ * The site-relative (`/`-separated) source files under `root` whose reads
25
+ * the check collects, sorted. Symbolic links are not followed.
26
+ */
27
+ export declare function listScannedFiles(root: string): string[];
28
+ /** A read located in the site: the file it sits in and its line. */
29
+ export interface SiteEnvRead extends EnvRead {
30
+ file: string;
31
+ }
32
+ /** Every read in the site at `root`, and every file that could not be read. */
33
+ export declare function collectSiteReads(root: string): {
34
+ reads: SiteEnvRead[];
35
+ unreadable: Array<{
36
+ file: string;
37
+ reason: string;
38
+ }>;
39
+ };
40
+ /** One `env_contract` row as written. */
41
+ export interface EnvContractRow {
42
+ name: string;
43
+ description: string;
44
+ secret: boolean;
45
+ scope?: EnvScope;
46
+ /** The 1-based `project.yaml` line the row's item starts on. */
47
+ line: number;
48
+ }
49
+ /** A fault in the block: the 1-based `project.yaml` line and what is wrong. */
50
+ export interface EnvContractFault {
51
+ line: number;
52
+ message: string;
53
+ }
54
+ export type EnvContract = {
55
+ present: false;
56
+ } | {
57
+ present: true;
58
+ rows: EnvContractRow[];
59
+ faults: EnvContractFault[];
60
+ };
61
+ export declare const ENV_SCOPES: readonly ["web", "api", "mcp"];
62
+ export type EnvScope = (typeof ENV_SCOPES)[number];
63
+ /**
64
+ * The top-level `env_contract:` block of `projectYamlText` (D2): absent, or
65
+ * its rows and every fault in it. A row whose name is valid is in `rows`
66
+ * even when another of its keys is at fault, so its name still counts as
67
+ * declared; the fault is reported either way.
68
+ */
69
+ export declare function readEnvContract(projectYamlText: string): EnvContract;
70
+ /**
71
+ * The topology `.ailk-site-manifest.json` records under `root` (D8): a
72
+ * missing file or key reads `"hub"`; an unknown value or a file that is not
73
+ * JSON is a fault. The `readSiteTopology` rule in create-ailk's `topology.ts`.
74
+ */
75
+ export declare function readManifestTopology(root: string): {
76
+ topology: SiteTopology;
77
+ } | {
78
+ fault: string;
79
+ };
80
+ /** The check's result: one line per finding, and the line to print on a pass. */
81
+ export interface DeclaredEnvResult {
82
+ findings: string[];
83
+ summary: string;
84
+ }
85
+ /**
86
+ * Check the site at `root` (D5): every literal env read has an
87
+ * `env_contract` row, every row is well-formed, and on a spoke
88
+ * `NEXT_PUBLIC_AILK_API_URL` is neither read nor declared. A hub with no
89
+ * block has nothing to check; a spoke with no block is a finding.
90
+ */
91
+ export declare function checkDeclaredEnv(root: string): DeclaredEnvResult;
92
+ /** A block the writer cannot read, so it never appends to it (D7). */
93
+ export declare class EnvContractError extends Error {
94
+ constructor(message: string);
95
+ }
96
+ /** What a row says about a name the writer knows (create-ailk's catalog). */
97
+ export interface EnvCatalogEntry {
98
+ description: string;
99
+ secret: boolean;
100
+ }
101
+ export interface PlanEnvRowsOptions {
102
+ /** The site's topology — passed by the caller, never read from the manifest. */
103
+ topology: SiteTopology;
104
+ /** Described rows for known names; any other name gets a placeholder row. */
105
+ catalog: Readonly<Record<string, EnvCatalogEntry>>;
106
+ /**
107
+ * Names the site depends on that an installed package reads on its behalf,
108
+ * so the scan never sees them (#7838). Each gets a row when it has none,
109
+ * with no reader and so no `scope`; a platform or refused name is skipped.
110
+ */
111
+ declare?: readonly string[];
112
+ }
113
+ /**
114
+ * `projectYamlText` with a row appended for every name the site at `root`
115
+ * reads that has none (D7), sorted by name, after the existing rows. Platform
116
+ * names, and on a spoke `SPOKE_REFUSED_ENV`, get no row. Pure: it reads the
117
+ * tree, never writes. `added` names the rows it wrote.
118
+ *
119
+ * @throws EnvContractError when the existing block has a fault — the writer
120
+ * never appends to a block it cannot read.
121
+ */
122
+ export declare function planEnvRows(root: string, projectYamlText: string, opts: PlanEnvRowsOptions): {
123
+ text: string;
124
+ added: string[];
125
+ };
126
+ /** What `appendEnvRows` did to `<root>/project.yaml`. */
127
+ export type AppendEnvRowsResult = {
128
+ state: "written";
129
+ added: string[];
130
+ } | {
131
+ state: "unchanged";
132
+ } | {
133
+ state: "no-project-yaml";
134
+ };
135
+ /**
136
+ * Plan and write `<root>/project.yaml`'s new rows (D7). Writes only when the
137
+ * text changed, so a second run changes nothing; never edits or removes a
138
+ * row. A site with no `project.yaml` is left alone.
139
+ *
140
+ * @throws EnvContractError on a block it cannot read (nothing is written).
141
+ */
142
+ export declare function appendEnvRows(root: string, opts: PlanEnvRowsOptions): AppendEnvRowsResult;