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,620 @@
1
+ /**
2
+ * `create-ailk remove-module <marketing|docs-blog>` — the inverse of
3
+ * `add-module` (#10040): take a module's public pages off a scaffolded site so
4
+ * they stay off.
5
+ *
6
+ * What a run does, in order (spec 10040 D1–D7):
7
+ *
8
+ * 1. refuses a module other than `marketing` or `docs-blog` (decision 6), a
9
+ * module the site does not carry, a spoke, and `marketing` while the
10
+ * site still carries `docs-blog` ("remove docs-blog first", D3);
11
+ * 2. derives the removal set from the scaffold's own tree, never a list
12
+ * (D2): for `marketing`, every directory under `apps/web/app/[locale]/`
13
+ * outside `HUB_KEPT_ROUTE_DIRS` and every `content/<locale>/<type>/`
14
+ * outside the kept legal types; for `docs-blog`, the slice's
15
+ * `ownedFiles` under `apps/web/` and its `pageContributions` content;
16
+ * 3. classifies every path (D5): an app path whose bytes differ from its
17
+ * record row, or from the running template's copy, or that neither the
18
+ * template nor a carried module delivers, is the site's own — so is any
19
+ * seed content no shipped seed layer renders identically. Those paths
20
+ * stop the run (exit 2) before any write unless `--force`;
21
+ * 4. deletes the set and prunes the directories it emptied;
22
+ * 5. retires each deleted recordable app path by KEEPING (or adding) its
23
+ * `.ailk-template-files.json` row with the template copy's hash (D1):
24
+ * `update` then reads it as "deleted locally" and never writes it back;
25
+ * 6. drops the module from `.ailk-site-manifest.json` (`modules` and the
26
+ * roster rows), regenerates `architecture.yaml` (the header without the
27
+ * module; `marketing`'s import permissions stay, D4), the module flags,
28
+ * the paid-sections registry and — unless the site has edited it — the
29
+ * nav, empty on a site without `marketing`.
30
+ *
31
+ * Pure module: no `import.meta.url`, so jest can import it directly — the
32
+ * package-rooted inputs are supplied by `programmatic.ts`'s `removeModule`,
33
+ * the `add-module.ts` / `addModule` split.
34
+ */
35
+ import { existsSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
36
+ import { dirname, join, relative, resolve, sep } from "node:path";
37
+ import { parseArgs } from "node:util";
38
+ import { readArchitectureHeader } from "./add-module-core.js";
39
+ import { syncNavConfig } from "./add-module.js";
40
+ import { renderTemplateFile } from "./copy.js";
41
+ import { deriveArchitecture } from "./derive-architecture.js";
42
+ import { MODULE_SLICES, normalizeModules } from "./module-architecture.js";
43
+ import { MODULE_FLAGS_PATH, PAID_MCP_TOOLS_PATH, PAID_SECTIONS_PATH, writePaidSections, } from "./paid-sections.js";
44
+ import { ParseError, parseModules } from "./parse-args.js";
45
+ import { SITE_ROSTER_PATH, readSiteRoster } from "./site-roster.js";
46
+ import { HUB_KEPT_ROUTE_DIRS, MODULE_SURFACES, ROUTE_ROOT, resolveSurfaces, } from "./surfaces.js";
47
+ import { BASE_LAYER_DIR, templateTreeDir } from "./template-manifest.js";
48
+ import { TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
49
+ import { readSiteTopology } from "./topology.js";
50
+ /** The modules `remove-module` takes (decision 6). */
51
+ export const REMOVABLE_MODULES = ["marketing", "docs-blog"];
52
+ /**
53
+ * The `content/<locale>/<type>/` directories a site keeps when it drops
54
+ * `marketing`: the seeds of the kept legal pages (`HUB_KEPT_ROUTES`).
55
+ */
56
+ export const HUB_KEPT_CONTENT_TYPES = [
57
+ "PrivacyPage",
58
+ "TermsPage",
59
+ "WorkingAgreementPage",
60
+ ];
61
+ /** The kept legal surfaces, by `GATEABLE_SURFACES` id — the only surfaces a hub's header lists. */
62
+ const HUB_KEPT_SURFACES = ["privacy", "terms", "working-agreement"];
63
+ /** A refusal the CLI exits 2 on — the same shape `add-module` throws. */
64
+ export class RemoveModuleRefusedError extends Error {
65
+ exitCode = 2;
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "RemoveModuleRefusedError";
69
+ }
70
+ }
71
+ // ── The removal set (D2) ─────────────────────────────────────────────────────
72
+ /** Every regular file at or under `abs`, as `/`-separated paths relative to `root`. A symlink is skipped. */
73
+ function filesUnder(root, rel) {
74
+ const out = [];
75
+ const stack = [rel];
76
+ while (stack.length > 0) {
77
+ const current = stack.pop();
78
+ const abs = join(root, ...current.split("/"));
79
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
80
+ const next = `${current}/${entry.name}`;
81
+ if (entry.isSymbolicLink())
82
+ continue;
83
+ if (entry.isDirectory())
84
+ stack.push(next);
85
+ else if (entry.isFile())
86
+ out.push(next);
87
+ }
88
+ }
89
+ return out.sort();
90
+ }
91
+ function isDir(abs) {
92
+ return existsSync(abs) && statSync(abs).isDirectory();
93
+ }
94
+ /** The directories `remove-module marketing` removes: the public route tree and the non-legal seeds. */
95
+ export function marketingRemovalDirs(targetDir) {
96
+ const dirs = [];
97
+ const routeRoot = join(targetDir, ...ROUTE_ROOT.split("/"));
98
+ if (isDir(routeRoot)) {
99
+ for (const entry of readdirSync(routeRoot, { withFileTypes: true })) {
100
+ if (!entry.isDirectory() || HUB_KEPT_ROUTE_DIRS.includes(entry.name))
101
+ continue;
102
+ dirs.push(`${ROUTE_ROOT}/${entry.name}`);
103
+ }
104
+ }
105
+ const contentRoot = join(targetDir, "content");
106
+ if (isDir(contentRoot)) {
107
+ for (const locale of readdirSync(contentRoot, { withFileTypes: true })) {
108
+ if (!locale.isDirectory())
109
+ continue;
110
+ for (const type of readdirSync(join(contentRoot, locale.name), { withFileTypes: true })) {
111
+ if (!type.isDirectory() || HUB_KEPT_CONTENT_TYPES.includes(type.name))
112
+ continue;
113
+ dirs.push(`content/${locale.name}/${type.name}`);
114
+ }
115
+ }
116
+ }
117
+ return dirs.sort();
118
+ }
119
+ /**
120
+ * The removal-set files a KEPT file imports — kept with their own imports,
121
+ * to a fixed point (#10040). `apps/web/app/[locale]/contact/` holds helpers
122
+ * the locale layout and `lib/in-page-magnet.ts` import beside its page;
123
+ * deleting them with the page breaks the kept tree's type-check. Only
124
+ * `apps/web` code is scanned; a kept file is any `.ts`/`.tsx` outside the
125
+ * removal set (node_modules, .next and dist excluded).
126
+ */
127
+ export function importedByKept(targetDir, removal) {
128
+ const removalSet = new Set(removal);
129
+ const keptBy = new Map();
130
+ const scanned = new Set();
131
+ const queue = [];
132
+ const webRoot = join(targetDir, "apps", "web");
133
+ const walk = (dir) => {
134
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
135
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === "dist")
136
+ continue;
137
+ const abs = join(dir, entry.name);
138
+ if (entry.isDirectory())
139
+ walk(abs);
140
+ else if (/\.(ts|tsx|mts|mjs|js)$/.test(entry.name)) {
141
+ const rel = relative(targetDir, abs).split(sep).join("/");
142
+ if (!removalSet.has(rel))
143
+ queue.push(rel);
144
+ }
145
+ }
146
+ };
147
+ if (isDir(webRoot))
148
+ walk(webRoot);
149
+ // `@/*` is apps/web's one tsconfig path alias (`"@/*": ["./*"]`); a bare
150
+ // package specifier is never a removal-set file, so nothing else resolves.
151
+ const resolveSpecifier = (fromRel, spec) => {
152
+ const base = spec.startsWith("@/")
153
+ ? resolve(webRoot, spec.slice(2))
154
+ : resolve(targetDir, dirname(fromRel), spec);
155
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.mjs`, `${base}.js`, join(base, "index.ts"), join(base, "index.tsx")]) {
156
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
157
+ return relative(targetDir, candidate).split(sep).join("/");
158
+ }
159
+ }
160
+ return undefined;
161
+ };
162
+ while (queue.length > 0) {
163
+ const rel = queue.pop();
164
+ if (scanned.has(rel))
165
+ continue;
166
+ scanned.add(rel);
167
+ const text = readFileSync(join(targetDir, ...rel.split("/")), "utf8");
168
+ for (const m of text.matchAll(/(?:from\s*|import\s*\(\s*|jest\.mock\(\s*|require\(\s*)["']((?:\.{1,2}|@)\/[^"']+)["']/g)) {
169
+ const target = resolveSpecifier(rel, m[1]);
170
+ if (target === undefined || !removalSet.has(target) || keptBy.has(target))
171
+ continue;
172
+ keptBy.set(target, rel);
173
+ queue.push(target);
174
+ }
175
+ }
176
+ return keptBy;
177
+ }
178
+ /** The paths a module's removal deletes, site-relative, only those on disk. */
179
+ export function removalSet(targetDir, moduleName) {
180
+ if (moduleName === "marketing") {
181
+ return marketingRemovalDirs(targetDir).flatMap((dir) => filesUnder(targetDir, dir));
182
+ }
183
+ const slice = MODULE_SLICES[moduleName];
184
+ const owned = (slice.ownedFiles ?? []).filter((p) => p.startsWith("apps/web/"));
185
+ const seeds = (slice.pageContributions ?? []).flatMap((c) => c.content ?? []);
186
+ return [...new Set([...owned, ...seeds])]
187
+ .filter((p) => existsSync(join(targetDir, ...p.split("/"))))
188
+ .sort();
189
+ }
190
+ /** The template's copy of `path`, rendered as `copyTemplate` wrote it, or undefined when the template lacks it. */
191
+ function templateCopy(layers, path) {
192
+ const abs = join(layers.templatesDir, ...path.split("/"));
193
+ if (!existsSync(abs) || !statSync(abs).isFile())
194
+ return undefined;
195
+ return renderTemplateFile(readFileSync(abs), path, layers.versions, layers.markers);
196
+ }
197
+ /** Whether any seed layer carries `path` at all. */
198
+ function seedLayerHas(layers, path) {
199
+ return layers.seedRoots.some((root) => {
200
+ const abs = join(root, ...path.split("/"));
201
+ return existsSync(abs) && statSync(abs).isFile();
202
+ });
203
+ }
204
+ /** Whether a seed layer renders `path` byte-identically to the site's copy. */
205
+ function seedMatches(layers, path, disk) {
206
+ for (const root of layers.seedRoots) {
207
+ const abs = join(root, ...path.split("/"));
208
+ if (!existsSync(abs) || !statSync(abs).isFile())
209
+ continue;
210
+ const raw = readFileSync(abs);
211
+ if (raw.equals(disk))
212
+ return true;
213
+ if (Buffer.from(renderTemplateFile(raw, path, layers.versions, layers.markers)).equals(disk)) {
214
+ return true;
215
+ }
216
+ }
217
+ return false;
218
+ }
219
+ /**
220
+ * Whether the site has edited `path` (D5): a recorded app path whose bytes
221
+ * differ from its row; an unrecorded one that differs from the template's
222
+ * copy; a path neither the template nor a carried module's bundle delivers
223
+ * (the site's own page); a `content/` seed no shipped layer renders
224
+ * identically. A carried module's own sample seed is the module's.
225
+ */
226
+ function isEdited(layers, targetDir, path) {
227
+ const disk = readFileSync(join(targetDir, ...path.split("/")));
228
+ // A carried module's own sample seeds are placeholders by contract (the
229
+ // starter-template contract R2); no shipped layer holds their bytes to
230
+ // compare, and they are never the site's own page.
231
+ if (layers.moduleSeeds.has(path) || layers.moduleSeedDirs.some((d) => path.startsWith(d))) {
232
+ return false;
233
+ }
234
+ if (path.startsWith("content/")) {
235
+ // A seed a shipped layer carries is compared byte for byte; one only the
236
+ // roster of record declares came from a module bundle with no copy on
237
+ // this machine, and is a placeholder by contract — never the site's own.
238
+ if (seedLayerHas(layers, path))
239
+ return !seedMatches(layers, path, disk);
240
+ if (layers.rosterSeeds.has(path) || layers.rosterSeedDirs.some((d) => path.startsWith(d))) {
241
+ return false;
242
+ }
243
+ return true;
244
+ }
245
+ const row = layers.record?.files[path];
246
+ if (row !== undefined)
247
+ return sha256Hex(disk) !== row.sha256;
248
+ const copy = templateCopy(layers, path);
249
+ if (copy !== undefined)
250
+ return !Buffer.from(copy).equals(disk);
251
+ if (layers.moduleCode.has(path))
252
+ return false;
253
+ return true;
254
+ }
255
+ function moduleDeliveries(modules) {
256
+ const code = new Set();
257
+ const seeds = new Set();
258
+ const seedDirs = [];
259
+ for (const name of modules) {
260
+ const slice = MODULE_SLICES[name];
261
+ for (const p of slice.ownedFiles ?? [])
262
+ code.add(p);
263
+ for (const dest of Object.values(slice.installMap ?? {})) {
264
+ if (dest.startsWith("content/"))
265
+ seeds.add(dest);
266
+ else
267
+ code.add(dest);
268
+ }
269
+ for (const c of slice.pageContributions ?? []) {
270
+ for (const p of c.content ?? [])
271
+ seeds.add(p);
272
+ if (c.contentDir !== undefined)
273
+ seedDirs.push(`${c.contentDir}/`);
274
+ }
275
+ }
276
+ return { code, seeds, seedDirs };
277
+ }
278
+ // ── The run ──────────────────────────────────────────────────────────────────
279
+ /** Remove every directory under `root` left empty, then `root` itself if empty and `removeRoot`. */
280
+ function pruneEmpty(abs, removeRoot) {
281
+ if (!isDir(abs))
282
+ return;
283
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
284
+ if (entry.isDirectory())
285
+ pruneEmpty(join(abs, entry.name), true);
286
+ }
287
+ if (removeRoot && readdirSync(abs).length === 0)
288
+ rmdirSync(abs);
289
+ }
290
+ /** `text` with its `"modules": [...]` value replaced. */
291
+ function withModules(text, modules) {
292
+ const rendered = modules.length === 0 ? "[]" : `[${modules.map((m) => JSON.stringify(m)).join(", ")}]`;
293
+ return text.replace(/"modules":\s*\[[^\]]*\]/, `"modules": ${rendered}`);
294
+ }
295
+ /**
296
+ * Removes one module from a scaffolded site. Every refusal is raised before
297
+ * the first write; the writes that follow (delete → record → architecture →
298
+ * manifest → generated files) are not atomic, but each is idempotent on its
299
+ * own inputs, so a run interrupted mid-way converges on the next invocation —
300
+ * `--force` is then needed only if the first run needed it.
301
+ */
302
+ export function removeModuleFromScaffold(opts) {
303
+ const targetDir = resolve(opts.targetDir);
304
+ const { moduleName } = opts;
305
+ // ── Refusals, before the first write ────────────────────────────────────
306
+ if (!REMOVABLE_MODULES.includes(moduleName)) {
307
+ throw new RemoveModuleRefusedError(`remove-module takes ${REMOVABLE_MODULES.join(" or ")}, not "${moduleName}" — a paid module's pages stay with its licence; only the public marketing tree and the docs-blog pages can be dropped (#10040)`);
308
+ }
309
+ const archPath = join(targetDir, "architecture.yaml");
310
+ if (!existsSync(archPath)) {
311
+ throw new RemoveModuleRefusedError(`not a scaffolded repo: no architecture.yaml at ${targetDir}. Run \`create-ailk <target-dir>\` to scaffold first.`);
312
+ }
313
+ if (readSiteTopology(targetDir) === "spoke") {
314
+ throw new RemoveModuleRefusedError("remove-module runs on a hub: a spoke is its public site, so it keeps the marketing module");
315
+ }
316
+ const current = readArchitectureHeader(readFileSync(archPath, "utf8"));
317
+ if (readSiteRoster(targetDir) === undefined) {
318
+ throw new RemoveModuleRefusedError("remove-module needs the site's roster of record (.ailk-site-manifest.json): the module list it records is what update and sync-routes read afterwards. A template-less scaffold has none.");
319
+ }
320
+ if (opts.fromScaffold !== true && !current.modules.includes(moduleName)) {
321
+ throw new RemoveModuleRefusedError(`this site does not carry "${moduleName}" (architecture.yaml modules: ${current.modules.join(", ")})`);
322
+ }
323
+ if (moduleName === "marketing" && current.modules.includes("docs-blog")) {
324
+ throw new RemoveModuleRefusedError("this site carries docs-blog, whose /docs and /blog pages the marketing removal would delete too — run `create-ailk remove-module docs-blog` first");
325
+ }
326
+ const recordPath = join(targetDir, TEMPLATE_RECORD_PATH);
327
+ const record = existsSync(recordPath)
328
+ ? parseTemplateRecord(readFileSync(recordPath, "utf8"))
329
+ : undefined;
330
+ const siteRoster = readSiteRoster(targetDir);
331
+ const seedRoots = [
332
+ opts.templatesDir,
333
+ join(opts.siteTemplatesDir, BASE_LAYER_DIR),
334
+ ...(siteRoster !== undefined
335
+ ? [join(opts.siteTemplatesDir, templateTreeDir(siteRoster.template.name))]
336
+ : []),
337
+ ];
338
+ const deliveries = moduleDeliveries(current.modules);
339
+ const rosterSeeds = new Set();
340
+ const rosterSeedDirs = [];
341
+ for (const row of siteRoster?.rows ?? []) {
342
+ const content = row.value["content"];
343
+ if (Array.isArray(content))
344
+ for (const p of content)
345
+ if (typeof p === "string")
346
+ rosterSeeds.add(p);
347
+ const seeds = row.value["seeds"];
348
+ if (Array.isArray(seeds))
349
+ for (const p of seeds)
350
+ if (typeof p === "string")
351
+ rosterSeeds.add(p);
352
+ const dir = row.value["contentDir"];
353
+ if (typeof dir === "string")
354
+ rosterSeedDirs.push(`${dir}/`);
355
+ }
356
+ const layers = {
357
+ templatesDir: opts.templatesDir,
358
+ seedRoots,
359
+ versions: opts.versions,
360
+ markers: opts.markers,
361
+ record,
362
+ moduleCode: deliveries.code,
363
+ moduleSeeds: deliveries.seeds,
364
+ moduleSeedDirs: deliveries.seedDirs,
365
+ rosterSeeds,
366
+ rosterSeedDirs,
367
+ };
368
+ const removable = removalSet(targetDir, moduleName);
369
+ const keptImports = importedByKept(targetDir, removable);
370
+ const paths = removable.filter((p) => !keptImports.has(p));
371
+ const edited = paths.filter((p) => isEdited(layers, targetDir, p));
372
+ if (edited.length > 0 && opts.force !== true) {
373
+ throw new RemoveModuleRefusedError([
374
+ `${edited.length} file(s) in ${moduleName}'s removal set are this site's own — edited, or never delivered by the template or a module bundle. Nothing was removed. Pass --force to delete them too:`,
375
+ ...edited.map((p) => ` ${p}`),
376
+ ].join("\n"));
377
+ }
378
+ // ── Delete, then prune the directories the deletions emptied ────────────
379
+ for (const p of paths)
380
+ rmSync(join(targetDir, ...p.split("/")));
381
+ if (moduleName === "marketing") {
382
+ pruneEmpty(join(targetDir, ...ROUTE_ROOT.split("/")), false);
383
+ pruneEmpty(join(targetDir, "content"), false);
384
+ }
385
+ else {
386
+ for (const p of paths) {
387
+ let dir = dirname(join(targetDir, ...p.split("/")));
388
+ while (dir !== targetDir && isDir(dir) && readdirSync(dir).length === 0) {
389
+ rmdirSync(dir);
390
+ dir = dirname(dir);
391
+ }
392
+ }
393
+ }
394
+ // ── Retire: the record row, kept or added (D1) ──────────────────────────
395
+ const retired = [];
396
+ if (record !== undefined) {
397
+ const files = { ...record.files };
398
+ for (const p of paths) {
399
+ if (!isRecordable(p))
400
+ continue;
401
+ if (files[p] !== undefined) {
402
+ retired.push(p);
403
+ continue;
404
+ }
405
+ const copy = templateCopy(layers, p);
406
+ if (copy === undefined)
407
+ continue; // nothing would ever write it back
408
+ files[p] = { sha256: sha256Hex(copy), version: opts.version };
409
+ retired.push(p);
410
+ }
411
+ writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files }));
412
+ }
413
+ // ── The module set, the surfaces, architecture.yaml ─────────────────────
414
+ const modules = normalizeModules(current.modules.filter((m) => m !== moduleName));
415
+ const dropSurfaces = new Set(moduleName === "marketing" ? [] : MODULE_SURFACES[moduleName] ?? []);
416
+ const kept = moduleName === "marketing"
417
+ ? current.surfaces.filter((s) => HUB_KEPT_SURFACES.includes(s))
418
+ : current.surfaces.filter((s) => !dropSurfaces.has(s));
419
+ const surfaces = resolveSurfaces(modules, kept);
420
+ writeFileSync(archPath, deriveArchitecture(modules, surfaces));
421
+ // ── The roster of record (.ailk-site-manifest.json) — first: the flags read it ─────────────────────
422
+ const roster = {
423
+ state: "none",
424
+ dropped: [],
425
+ path: siteRoster?.path ?? SITE_ROSTER_PATH,
426
+ };
427
+ if (siteRoster !== undefined) {
428
+ // A marketing row is dropped when its route's first segment is no
429
+ // longer a route directory: the kept groups' children (`sign-in`,
430
+ // `account`, `leads`, …) and the kept legal dirs survive, `/` and every
431
+ // public page go. A docs-blog row is the module's own contribution.
432
+ const keptSegments = new Set();
433
+ for (const dir of HUB_KEPT_ROUTE_DIRS) {
434
+ const abs = join(targetDir, ...ROUTE_ROOT.split("/"), dir);
435
+ if (!isDir(abs))
436
+ continue;
437
+ if (dir.startsWith("(")) {
438
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
439
+ if (entry.isDirectory())
440
+ keptSegments.add(entry.name);
441
+ }
442
+ }
443
+ else {
444
+ keptSegments.add(dir);
445
+ }
446
+ }
447
+ const dropRoutes = moduleName === "marketing"
448
+ ? (route) => !keptSegments.has(route.split("/")[1] ?? "")
449
+ : (route) => (MODULE_SLICES[moduleName].pageContributions ?? []).some((c) => c.route === route);
450
+ let text = siteRoster.text;
451
+ const keptRows = siteRoster.rows.filter((row) => {
452
+ const route = String(row.value["route"]);
453
+ const drop = dropRoutes(route);
454
+ if (drop)
455
+ roster.dropped.push(route);
456
+ return !drop;
457
+ });
458
+ if (roster.dropped.length > 0) {
459
+ // Rebuild the rows array from the kept rows' verbatim text, with the
460
+ // separator the file already uses between its first two rows.
461
+ const first = siteRoster.rows[0];
462
+ const second = siteRoster.rows[1];
463
+ let sep = ",\n ";
464
+ if (first !== undefined && second !== undefined) {
465
+ const a = text.indexOf(first.text, siteRoster.open);
466
+ const b = text.indexOf(second.text, a + first.text.length);
467
+ if (a !== -1 && b !== -1)
468
+ sep = text.slice(a + first.text.length, b);
469
+ }
470
+ const body = keptRows.length === 0
471
+ ? ""
472
+ : `${siteRoster.lead}${keptRows.map((r) => r.text).join(sep)}${siteRoster.trail}`;
473
+ text = `${text.slice(0, siteRoster.open + 1)}${body}${text.slice(siteRoster.close)}`;
474
+ }
475
+ const next = withModules(text, modules);
476
+ if (next !== siteRoster.text) {
477
+ writeFileSync(join(targetDir, siteRoster.path), next);
478
+ roster.state = "trimmed";
479
+ }
480
+ else {
481
+ roster.state = "unchanged";
482
+ }
483
+ }
484
+ // ── The generated files that follow the module set ──────────────────────
485
+ const generated = [PAID_SECTIONS_PATH, MODULE_FLAGS_PATH, PAID_MCP_TOOLS_PATH].map((path) => {
486
+ const abs = join(targetDir, path);
487
+ return { path, abs, before: existsSync(abs) ? readFileSync(abs, "utf8") : undefined };
488
+ });
489
+ writePaidSections(targetDir);
490
+ const report = [];
491
+ for (const { path, abs, before } of generated) {
492
+ if (existsSync(abs) && readFileSync(abs, "utf8") !== before) {
493
+ report.push({ path, action: "written" });
494
+ }
495
+ }
496
+ const navRow = syncNavConfig(targetDir, current.surfaces, surfaces, moduleName === "marketing" ? { publicPages: false } : {});
497
+ if (navRow !== undefined)
498
+ report.push(navRow);
499
+ // ── Config patches a module leaves behind (D7) ──────────────────────────
500
+ const review = [];
501
+ for (const p of MODULE_SLICES[moduleName].patches ?? []) {
502
+ const ops = p.ops.map((op) => op.op).join(", ");
503
+ review.push({
504
+ path: p.target,
505
+ reason: `${moduleName}'s ${ops} patch is left in place — the file still builds with it; reverse it by hand if you want the module's dependencies and config gone`,
506
+ });
507
+ }
508
+ return {
509
+ targetDir,
510
+ moduleName,
511
+ modules,
512
+ removed: paths,
513
+ retired: retired.sort(),
514
+ forced: opts.force === true ? edited : [],
515
+ keptImports: [...keptImports].map(([path, importedBy]) => ({ path, importedBy })).sort((a, b) => a.path.localeCompare(b.path)),
516
+ review,
517
+ report,
518
+ roster,
519
+ };
520
+ }
521
+ // ── The CLI surface ──────────────────────────────────────────────────────────
522
+ /** The report `remove-module` prints. */
523
+ export function formatRemoveModuleReport(result) {
524
+ const lines = [`[create-ailk] remove-module ${result.moduleName} → ${result.targetDir}`];
525
+ if (result.removed.length === 0) {
526
+ lines.push(`Nothing to remove — the site carries none of ${result.moduleName}'s pages.`);
527
+ }
528
+ else {
529
+ lines.push(`Removed (${result.removed.length}):`);
530
+ lines.push(...result.removed.map((p) => ` ${p}`));
531
+ }
532
+ if (result.forced.length > 0) {
533
+ lines.push(`Removed under --force, the site's own (${result.forced.length}):`);
534
+ lines.push(...result.forced.map((p) => ` ${p}`));
535
+ }
536
+ if (result.retired.length > 0) {
537
+ lines.push(`Retired in ${TEMPLATE_RECORD_PATH} (${result.retired.length}): \`create-ailk update\` keeps these deleted.`);
538
+ }
539
+ for (const row of result.report) {
540
+ if (row.action === "written")
541
+ lines.push(`Regenerated: ${row.path}`);
542
+ else if (row.action === "skipped") {
543
+ lines.push(`Needs manual review — the site has edited ${row.path}; the regenerated version would be:`);
544
+ lines.push(...row.wanted.split("\n").map((l) => ` ${l}`));
545
+ }
546
+ }
547
+ if (result.keptImports.length > 0) {
548
+ lines.push(`Kept — a kept file imports them (${result.keptImports.length}):`);
549
+ for (const { path, importedBy } of result.keptImports)
550
+ lines.push(` ${path} ← ${importedBy}`);
551
+ }
552
+ if (result.review.length > 0) {
553
+ lines.push(`Left in place (${result.review.length}):`);
554
+ for (const { path, reason } of result.review)
555
+ lines.push(` ${path} — ${reason}`);
556
+ }
557
+ lines.push(`architecture.yaml modules: ${result.modules.length > 0 ? result.modules.join(", ") : "(none)"}`);
558
+ if (result.roster.state === "none") {
559
+ lines.push(`Page roster: none. This site has no ${SITE_ROSTER_PATH}.`);
560
+ }
561
+ else if (result.roster.state === "trimmed") {
562
+ lines.push(`Page roster: dropped ${result.roster.dropped.length} row(s) from ${result.roster.path}: ${result.roster.dropped.join(", ")}`);
563
+ }
564
+ else {
565
+ lines.push(`Page roster: ${result.roster.path} unchanged`);
566
+ }
567
+ return lines.join("\n") + "\n";
568
+ }
569
+ export const REMOVE_MODULE_HELP = `create-ailk remove-module — take a module's public pages off an existing site.
570
+
571
+ Usage:
572
+ create-ailk remove-module <module> [<target-dir>] [--force]
573
+
574
+ Arguments:
575
+ <module> marketing or docs-blog. Nothing else can be removed.
576
+ <target-dir> An already-scaffolded repo (contains
577
+ architecture.yaml). Defaults to the current dir.
578
+
579
+ Options:
580
+ --force Also delete files the site has edited or authored
581
+ inside the module's pages. Without it, such files
582
+ are listed and nothing is removed.
583
+ --help, -h Print this message.
584
+
585
+ Deletes the module's route directories and seed content, drops the module
586
+ from .ailk-site-manifest.json and architecture.yaml, and records every removed
587
+ app file as retired in .ailk-template-files.json, so create-ailk update and
588
+ sync-routes never bring it back. A site without marketing redirects / to
589
+ /account, serves robots.txt disallowing everything, sends noindex on every page
590
+ and answers 404 at sitemap.xml and llms.txt. Removing marketing while docs-blog
591
+ is installed is refused: remove docs-blog first.
592
+ `;
593
+ /** Parse the argv AFTER the `remove-module` word, the `parseAddModuleArgs` shape. */
594
+ export function parseRemoveModuleArgs(argv) {
595
+ const parsed = parseArgs({
596
+ args: argv,
597
+ strict: true,
598
+ allowPositionals: true,
599
+ options: {
600
+ help: { type: "boolean", default: false, short: "h" },
601
+ force: { type: "boolean", default: false },
602
+ },
603
+ });
604
+ const help = parsed.values.help === true;
605
+ const force = parsed.values.force === true;
606
+ const [raw, target, ...rest] = parsed.positionals;
607
+ if (rest.length > 0) {
608
+ throw new ParseError(`expected a module and at most one target directory, got ${parsed.positionals.length} arguments`);
609
+ }
610
+ if (raw === undefined) {
611
+ if (help)
612
+ return { module: undefined, target: undefined, force, help };
613
+ throw new ParseError("remove-module needs a module name, e.g. `create-ailk remove-module marketing`");
614
+ }
615
+ const modules = parseModules(raw);
616
+ if (modules.length !== 1) {
617
+ throw new ParseError(`remove-module takes exactly one module, got "${raw}" — run it once per module`);
618
+ }
619
+ return { module: modules[0], target, force, help };
620
+ }
@@ -52,7 +52,7 @@ export interface SeedFlowsResult {
52
52
  /** The whole report, one line per row — what `seedFlows` printed as it went. */
53
53
  export declare function formatSeedFlowsReport(result: SeedFlowsResult): string;
54
54
  export declare function seedFlows(opts: SeedFlowsOptions): Promise<SeedFlowsResult>;
55
- export declare const SPOKE_FLOWS_README = "# flows/\n\nEach file here is one **declared flow**: `flows/<slug>.json` holds one flow\nconfig, exactly as the hub stores it. This repo owns these configs \u2014 the hub\nruns them for this site.\n\n## What a spoke may declare\n\nA spoke key may seed only what this site's own flow pages show:\n`slug`, `defaultLocale`, `steps`, `thankYou` and `flags`. Anything the\nhub acts on outside these pages \u2014 emails, deliverables, pricing, registers,\nlistings \u2014 is refused (`field_not_seedable`). A flow that needs one of those\nis authored in the hub's Admin instead.\n\nThe file name must be the config's `slug` plus `.json`.\n\n## Seeding them into the hub\n\nRun this as part of each rollout, from the site's root, with the spoke key\nyour hub issued set as `AILK_SPOKE_KEY` in the environment (a shell or CI\nsecret \u2014 never a file in this repo):\n\n npx create-ailk seed-flows .\n\nThe hub origin comes from `--hub-url`, else `AILK_API_URL`, else the\n`AILK_API_URL=` line of `apps/web/.env.local`. The key is read from the\nenvironment only. Each flow answers `created`, `updated` or `unchanged`,\nso running it again is safe. `--dry-run` checks the files and sends nothing.\n\nThe hub's operator must first turn seeding on (`AILK_SPOKE_FLOW_SEED=true`\non the hub). Until it is on and this command has run, links to these flows\n(`/flows/<slug>`) reach no flow.\n\n## The starter's placeholder flows\n\nThe `signal-collectors` module ships two declared flows here:\n`flows/diagnostic.json` (an email step, then two questions) and\n`flows/join-waitlist.json` (an email and an optional name). Every visible\nstring in both is a `{brace}` placeholder: replace each one with your own\ncopy. Both go live on the hub with the first seed, so each line this command\nprints for them says they still carry placeholder copy.\n\nA flow is reached at `/flows/<slug>`. To give one its own page pair, run:\n\n npx create-ailk add-route magnet --path /diagnostic --flow diagnostic\n npx create-ailk add-route magnet --path /waitlist --flow join-waitlist\n\nA job application comes with the `careers` surface. Contact, newsletter and\njob application are the hub's own standard magnets: the hub seeds them in\nevery workspace and refuses a spoke's copy of each, so they need no file\nhere. If the hub's Admin already owns a magnet under one of these two slugs,\nthe hub refuses the seed (`slug_owned`); rename the file and its `slug`.\n\n## Who owns what\n\nThe next seed that differs overwrites the hub's copy of a flow's config, so\nedit flows here, not in the Admin. A flow's status (live or draft) is the\nhub's: switching a seeded flow to draft in the Admin keeps it draft through\nevery later seed. Removing a file here does not delete the hub's flow. If\nthe Admin adds a block only it can author (an email, a deliverable, an\noffer), the hub refuses the next seed of that flow (`slug_owned`) rather\nthan delete the block.\n";
55
+ export declare const SPOKE_FLOWS_README = "# flows/\n\nEach file here is one **declared flow**: `flows/<slug>.json` holds one flow\nconfig, exactly as the hub stores it. This repo owns these configs \u2014 the hub\nruns them for this site.\n\n## What a spoke may declare\n\nA spoke key may seed only what this site's own flow pages show:\n`slug`, `defaultLocale`, `steps`, `thankYou` and `flags`. Anything the\nhub acts on outside these pages \u2014 emails, deliverables, pricing, registers,\nlistings \u2014 is refused (`field_not_seedable`). A flow that needs one of those\nis authored in the hub's Admin instead.\n\nThe file name must be the config's `slug` plus `.json`.\n\n## Seeding them into the hub\n\nRun this as part of each rollout, from the site's root, with the spoke key\nyour hub issued set as `AILK_SPOKE_KEY` in the environment (a shell or CI\nsecret \u2014 never a file in this repo):\n\n npx create-ailk seed-flows . --hub-url <the hub's origin>\n\nThe hub origin comes from `--hub-url`, else `AILK_API_URL`; the\n`AILK_API_URL=` line of `apps/web/.env.local` is read for `--dry-run`\nonly, and a send whose origin only that file names is refused. The key is\nread from the environment only. Each flow answers `created`, `updated` or `unchanged`,\nso running it again is safe. `--dry-run` checks the files and sends nothing.\n\nThe hub's operator must first turn seeding on (`AILK_SPOKE_FLOW_SEED=true`\non the hub). Until it is on and this command has run, links to these flows\n(`/flows/<slug>`) reach no flow.\n\n## The starter's placeholder flows\n\nThe `signal-collectors` module ships two declared flows here:\n`flows/diagnostic.json` (an email step, then two questions) and\n`flows/join-waitlist.json` (an email and an optional name). Every visible\nstring in both is a `{brace}` placeholder: replace each one with your own\ncopy. Both go live on the hub with the first seed, so each line this command\nprints for them says they still carry placeholder copy.\n\nA flow is reached at `/flows/<slug>`. To give one its own page pair, run:\n\n npx create-ailk add-route magnet --path /diagnostic --flow diagnostic\n npx create-ailk add-route magnet --path /waitlist --flow join-waitlist\n\nA job application comes with the `careers` surface. Contact, newsletter and\njob application are the hub's own standard magnets: the hub seeds them in\nevery workspace and refuses a spoke's copy of each, so they need no file\nhere. If the hub's Admin already owns a magnet under one of these two slugs,\nthe hub refuses the seed (`slug_owned`); rename the file and its `slug`.\n\n## Who owns what\n\nThe next seed that differs overwrites the hub's copy of a flow's config, so\nedit flows here, not in the Admin. A flow's status (live or draft) is the\nhub's: switching a seeded flow to draft in the Admin keeps it draft through\nevery later seed. Removing a file here does not delete the hub's flow. If\nthe Admin adds a block only it can author (an email, a deliverable, an\noffer), the hub refuses the next seed of that flow (`slug_owned`) rather\nthan delete the block.\n";
56
56
  /**
57
57
  * Write `SPOKE_FLOWS_README` to `<targetDir>/flows/README.md` unless that
58
58
  * file exists. Returns whether it wrote.
@@ -64,5 +64,5 @@ export interface SeedFlowsArgs {
64
64
  dryRun: boolean;
65
65
  help: boolean;
66
66
  }
67
- export declare const SEED_FLOWS_HELP = "create-ailk seed-flows \u2014 send a spoke's declared flows to its hub.\n\nUsage:\n AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]\n\nArguments:\n <dir> The spoke site. Defaults to the current dir.\n\nOptions:\n --hub-url <origin> The hub's origin. Else AILK_API_URL, else the\n AILK_API_URL= line of apps/web/.env.local. https:\n only (http: on localhost or 127.0.0.1).\n --dry-run Check every flows/*.json and print what would be sent.\n Sends nothing and reads no key.\n -h, --help Show this help.\n\nEach flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with\nthe spoke key, which is read from AILK_SPOKE_KEY only. The hub must have\nAILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any\nfailed, 2 on a refusal (nothing is sent).\n";
67
+ export declare const SEED_FLOWS_HELP = "create-ailk seed-flows \u2014 send a spoke's declared flows to its hub.\n\nUsage:\n AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]\n\nArguments:\n <dir> The spoke site. Defaults to the current dir.\n\nOptions:\n --hub-url <origin> The hub's origin. Else AILK_API_URL. The\n AILK_API_URL= line of apps/web/.env.local is read\n for --dry-run only. https: only (http: on\n localhost or 127.0.0.1).\n --dry-run Check every flows/*.json and print what would be sent.\n Sends nothing and reads no key.\n -h, --help Show this help.\n\nEach flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with\nthe spoke key, which is read from AILK_SPOKE_KEY only. The hub must have\nAILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any\nfailed, 2 on a refusal (nothing is sent).\n";
68
68
  export declare function parseSeedFlowsArgs(argv: string[]): SeedFlowsArgs;