create-ailk 0.14.0 → 0.15.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -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,22 @@ 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
+ /** The create-ailk version that wrote the scaffold (`CREATE_AILK_VERSION`). */
17
+ patternVersion: string;
13
18
  siteTemplatesDir: string;
14
19
  templatesDir: string;
15
20
  markers: MarkerMap;
16
21
  }
17
22
  /**
18
- * The manifest's next text: `modules` (and `level`, when derived) after
19
- * `requires`, and a row for every route present on disk.
23
+ * The manifest's next text: `modules` (and `level`, when derived), then
24
+ * `topology` and `patternVersion`, after `requires`, and a row for every
25
+ * route present on disk.
20
26
  *
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.
27
+ * @throws AddModuleRefusedError when the file already records `modules`,
28
+ * `level`, `topology` or `patternVersion`, or its layout is not the
29
+ * generator's (`rows` last, `requires` at the top level) — guessing would
30
+ * silently mis-record the site.
24
31
  */
25
32
  export declare function planSiteManifest(opts: PlanSiteManifestOptions): string;
@@ -15,6 +15,10 @@
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`);
18
22
  * - a row for every route present on disk — each module's contributed rows
19
23
  * through `planRosterAppend` (add-module's own append), the sample pages
20
24
  * a module's bundle installs (`sampleRows`), and a code-route row for any
@@ -51,16 +55,18 @@ const CODE_ROUTE_ROW = {
51
55
  sections: [],
52
56
  };
53
57
  /**
54
- * The manifest's next text: `modules` (and `level`, when derived) after
55
- * `requires`, and a row for every route present on disk.
58
+ * The manifest's next text: `modules` (and `level`, when derived), then
59
+ * `topology` and `patternVersion`, after `requires`, and a row for every
60
+ * route present on disk.
56
61
  *
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.
62
+ * @throws AddModuleRefusedError when the file already records `modules`,
63
+ * `level`, `topology` or `patternVersion`, or its layout is not the
64
+ * generator's (`rows` last, `requires` at the top level) — guessing would
65
+ * silently mis-record the site.
60
66
  */
61
67
  export function planSiteManifest(opts) {
62
68
  const { roster } = opts;
63
- for (const key of ["modules", "level"]) {
69
+ for (const key of ["modules", "level", "topology", "patternVersion"]) {
64
70
  if (key in roster.doc) {
65
71
  throw new AddModuleRefusedError(`${roster.path} already records "${key}"`);
66
72
  }
@@ -81,7 +87,7 @@ export function planSiteManifest(opts) {
81
87
  ...codeRouteRows(opts.targetDir, declared),
82
88
  ];
83
89
  const withRows = appendRows(appended, extra, roster);
84
- return insertRecord(withRows, opts.modules, opts.level, roster.path);
90
+ return insertRecord(withRows, opts, roster.path);
85
91
  }
86
92
  /**
87
93
  * The rows for the sample pages a module's bundle installs through its
@@ -233,8 +239,12 @@ function appendRows(text, rows, roster) {
233
239
  ? text.slice(0, end) + rendered + roster.trail + text.slice(close)
234
240
  : text.slice(0, end) + "," + rendered + text.slice(end);
235
241
  }
236
- /** `text` with `modules` (and `level`, when derived) inserted after the top-level `requires`. */
237
- function insertRecord(text, modules, level, label) {
242
+ /**
243
+ * `text` with `modules` (and `level`, when derived), `topology` and
244
+ * `patternVersion` inserted after the top-level `requires`.
245
+ */
246
+ function insertRecord(text, opts, label) {
247
+ const { modules, level, topology, patternVersion } = opts;
238
248
  const anchor = '\n "requires": ';
239
249
  const at = text.indexOf(anchor);
240
250
  if (at === -1 || text.indexOf(anchor, at + 1) !== -1) {
@@ -243,11 +253,15 @@ function insertRecord(text, modules, level, label) {
243
253
  const end = text.indexOf("]", at) + 1;
244
254
  const name = '"modules": ';
245
255
  const record = `,\n ${name}${renderJson([...modules], 2, name.length, 1)}` +
246
- (level !== undefined ? `,\n "level": ${JSON.stringify(level)}` : "");
256
+ (level !== undefined ? `,\n "level": ${JSON.stringify(level)}` : "") +
257
+ `,\n "topology": ${JSON.stringify(topology)}` +
258
+ `,\n "patternVersion": ${JSON.stringify(patternVersion)}`;
247
259
  const next = text.slice(0, end) + record + text.slice(end);
248
260
  const doc = JSON.parse(next);
249
261
  if (!isDeepStrictEqual(doc["modules"], [...modules]) ||
250
- doc["level"] !== level) {
262
+ doc["level"] !== level ||
263
+ doc["topology"] !== topology ||
264
+ doc["patternVersion"] !== patternVersion) {
251
265
  throw new AddModuleRefusedError(`${label}: the module set could not be recorded beside "requires"`);
252
266
  }
253
267
  return next;
@@ -33,6 +33,7 @@ import { parseArgs } from "node:util";
33
33
  import { copyTemplate } from "./copy.js";
34
34
  import { substituteMarkers } from "./substitute.js";
35
35
  import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
36
+ import { isSpokeExcluded, readSiteTopology } from "./topology.js";
36
37
  /** Template-relative prefix every `apps/web` route directory sits under. */
37
38
  const ROUTE_ROOT = "apps/web/app/[locale]";
38
39
  /**
@@ -163,12 +164,20 @@ export function syncRoutes(opts) {
163
164
  skippedSurfaces.push(surface.id);
164
165
  }
165
166
  skippedSurfaces.sort();
167
+ // #7426 D13 — on a spoke, a route dir the spoke does not carry (the
168
+ // `(auth)` and `(authed)` groups) is out of scope: neither reported nor
169
+ // copied. This is the one filter point for add-module, add-route and the
170
+ // sync-routes subcommand alike; a hub's scope is unchanged.
171
+ const spoke = readSiteTopology(targetAbs) === "spoke"
172
+ ? { mcp: existsSync(join(targetAbs, "apps", "mcp")) }
173
+ : undefined;
166
174
  const inScope = listTemplateRouteDirs(opts.templatesDir)
167
175
  .map(reportFor)
168
176
  // "authed" (#5014): the `(authed)` route group ships unconditionally in
169
- // every scaffold today, the same as "core" — it is checked regardless of
177
+ // every hub scaffold, the same as "core" — it is checked regardless of
170
178
  // `carried`, which only tracks GATEABLE_SURFACES opt-in.
171
- .filter((r) => r.surface === "core" || r.surface === "authed" || carried.has(r.surface));
179
+ .filter((r) => r.surface === "core" || r.surface === "authed" || carried.has(r.surface))
180
+ .filter((r) => spoke === undefined || !isSpokeExcluded(`${ROUTE_ROOT}/${r.route}`, spoke));
172
181
  // Identical to scaffoldSync's defaults, so a synced route matches a fresh
173
182
  // scaffold of the same target byte-for-byte.
174
183
  const markers = {
@@ -0,0 +1,131 @@
1
+ import { type ModuleName } from "./parse-args.js";
2
+ /** The two topologies. `hub` is the default and the only one before #7426. */
3
+ export declare const TOPOLOGIES: readonly ["hub", "spoke"];
4
+ export type Topology = (typeof TOPOLOGIES)[number];
5
+ /** A refusal or a malformed input this module names. */
6
+ export declare class TopologyError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Roots no spoke carries. `apps/mcp` joins them with `--without-mcp`. */
10
+ export declare const SPOKE_REMOVED_ROOTS: readonly string[];
11
+ /** The root `--without-mcp` removes. */
12
+ export declare const SPOKE_MCP_ROOT = "apps/mcp";
13
+ /**
14
+ * The sign-in and database files a spoke does not carry — files and
15
+ * directories, scaffold-relative. Every other file that imports one of them
16
+ * goes with it (`spokeExclusions`).
17
+ */
18
+ export declare const SPOKE_REMOVAL_SEEDS: readonly string[];
19
+ /** The two generated seam files (D5). A seam is rewritten, never removed. */
20
+ export declare const WEB_SEAM_PATH = "apps/web/lib/topology.generated.ts";
21
+ export declare const MCP_SEAM_PATH = "apps/mcp/src/lib/topology.generated.ts";
22
+ export declare const SPOKE_SEAM_FILES: readonly string[];
23
+ /**
24
+ * The closure's allow-list: a file `spokeExclusions` removes must sit under
25
+ * one of these prefixes, or under a `__tests__/` directory. A member outside
26
+ * both is a defect — a shared file started importing a sign-in or database
27
+ * file — and `spokeExclusions` throws naming it rather than cut it silently.
28
+ */
29
+ export declare const SPOKE_REMOVABLE_PREFIXES: readonly string[];
30
+ export interface SpokeShapeOptions {
31
+ /** Whether the spoke keeps `apps/mcp` (the default; `--without-mcp` drops it). */
32
+ mcp: boolean;
33
+ }
34
+ /** The removed roots for these options. */
35
+ export declare function spokeRemovedRoots(opts: SpokeShapeOptions): string[];
36
+ /** Whether a closure member is one the registry allows a spoke to drop. */
37
+ export declare function isRemovableClosureMember(path: string): boolean;
38
+ /**
39
+ * Every path a spoke does not carry, computed over the built `templates/`
40
+ * tree: the removed roots, the seeds that apply, and every source file under
41
+ * the kept apps that reaches a seed through relative imports, to a fixed
42
+ * point. Sorted; directories and files alike, matched at a directory
43
+ * boundary by `isSpokeExcluded` and `copyTemplate`'s `excludePaths`.
44
+ *
45
+ * The walk is the relative-import resolution `importClosure` uses
46
+ * (`lib/import-graph.ts`), with ONE difference: a seam file is never a
47
+ * candidate, so the walk never crosses it. The hub render of a seam imports
48
+ * a seed by design (D5); its importers keep their place on a spoke because
49
+ * the spoke render of the same file imports none.
50
+ *
51
+ * @throws TopologyError naming every closure member outside
52
+ * `SPOKE_REMOVABLE_PREFIXES` and not a test — a new shared file importing
53
+ * a sign-in or database file, which removing would silently break the
54
+ * spoke (spec § 5.3: escalate, never widen).
55
+ */
56
+ export declare function spokeExclusions(templatesDir: string, opts: SpokeShapeOptions): string[];
57
+ export interface IsSpokeExcludedOptions extends SpokeShapeOptions {
58
+ /**
59
+ * The full `spokeExclusions(...)` list, when the caller computed it. Absent,
60
+ * only the removed roots and the seeds are checked — exact for any path
61
+ * that is not a file of the built `templates/` tree (a module bundle's
62
+ * file, a route directory), since only a template file can be a closure
63
+ * member.
64
+ */
65
+ exclusions?: readonly string[];
66
+ }
67
+ /**
68
+ * The one predicate for "a spoke does not carry this path" (the Phase 2
69
+ * generator contract). Scaffold-relative, `/`- or `\`-separated.
70
+ */
71
+ export declare function isSpokeExcluded(path: string, opts: IsSpokeExcludedOptions): boolean;
72
+ /** `apps/web/lib/topology.generated.ts` for `topology` (D5). */
73
+ export declare function renderWebSeam(topology: Topology): string;
74
+ /** `apps/mcp/src/lib/topology.generated.ts` for `topology` (D5). */
75
+ export declare function renderMcpSeam(topology: Topology): string;
76
+ /** `pnpm-workspace.yaml` without its `database` package entry. */
77
+ export declare function transformWorkspaceYaml(text: string): string;
78
+ /** The root `package.json` without its `db:*` scripts. */
79
+ export declare function transformRootPackageJson(text: string): string;
80
+ /** The root `tsconfig.json` without a reference under a removed root. */
81
+ export declare function transformRootTsconfig(text: string, opts: SpokeShapeOptions): string;
82
+ /**
83
+ * An app's `package.json` without its `@working-theory/database` dependency.
84
+ * @throws TopologyError when `required` and the dependency is absent.
85
+ */
86
+ export declare function transformAppPackageJson(text: string, label: string, required: boolean): string;
87
+ /**
88
+ * Apply every D6 root-file transform to the spoke at `targetDir` (after the
89
+ * base copy, before any module or template layer), and write the spoke
90
+ * render of each seam it carries (D5).
91
+ * @throws TopologyError naming the file whose anchor is missing.
92
+ */
93
+ export declare function applySpokeTransforms(targetDir: string, opts: SpokeShapeOptions): void;
94
+ /** The env var a spoke reaches its hub through. */
95
+ export declare const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
96
+ /**
97
+ * Write `AILK_API_URL=<origin>` to the spoke's `apps/web/.env.local` (D2) —
98
+ * the `auth-secret-file.ts` never-clobber rule: an assignment already in any
99
+ * env file Next loads for `apps/web` is kept, and `.env.local` stays
100
+ * gitignored. Never writes `AILK_SPOKE_KEY` or `AILK_VISITOR_FORWARD_KEY`:
101
+ * the hub issues those.
102
+ */
103
+ export declare function writeHubOriginEnv(targetDir: string, origin: string): void;
104
+ /** The `hard_floors:` block a spoke's `project.yaml` carries (D9). */
105
+ export declare function renderSpokeHardFloors(opts: SpokeShapeOptions): string;
106
+ /** `project.yaml` with its `hard_floors:` block replaced by the spoke's (D9). */
107
+ export declare function transformProjectYaml(text: string, opts: SpokeShapeOptions): string;
108
+ /** The modules a spoke may carry — every slice declaring `spoke: true`. */
109
+ export declare function spokeModules(): ModuleName[];
110
+ /** The refusal for a module a spoke cannot carry, or undefined when admitted. */
111
+ export declare function spokeModuleRefusal(name: ModuleName): string | undefined;
112
+ /**
113
+ * The topology `<targetDir>` records. A missing manifest, or one without a
114
+ * `topology` key, reads as `"hub"` — every site scaffolded before #7426.
115
+ * @throws TopologyError on an unreadable manifest or an unknown value.
116
+ */
117
+ export declare function readSiteTopology(targetDir: string): Topology;
118
+ /**
119
+ * Hosts a spoke may reach over plain `http:` — the set
120
+ * `packages/api-client/src/http.ts` sends the spoke key under. Named here
121
+ * rather than imported: create-ailk carries no runtime dependency.
122
+ */
123
+ export declare const LOOPBACK_HOSTS: readonly string[];
124
+ /**
125
+ * The hub origin for a spoke: `--hub-url`, else `AILK_API_URL`. An `https:`
126
+ * URL, or `http:` on a loopback host, with no path but `/`, no query and no
127
+ * fragment — normalized to its origin with no trailing slash.
128
+ * @throws TopologyError naming both inputs when neither is set or the value
129
+ * is not such an origin. Never echoes a query or fragment.
130
+ */
131
+ export declare function normalizeHubUrl(raw: string | undefined): string;