create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -19,26 +19,32 @@
19
19
  * - Expected bytes are what `copyTemplate` would write. The copier's
20
20
  * text/binary + marker rules are NOT re-implemented here: the template's
21
21
  * route root is copied once into a scratch dir and compared from there.
22
- * - Never overwrite, never delete. A route dir the site has changed is
23
- * reported as modified and left alone; `--apply` only creates directories
24
- * that do not exist, so a second run is a no-op. When the site has edited
25
- * its structured-data test, a route `--apply` adds that the template
26
- * exempts there is reported with the exact `NO_STRUCTURED_DATA_ROUTES`
27
- * entry to add — the test itself is never written (#8704).
22
+ * - Never overwrite, never delete — with one named exception. A route dir
23
+ * the site has changed is reported as modified and left alone; `--apply`
24
+ * only creates directories that do not exist, so a second run is a no-op.
25
+ * When the site has edited its structured-data test, a route `--apply`
26
+ * adds that the template exempts there is reported with the exact
27
+ * `NO_STRUCTURED_DATA_ROUTES` entry to add — the test itself is never
28
+ * written (#8704). The exception is the retired legacy spoke checkout
29
+ * (#9142): on a spoke, `--apply` deletes its files that are still
30
+ * byte-identical to the template record and rewrites the generated
31
+ * billing action off them (`retireLegacySpokeCheckout`); an edited or
32
+ * unrecorded file is reported and left alone.
28
33
  *
29
34
  * Pure module: no `import.meta.url`, so jest can import it directly. The
30
35
  * package's own `templates/` dir is supplied by programmatic.ts.
31
36
  */
32
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
37
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
33
38
  import { tmpdir } from "node:os";
34
- import { basename, dirname, join, resolve, sep } from "node:path";
39
+ import { basename, dirname, join, posix, resolve, sep } from "node:path";
35
40
  import { parseArgs } from "node:util";
36
- import { copyTemplate } from "./copy.js";
41
+ import { copyTemplate, renderTemplateFile } from "./copy.js";
42
+ import { SPOKE_BILLING_ACTION_PATH } from "./derive-architecture.js";
37
43
  import { appendEnvRows } from "./lib/declared-env.js";
38
44
  import { ENV_CATALOG } from "./lib/env-catalog.js";
39
- import { substituteMarkers } from "./substitute.js";
45
+ import { BILLING_ACTION, BILLING_ACTION_MARKER, RETIRED_CHECKOUT_E2E_MARKER, RETIRED_CHECKOUT_E2E_WORKFLOW_PATH, RETIRED_SPOKE_CHECKOUT_PATHS, } from "./spoke-checkout.js";
40
46
  import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
41
- import { TEMPLATE_RECORD_PATH, parseTemplateRecord, sha256Hex } from "./template-record.js";
47
+ import { TEMPLATE_RECORD_PATH, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
42
48
  import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
43
49
  /** Template-relative prefix every `apps/web` route directory sits under. */
44
50
  const ROUTE_ROOT = "apps/web/app/[locale]";
@@ -50,12 +56,184 @@ export const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data
50
56
  * `projects/apply` (#5048) is signal-collectors' own owned file
51
57
  * (`ListingApplicationClient.tsx`, D-48) — its module bundle restores it
52
58
  * whenever signal-collectors is selected, independent of the unrelated
53
- * "projects" content surface's own selection. See the doc comment on
54
- * `syncRoutes`'s `carried` computation.
59
+ * "projects" content surface's own selection. `careers/apply` and
60
+ * `careers/[slug]` (#8840 D13) are the same case: signal-collectors' bundle
61
+ * lands the Job application walks (`careers/apply/page.tsx`,
62
+ * `careers/[slug]/apply/**`) whenever the module is selected, so a site that
63
+ * turned the careers pages off must not read as carrying "careers" — a site
64
+ * that carries them keeps `careers/page.tsx`, which still counts. See the doc
65
+ * comment on `syncRoutes`'s `carried` computation.
55
66
  */
56
67
  const NESTED_FOREIGN_MODULE_ENTRIES = new Set([
57
68
  "projects/apply",
69
+ "careers/apply",
70
+ "careers/[slug]",
58
71
  ]);
72
+ /** Extensions whose files are scanned for import specifiers (#9406 DD1). */
73
+ const SOURCE_FILE = /\.(?:ts|tsx|js|jsx|mjs|mts)$/;
74
+ /**
75
+ * Static import and re-export (`import … from`, `export … from`), side-effect
76
+ * (`import "…"`) and dynamic (`import("…")`) specifiers (#9406 DD1). Matched
77
+ * over the whole text: `[^'"]*?` spans newlines, so a multi-line
78
+ * `import {\n a,\n} from "…"` is caught from its `import` line.
79
+ */
80
+ const IMPORT_PATTERNS = [
81
+ /^\s*(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/gm,
82
+ /^\s*import\s*['"]([^'"]+)['"]/gm,
83
+ /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
84
+ ];
85
+ /** The site-local (`./`, `../`) import specifiers of a source text, deduplicated. */
86
+ function relativeImportSpecifiers(text) {
87
+ const out = new Set();
88
+ for (const pattern of IMPORT_PATTERNS) {
89
+ for (const match of text.matchAll(pattern)) {
90
+ const specifier = match[1];
91
+ if (specifier !== undefined && (specifier.startsWith("./") || specifier.startsWith("../"))) {
92
+ out.add(specifier);
93
+ }
94
+ }
95
+ }
96
+ return [...out];
97
+ }
98
+ /**
99
+ * The scaffold-relative files `specifier`, imported from `importer`, may name —
100
+ * in #9406 DD2's probe order: a `.js`/`.jsx` specifier's `.ts`/`.tsx` spelling
101
+ * first, then the exact path, the extension probes and the directory index.
102
+ * Empty when the path climbs out of the scaffold root.
103
+ */
104
+ function importCandidates(importer, specifier) {
105
+ const base = posix.normalize(posix.join(posix.dirname(importer), specifier));
106
+ if (base === ".." || base.startsWith("../"))
107
+ return [];
108
+ const out = [];
109
+ const js = /\.(jsx?)$/.exec(base);
110
+ if (js !== null) {
111
+ const stem = base.slice(0, -js[0].length);
112
+ if (js[1] === "js")
113
+ out.push(`${stem}.ts`);
114
+ out.push(`${stem}.tsx`);
115
+ }
116
+ out.push(base);
117
+ for (const ext of [".ts", ".tsx", ".js", ".jsx", ".mjs"])
118
+ out.push(base + ext);
119
+ out.push(`${base}/index.ts`, `${base}/index.tsx`);
120
+ return out;
121
+ }
122
+ /** Whether `root`/`rel` (`/`-separated) is an existing regular file. */
123
+ function isRegularFile(root, rel) {
124
+ try {
125
+ return statSync(join(root, ...rel.split("/"))).isFile();
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * The deepest route in `missingRoutes` a scaffold-relative path sits under —
133
+ * the route whose own copy writes it — or undefined.
134
+ */
135
+ function ownerRoute(path, missingRoutes) {
136
+ if (!path.startsWith(`${ROUTE_ROOT}/`))
137
+ return undefined;
138
+ const rel = path.slice(ROUTE_ROOT.length + 1);
139
+ let owner;
140
+ for (const m of missingRoutes) {
141
+ if (rel.startsWith(`${m}/`) && (owner === undefined || m.length > owner.length))
142
+ owner = m;
143
+ }
144
+ return owner;
145
+ }
146
+ /**
147
+ * Resolve the site-local imports of every file `route`'s own copy writes, and
148
+ * transitively of every helper that brings (#9406 DD1–DD3). Each import is
149
+ * resolved against the site first, then the template. A file the site has
150
+ * ends the walk there (the site owns its closure); a file another missing
151
+ * route's copy brings is recorded for DD4's propagation; a file only the
152
+ * template has is a copy — unless the spoke drops it or its surface is not
153
+ * carried, which refuses the route.
154
+ */
155
+ function walkRouteImports(route, ctx) {
156
+ const inScratch = (path) => path.startsWith(`${ROUTE_ROOT}/`) ? path.slice(ROUTE_ROOT.length + 1) : undefined;
157
+ const templateHas = (path) => {
158
+ const rel = inScratch(path);
159
+ return rel !== undefined
160
+ ? isRegularFile(ctx.scratch, rel)
161
+ : isRegularFile(ctx.templatesDir, path);
162
+ };
163
+ // The bytes `copyTemplate` (a route file) or `copyDependencyFile` (any
164
+ // other) would write for `path`.
165
+ const templateBytes = (path) => {
166
+ const rel = inScratch(path);
167
+ if (rel !== undefined)
168
+ return readFileSync(join(ctx.scratch, ...rel.split("/")));
169
+ return Buffer.from(renderTemplateFile(readFileSync(join(ctx.templatesDir, ...path.split("/"))), path, ctx.versions, ctx.markers));
170
+ };
171
+ const copies = new Set();
172
+ const differs = new Set();
173
+ const unresolved = [];
174
+ const reached = [];
175
+ const queue = regularFilesUnder(ctx.scratch, route)
176
+ .map((rel) => `${ROUTE_ROOT}/${rel}`)
177
+ .filter((path) => ownerRoute(path, ctx.missingRoutes) === route);
178
+ const visited = new Set(queue);
179
+ for (let from = queue.shift(); from !== undefined; from = queue.shift()) {
180
+ if (!SOURCE_FILE.test(from))
181
+ continue;
182
+ const importer = from;
183
+ for (const specifier of relativeImportSpecifiers(templateBytes(importer).toString("utf8"))) {
184
+ const candidates = importCandidates(importer, specifier);
185
+ const inSite = candidates.find((c) => isRegularFile(ctx.targetAbs, c));
186
+ const path = inSite ?? candidates.find(templateHas);
187
+ if (path === undefined) {
188
+ unresolved.push({ specifier, from: importer, reason: "not in the template" });
189
+ continue;
190
+ }
191
+ const owner = ownerRoute(path, ctx.missingRoutes);
192
+ if (owner !== undefined) {
193
+ if (owner !== route)
194
+ reached.push({ path, owner, specifier, from: importer });
195
+ continue;
196
+ }
197
+ if (inSite !== undefined) {
198
+ const siteBytes = readFileSync(join(ctx.targetAbs, ...path.split("/")));
199
+ if (templateHas(path) && !templateBytes(path).equals(siteBytes))
200
+ differs.add(path);
201
+ continue;
202
+ }
203
+ if (ctx.spoke !== undefined && isSpokeExcluded(path, ctx.spoke)) {
204
+ unresolved.push({ specifier, from: importer, reason: "the spoke does not carry it" });
205
+ continue;
206
+ }
207
+ const routeRel = inScratch(path);
208
+ const surface = routeRel === undefined ? "core" : surfaceForRouteDir(routeRel);
209
+ if (surface === undefined || !ctx.carried.has(surface)) {
210
+ unresolved.push({
211
+ specifier,
212
+ from: importer,
213
+ reason: `its surface (${surface ?? "none"}) is not carried by this site`,
214
+ });
215
+ continue;
216
+ }
217
+ copies.add(path);
218
+ if (!visited.has(path)) {
219
+ visited.add(path);
220
+ queue.push(path);
221
+ }
222
+ }
223
+ }
224
+ return {
225
+ route,
226
+ copies: [...copies].sort(),
227
+ differs: [...differs].sort(),
228
+ unresolved: sortUnresolved(unresolved),
229
+ reached,
230
+ };
231
+ }
232
+ /** `unresolved` rows sorted by `from`, then `specifier`. */
233
+ function sortUnresolved(rows) {
234
+ const key = (row) => `${row.from}\u0000${row.specifier}`;
235
+ return [...rows].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
236
+ }
59
237
  /**
60
238
  * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
61
239
  * brace scan that skips string literals and comments. Empty when the
@@ -229,6 +407,141 @@ function isModified(expectedDir, targetDir, skip = () => false) {
229
407
  }
230
408
  return false;
231
409
  }
410
+ /** Directories a retirement never removes, even when it leaves them empty. */
411
+ const RETIRE_STOP_DIRS = new Set([
412
+ "apps/web/app/api",
413
+ "apps/web/lib",
414
+ "apps/web/lib/__tests__",
415
+ ".github/workflows",
416
+ ]);
417
+ /** Every regular file at or under `rel` in `targetAbs`, `/`-separated. A symlink is skipped, never followed. */
418
+ function regularFilesUnder(targetAbs, rel) {
419
+ const abs = join(targetAbs, ...rel.split("/"));
420
+ let stat;
421
+ try {
422
+ stat = lstatSync(abs);
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ if (stat.isFile())
428
+ return [rel];
429
+ if (!stat.isDirectory())
430
+ return [];
431
+ return readdirSync(abs)
432
+ .sort()
433
+ .flatMap((name) => regularFilesUnder(targetAbs, `${rel}/${name}`));
434
+ }
435
+ /** The first line of a text file. */
436
+ function firstLine(text) {
437
+ const eol = text.indexOf("\n");
438
+ return eol === -1 ? text : text.slice(0, eol);
439
+ }
440
+ /**
441
+ * Retire the legacy env-key spoke checkout from a spoke (#9142): its buy
442
+ * route, Stripe webhook route, `lib/checkout.ts`, their tests
443
+ * (`RETIRED_SPOKE_CHECKOUT_PATHS`) and the generated checkout e2e workflow.
444
+ *
445
+ * - A copied file is deleted only when the template record lists it and its
446
+ * bytes still hash to the recorded copy — the rule `create-ailk update`
447
+ * uses for a file the template dropped; its record row goes with it. An
448
+ * edited file, or one the record does not list, is reported in `kept`.
449
+ * - The workflow, never recorded, is deleted only when its first line is
450
+ * still create-ailk's generated marker.
451
+ * - The billing action (a spoke that signs in through its hub) imported
452
+ * `lib/checkout.ts`; when its first line is still the generated marker and
453
+ * it differs from the current render, it is rewritten to `BILLING_ACTION`.
454
+ * An edited one that still imports `lib/checkout` is reported in `kept`.
455
+ *
456
+ * Without `apply` nothing is written: the lists say what `--apply` would do.
457
+ * Directories the deletes leave empty are removed, up to `RETIRE_STOP_DIRS`.
458
+ */
459
+ export function retireLegacySpokeCheckout(targetAbs, apply) {
460
+ const retired = [];
461
+ const kept = [];
462
+ const rewritten = [];
463
+ const recordPath = join(targetAbs, TEMPLATE_RECORD_PATH);
464
+ let record;
465
+ if (existsSync(recordPath)) {
466
+ try {
467
+ record = parseTemplateRecord(readFileSync(recordPath, "utf8"));
468
+ }
469
+ catch {
470
+ record = undefined;
471
+ }
472
+ }
473
+ for (const path of RETIRED_SPOKE_CHECKOUT_PATHS.flatMap((rel) => regularFilesUnder(targetAbs, rel))) {
474
+ const recorded = record?.files[path]?.sha256;
475
+ if (recorded === undefined) {
476
+ kept.push({
477
+ path,
478
+ reason: record === undefined
479
+ ? "no readable template record, so an edit cannot be told apart"
480
+ : "not in the template record",
481
+ });
482
+ }
483
+ else if (sha256Hex(readFileSync(join(targetAbs, ...path.split("/")))) !== recorded) {
484
+ kept.push({ path, reason: "edited here" });
485
+ }
486
+ else {
487
+ retired.push(path);
488
+ }
489
+ }
490
+ const workflow = regularFilesUnder(targetAbs, RETIRED_CHECKOUT_E2E_WORKFLOW_PATH);
491
+ for (const path of workflow) {
492
+ const text = readFileSync(join(targetAbs, ...path.split("/")), "utf8");
493
+ if (firstLine(text) === RETIRED_CHECKOUT_E2E_MARKER)
494
+ retired.push(path);
495
+ else
496
+ kept.push({ path, reason: "edited here" });
497
+ }
498
+ const billingAbs = join(targetAbs, ...SPOKE_BILLING_ACTION_PATH.split("/"));
499
+ if (regularFilesUnder(targetAbs, SPOKE_BILLING_ACTION_PATH).length === 1) {
500
+ const text = readFileSync(billingAbs, "utf8");
501
+ if (text !== BILLING_ACTION) {
502
+ if (firstLine(text) === BILLING_ACTION_MARKER) {
503
+ rewritten.push(SPOKE_BILLING_ACTION_PATH);
504
+ }
505
+ else if (/lib\/checkout["'`]/.test(text)) {
506
+ kept.push({
507
+ path: SPOKE_BILLING_ACTION_PATH,
508
+ reason: "edited here, and it imports the retired lib/checkout",
509
+ });
510
+ }
511
+ }
512
+ }
513
+ if (apply) {
514
+ for (const path of retired) {
515
+ const abs = resolve(join(targetAbs, ...path.split("/")));
516
+ if (!abs.startsWith(targetAbs + sep)) {
517
+ throw new Error(`refusing to delete outside target dir: ${abs}`);
518
+ }
519
+ rmSync(abs);
520
+ let dir = dirname(path);
521
+ while (dir !== "." && !RETIRE_STOP_DIRS.has(dir)) {
522
+ const dirAbs = join(targetAbs, ...dir.split("/"));
523
+ if (!existsSync(dirAbs) || readdirSync(dirAbs).length > 0)
524
+ break;
525
+ rmdirSync(dirAbs);
526
+ dir = dirname(dir);
527
+ }
528
+ }
529
+ if (rewritten.length > 0)
530
+ writeFileSync(billingAbs, BILLING_ACTION);
531
+ if (record !== undefined && retired.some((path) => path in record.files)) {
532
+ const files = { ...record.files };
533
+ for (const path of retired)
534
+ delete files[path];
535
+ writeFileSync(recordPath, serializeTemplateRecord({ ...record, files }));
536
+ }
537
+ }
538
+ const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
539
+ return {
540
+ retired: retired.sort(byPath),
541
+ kept: kept.sort((a, b) => byPath(a.path, b.path)),
542
+ rewritten,
543
+ };
544
+ }
232
545
  /**
233
546
  * Compare an already-scaffolded repo's route tree with the template's and,
234
547
  * when `apply` is set, copy in the route dirs it is missing.
@@ -313,6 +626,7 @@ export function syncRoutes(opts) {
313
626
  const targetRouteRoot = join(targetAbs, ROUTE_ROOT);
314
627
  const missing = [];
315
628
  const modified = [];
629
+ let walks = [];
316
630
  const scratch = mkdtempSync(join(tmpdir(), "ailk-sync-routes-"));
317
631
  try {
318
632
  copyTemplate({
@@ -330,18 +644,73 @@ export function syncRoutes(opts) {
330
644
  modified.push(report);
331
645
  }
332
646
  }
647
+ // #9406 — resolve each missing route's site-local imports while the
648
+ // scratch render is still on disk.
649
+ walks = missing.map((r) => walkRouteImports(r.route, {
650
+ scratch,
651
+ templatesDir: opts.templatesDir,
652
+ targetAbs,
653
+ missingRoutes: missing.map((m) => m.route),
654
+ versions: opts.versions,
655
+ markers,
656
+ spoke,
657
+ carried: new Set(["core", "authed", ...carried]),
658
+ }));
333
659
  }
334
660
  finally {
335
661
  rmSync(scratch, { recursive: true, force: true });
336
662
  }
663
+ // #9406 DD4 — a route refuses when an import resolves nowhere; refusal
664
+ // inherits to its sub-routes and to any route importing a file only a
665
+ // refused route's copy would write, iterated to a fixpoint. Never upward:
666
+ // an ancestor is still copied without the refused sub-tree.
667
+ const missingRoutes = missing.map((r) => r.route);
668
+ const refusedSet = new Set(walks.filter((w) => w.unresolved.length > 0).map((w) => w.route));
669
+ for (let changed = true; changed;) {
670
+ changed = false;
671
+ for (const walk of walks) {
672
+ if (refusedSet.has(walk.route))
673
+ continue;
674
+ if (missingRoutes.some((a) => refusedSet.has(a) && walk.route.startsWith(`${a}/`))) {
675
+ refusedSet.add(walk.route);
676
+ changed = true;
677
+ continue;
678
+ }
679
+ const dangling = walk.reached.filter((r) => refusedSet.has(r.owner));
680
+ if (dangling.length > 0) {
681
+ walk.unresolved = sortUnresolved([
682
+ ...walk.unresolved,
683
+ ...dangling.map(({ path, owner, specifier, from }) => ({
684
+ specifier,
685
+ from,
686
+ reason: `${path} is brought by ${owner}, which is refused`,
687
+ })),
688
+ ]);
689
+ refusedSet.add(walk.route);
690
+ changed = true;
691
+ }
692
+ }
693
+ }
694
+ const refused = missingRoutes.filter((r) => refusedSet.has(r)).sort();
695
+ const routeImports = walks
696
+ .filter((w) => w.copies.length + w.differs.length + w.unresolved.length > 0)
697
+ .map(({ route, copies, differs, unresolved }) => ({ route, copies, differs, unresolved }))
698
+ .sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
337
699
  // #5014 third clause: a route's ROUTE_DEPENDENCIES files must exist in the
338
700
  // target too, or `--apply` leaves a route that cannot compile. Computed
339
701
  // from `missing` only — a route already present is assumed to already have
340
702
  // brought its own dependencies along (or the site owner has otherwise
341
703
  // satisfied them); re-checking every IN-SCOPE route's dependencies on every
342
704
  // run would report a pre-existing, unrelated gap as if this sync caused it.
705
+ // #9406 — unioned with the copies each route's imports resolve to, and
706
+ // never for a refused route: its helpers are neither required nor copied.
343
707
  const missingDependencies = [
344
- ...new Set(missing.flatMap((r) => r.dependencies.filter((dep) => !existsSync(join(targetAbs, dep))))),
708
+ ...new Set(missing
709
+ .filter((r) => !refusedSet.has(r.route))
710
+ .flatMap((r) => [
711
+ ...r.dependencies.filter((dep) => !existsSync(join(targetAbs, dep))),
712
+ ...(walks.find((w) => w.route === r.route)?.copies ?? []),
713
+ ])),
345
714
  ].sort();
346
715
  const missingMessageNamespaces = [
347
716
  ...new Set(missing.flatMap((r) => r.messageNamespaces.filter((ns) => !hasMessageNamespace(targetAbs, ns)))),
@@ -349,11 +718,11 @@ export function syncRoutes(opts) {
349
718
  const applied = [];
350
719
  const appliedDependencies = [];
351
720
  if (opts.apply === true) {
352
- const missingRoutes = missing.map((r) => r.route);
353
- for (const route of missingRoutes) {
721
+ const writable = missingRoutes.filter((r) => !refusedSet.has(r));
722
+ for (const route of writable) {
354
723
  applied.push(route);
355
724
  // An ancestor route dir's subtree copy already brings this one.
356
- if (missingRoutes.some((m) => route.startsWith(m + "/")))
725
+ if (writable.some((m) => route.startsWith(m + "/")))
357
726
  continue;
358
727
  const dst = resolve(join(targetRouteRoot, route));
359
728
  if (!dst.startsWith(targetAbs + sep)) {
@@ -364,6 +733,10 @@ export function syncRoutes(opts) {
364
733
  targetDir: dst,
365
734
  versions: opts.versions,
366
735
  markers,
736
+ // #9406 DD4 — hold back each refused sub-route from the ancestor's copy.
737
+ excludePaths: refused
738
+ .filter((r) => r.startsWith(route + "/"))
739
+ .map((r) => r.slice(route.length + 1)),
367
740
  });
368
741
  }
369
742
  for (const dep of missingDependencies) {
@@ -371,7 +744,7 @@ export function syncRoutes(opts) {
371
744
  if (!dst.startsWith(targetAbs + sep)) {
372
745
  throw new Error(`refusing to write outside target dir: ${dst}`);
373
746
  }
374
- copyDependencyFile(join(opts.templatesDir, dep), dst, markers);
747
+ copyDependencyFile(opts.templatesDir, dep, dst, opts.versions, markers);
375
748
  appliedDependencies.push(dep);
376
749
  }
377
750
  // #7874 D9 — a spoke declares what the copied routes read, as the last
@@ -387,11 +760,16 @@ export function syncRoutes(opts) {
387
760
  missing,
388
761
  modified,
389
762
  applied,
763
+ refused,
764
+ routeImports,
390
765
  missingDependencies,
391
766
  appliedDependencies,
392
767
  missingMessageNamespaces,
393
768
  skippedSurfaces,
394
769
  structuredDataEntries: structuredDataEntriesFor(targetAbs, opts.templatesDir, applied),
770
+ retiredCheckout: spoke !== undefined
771
+ ? retireLegacySpokeCheckout(targetAbs, opts.apply === true)
772
+ : { retired: [], kept: [], rewritten: [] },
395
773
  };
396
774
  }
397
775
  /**
@@ -420,19 +798,20 @@ function hasMessageNamespace(targetAbs, namespace) {
420
798
  }
421
799
  }
422
800
  /**
423
- * Copy one dependency file from the template into the target, applying the
424
- * same marker substitution `copyTemplate` applies to text files — every
425
- * `ROUTE_DEPENDENCIES` entry is `.ts`/`.tsx`, always text. Never overwrites:
426
- * the caller (`syncRoutes`) only calls this for a path already confirmed
427
- * absent from the target, but the check is repeated here so this helper is
428
- * safe to call on its own.
801
+ * Copy one dependency file (`rel`, template-relative) from the template into
802
+ * the target through `renderTemplateFile` — the renderer `copyTemplate` uses,
803
+ * so a text file gets its markers and a non-text asset an import reached
804
+ * (`./logo.png`, #9406) is copied byte-for-byte. Never overwrites: the caller
805
+ * (`syncRoutes`) only calls this for a path already confirmed absent from the
806
+ * target, but the check is repeated here so this helper is safe to call on
807
+ * its own.
429
808
  */
430
- function copyDependencyFile(src, dst, markers) {
809
+ function copyDependencyFile(templatesDir, rel, dst, versions, markers) {
431
810
  if (existsSync(dst))
432
811
  return;
433
812
  mkdirSync(dirname(dst), { recursive: true });
434
- const text = readFileSync(src, "utf8");
435
- writeFileSync(dst, substituteMarkers(text, markers));
813
+ const bytes = readFileSync(join(templatesDir, ...rel.split("/")));
814
+ writeFileSync(dst, renderTemplateFile(bytes, rel, versions, markers));
436
815
  }
437
816
  /** Render the report the CLI prints after a run. Ends with a newline. */
438
817
  export function formatSyncRoutesReport(result, apply) {
@@ -446,7 +825,7 @@ export function formatSyncRoutesReport(result, apply) {
446
825
  }
447
826
  else if (apply) {
448
827
  lines.push(`[create-ailk] Copied ${result.applied.length} route(s):`);
449
- lines.push(...result.missing.map(entry));
828
+ lines.push(...result.missing.filter((r) => result.applied.includes(r.route)).map(entry));
450
829
  }
451
830
  else {
452
831
  lines.push(`[create-ailk] ${result.missing.length} template route(s) missing:`);
@@ -459,6 +838,32 @@ export function formatSyncRoutesReport(result, apply) {
459
838
  if (result.skippedSurfaces.length > 0) {
460
839
  lines.push(`[create-ailk] Surfaces this site does not carry (skipped): ${result.skippedSurfaces.join(", ")}`);
461
840
  }
841
+ // #9406 DD5 — per missing route, what its imports copy, leave, or cannot
842
+ // resolve; then the routes a dangling import holds back.
843
+ if (result.routeImports.length > 0) {
844
+ lines.push("[create-ailk] Local modules the missing route(s) import:");
845
+ for (const { route, copies, differs, unresolved } of result.routeImports) {
846
+ lines.push(` ${route}`);
847
+ if (copies.length > 0) {
848
+ lines.push(" copies:");
849
+ lines.push(...copies.map((path) => ` ${path}`));
850
+ }
851
+ if (differs.length > 0) {
852
+ lines.push(" left as-is (differs from the template):");
853
+ lines.push(...differs.map((path) => ` ${path}`));
854
+ }
855
+ if (unresolved.length > 0) {
856
+ lines.push(" cannot resolve:");
857
+ lines.push(...unresolved.map(({ specifier, from, reason }) => ` ${specifier} (from ${from}): ${reason}`));
858
+ }
859
+ }
860
+ }
861
+ if (result.refused.length > 0) {
862
+ lines.push(apply
863
+ ? `[create-ailk] ${result.refused.length} route(s) not copied — an import does not resolve:`
864
+ : `[create-ailk] ${result.refused.length} route(s) would not be copied — an import does not resolve:`);
865
+ lines.push(...result.refused.map((route) => ` ${route}`));
866
+ }
462
867
  // #5014 third clause: the routes/messages/lib a synced route needs to
463
868
  // compile, reported (and, for lib files, applied) alongside the route.
464
869
  if (result.missingDependencies.length > 0) {
@@ -478,6 +883,25 @@ export function formatSyncRoutesReport(result, apply) {
478
883
  lines.push(...entry.split("\n").map((line) => ` ${line}`));
479
884
  }
480
885
  }
886
+ // #9142 — the retired legacy spoke checkout, named so the operator sees
887
+ // exactly what left and what stays for them to delete.
888
+ const retiredCheckout = result.retiredCheckout;
889
+ if (retiredCheckout.retired.length > 0) {
890
+ lines.push(apply
891
+ ? `[create-ailk] Removed ${retiredCheckout.retired.length} file(s) of the retired legacy spoke checkout (a spoke sells through its hub's Offerings buy route):`
892
+ : `[create-ailk] ${retiredCheckout.retired.length} file(s) of the retired legacy spoke checkout to remove with --apply (a spoke sells through its hub's Offerings buy route):`);
893
+ lines.push(...retiredCheckout.retired.map((path) => ` ${path}`));
894
+ }
895
+ if (retiredCheckout.rewritten.length > 0) {
896
+ lines.push(apply
897
+ ? "[create-ailk] Rewrote the generated billing action off the retired lib/checkout:"
898
+ : "[create-ailk] The generated billing action imports the retired lib/checkout; --apply rewrites it:");
899
+ lines.push(...retiredCheckout.rewritten.map((path) => ` ${path}`));
900
+ }
901
+ if (retiredCheckout.kept.length > 0) {
902
+ lines.push(`[create-ailk] ${retiredCheckout.kept.length} file(s) of the retired legacy spoke checkout left in place — delete or rewrite by hand:`);
903
+ lines.push(...retiredCheckout.kept.map(({ path, reason }) => ` ${path} (${reason})`));
904
+ }
481
905
  if (result.missing.length > 0 && !apply) {
482
906
  lines.push("Run `create-ailk sync-routes <target> --apply` to copy the missing routes.");
483
907
  }
@@ -500,7 +924,9 @@ Options:
500
924
 
501
925
  Checks every core route plus the routes of each surface the site carries; a
502
926
  surface gated off at scaffold time is skipped, not drift. A route the site has
503
- changed locally is reported and never overwritten. Nothing is ever deleted.
927
+ changed locally is reported and never overwritten. Nothing is deleted except,
928
+ on a spoke, the retired legacy checkout's files that still match create-ailk's
929
+ copy: --apply removes them and rewrites the generated billing action (#9142).
504
930
  `;
505
931
  /**
506
932
  * Parse the argv AFTER the `sync-routes` word. Strict `node:util` parseArgs,
@@ -3,7 +3,7 @@ export declare const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
3
3
  /**
4
4
  * The app paths a generator rewrites after `copyTemplate` lands its copy
5
5
  * (§1): the nav, the site chrome, the site fonts, the paid-sections
6
- * registry, the four spoke seams, and a commerce spoke's checkout config,
6
+ * registry and its module-presence flags (#8843 D10), the four spoke seams, and a commerce spoke's checkout config,
7
7
  * which the checkout writer stamps and the site owns from then on (#8748
8
8
  * D2). Composed from each writer's own exported path constant, so a renamed
9
9
  * constant carries its path along here too (#8522 D2).
@@ -18,7 +18,7 @@
18
18
  import { createHash } from "node:crypto";
19
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
- import { PAID_SECTIONS_PATH } from "./paid-sections.js";
21
+ import { MODULE_FLAGS_PATH, PAID_SECTIONS_PATH } from "./paid-sections.js";
22
22
  import { SITE_CHROME_PATH } from "./site-chrome.js";
23
23
  import { SITE_FONTS_PATH } from "./site-fonts.js";
24
24
  import { SPOKE_CHECKOUT_CONFIG_PATH } from "./spoke-checkout.js";
@@ -29,7 +29,7 @@ export const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
29
29
  /**
30
30
  * The app paths a generator rewrites after `copyTemplate` lands its copy
31
31
  * (§1): the nav, the site chrome, the site fonts, the paid-sections
32
- * registry, the four spoke seams, and a commerce spoke's checkout config,
32
+ * registry and its module-presence flags (#8843 D10), the four spoke seams, and a commerce spoke's checkout config,
33
33
  * which the checkout writer stamps and the site owns from then on (#8748
34
34
  * D2). Composed from each writer's own exported path constant, so a renamed
35
35
  * constant carries its path along here too (#8522 D2).
@@ -39,6 +39,7 @@ export const REGENERATED_APP_PATHS = [
39
39
  SITE_CHROME_PATH,
40
40
  SITE_FONTS_PATH,
41
41
  PAID_SECTIONS_PATH,
42
+ MODULE_FLAGS_PATH,
42
43
  ...SPOKE_SEAM_FILES,
43
44
  SPOKE_CHECKOUT_CONFIG_PATH,
44
45
  ];
package/dist/update.js CHANGED
@@ -26,6 +26,7 @@ import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core
26
26
  import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
27
27
  import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
28
28
  import { mergeThreeWay } from "./lib/merge-three-way.js";
29
+ import { MODULE_FLAGS_PATH, writeModuleFlags } from "./paid-sections.js";
29
30
  import { ParseError } from "./parse-args.js";
30
31
  import { SITE_ROSTER_PATH } from "./site-roster.js";
31
32
  import { SPOKE_CHECKOUT_TEMPLATE_PATHS } from "./spoke-checkout.js";
@@ -514,6 +515,15 @@ export async function updateSite(opts) {
514
515
  if (recordChanged) {
515
516
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
516
517
  }
518
+ // #8843 D10 — the module-presence flags are generated, never copied
519
+ // (REGENERATED_APP_PATHS): render them from this site's own
520
+ // apps/web/package.json on every run, so a site updated onto a layout
521
+ // that imports them has them before it builds. Not recorded.
522
+ const flags = writeModuleFlags(targetDir);
523
+ if (flags === "added")
524
+ moved.added.push(MODULE_FLAGS_PATH);
525
+ else if (flags === "updated")
526
+ moved.updated.push(MODULE_FLAGS_PATH);
517
527
  return {
518
528
  targetDir,
519
529
  version,