create-ailk 0.27.0 → 0.28.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 (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -0,0 +1,394 @@
1
+ /**
2
+ * The page body owns its H1 (#7495, spec docs/specs/7495-the-page-body-owns-its-spec.md
3
+ * D3–D5, D8): on a page `apps/web` renders in `bodyMode: "mdx-body"`, the one
4
+ * `<h1>` a reader sees is the body's `# ` heading — renderPage prints nothing
5
+ * from frontmatter `title` or `description` there.
6
+ *
7
+ * This module is the one owner of that rule, used by `create-ailk update`
8
+ * (the consumer's migration), `tools/content-compile` (the content check)
9
+ * and the tests:
10
+ *
11
+ * - which pages are mdx-body — read from the site's own `apps/web` routes,
12
+ * always or only when frontmatter `sections[]` is empty (`mdxBodyRule`,
13
+ * `isMdxBodyPage`);
14
+ * - how many `# ` headings a body has (`countBodyHeadings`);
15
+ * - the codemod that gives a page with none `# <title>` and its description
16
+ * paragraph (`addBodyHeading`, `planBodyHeadings`, `migrateBodyHeadings`).
17
+ * A page with one or more headings is never written.
18
+ *
19
+ * Frontmatter is read with a dependency-free subset of YAML for the three
20
+ * keys it needs — `create-ailk` ships exactly one runtime dependency (`tar`),
21
+ * the `design-package.ts` posture — and any form the subset does not cover
22
+ * is refused, never guessed at. No relative imports: `tools/` imports this
23
+ * file from source.
24
+ */
25
+ import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ // The `renderPage({ … })` discovery regexes of `tools/lint-mdx-body-compiles.ts`.
28
+ const RENDER_PAGE_CALL_RE = /renderPage\s*\(\s*\{[\s\S]*?\}\s*\)/g;
29
+ const TYPE_RE = /type\s*:\s*["']([A-Za-z]+)["']/;
30
+ const MDX_BODY_MODE_RE = /bodyMode\s*:\s*["']mdx-body["']/;
31
+ // `...(composed ? {} : { bodyMode: "mdx-body" … })` — the routes' sections test.
32
+ const CONDITIONAL_MDX_BODY_RE = /\?\s*\{\s*\}\s*:\s*\{[^}]*bodyMode\s*:\s*["']mdx-body["']/;
33
+ // A content hub's `HubConfig` (#10121): `hubPageInput` sets `bodyMode` from
34
+ // its `body` — `mdx-body` always, `composed` unless `sections[]` is non-empty.
35
+ // A hub page holds one config, so its one `listType` and one `body` pair in
36
+ // any order; a file with more of either is not read.
37
+ const HUB_INPUT_RE = /\bhubPageInput\s*\(/;
38
+ const HUB_LIST_TYPE_RE = /\blistType\s*:\s*["']([A-Za-z]+)["']/g;
39
+ const HUB_BODY_RE = /\bbody\s*:\s*["'](mdx-body|composed|sections)["']/g;
40
+ const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
41
+ const SKIPPED_DIRS = new Set(["node_modules", ".next", "dist"]);
42
+ function pageFiles(dir, out = []) {
43
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
44
+ if (SKIPPED_DIRS.has(entry.name))
45
+ continue;
46
+ const path = join(dir, entry.name);
47
+ if (entry.isDirectory())
48
+ pageFiles(path, out);
49
+ else if (entry.isFile() && (entry.name === "page.tsx" || entry.name === "page.ts"))
50
+ out.push(path);
51
+ }
52
+ return out;
53
+ }
54
+ /**
55
+ * Scan `<appsRoot>/apps/web/app/**\/page.{ts,tsx}` for `renderPage({…})` calls
56
+ * naming `bodyMode: "mdx-body"`. A call whose `bodyMode` sits in a conditional
57
+ * spread is `"unless-sections"`, any other `"always"`; `"always"` wins when two
58
+ * routes name one type. A content hub's `HubConfig` reads the same way, from
59
+ * its `body`. A missing `apps/web/app` gives an empty map.
60
+ */
61
+ export function mdxBodyRule(appsRoot) {
62
+ const rule = new Map();
63
+ const appDir = join(appsRoot, "apps", "web", "app");
64
+ if (!existsSync(appDir))
65
+ return rule;
66
+ for (const file of pageFiles(appDir)) {
67
+ const source = readFileSync(file, "utf8");
68
+ if (HUB_INPUT_RE.test(source)) {
69
+ const types = [...source.matchAll(HUB_LIST_TYPE_RE)];
70
+ const bodies = [...source.matchAll(HUB_BODY_RE)];
71
+ if (types.length === 1 && bodies.length === 1) {
72
+ const type = types[0][1];
73
+ const body = bodies[0][1];
74
+ if (body === "mdx-body")
75
+ rule.set(type, "always");
76
+ else if (body === "composed" && !rule.has(type))
77
+ rule.set(type, "unless-sections");
78
+ }
79
+ }
80
+ for (const call of source.matchAll(RENDER_PAGE_CALL_RE)) {
81
+ const block = call[0];
82
+ if (!MDX_BODY_MODE_RE.test(block))
83
+ continue;
84
+ const type = TYPE_RE.exec(block)?.[1];
85
+ if (type === undefined)
86
+ continue;
87
+ if (CONDITIONAL_MDX_BODY_RE.test(block)) {
88
+ if (!rule.has(type))
89
+ rule.set(type, "unless-sections");
90
+ }
91
+ else {
92
+ rule.set(type, "always");
93
+ }
94
+ }
95
+ }
96
+ return rule;
97
+ }
98
+ /** The routes' own `composed` test: mdx-body unless `sections` is a non-empty array. */
99
+ export function isMdxBodyPage(type, frontmatter, rule) {
100
+ const mode = rule.get(type);
101
+ if (mode === "always")
102
+ return true;
103
+ if (mode !== "unless-sections")
104
+ return false;
105
+ const sections = frontmatter.sections;
106
+ return !(Array.isArray(sections) && sections.length > 0);
107
+ }
108
+ // ── The heading count (D4) ───────────────────────────────────────────────────
109
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
110
+ const ATX_H1_RE = /^ {0,3}# +\S/;
111
+ /**
112
+ * ATX level-1 headings (`^ {0,3}# +\S`) outside fenced code blocks and
113
+ * multi-line `{/* … *\/}` comments. `##`, `#text` and Setext headings do not
114
+ * count. A fence closes, as in CommonMark, on a run of its own character at
115
+ * least as long as the opening one, with nothing after it.
116
+ */
117
+ export function countBodyHeadings(body) {
118
+ let fence;
119
+ let inComment = false;
120
+ let count = 0;
121
+ for (const line of body.split(/\r?\n/)) {
122
+ if (inComment) {
123
+ if (line.includes("*/}"))
124
+ inComment = false;
125
+ continue;
126
+ }
127
+ const fenceMatch = FENCE_RE.exec(line);
128
+ if (fence !== undefined) {
129
+ const run = fenceMatch?.[1];
130
+ if (run !== undefined &&
131
+ run[0] === fence[0] &&
132
+ run.length >= fence.length &&
133
+ line.slice(fenceMatch[0].length).trim() === "") {
134
+ fence = undefined;
135
+ }
136
+ continue;
137
+ }
138
+ if (fenceMatch !== null) {
139
+ fence = fenceMatch[1];
140
+ continue;
141
+ }
142
+ if (ATX_H1_RE.test(line))
143
+ count += 1;
144
+ const open = line.lastIndexOf("{/*");
145
+ if (open !== -1 && !line.includes("*/", open + 3))
146
+ inComment = true;
147
+ }
148
+ return count;
149
+ }
150
+ function splitPage(raw) {
151
+ const open = /^\uFEFF?---[ \t]*\r?\n/.exec(raw);
152
+ if (open === null)
153
+ return undefined;
154
+ const close = /^---[ \t]*(?:\r?\n|$)/m;
155
+ const rest = raw.slice(open[0].length);
156
+ const match = close.exec(rest);
157
+ if (match === null)
158
+ return undefined;
159
+ return {
160
+ frontmatter: rest.slice(0, match.index).split(/\r?\n/),
161
+ bodyStart: open[0].length + match.index + match[0].length,
162
+ };
163
+ }
164
+ class Refused extends Error {
165
+ }
166
+ const refusedKey = (key) => new Refused(`frontmatter the subset reader does not read: ${key}`);
167
+ /** The value after `<key>:` on its top-level line, and the lines after it. */
168
+ function topLevel(lines, key) {
169
+ const prefix = `${key}:`;
170
+ const index = lines.findIndex((line) => line.startsWith(prefix));
171
+ if (index === -1)
172
+ return undefined;
173
+ return { value: lines[index].slice(prefix.length).trim(), next: lines.slice(index + 1) };
174
+ }
175
+ const nextNonBlank = (lines) => lines.find((line) => line.trim() !== "");
176
+ /** The key's value goes on below it: its next non-blank line is indented. */
177
+ const continues = (next) => /^[ \t]/.test(nextNonBlank(next) ?? "");
178
+ const YAML_INDICATOR_RE = /^[-?:,[\]{}#&*!|>'"%@`]/;
179
+ /** A single-line plain or double-quoted scalar; `""` for an absent or empty key. */
180
+ function readScalar(lines, key) {
181
+ const entry = topLevel(lines, key);
182
+ if (entry === undefined)
183
+ return "";
184
+ const { value, next } = entry;
185
+ if (continues(next))
186
+ throw refusedKey(key);
187
+ if (value === "")
188
+ return "";
189
+ if (value.startsWith('"')) {
190
+ try {
191
+ const decoded = JSON.parse(value);
192
+ if (typeof decoded === "string")
193
+ return decoded;
194
+ }
195
+ catch {
196
+ // refused below
197
+ }
198
+ throw refusedKey(key);
199
+ }
200
+ if (YAML_INDICATOR_RE.test(value) || /\s#/.test(value))
201
+ throw refusedKey(key);
202
+ return value;
203
+ }
204
+ /**
205
+ * `sections` as empty (absent, `[]`) or non-empty: a one-line flow sequence
206
+ * with content, or an empty value whose next line opens a block sequence
207
+ * (`- `, indented or not) or, indented, a multi-line flow sequence (`[` and
208
+ * anything but `]`).
209
+ */
210
+ function readSectionsNonEmpty(lines) {
211
+ const entry = topLevel(lines, "sections");
212
+ if (entry === undefined)
213
+ return false;
214
+ const value = entry.value.replace(/\s/g, "");
215
+ if (value === "[]")
216
+ return false;
217
+ if (value.startsWith("[") && value.endsWith("]"))
218
+ return true;
219
+ const rest = entry.next.filter((line) => line.trim() !== "");
220
+ if (value === "" && rest[0] !== undefined) {
221
+ const first = rest[0].trim();
222
+ if (first.startsWith("- "))
223
+ return true;
224
+ if (/^[ \t]/.test(rest[0])) {
225
+ if (first.replace(/\s/g, "") === "[]")
226
+ return false;
227
+ if (first.startsWith("[")) {
228
+ const after = first.slice(1).trim() || (rest[1]?.trim() ?? "");
229
+ if (after !== "" && !after.startsWith("]"))
230
+ return true;
231
+ }
232
+ }
233
+ }
234
+ throw refusedKey("sections");
235
+ }
236
+ // ── Inserting the heading (D5) ───────────────────────────────────────────────
237
+ const MDX_ESCAPED_RE = /[\\{<`*_[]/g;
238
+ /**
239
+ * Text for an MDX body line: whitespace runs collapsed, trimmed, and every
240
+ * `\`, `{`, `<`, `` ` ``, `*`, `_` and `[` backslash-escaped.
241
+ */
242
+ export function mdxText(text) {
243
+ return text.replace(/\s+/g, " ").trim().replace(MDX_ESCAPED_RE, (char) => `\\${char}`);
244
+ }
245
+ const headingText = (title) => mdxText(title).replace(/#$/, "\\#");
246
+ // A leading block marker would start a heading, list or quote; a leading
247
+ // `import `/`export ` would make the line ESM, so its first letter becomes a
248
+ // character reference that renders as the same letter.
249
+ const paragraphText = (description) => mdxText(description)
250
+ .replace(/^[#+>-]|^(\d+)([.)])/, (marker, digits, delimiter) => digits === undefined ? `\\${marker}` : `${digits}\\${delimiter}`)
251
+ .replace(/^(?:import|export)\b/, (word) => `&#${word.charCodeAt(0)};${word.slice(1)}`);
252
+ /** Offset in `body` after leading blank lines and a leading ESM block. */
253
+ function insertionPoint(body) {
254
+ const lines = body.split(/(?<=\n)/);
255
+ let at = 0;
256
+ let i = 0;
257
+ const blank = (line) => line !== undefined && line.trim() === "";
258
+ while (blank(lines[i]))
259
+ at += lines[i++].length;
260
+ if (!/^(?:import|export) /.test(lines[i] ?? ""))
261
+ return { at, afterEsm: false };
262
+ while (i < lines.length && !blank(lines[i]))
263
+ at += lines[i++].length;
264
+ const sawBlank = blank(lines[i]);
265
+ while (blank(lines[i]))
266
+ at += lines[i++].length;
267
+ return { at, afterEsm: !sawBlank };
268
+ }
269
+ function planPage(raw, rule, type) {
270
+ const mode = rule.get(type);
271
+ if (mode === undefined)
272
+ return { kind: "skip" };
273
+ const split = splitPage(raw);
274
+ const body = split === undefined ? raw : raw.slice(split.bodyStart);
275
+ if (countBodyHeadings(body) > 0)
276
+ return { kind: "skip" };
277
+ if (split === undefined)
278
+ return { kind: "refuse", reason: "no frontmatter" };
279
+ try {
280
+ if (mode === "unless-sections" && readSectionsNonEmpty(split.frontmatter)) {
281
+ return { kind: "skip" };
282
+ }
283
+ const title = readScalar(split.frontmatter, "title");
284
+ if (title.trim() === "")
285
+ return { kind: "refuse", reason: "no title" };
286
+ const description = readScalar(split.frontmatter, "description");
287
+ if (description.trim() === "")
288
+ return { kind: "refuse", reason: "no description" };
289
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
290
+ const { at, afterEsm } = insertionPoint(body);
291
+ const rest = body.slice(at);
292
+ const front = raw.slice(0, split.bodyStart);
293
+ // A file that ends at its closing `---` with no line ending needs one first.
294
+ const lead = afterEsm
295
+ ? body.slice(0, at).endsWith("\n") ? eol : eol + eol
296
+ : front.endsWith("\n") ? "" : eol;
297
+ const inserted = `${lead}# ${headingText(title)}${eol}${eol}${paragraphText(description)}${eol}` +
298
+ (rest === "" ? "" : eol);
299
+ return { kind: "write", text: front + body.slice(0, at) + inserted + rest };
300
+ }
301
+ catch (err) {
302
+ if (err instanceof Refused)
303
+ return { kind: "refuse", reason: err.message };
304
+ throw err;
305
+ }
306
+ }
307
+ /**
308
+ * The new file text for an mdx-body page with zero `# ` headings — its
309
+ * `# <title>` and description paragraph inserted at the top of the body —
310
+ * else `undefined`. A page the subset reader refuses, with no frontmatter, or
311
+ * with an empty `title` or `description`, is never written.
312
+ */
313
+ export function addBodyHeading(raw, rule, type) {
314
+ const plan = planPage(raw, rule, type);
315
+ return plan.kind === "write" ? plan.text : undefined;
316
+ }
317
+ /**
318
+ * Every `<contentDir>/<locale>/<Type>/<slug>.mdx` whose `<Type>` the rule
319
+ * names, sorted. Symlinks are skipped, as `copy.ts` and `update.ts` skip
320
+ * them, so the codemod never reads or writes outside `contentDir`.
321
+ */
322
+ function rulePages(contentDir, rule) {
323
+ if (!existsSync(contentDir))
324
+ return [];
325
+ const pages = [];
326
+ for (const locale of readdirSync(contentDir, { withFileTypes: true })) {
327
+ if (!locale.isDirectory() || !LOCALE_RE.test(locale.name))
328
+ continue;
329
+ for (const type of rule.keys()) {
330
+ const typeDir = join(contentDir, locale.name, type);
331
+ if (!existsSync(typeDir) || !lstatSync(typeDir).isDirectory())
332
+ continue;
333
+ for (const entry of readdirSync(typeDir, { withFileTypes: true })) {
334
+ if (entry.isFile() && entry.name.endsWith(".mdx")) {
335
+ pages.push({ path: join(typeDir, entry.name), type });
336
+ }
337
+ }
338
+ }
339
+ }
340
+ return pages.sort((a, b) => a.path.localeCompare(b.path));
341
+ }
342
+ /** What `migrateBodyHeadings` would write, and the heading-less pages it cannot. Never throws for a page. */
343
+ export function planBodyHeadings(contentDir, rule) {
344
+ const plan = { writes: [], refused: [] };
345
+ for (const { path, type } of rulePages(contentDir, rule)) {
346
+ let page;
347
+ try {
348
+ page = planPage(readFileSync(path, "utf8"), rule, type);
349
+ }
350
+ catch (err) {
351
+ plan.refused.push({ path, reason: `could not be read: ${String(err)}` });
352
+ continue;
353
+ }
354
+ if (page.kind === "write")
355
+ plan.writes.push({ path, text: page.text });
356
+ else if (page.kind === "refuse")
357
+ plan.refused.push({ path, reason: page.reason });
358
+ }
359
+ return plan;
360
+ }
361
+ /** Every mdx-body page whose body does not have exactly one `# ` heading. */
362
+ export function headingViolations(contentDir, rule) {
363
+ const violations = [];
364
+ for (const { path, type } of rulePages(contentDir, rule)) {
365
+ let raw;
366
+ try {
367
+ raw = readFileSync(path, "utf8");
368
+ }
369
+ catch {
370
+ continue;
371
+ }
372
+ const split = splitPage(raw);
373
+ if (rule.get(type) === "unless-sections" && split !== undefined) {
374
+ try {
375
+ if (readSectionsNonEmpty(split.frontmatter))
376
+ continue;
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ }
382
+ const count = countBodyHeadings(split === undefined ? raw : raw.slice(split.bodyStart));
383
+ if (count !== 1)
384
+ violations.push({ path, count });
385
+ }
386
+ return violations;
387
+ }
388
+ /** Write each planned page; the paths written and the refused rows. */
389
+ export function migrateBodyHeadings(contentDir, rule) {
390
+ const { writes, refused } = planBodyHeadings(contentDir, rule);
391
+ for (const { path, text } of writes)
392
+ writeFileSync(path, text);
393
+ return { written: writes.map((write) => write.path), refused };
394
+ }
package/dist/cli.js CHANGED
@@ -60,7 +60,8 @@ import { withNpmTokenUserConfig } from "./lib/npm-auth.js";
60
60
  import { FREE_PREREQUISITES_NOTE, LICENSED_PREREQUISITES_NOTE, isLicensedInstall, proCallout, } from "./lib/pro-callout.js";
61
61
  import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
62
62
  import { PLUGIN_INSTALL_LINES, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
63
- import { addModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
63
+ import { addModule, removeModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
64
+ import { REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, } from "./remove-module.js";
64
65
  import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
65
66
  import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
66
67
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
@@ -146,6 +147,32 @@ async function main() {
146
147
  }
147
148
  process.exit(0);
148
149
  }
150
+ // `remove-module <module> [dir] [--force]` (#10040): the inverse of
151
+ // add-module for `marketing` and `docs-blog`. A refusal it names (another
152
+ // module, a spoke, docs-blog still installed, the site's own files in the
153
+ // set without --force) exits 2 before anything is written.
154
+ if (argv[0] === "remove-module") {
155
+ const args = parseRemoveModuleArgs(argv.slice(1));
156
+ if (args.help || args.module === undefined) {
157
+ process.stdout.write(REMOVE_MODULE_HELP);
158
+ process.exit(0);
159
+ }
160
+ try {
161
+ const result = removeModule({
162
+ targetDir: args.target ?? process.cwd(),
163
+ moduleName: args.module,
164
+ force: args.force,
165
+ });
166
+ process.stdout.write(formatRemoveModuleReport(result));
167
+ }
168
+ catch (err) {
169
+ if (!(err instanceof RemoveModuleRefusedError))
170
+ throw err;
171
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
172
+ process.exit(err.exitCode);
173
+ }
174
+ process.exit(0);
175
+ }
149
176
  // `add-route <route> [dir]` (#6947): add a route surface the site was
150
177
  // scaffolded without — add-module's route, architecture and nav steps with
151
178
  // no licence and no bundle. A refusal it names (an unknown route, one a
@@ -29,8 +29,8 @@ export const CONTENT_PAYLOADS = Object.freeze([
29
29
  kind: "template-set",
30
30
  name: "scaffold-commerce",
31
31
  feature: "commerce",
32
- version: "1.0.1",
33
- digest: "112704a7910dba3275defc0cb86b1b4f33fc8f262f4c09320e8f6b3a8d873916",
32
+ version: "1.0.2",
33
+ digest: "af6faf7bd35b4316bfa4bab1560f68c740c27f3ad9cf9dd4feca3187e70c14ad",
34
34
  sourceRoot: "packages/create-ailk/module-templates/commerce/.claude/skills/scaffold-commerce",
35
35
  files: Object.freeze([
36
36
  "references/lookup-keys-template.md",
@@ -239,7 +239,11 @@ export function deriveArchitecture(modules = ["marketing"], surfaces, options =
239
239
  const spoke = options.topology === "spoke";
240
240
  const keepMcp = options.mcp !== false;
241
241
  const selected = normalizeModules(modules);
242
- const slices = selected.map((m) => MODULE_SLICES[m]);
242
+ // #10040 D4 — `marketing`'s `webImports` and `packages` are the base web
243
+ // app's permissions (the `(authed)` pages import `@working-theory/ui` and
244
+ // `@working-theory/theme` too), so they are composed whatever the selected
245
+ // set; only the `# Modules:` header and the `surfaces:` block follow it.
246
+ const slices = normalizeModules(["marketing", ...selected]).map((m) => MODULE_SLICES[m]);
243
247
  // The declared route surface (#4154). A scaffold's live-route set is a
244
248
  // function of this list; the `surfaces:` block below is its machine-readable
245
249
  // record, so "which routes does this site declare" is answerable without
@@ -392,7 +396,9 @@ export function deriveArchitecture(modules = ["marketing"], surfaces, options =
392
396
  "# To change them, re-run create-ailk (or re-derive) with the new module",
393
397
  "# set — do NOT hand-edit. Enforced by tools/lint-architecture.ts.",
394
398
  "#",
395
- `# Modules: ${selected.join(", ")}`,
399
+ // #10040 — a site that dropped every module (a keyless scaffold minus
400
+ // `marketing`) still needs a readable header: `none`, never a blank.
401
+ `# Modules: ${selected.length > 0 ? selected.join(", ") : "none"}`,
396
402
  `# Surfaces: ${enabledSurfaces.length > 0 ? enabledSurfaces.join(", ") : "core only"}`,
397
403
  ...(spoke ? ["# Topology: spoke"] : []),
398
404
  "",
package/dist/index.d.ts CHANGED
@@ -5,10 +5,11 @@
5
5
  */
6
6
  export { copyTemplate, type CopyTemplateOptions } from "./copy.js";
7
7
  export { parseCliArgs, parseModules, ParseError, helpText, RECOGNIZED_MODULES, type ModuleName, type CliOptions, } from "./parse-args.js";
8
- export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, LicenseRequiredError, PAID_MODULES, classifyModules, formatSurfaceDrift, scaffold, scaffoldSync, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, type AddModuleOptions, type ResolvePaidModulesOptions, type ScaffoldOptions, type ScaffoldResult, type SyncArchitectureOptions, type SyncArchitectureResult, type SyncCatalogResult, type SyncClaudeMdResult, type SyncRoutesSyncOptions, } from "./programmatic.js";
8
+ export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, removeModule, LicenseRequiredError, PAID_MODULES, classifyModules, formatSurfaceDrift, scaffold, scaffoldSync, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, type AddModuleOptions, type RemoveModuleCliOptions, type ResolvePaidModulesOptions, type ScaffoldOptions, type ScaffoldResult, type SyncArchitectureOptions, type SyncArchitectureResult, type SyncCatalogResult, type SyncClaudeMdResult, type SyncRoutesSyncOptions, } from "./programmatic.js";
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 { REMOVABLE_MODULES, REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, removeModuleFromScaffold, type RemoveModuleOptions, type RemoveModuleResult, } from "./remove-module.js";
12
13
  export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, type ApplyDesignArgs, type ApplyDesignChange, type ApplyDesignOptions, type ApplyDesignResult, } from "./apply-design.js";
13
14
  export type { HomeSeedOutcome } from "./design-home.js";
14
15
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export { copyTemplate } from "./copy.js";
7
7
  export { parseCliArgs, parseModules, ParseError, helpText, RECOGNIZED_MODULES, } from "./parse-args.js";
8
- export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, LicenseRequiredError, PAID_MODULES, classifyModules, formatSurfaceDrift, scaffold, scaffoldSync, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
8
+ export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, removeModule, LicenseRequiredError, PAID_MODULES, classifyModules, formatSurfaceDrift, scaffold, scaffoldSync, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
9
9
  // The paid-module fetch/extract primitives (K1, #5089 — Flavor 3). Exported
10
10
  // so tooling (and this repo's own evidence-run scripts) can drive the fetch
11
11
  // path directly without re-implementing its shape.
@@ -14,6 +14,9 @@ export { extractModuleBundle, } from "./lib/extract-module-bundle.js";
14
14
  // `create-ailk add-module` (#6653) — apply a purchased module to an existing
15
15
  // site in place. The pure core, beside `sync-routes`'s.
16
16
  export { ADD_MODULE_HELP, AddModuleRefusedError, applyModuleToScaffold, formatAddModuleReport, parseAddModuleArgs, } from "./add-module.js";
17
+ // `create-ailk remove-module` (#10040) — the inverse of add-module for the
18
+ // marketing and docs-blog modules. The pure core, beside add-module's.
19
+ export { REMOVABLE_MODULES, REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, removeModuleFromScaffold, } from "./remove-module.js";
17
20
  // `create-ailk apply-design` (#6819) — make an existing site wear a design,
18
21
  // changing only its look. The pure core, beside `add-module`'s.
19
22
  export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
@@ -1,6 +1,9 @@
1
1
  export declare const AUTH_SECRET_ENV_VAR = "BETTER_AUTH_SECRET";
2
+ export declare const AUTH_URL_ENV_VAR = "BETTER_AUTH_URL";
2
3
  /** The files the writer owns, web first. */
3
4
  export declare const AUTH_SECRET_ENV_FILES: readonly [string, string];
5
+ /** Each app's loopback origin, in `AUTH_SECRET_ENV_FILES` order. */
6
+ export declare const AUTH_URL_DEFAULTS: readonly ["http://localhost:3000", "http://localhost:3001"];
4
7
  /**
5
8
  * `.gitignore` lines that already ignore `apps/<app>/.env.local`. An explicit
6
9
  * set, never a glob evaluation; `/.env.local` is absent because, anchored to
@@ -13,7 +16,8 @@ export interface WriteAuthSecretOptions {
13
16
  }
14
17
  /**
15
18
  * Give the scaffold at `dir` a `BETTER_AUTH_SECRET` (F1–F4 of the #6107
16
- * spec). Generates at most one value, and only when no app already has one.
17
- * Idempotent — a second call changes no byte.
19
+ * spec) and each app its loopback `BETTER_AUTH_URL` (#8620). Generates at
20
+ * most one secret, and only when no app already has one. Idempotent — a
21
+ * second call changes no byte.
18
22
  */
19
23
  export declare function writeAuthSecretFile(dir: string, opts?: WriteAuthSecretOptions): void;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Where a scaffold keeps its `BETTER_AUTH_SECRET` (#6107).
2
+ * Where a scaffold keeps its `BETTER_AUTH_SECRET` (#6107) and its
3
+ * `BETTER_AUTH_URL` (#8620, spec 9965 D10).
3
4
  *
4
5
  * `apps/web/auth.ts` refuses to serve in production without the secret
5
6
  * (#6044), and so does the Admin app's `auth.ts`. A fresh scaffold therefore
@@ -10,10 +11,15 @@
10
11
  * - `apps/admin/.env.local` — when the scaffold carries `apps/admin`, with
11
12
  * the SAME value (both apps sign cookies for one site).
12
13
  *
14
+ * Both `auth.ts` files also refuse to serve in production without
15
+ * `BETTER_AUTH_URL`, so each file gets that app's loopback origin too
16
+ * (`AUTH_URL_DEFAULTS`) — the origin `next start` answers on before the
17
+ * operator sets a public one.
18
+ *
13
19
  * Never clobber, never shadow: a value already assigned in any env file Next
14
20
  * loads for the app under `next start` is kept, and no new `.env.local` is
15
- * created above it. Never print: no message, log line or return value
16
- * carries the secret.
21
+ * created above it — per variable. Never print: no message, log line or
22
+ * return value carries the secret.
17
23
  *
18
24
  * Pure module: no `import.meta.url`, so jest imports it directly.
19
25
  */
@@ -22,11 +28,17 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
22
28
  import { dirname, join } from "node:path";
23
29
  import { appendLine, writeOwnerOnly } from "./license-key-files.js";
24
30
  export const AUTH_SECRET_ENV_VAR = "BETTER_AUTH_SECRET";
31
+ export const AUTH_URL_ENV_VAR = "BETTER_AUTH_URL";
25
32
  /** The files the writer owns, web first. */
26
33
  export const AUTH_SECRET_ENV_FILES = [
27
34
  join("apps", "web", ".env.local"),
28
35
  join("apps", "admin", ".env.local"),
29
36
  ];
37
+ /** Each app's loopback origin, in `AUTH_SECRET_ENV_FILES` order. */
38
+ export const AUTH_URL_DEFAULTS = [
39
+ "http://localhost:3000",
40
+ "http://localhost:3001",
41
+ ];
30
42
  /**
31
43
  * `.gitignore` lines that already ignore `apps/<app>/.env.local`. An explicit
32
44
  * set, never a glob evaluation; `/.env.local` is absent because, anchored to
@@ -45,17 +57,20 @@ const NEXT_START_ENV_FILES = [
45
57
  ".env.production",
46
58
  ".env",
47
59
  ];
48
- /** A line assigning the variable, with or without `export`; group 1 is the value. */
49
- const ENV_LINE = new RegExp(`^\\s*(?:export\\s+)?${AUTH_SECRET_ENV_VAR}\\s*=(.*)$`);
60
+ /** A line assigning `name`, with or without `export`; group 1 is the value. */
61
+ function envLine(name) {
62
+ return new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=(.*)$`);
63
+ }
50
64
  /**
51
65
  * The value `text` assigns — the LAST assignment, as dotenv reads it — or
52
66
  * `undefined` when it assigns none. An empty value counts as none, exactly as
53
67
  * `auth.ts` treats it.
54
68
  */
55
- function assignedValue(text) {
69
+ function assignedValue(text, name) {
70
+ const pattern = envLine(name);
56
71
  let value;
57
72
  for (const line of text.split("\n")) {
58
- const m = ENV_LINE.exec(line);
73
+ const m = pattern.exec(line);
59
74
  if (m !== null) {
60
75
  const raw = (m[1] ?? "").trim();
61
76
  const quoted = /^(["'])(.*)\1$/.exec(raw);
@@ -64,13 +79,13 @@ function assignedValue(text) {
64
79
  }
65
80
  return value === "" ? undefined : value;
66
81
  }
67
- /** The effective value for the app at `appDir`, by Next's precedence. */
68
- function effectiveValue(appDir) {
69
- for (const name of NEXT_START_ENV_FILES) {
70
- const path = join(appDir, name);
82
+ /** The effective value of `name` for the app at `appDir`, by Next's precedence. */
83
+ function effectiveValue(appDir, name) {
84
+ for (const file of NEXT_START_ENV_FILES) {
85
+ const path = join(appDir, file);
71
86
  if (!existsSync(path))
72
87
  continue;
73
- const value = assignedValue(readFileSync(path, "utf8"));
88
+ const value = assignedValue(readFileSync(path, "utf8"), name);
74
89
  if (value !== undefined)
75
90
  return value;
76
91
  }
@@ -78,29 +93,40 @@ function effectiveValue(appDir) {
78
93
  }
79
94
  /**
80
95
  * Give the scaffold at `dir` a `BETTER_AUTH_SECRET` (F1–F4 of the #6107
81
- * spec). Generates at most one value, and only when no app already has one.
82
- * Idempotent — a second call changes no byte.
96
+ * spec) and each app its loopback `BETTER_AUTH_URL` (#8620). Generates at
97
+ * most one secret, and only when no app already has one. Idempotent — a
98
+ * second call changes no byte.
83
99
  */
84
100
  export function writeAuthSecretFile(dir, opts = {}) {
85
101
  // Web always; Admin only when the scaffold carries `apps/admin`.
86
- const apps = AUTH_SECRET_ENV_FILES.map((file) => ({
102
+ const apps = AUTH_SECRET_ENV_FILES.map((file, i) => ({
87
103
  file: join(dir, file),
88
104
  appDir: dirname(join(dir, file)),
105
+ url: AUTH_URL_DEFAULTS[i],
89
106
  })).filter(({ appDir }, i) => i === 0 || existsSync(appDir));
90
107
  // An existing value — web's first — is shared; a fresh one is generated
91
108
  // only when no app has one.
92
109
  let secret = apps
93
- .map(({ appDir }) => effectiveValue(appDir))
110
+ .map(({ appDir }) => effectiveValue(appDir, AUTH_SECRET_ENV_VAR))
94
111
  .find((v) => v !== undefined);
95
112
  const secretFor = () => {
96
113
  secret ??= (opts.randomBytes ?? nodeRandomBytes)(32).toString("base64url");
97
114
  return secret;
98
115
  };
99
- for (const { file, appDir } of apps) {
100
- if (effectiveValue(appDir) !== undefined)
116
+ for (const { file, appDir, url } of apps) {
117
+ const lines = [];
118
+ if (effectiveValue(appDir, AUTH_SECRET_ENV_VAR) === undefined) {
119
+ lines.push(`${AUTH_SECRET_ENV_VAR}=${secretFor()}`);
120
+ }
121
+ if (effectiveValue(appDir, AUTH_URL_ENV_VAR) === undefined) {
122
+ lines.push(`${AUTH_URL_ENV_VAR}=${url}`);
123
+ }
124
+ if (lines.length === 0)
101
125
  continue;
102
- const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
103
- writeOwnerOnly(file, appendLine(existing, `${AUTH_SECRET_ENV_VAR}=${secretFor()}`));
126
+ let text = existsSync(file) ? readFileSync(file, "utf8") : "";
127
+ for (const line of lines)
128
+ text = appendLine(text, line);
129
+ writeOwnerOnly(file, text);
104
130
  }
105
131
  const ignorePath = join(dir, ".gitignore");
106
132
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";