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
@@ -0,0 +1,350 @@
1
+ /**
2
+ * `create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]` — send a
3
+ * spoke's declared flows to its hub (#7876).
4
+ *
5
+ * A spoke's flows live in its own repo as `flows/<slug>.json`, each one flow
6
+ * config exactly as the hub stores it (D6). This command is the rollout step
7
+ * that writes them into the spoke's hub workspace: one
8
+ * `PUT <hub>/v1/flows/<slug>/definition` per file, carrying the spoke key, and
9
+ * the hub answers `created`, `updated` or `unchanged` (D4) — so a second run
10
+ * writes nothing.
11
+ *
12
+ * In order (D7): the site must be a spoke; every `flows/*.json` must parse
13
+ * and carry a `slug` equal to its file name; `--dry-run` stops there; the hub
14
+ * origin comes from `--hub-url`, else the `AILK_API_URL` environment
15
+ * variable, else the `AILK_API_URL=` line of `apps/web/.env.local`, and must
16
+ * be `https:` (or `http:` on a loopback host); the key comes from the
17
+ * `AILK_SPOKE_KEY` environment variable and nowhere else. Every refusal is
18
+ * thrown before the first request. The hub origin and its source are printed
19
+ * before any request, so where the key goes is visible before it is sent.
20
+ *
21
+ * The key is sent only as `x-ailk-spoke-key`, never followed across a
22
+ * redirect (`redirect: "error"`), and never printed. Of a hub's answer only a
23
+ * status and a `[a-z0-9_]` error code are printed — no other hub-supplied
24
+ * text reaches the terminal (SEC-7876-S1).
25
+ *
26
+ * Pure module: no `import.meta.url`, so jest can import it directly. `fetch`
27
+ * and `env` are injectable. Spec: docs/specs/7876-flows-as-declared-data-spec.md.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
30
+ import { join, resolve } from "node:path";
31
+ import { parseArgs } from "node:util";
32
+ import { ParseError } from "./parse-args.js";
33
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
34
+ /** The environment variable the spoke key is read from — and nowhere else. */
35
+ export const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
36
+ /** The header the key travels in (`packages/api-client`'s spoke header). */
37
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
38
+ /** `flowSlugSchema`'s rule (`@working-theory/validation`), restated: no runtime dep. */
39
+ const FLOW_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
40
+ const FLOW_SLUG_MAX = 59;
41
+ /** The only hub-supplied text printed: a code of this shape, else `unknown`. */
42
+ const SAFE_CODE = /^[a-z0-9_]{1,64}$/;
43
+ const REQUEST_TIMEOUT_MS = 15_000;
44
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
45
+ export class SeedFlowsRefusedError extends Error {
46
+ exitCode = 2;
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = "SeedFlowsRefusedError";
50
+ }
51
+ }
52
+ function readDeclaredFlows(flowsDir) {
53
+ if (!existsSync(flowsDir))
54
+ return [];
55
+ const files = readdirSync(flowsDir)
56
+ .filter((name) => name.endsWith(".json"))
57
+ .sort();
58
+ return files.map((file) => {
59
+ let config;
60
+ try {
61
+ config = JSON.parse(readFileSync(join(flowsDir, file), "utf8"));
62
+ }
63
+ catch {
64
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} is not JSON. Nothing was sent.`);
65
+ }
66
+ if (config === null ||
67
+ typeof config !== "object" ||
68
+ Array.isArray(config)) {
69
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} is not a flow config object. Nothing was sent.`);
70
+ }
71
+ const slug = config["slug"];
72
+ const expected = file.slice(0, -".json".length);
73
+ if (typeof slug !== "string" ||
74
+ !FLOW_SLUG.test(slug) ||
75
+ slug.length > FLOW_SLUG_MAX) {
76
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} has no valid "slug" (lowercase letters, digits and single hyphens, at most ${FLOW_SLUG_MAX} characters). Nothing was sent.`);
77
+ }
78
+ if (slug !== expected) {
79
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} declares slug "${slug}"; a declared flow's file is named <slug>.json. Nothing was sent.`);
80
+ }
81
+ return { slug, file, config: config };
82
+ });
83
+ }
84
+ // ── The hub origin ──────────────────────────────────────────────────────────
85
+ /** The `AILK_API_URL=` value in `apps/web/.env.local`, if one is assigned. */
86
+ function readEnvLocalHubUrl(targetDir) {
87
+ const path = join(targetDir, "apps", "web", ".env.local");
88
+ if (!existsSync(path))
89
+ return undefined;
90
+ const line = readFileSync(path, "utf8")
91
+ .split(/\r?\n/)
92
+ .map((l) => /^\s*(?:export\s+)?AILK_API_URL\s*=\s*(.*)$/.exec(l))
93
+ .filter((m) => m !== null)
94
+ .pop(); // dotenv: the last assignment wins
95
+ const value = line?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2");
96
+ return value !== undefined && value !== "" ? value : undefined;
97
+ }
98
+ const HUB_SOURCES_HINT = `The hub origin comes from --hub-url, else the ${HUB_ORIGIN_ENV_VAR} environment variable, else the ${HUB_ORIGIN_ENV_VAR}= line of apps/web/.env.local.`;
99
+ function resolveHub(targetDir, hubUrl, env) {
100
+ const fromEnv = env[HUB_ORIGIN_ENV_VAR];
101
+ const candidates = [
102
+ ["--hub-url", hubUrl],
103
+ [HUB_ORIGIN_ENV_VAR, fromEnv],
104
+ ["apps/web/.env.local", readEnvLocalHubUrl(targetDir)],
105
+ ];
106
+ const picked = candidates.find(([, value]) => value !== undefined && value.trim() !== "");
107
+ if (picked === undefined) {
108
+ throw new SeedFlowsRefusedError(`seed-flows: no hub origin was given. ${HUB_SOURCES_HINT} Nothing was sent.`);
109
+ }
110
+ const [source, raw] = picked;
111
+ try {
112
+ return { origin: normalizeHubUrl(raw), source };
113
+ }
114
+ catch (err) {
115
+ if (!(err instanceof TopologyError))
116
+ throw err;
117
+ throw new SeedFlowsRefusedError(`seed-flows: ${err.message} (from ${source}). ${HUB_SOURCES_HINT} Nothing was sent.`);
118
+ }
119
+ }
120
+ // ── Sending ─────────────────────────────────────────────────────────────────
121
+ function safeCode(value) {
122
+ return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
123
+ }
124
+ async function sendFlow(fetchImpl, origin, key, flow) {
125
+ let response;
126
+ try {
127
+ response = await fetchImpl(`${origin}/v1/flows/${flow.slug}/definition`, {
128
+ method: "PUT",
129
+ headers: {
130
+ "content-type": "application/json",
131
+ [SPOKE_KEY_HEADER]: key,
132
+ },
133
+ body: JSON.stringify(flow.config),
134
+ // A redirect would re-send the key and the body to another URL.
135
+ redirect: "error",
136
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
137
+ });
138
+ }
139
+ catch (err) {
140
+ const name = err instanceof Error ? err.name.toLowerCase() : undefined;
141
+ return { slug: flow.slug, ok: false, failure: `network ${safeCode(name)}` };
142
+ }
143
+ let body;
144
+ try {
145
+ body = await response.json();
146
+ }
147
+ catch {
148
+ body = undefined;
149
+ }
150
+ const envelope = (body ?? {});
151
+ if (response.status === 200) {
152
+ const result = envelope.data?.result;
153
+ if (result === "created" ||
154
+ result === "updated" ||
155
+ result === "unchanged") {
156
+ return {
157
+ slug: flow.slug,
158
+ ok: true,
159
+ result,
160
+ status: safeCode(envelope.data?.status),
161
+ };
162
+ }
163
+ return { slug: flow.slug, ok: false, failure: "200 unknown" };
164
+ }
165
+ return {
166
+ slug: flow.slug,
167
+ ok: false,
168
+ failure: `${response.status} ${safeCode(envelope.error?.code)}`,
169
+ };
170
+ }
171
+ // ── The report ──────────────────────────────────────────────────────────────
172
+ function hubLine(hub) {
173
+ return hub === undefined
174
+ ? "hub: none resolved"
175
+ : `hub: ${hub.origin} (from ${hub.source})`;
176
+ }
177
+ function flowLine(outcome) {
178
+ if (outcome.ok === "dry-run")
179
+ return `${outcome.slug}: would send`;
180
+ if (outcome.ok === false)
181
+ return `${outcome.slug}: failed — ${outcome.failure}`;
182
+ const draft = outcome.status === "draft" ? " (status draft — not shown to visitors)" : "";
183
+ return `${outcome.slug}: ${outcome.result}${draft}`;
184
+ }
185
+ /** The whole report, one line per row — what `seedFlows` printed as it went. */
186
+ export function formatSeedFlowsReport(result) {
187
+ if (result.empty !== undefined)
188
+ return `${result.empty}\n`;
189
+ return `${[hubLine(result.hub), ...result.flows.map(flowLine)].join("\n")}\n`;
190
+ }
191
+ // ── The command ─────────────────────────────────────────────────────────────
192
+ export async function seedFlows(opts) {
193
+ const targetDir = resolve(opts.targetDir);
194
+ const env = opts.env ?? process.env;
195
+ const onLine = opts.onLine ?? (() => undefined);
196
+ let topology;
197
+ try {
198
+ topology = readSiteTopology(targetDir);
199
+ }
200
+ catch (err) {
201
+ if (!(err instanceof TopologyError))
202
+ throw err;
203
+ throw new SeedFlowsRefusedError(`seed-flows: ${err.message}`);
204
+ }
205
+ if (topology !== "spoke") {
206
+ throw new SeedFlowsRefusedError(`seed-flows: ${opts.targetDir} is a hub; a hub's flows are authored in its Admin or seeded with seed-presets.`);
207
+ }
208
+ const flows = readDeclaredFlows(join(targetDir, "flows"));
209
+ if (flows.length === 0) {
210
+ const empty = `no declared flows in ${opts.targetDir}/flows`;
211
+ onLine(empty);
212
+ return { empty, flows: [], exitCode: 0 };
213
+ }
214
+ if (opts.dryRun === true) {
215
+ let hub;
216
+ try {
217
+ hub = resolveHub(targetDir, opts.hubUrl, env);
218
+ }
219
+ catch (err) {
220
+ if (!(err instanceof SeedFlowsRefusedError))
221
+ throw err;
222
+ hub = undefined;
223
+ }
224
+ const outcomes = flows.map((flow) => ({
225
+ slug: flow.slug,
226
+ ok: "dry-run",
227
+ }));
228
+ onLine(hubLine(hub));
229
+ outcomes.forEach((outcome) => onLine(flowLine(outcome)));
230
+ return { ...(hub !== undefined && { hub }), flows: outcomes, exitCode: 0 };
231
+ }
232
+ const hub = resolveHub(targetDir, opts.hubUrl, env);
233
+ const key = env[SPOKE_KEY_ENV_VAR]?.trim() ?? "";
234
+ if (key === "") {
235
+ throw new SeedFlowsRefusedError(`seed-flows: set ${SPOKE_KEY_ENV_VAR} to the spoke key your hub issued. It is read from the environment only — never a flag or a file. Nothing was sent.`);
236
+ }
237
+ onLine(hubLine(hub));
238
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
239
+ const outcomes = [];
240
+ for (const flow of flows) {
241
+ const outcome = await sendFlow(fetchImpl, hub.origin, key, flow);
242
+ onLine(flowLine(outcome));
243
+ outcomes.push(outcome);
244
+ }
245
+ return {
246
+ hub,
247
+ flows: outcomes,
248
+ exitCode: outcomes.every((o) => o.ok === true) ? 0 : 1,
249
+ };
250
+ }
251
+ // ── The spoke's flows/README.md (D9) ────────────────────────────────────────
252
+ export const SPOKE_FLOWS_README = `# flows/
253
+
254
+ Each file here is one **declared flow**: \`flows/<slug>.json\` holds one flow
255
+ config, exactly as the hub stores it. This repo owns these configs — the hub
256
+ runs them for this site.
257
+
258
+ ## What a spoke may declare
259
+
260
+ A spoke key may seed only what this site's own flow pages show:
261
+ \`slug\`, \`defaultLocale\`, \`steps\`, \`thankYou\` and \`flags\`. Anything the
262
+ hub acts on outside these pages — emails, deliverables, pricing, registers,
263
+ listings — is refused (\`field_not_seedable\`). A flow that needs one of those
264
+ is authored in the hub's Admin instead.
265
+
266
+ The file name must be the config's \`slug\` plus \`.json\`.
267
+
268
+ ## Seeding them into the hub
269
+
270
+ Run this as part of each rollout, from the site's root, with the spoke key
271
+ your hub issued set as \`AILK_SPOKE_KEY\` in the environment (a shell or CI
272
+ secret — never a file in this repo):
273
+
274
+ npx create-ailk seed-flows .
275
+
276
+ The hub origin comes from \`--hub-url\`, else \`AILK_API_URL\`, else the
277
+ \`AILK_API_URL=\` line of \`apps/web/.env.local\`. The key is read from the
278
+ environment only. Each flow answers \`created\`, \`updated\` or \`unchanged\`,
279
+ so running it again is safe. \`--dry-run\` checks the files and sends nothing.
280
+
281
+ The hub's operator must first turn seeding on (\`AILK_SPOKE_FLOW_SEED=true\`
282
+ on the hub). Until it is on and this command has run, links to these flows
283
+ (\`/flows/<slug>\`) reach no flow.
284
+
285
+ ## Who owns what
286
+
287
+ The next seed that differs overwrites the hub's copy of a flow's config, so
288
+ edit flows here, not in the Admin. A flow's status (live or draft) is the
289
+ hub's: switching a seeded flow to draft in the Admin keeps it draft through
290
+ every later seed. Removing a file here does not delete the hub's flow. If
291
+ the Admin adds a block only it can author (an email, a deliverable, an
292
+ offer), the hub refuses the next seed of that flow (\`slug_owned\`) rather
293
+ than delete the block.
294
+ `;
295
+ /**
296
+ * Write `SPOKE_FLOWS_README` to `<targetDir>/flows/README.md` unless that
297
+ * file exists. Returns whether it wrote.
298
+ */
299
+ export function writeSpokeFlowsReadme(targetDir) {
300
+ const dir = join(targetDir, "flows");
301
+ const path = join(dir, "README.md");
302
+ if (existsSync(path))
303
+ return false;
304
+ mkdirSync(dir, { recursive: true });
305
+ writeFileSync(path, SPOKE_FLOWS_README);
306
+ return true;
307
+ }
308
+ export const SEED_FLOWS_HELP = `create-ailk seed-flows — send a spoke's declared flows to its hub.
309
+
310
+ Usage:
311
+ AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]
312
+
313
+ Arguments:
314
+ <dir> The spoke site. Defaults to the current dir.
315
+
316
+ Options:
317
+ --hub-url <origin> The hub's origin. Else AILK_API_URL, else the
318
+ AILK_API_URL= line of apps/web/.env.local. https:
319
+ only (http: on localhost or 127.0.0.1).
320
+ --dry-run Check every flows/*.json and print what would be sent.
321
+ Sends nothing and reads no key.
322
+ -h, --help Show this help.
323
+
324
+ Each flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with
325
+ the spoke key, which is read from AILK_SPOKE_KEY only. The hub must have
326
+ AILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any
327
+ failed, 2 on a refusal (nothing is sent).
328
+ `;
329
+ export function parseSeedFlowsArgs(argv) {
330
+ const parsed = parseArgs({
331
+ args: argv,
332
+ strict: true,
333
+ allowPositionals: true,
334
+ options: {
335
+ "hub-url": { type: "string" },
336
+ "dry-run": { type: "boolean", default: false },
337
+ help: { type: "boolean", default: false, short: "h" },
338
+ },
339
+ });
340
+ const [target, ...rest] = parsed.positionals;
341
+ if (rest.length > 0) {
342
+ throw new ParseError(`seed-flows takes at most one target directory, got ${parsed.positionals.length} arguments`);
343
+ }
344
+ return {
345
+ target,
346
+ hubUrl: parsed.values["hub-url"],
347
+ dryRun: parsed.values["dry-run"] === true,
348
+ help: parsed.values.help === true,
349
+ };
350
+ }
@@ -76,6 +76,8 @@ export interface TemplateLayout {
76
76
  colorMode?: SiteChromeColorMode;
77
77
  /** #7742 D1 — `minimal`: the minimal closing slide, consent bar and themed 404. */
78
78
  chromeStyle?: SiteChromeChromeStyle;
79
+ /** #7797 D1 — route paths whose page ends on the closing footer (wordmark, contact lines, legal line). */
80
+ closingFooterPaths?: readonly string[];
79
81
  }
80
82
  /** `TemplateLayout` with every field resolved — what `site-chrome.ts` exports. */
81
83
  export interface ResolvedSiteChrome {
@@ -93,6 +95,8 @@ export interface ResolvedSiteChrome {
93
95
  colorMode?: "light";
94
96
  /** Present only when it resolves to "minimal" — never "default" (#7742 D1). */
95
97
  chromeStyle?: "minimal";
98
+ /** Present only when a non-empty list resolves — never `[]` (#7797 D1). */
99
+ closingFooterPaths?: readonly string[];
96
100
  }
97
101
  /** Defaults (#5612 D1; banner off since #6469). */
98
102
  export declare const DEFAULT_SITE_CHROME: ResolvedSiteChrome;
@@ -66,6 +66,14 @@ const HOME_VALUES = ["default", "deck"];
66
66
  const INNER_PAGES_VALUES = ["default", "bare"];
67
67
  const COLOR_MODE_VALUES = ["user", "light"];
68
68
  const CHROME_STYLE_VALUES = ["default", "minimal"];
69
+ /**
70
+ * A `closingFooterPaths` member (#7797): a route path as the site writes it —
71
+ * a leading "/" and no query, fragment, whitespace or control character. The
72
+ * layout normalizes the visitor's pathname (locale prefix, trailing slash)
73
+ * before comparing, so "/about" and "/about/" name the same page.
74
+ */
75
+ // eslint-disable-next-line no-control-regex -- the class deliberately refuses control characters
76
+ const CLOSING_FOOTER_PATH = /^\/[^\s?#\u0000-\u001f]*$/;
69
77
  /**
70
78
  * Validate an UNTRUSTED `layout` value (a bundle's `ailk-template.json`
71
79
  * `layout` field — arbitrary JSON, never compile-time checked the way the
@@ -144,6 +152,16 @@ export function validateTemplateLayout(value, subject) {
144
152
  }
145
153
  result.chromeStyle = raw.chromeStyle;
146
154
  }
155
+ if ("closingFooterPaths" in raw && raw.closingFooterPaths !== undefined) {
156
+ const paths = raw.closingFooterPaths;
157
+ const badIndex = Array.isArray(paths)
158
+ ? paths.findIndex((p) => typeof p !== "string" || !CLOSING_FOOTER_PATH.test(p))
159
+ : -1;
160
+ if (!Array.isArray(paths) || paths.length === 0 || badIndex !== -1) {
161
+ throw new TemplateLayoutError(`${subject}: layout.closingFooterPaths must be a non-empty list of route paths ("/about") with no query or fragment — got ${JSON.stringify(badIndex !== -1 ? paths[badIndex] : paths)}`);
162
+ }
163
+ result.closingFooterPaths = paths;
164
+ }
147
165
  return result;
148
166
  }
149
167
  /**
@@ -195,6 +213,12 @@ export function resolveSiteChrome(layout, designLayout) {
195
213
  if ((layout?.chromeStyle ?? fromDesign.chromeStyle) === "minimal") {
196
214
  resolved.chromeStyle = "minimal";
197
215
  }
216
+ // #7797 D1 — the list resolves as a whole (an explicit layout wins over the
217
+ // design's), and only a non-empty list is ever a key.
218
+ const closingFooterPaths = layout?.closingFooterPaths ?? fromDesign.closingFooterPaths;
219
+ if (closingFooterPaths !== undefined && closingFooterPaths.length > 0) {
220
+ resolved.closingFooterPaths = closingFooterPaths;
221
+ }
198
222
  return resolved;
199
223
  }
200
224
  /**
@@ -225,6 +249,10 @@ export function renderSiteChrome(resolved) {
225
249
  // only for the opt-in values.
226
250
  const colorModeValue = resolved.colorMode === "light" ? '\n colorMode: "light",' : "";
227
251
  const chromeStyleValue = resolved.chromeStyle === "minimal" ? '\n chromeStyle: "minimal",' : "";
252
+ // #7797 D1 — the same form: member and bullet always, the value line only for a non-empty list.
253
+ const closingFooterPathsValue = resolved.closingFooterPaths === undefined
254
+ ? ""
255
+ : `\n closingFooterPaths: [${resolved.closingFooterPaths.map((p) => JSON.stringify(p)).join(", ")}],`;
228
256
  return `// site-chrome.ts — resolved template layout knobs.
229
257
  //
230
258
  // GENERATED by create-ailk from the declared template's \`layout\` block
@@ -247,7 +275,9 @@ export function renderSiteChrome(resolved) {
247
275
  // switch; dark appears only as a deck ground.
248
276
  // - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
249
277
  // contact lines and one legal line; the consent bar is a hairline bar on
250
- // the page ground; the 404 renders in the site's theme.${groundsComment}
278
+ // the page ground; the 404 renders in the site's theme.
279
+ // - closingFooterPaths → each listed inner page ends on the closing footer
280
+ // (the wordmark, contact lines and legal line) instead of its usual footer.${groundsComment}
251
281
 
252
282
  export interface SiteChrome {
253
283
  nav: "full" | "mark-only";
@@ -257,14 +287,15 @@ export interface SiteChrome {
257
287
  innerPages?: "bare";
258
288
  deckPresentation?: true;
259
289
  colorMode?: "light";
260
- chromeStyle?: "minimal";${groundsMember}
290
+ chromeStyle?: "minimal";
291
+ closingFooterPaths?: readonly string[];${groundsMember}
261
292
  }
262
293
 
263
294
  export const siteChrome: SiteChrome = {
264
295
  nav: ${JSON.stringify(resolved.nav)},
265
296
  footer: ${JSON.stringify(resolved.footer)},
266
297
  announcementBanner: ${JSON.stringify(resolved.announcementBanner)},
267
- home: ${JSON.stringify(resolved.home)},${groundsValue}${innerPagesValue}${presentationValue}${colorModeValue}${chromeStyleValue}
298
+ home: ${JSON.stringify(resolved.home)},${groundsValue}${innerPagesValue}${presentationValue}${colorModeValue}${chromeStyleValue}${closingFooterPathsValue}
268
299
  };
269
300
  `;
270
301
  }
@@ -2,6 +2,7 @@ import type { Level } from "./lib/module-tier.js";
2
2
  import type { ModuleName } from "./parse-args.js";
3
3
  import { type SiteRoster } from "./site-roster.js";
4
4
  import type { MarkerMap } from "./substitute.js";
5
+ import type { Topology } from "./topology.js";
5
6
  export interface PlanSiteManifestOptions {
6
7
  targetDir: string;
7
8
  /** The installed manifest, read after every layer landed. */
@@ -10,16 +11,65 @@ export interface PlanSiteManifestOptions {
10
11
  modules: readonly ModuleName[];
11
12
  /** The level the key path derived, or undefined when it derived none. */
12
13
  level: Level | undefined;
14
+ /** The scaffold's topology (#7426 D10). */
15
+ topology: Topology;
16
+ /**
17
+ * `"hub"` on a spoke that signs in through its hub (#7894 D1); absent
18
+ * otherwise, so every other manifest is byte-identical to before.
19
+ */
20
+ signIn?: "hub";
21
+ /** The create-ailk version that wrote the scaffold (`CREATE_AILK_VERSION`). */
22
+ patternVersion: string;
13
23
  siteTemplatesDir: string;
14
24
  templatesDir: string;
15
25
  markers: MarkerMap;
16
26
  }
17
27
  /**
18
- * The manifest's next text: `modules` (and `level`, when derived) after
19
- * `requires`, and a row for every route present on disk.
28
+ * The manifest's next text: `modules` (and `level`, when derived), then
29
+ * `topology` and `patternVersion`, after `requires`, and a row for every
30
+ * route present on disk.
20
31
  *
21
- * @throws AddModuleRefusedError when the file already records `modules` or
22
- * `level`, or its layout is not the generator's (`rows` last, `requires`
23
- * at the top level) — guessing would silently mis-record the site.
32
+ * @throws AddModuleRefusedError when the file already records `modules`,
33
+ * `level`, `topology` or `patternVersion`, or its layout is not the
34
+ * generator's (`rows` last, `requires` at the top level) — guessing would
35
+ * silently mis-record the site.
24
36
  */
25
37
  export declare function planSiteManifest(opts: PlanSiteManifestOptions): string;
38
+ /** The two fields `create-ailk adopt` records (#7875). */
39
+ export interface TopologyRecord {
40
+ topology: Topology;
41
+ patternVersion: string;
42
+ }
43
+ /**
44
+ * `text` with `topology` and `patternVersion` inserted directly after the
45
+ * last of `requires`, `modules`, `level` the file records — the position
46
+ * `planSiteManifest` gives them (#7875 D3). Every other byte is the input's.
47
+ *
48
+ * @throws AddModuleRefusedError when the file already records either field,
49
+ * records none of the anchor keys, records one more than once at the top
50
+ * level, or the result does not read back the two values exactly.
51
+ */
52
+ export declare function planTopologyRecord(text: string, record: TopologyRecord, label: string): string;
53
+ export interface PlanAdoptedSiteManifestOptions extends TopologyRecord {
54
+ targetDir: string;
55
+ /** The template the site was built from — a `SHAPE_STARTERS` or `resolveTemplate` name. */
56
+ template: string;
57
+ /** The site's module set. */
58
+ modules: readonly ModuleName[];
59
+ siteTemplatesDir: string;
60
+ templatesDir: string;
61
+ markers: MarkerMap;
62
+ }
63
+ /**
64
+ * The manifest for a site that has none (#7875 D4): a seed naming the
65
+ * template, its `requires` and no rows, parsed the way `readSiteRoster`
66
+ * reads a manifest, then `planSiteManifest` over it — so module rows, sample
67
+ * rows and a code-route row per page route on disk arrive exactly as a
68
+ * scaffold's would. No `level` (it comes only from a licence key), and none
69
+ * of the template's `shape`, `design`, `categories`, `layout` or `theme`:
70
+ * they would claim a look the site may not wear.
71
+ *
72
+ * @throws AddModuleRefusedError when the template is undeclared, or as
73
+ * `planSiteManifest` does.
74
+ */
75
+ export declare function planAdoptedSiteManifest(opts: PlanAdoptedSiteManifestOptions): string;