create-ailk 0.19.1 → 0.21.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 (199) hide show
  1. package/README.md +84 -7
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/cli.js +35 -0
  7. package/dist/copy.d.ts +41 -0
  8. package/dist/copy.js +87 -21
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/lib/design-checks.d.ts +6 -1
  12. package/dist/lib/design-checks.js +23 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  14. package/dist/lib/fetch-template-snapshot.js +205 -0
  15. package/dist/lib/merge-three-way.d.ts +31 -0
  16. package/dist/lib/merge-three-way.js +65 -0
  17. package/dist/module-architecture.js +55 -2
  18. package/dist/programmatic.d.ts +7 -1
  19. package/dist/programmatic.js +76 -22
  20. package/dist/site-roster.d.ts +4 -2
  21. package/dist/site-roster.js +7 -5
  22. package/dist/spoke-checkout.d.ts +7 -0
  23. package/dist/spoke-checkout.js +8 -1
  24. package/dist/starter-grid.js +16 -0
  25. package/dist/surfaces.js +78 -2
  26. package/dist/sync-routes.d.ts +28 -0
  27. package/dist/sync-routes.js +123 -1
  28. package/dist/template-record.d.ts +59 -0
  29. package/dist/template-record.js +183 -0
  30. package/dist/update.d.ts +53 -0
  31. package/dist/update.js +606 -0
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  51. package/templates/.claude/hooks/architecture-check.sh +8 -0
  52. package/templates/.claude/rules/loop-contract.md +129 -0
  53. package/templates/.claude/settings.json +3 -2
  54. package/templates/.env.example +13 -0
  55. package/templates/CLAUDE.md +1 -1
  56. package/templates/CONVENTIONS.md +3 -2
  57. package/templates/README.md +26 -25
  58. package/templates/_gitignore +162 -0
  59. package/templates/apps/api/CLAUDE.md +18 -10
  60. package/templates/apps/api/jest.config.cjs +1 -0
  61. package/templates/apps/api/package.json +2 -1
  62. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  63. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  64. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  65. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  66. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  67. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  68. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  69. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  70. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  71. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  72. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  74. package/templates/apps/api/src/middleware/auth.ts +8 -4
  75. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  76. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  77. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  78. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  79. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  80. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  81. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  82. package/templates/apps/api/src/server.ts +14 -2
  83. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  84. package/templates/apps/api/src/services/audit-log.ts +72 -10
  85. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  86. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  87. package/templates/apps/api/src/services/project-listings.ts +6 -1
  88. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  89. package/templates/apps/api/vercel.json +1 -1
  90. package/templates/apps/mcp/CLAUDE.md +19 -7
  91. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
  92. package/templates/apps/mcp/jest.config.cjs +1 -0
  93. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  94. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  95. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  96. package/templates/apps/web/CLAUDE.md +26 -1
  97. package/templates/apps/web/_gitignore +6 -0
  98. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  99. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  100. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  111. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  115. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  118. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  124. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  125. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  129. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  132. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  133. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  134. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  135. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  137. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  138. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  139. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
  148. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  149. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  150. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  151. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/layout.tsx +46 -4
  154. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  155. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  156. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  157. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  158. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  160. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  161. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  162. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  163. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  164. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  165. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  166. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  167. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  168. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  169. package/templates/apps/web/jest.config.cjs +4 -0
  170. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  171. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  172. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  173. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
  174. package/templates/apps/web/lib/account-config.ts +31 -16
  175. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  176. package/templates/apps/web/lib/deck-page.ts +186 -0
  177. package/templates/apps/web/lib/relay.ts +20 -0
  178. package/templates/apps/web/messages/en.json +83 -9
  179. package/templates/apps/web/next.config.mjs +8 -2
  180. package/templates/database/CHANGELOG.md +48 -0
  181. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  182. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/__tests__/seed.test.ts +39 -2
  186. package/templates/database/_gitignore +8 -0
  187. package/templates/database/inbox/schema.prisma +38 -12
  188. package/templates/database/jest.config.cjs +1 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/database/seed.ts +29 -0
  196. package/templates/project.yaml +6 -3
  197. package/templates/tools/jest/pin-timezone.cjs +34 -0
  198. package/versions.json +35 -35
  199. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -21,7 +21,10 @@
21
21
  * route root is copied once into a scratch dir and compared from there.
22
22
  * - Never overwrite, never delete. A route dir the site has changed is
23
23
  * reported as modified and left alone; `--apply` only creates directories
24
- * that do not exist, so a second run is a no-op.
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).
25
28
  *
26
29
  * Pure module: no `import.meta.url`, so jest can import it directly. The
27
30
  * package's own `templates/` dir is supplied by programmatic.ts.
@@ -35,9 +38,12 @@ import { appendEnvRows } from "./lib/declared-env.js";
35
38
  import { ENV_CATALOG } from "./lib/env-catalog.js";
36
39
  import { substituteMarkers } from "./substitute.js";
37
40
  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";
38
42
  import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
39
43
  /** Template-relative prefix every `apps/web` route directory sits under. */
40
44
  const ROUTE_ROOT = "apps/web/app/[locale]";
45
+ /** The site test that fails a route neither rendered through `renderPage` nor in its `NO_STRUCTURED_DATA_ROUTES` (#8704). */
46
+ export const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data.test.tsx";
41
47
  /**
42
48
  * `<surface id>/<entry name>` pairs that live directly under a GATEABLE
43
49
  * surface's top-level directory but belong to a DIFFERENT module (#5366).
@@ -50,6 +56,114 @@ const ROUTE_ROOT = "apps/web/app/[locale]";
50
56
  const NESTED_FOREIGN_MODULE_ENTRIES = new Set([
51
57
  "projects/apply",
52
58
  ]);
59
+ /**
60
+ * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
61
+ * brace scan that skips string literals and comments. Empty when the
62
+ * file declares no such array. It assumes what the template's array holds:
63
+ * object literals of string, boolean and identifier-array values — no regex
64
+ * literal or `${…}` interpolation.
65
+ */
66
+ export function parseStructuredDataExemptions(source) {
67
+ const decl = source.indexOf("const NO_STRUCTURED_DATA_ROUTES");
68
+ if (decl === -1)
69
+ return [];
70
+ const open = /=\s*\[/.exec(source.slice(decl));
71
+ if (open === null)
72
+ return [];
73
+ const out = [];
74
+ let depth = 0;
75
+ let start = -1;
76
+ for (let i = decl + open.index + open[0].length; i < source.length; i++) {
77
+ const ch = source[i];
78
+ if (ch === '"' || ch === "'" || ch === "`") {
79
+ for (i++; i < source.length && source[i] !== ch; i++) {
80
+ if (source[i] === "\\")
81
+ i++;
82
+ }
83
+ continue;
84
+ }
85
+ if (ch === "/" && source[i + 1] === "/") {
86
+ const eol = source.indexOf("\n", i);
87
+ i = eol === -1 ? source.length : eol;
88
+ continue;
89
+ }
90
+ if (ch === "/" && source[i + 1] === "*") {
91
+ const end = source.indexOf("*/", i + 2);
92
+ i = end === -1 ? source.length : end + 1;
93
+ continue;
94
+ }
95
+ if (ch === "{") {
96
+ if (depth === 0)
97
+ start = i;
98
+ depth++;
99
+ }
100
+ else if (ch === "}") {
101
+ depth--;
102
+ if (depth === 0 && start !== -1) {
103
+ const raw = source.slice(start, i + 1) + (source[i + 1] === "," ? "," : "");
104
+ const route = /\broute:\s*["']([^"']+)["']/.exec(raw)?.[1];
105
+ if (route !== undefined) {
106
+ const indent = /[ \t]*$/.exec(source.slice(source.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
107
+ const text = raw
108
+ .split("\n")
109
+ .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
110
+ .join("\n");
111
+ out.push({ route, subtree: /\bsubtree:\s*true\b/.test(raw), text });
112
+ }
113
+ start = -1;
114
+ }
115
+ }
116
+ else if (ch === "]" && depth === 0) {
117
+ break;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * The first exemption covering `route` by the structured-data test's route
124
+ * rule (`route` itself, or under a `subtree` entry). An entry's `requires`
125
+ * strings are not checked: they key only the generated spoke checkout pages,
126
+ * which no template route dir — so no `--apply` — ever adds.
127
+ */
128
+ function exemptionFor(exemptions, route) {
129
+ return exemptions.find((e) => route === e.route || (e.subtree && route.startsWith(`${e.route}/`)));
130
+ }
131
+ /**
132
+ * The template `NO_STRUCTURED_DATA_ROUTES` entries the site's structured-data
133
+ * test must gain for `added` routes (#8704). Empty unless that test is
134
+ * recorded and site-edited — an untouched copy is `create-ailk update`'s to
135
+ * bring forward, and an unrecorded one cannot be told apart from an edit.
136
+ */
137
+ function structuredDataEntriesFor(targetAbs, templatesDir, added) {
138
+ const sitePath = join(targetAbs, STRUCTURED_DATA_TEST_PATH);
139
+ const templatePath = join(templatesDir, STRUCTURED_DATA_TEST_PATH);
140
+ const recordPath = join(targetAbs, TEMPLATE_RECORD_PATH);
141
+ if (added.length === 0 || !existsSync(sitePath) || !existsSync(templatePath) || !existsSync(recordPath)) {
142
+ return [];
143
+ }
144
+ let recordedHash;
145
+ try {
146
+ recordedHash = parseTemplateRecord(readFileSync(recordPath, "utf8")).files[STRUCTURED_DATA_TEST_PATH]?.sha256;
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ const siteBytes = readFileSync(sitePath);
152
+ if (recordedHash === undefined || sha256Hex(siteBytes) === recordedHash)
153
+ return [];
154
+ const templateExemptions = parseStructuredDataExemptions(readFileSync(templatePath, "utf8"));
155
+ const siteExemptions = parseStructuredDataExemptions(siteBytes.toString("utf8"));
156
+ const byEntry = new Map();
157
+ for (const route of [...added].sort()) {
158
+ const needed = exemptionFor(templateExemptions, route);
159
+ if (needed === undefined || exemptionFor(siteExemptions, route) !== undefined)
160
+ continue;
161
+ byEntry.set(needed.text, [...(byEntry.get(needed.text) ?? []), route]);
162
+ }
163
+ return [...byEntry.entries()]
164
+ .sort(([a], [b]) => a.localeCompare(b))
165
+ .map(([entry, routes]) => ({ routes, entry }));
166
+ }
53
167
  /**
54
168
  * The template's route directories, ROUTE_ROOT-relative and sorted.
55
169
  *
@@ -276,6 +390,7 @@ export function syncRoutes(opts) {
276
390
  appliedDependencies,
277
391
  missingMessageNamespaces,
278
392
  skippedSurfaces,
393
+ structuredDataEntries: structuredDataEntriesFor(targetAbs, opts.templatesDir, applied),
279
394
  };
280
395
  }
281
396
  /**
@@ -355,6 +470,13 @@ export function formatSyncRoutesReport(result, apply) {
355
470
  lines.push(`[create-ailk] Also required — message namespace(s) not found in apps/web/messages/en.json (add manually, not auto-merged):`);
356
471
  lines.push(...result.missingMessageNamespaces.map((ns) => ` ${ns}`));
357
472
  }
473
+ if (result.structuredDataEntries.length > 0) {
474
+ lines.push(`[create-ailk] Also required — ${STRUCTURED_DATA_TEST_PATH} is edited here, so add these NO_STRUCTURED_DATA_ROUTES entries to it by hand (not written):`);
475
+ for (const { routes, entry } of result.structuredDataEntries) {
476
+ lines.push(` for ${routes.join(", ")}:`);
477
+ lines.push(...entry.split("\n").map((line) => ` ${line}`));
478
+ }
479
+ }
358
480
  if (result.missing.length > 0 && !apply) {
359
481
  lines.push("Run `create-ailk sync-routes <target> --apply` to copy the missing routes.");
360
482
  }
@@ -0,0 +1,59 @@
1
+ /** Where the per-file record lives, beside `.ailk-site-manifest.json`. */
2
+ export declare const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
3
+ /**
4
+ * The app paths a generator rewrites after `copyTemplate` lands its copy
5
+ * (§1): the nav, the site chrome, the site fonts, the paid-sections
6
+ * registry, and the four spoke seams. Composed from each writer's own
7
+ * exported path constant, so a renamed constant carries its path along
8
+ * here too (#8522 D2).
9
+ */
10
+ export declare const REGENERATED_APP_PATHS: readonly string[];
11
+ /**
12
+ * Whether a destination path (`/`-separated, as `templateDestination`
13
+ * returns it) is one `update` ever records or touches (#8522 D2): under one
14
+ * of the three app trees, not a `package.json`, not an `.env*` file, no
15
+ * `.claude` segment, and not a path a generator rewrites after the copy.
16
+ */
17
+ export declare function isRecordable(destRel: string): boolean;
18
+ /** The hex sha256 digest of `bytes` — the one hash every record row uses. */
19
+ export declare function sha256Hex(bytes: Uint8Array): string;
20
+ export interface TemplateFileRow {
21
+ sha256: string;
22
+ version: string;
23
+ }
24
+ export interface TemplateRecord {
25
+ schema: 1;
26
+ projectName: string;
27
+ files: Record<string, TemplateFileRow>;
28
+ }
29
+ /** Thrown by `parseTemplateRecord` on a malformed or unsafe record. */
30
+ export declare class TemplateRecordError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Parse `.ailk-template-files.json` text, refusing — naming the field — on:
35
+ * JSON that does not parse; `schema !== 1`; an unknown top-level key; an
36
+ * empty `projectName`; a `files` key that is not a recordable path; a
37
+ * `sha256` that is not 64 lowercase hex; a `version` that is not a semver
38
+ * string (#8522 D1).
39
+ */
40
+ export declare function parseTemplateRecord(text: string): TemplateRecord;
41
+ /** `record` as `scaffold()` and `update` write it: sorted keys, trailing newline. */
42
+ export declare function serializeTemplateRecord(record: TemplateRecord): string;
43
+ export interface PlanTemplateRecordOptions {
44
+ /** The freshly scaffolded site. */
45
+ targetDir: string;
46
+ /** Destination path -> sha256 of the bytes `copyTemplate`'s `onWrite` wrote, for every recordable path. */
47
+ copied: ReadonlyMap<string, string>;
48
+ /** The create-ailk version that wrote this scaffold. */
49
+ version: string;
50
+ projectName: string;
51
+ }
52
+ /**
53
+ * The record a fresh scaffold writes (#8522 D2): one row per copied path
54
+ * whose file still exists and whose current bytes still hash to the copy —
55
+ * a later layer (a module bundle's patch, a generator) that rewrote the
56
+ * file drops it from the record, exactly as it is absent from the record a
57
+ * `create-ailk update` keeps current for it.
58
+ */
59
+ export declare function planTemplateRecord(opts: PlanTemplateRecordOptions): TemplateRecord;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `.ailk-template-files.json` — the per-file record of what a scaffold's
3
+ * copied app code is (#8522 D1/D2).
4
+ *
5
+ * Written beside `.ailk-site-manifest.json` at the site root, it lists every
6
+ * `apps/web/`, `apps/api/` and `apps/mcp/` file the base `templates/` copy
7
+ * wrote and no later layer rewrote, with the sha256 of the template's copy
8
+ * of that file and the create-ailk version it came from. `create-ailk
9
+ * update` (`update.ts`) reads it to tell an untouched copy from an owner's
10
+ * edit; `scaffold()` (`programmatic.ts`) writes it, as its last write, from
11
+ * the `onWrite` hook `copyTemplate` (`copy.ts`) calls once per file.
12
+ *
13
+ * Pure module: no `import.meta.url`, no filesystem write — `planTemplateRecord`
14
+ * only READS the scaffold's own disk to confirm a copied path's final bytes
15
+ * (a later layer may have rewritten it, D2); every write goes through the
16
+ * caller.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { PAID_SECTIONS_PATH } from "./paid-sections.js";
22
+ import { SITE_CHROME_PATH } from "./site-chrome.js";
23
+ import { SITE_FONTS_PATH } from "./site-fonts.js";
24
+ import { NAV_CONFIG_PATH } from "./surfaces.js";
25
+ import { SPOKE_SEAM_FILES } from "./topology.js";
26
+ /** Where the per-file record lives, beside `.ailk-site-manifest.json`. */
27
+ export const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
28
+ /**
29
+ * The app paths a generator rewrites after `copyTemplate` lands its copy
30
+ * (§1): the nav, the site chrome, the site fonts, the paid-sections
31
+ * registry, and the four spoke seams. Composed from each writer's own
32
+ * exported path constant, so a renamed constant carries its path along
33
+ * here too (#8522 D2).
34
+ */
35
+ export const REGENERATED_APP_PATHS = [
36
+ NAV_CONFIG_PATH,
37
+ SITE_CHROME_PATH,
38
+ SITE_FONTS_PATH,
39
+ PAID_SECTIONS_PATH,
40
+ ...SPOKE_SEAM_FILES,
41
+ ];
42
+ const REGENERATED_SET = new Set(REGENERATED_APP_PATHS);
43
+ const RECORDABLE_APP_ROOT = /^apps\/(web|api|mcp)\//;
44
+ /**
45
+ * Whether a destination path (`/`-separated, as `templateDestination`
46
+ * returns it) is one `update` ever records or touches (#8522 D2): under one
47
+ * of the three app trees, not a `package.json`, not an `.env*` file, no
48
+ * `.claude` segment, and not a path a generator rewrites after the copy.
49
+ */
50
+ export function isRecordable(destRel) {
51
+ if (!RECORDABLE_APP_ROOT.test(destRel))
52
+ return false;
53
+ const segments = destRel.split("/");
54
+ const base = segments[segments.length - 1] ?? "";
55
+ if (base === "package.json")
56
+ return false;
57
+ if (base.startsWith(".env"))
58
+ return false;
59
+ if (segments.includes(".claude"))
60
+ return false;
61
+ if (REGENERATED_SET.has(destRel))
62
+ return false;
63
+ return true;
64
+ }
65
+ /** The hex sha256 digest of `bytes` — the one hash every record row uses. */
66
+ export function sha256Hex(bytes) {
67
+ return createHash("sha256").update(bytes).digest("hex");
68
+ }
69
+ /** Thrown by `parseTemplateRecord` on a malformed or unsafe record. */
70
+ export class TemplateRecordError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = "TemplateRecordError";
74
+ }
75
+ }
76
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
77
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
78
+ /**
79
+ * Whether `key` is traversal-safe: no `..`, no empty segment, no backslash,
80
+ * no leading `/` (D1). This alone does not make `key` a path `update` may
81
+ * touch — `parseTemplateRecord` also requires `isRecordable(key)`, so a
82
+ * tampered record can never direct a write or delete outside the three app
83
+ * trees nor at a non-recordable path (`package.json`, `.env*`, a
84
+ * `REGENERATED_APP_PATHS` entry, …) inside them.
85
+ */
86
+ function isSafeRecordKey(key) {
87
+ if (key.length === 0 || key.startsWith("/") || key.includes("\\")) {
88
+ return false;
89
+ }
90
+ const segments = key.split("/");
91
+ return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
92
+ }
93
+ /**
94
+ * Parse `.ailk-template-files.json` text, refusing — naming the field — on:
95
+ * JSON that does not parse; `schema !== 1`; an unknown top-level key; an
96
+ * empty `projectName`; a `files` key that is not a recordable path; a
97
+ * `sha256` that is not 64 lowercase hex; a `version` that is not a semver
98
+ * string (#8522 D1).
99
+ */
100
+ export function parseTemplateRecord(text) {
101
+ let doc;
102
+ try {
103
+ doc = JSON.parse(text);
104
+ }
105
+ catch (err) {
106
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} is not valid JSON: ${err.message}`);
107
+ }
108
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
109
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} is not a JSON object`);
110
+ }
111
+ const record = doc;
112
+ const allowed = new Set(["schema", "projectName", "files"]);
113
+ for (const key of Object.keys(record)) {
114
+ if (!allowed.has(key)) {
115
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} has an unknown top-level key "${key}"`);
116
+ }
117
+ }
118
+ if (record["schema"] !== 1) {
119
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "schema" must be 1, got ${JSON.stringify(record["schema"])}`);
120
+ }
121
+ const projectName = record["projectName"];
122
+ if (typeof projectName !== "string" || projectName.length === 0) {
123
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "projectName" must be a non-empty string`);
124
+ }
125
+ const filesRaw = record["files"];
126
+ if (typeof filesRaw !== "object" || filesRaw === null || Array.isArray(filesRaw)) {
127
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" must be an object`);
128
+ }
129
+ const files = {};
130
+ for (const [key, value] of Object.entries(filesRaw)) {
131
+ if (!isSafeRecordKey(key)) {
132
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has an unsafe key "${key}"`);
133
+ }
134
+ if (!isRecordable(key)) {
135
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files" has a key "${key}" that is not a recordable path`);
136
+ }
137
+ if (typeof value !== "object" || value === null) {
138
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files"["${key}"] must be an object`);
139
+ }
140
+ const row = value;
141
+ const sha = row["sha256"];
142
+ if (typeof sha !== "string" || !SHA256_PATTERN.test(sha)) {
143
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files"["${key}"].sha256 must be 64 lowercase hex characters`);
144
+ }
145
+ const version = row["version"];
146
+ if (typeof version !== "string" || !VERSION_PATTERN.test(version)) {
147
+ throw new TemplateRecordError(`${TEMPLATE_RECORD_PATH} "files"["${key}"].version must be a semver string`);
148
+ }
149
+ files[key] = { sha256: sha, version };
150
+ }
151
+ return { schema: 1, projectName, files };
152
+ }
153
+ /** `record` as `scaffold()` and `update` write it: sorted keys, trailing newline. */
154
+ export function serializeTemplateRecord(record) {
155
+ const files = {};
156
+ for (const key of Object.keys(record.files).sort()) {
157
+ files[key] = record.files[key];
158
+ }
159
+ return (JSON.stringify({ schema: record.schema, projectName: record.projectName, files }, null, 2) +
160
+ "\n");
161
+ }
162
+ /**
163
+ * The record a fresh scaffold writes (#8522 D2): one row per copied path
164
+ * whose file still exists and whose current bytes still hash to the copy —
165
+ * a later layer (a module bundle's patch, a generator) that rewrote the
166
+ * file drops it from the record, exactly as it is absent from the record a
167
+ * `create-ailk update` keeps current for it.
168
+ */
169
+ export function planTemplateRecord(opts) {
170
+ const files = {};
171
+ for (const path of [...opts.copied.keys()].sort()) {
172
+ const hash = opts.copied.get(path);
173
+ if (hash === undefined)
174
+ continue;
175
+ const abs = join(opts.targetDir, ...path.split("/"));
176
+ if (!existsSync(abs))
177
+ continue;
178
+ if (sha256Hex(readFileSync(abs)) !== hash)
179
+ continue;
180
+ files[path] = { sha256: hash, version: opts.version };
181
+ }
182
+ return { schema: 1, projectName: opts.projectName, files };
183
+ }
@@ -0,0 +1,53 @@
1
+ /** A refusal the CLI exits 2 on, thrown before anything is written. */
2
+ export declare class UpdateRefusedError extends Error {
3
+ readonly exitCode = 2;
4
+ constructor(message: string);
5
+ }
6
+ export interface UpdateSiteOptions {
7
+ /** The existing site. */
8
+ targetDir: string;
9
+ /** The running package's own `templates/` directory. */
10
+ templatesDir: string;
11
+ /** The running create-ailk version. */
12
+ version: string;
13
+ /** Overrides `AILK_NPM_REGISTRY_URL` / the public npm registry default. */
14
+ registryUrl?: string;
15
+ /** Injected fetch for tests. Never a live network call in CI. */
16
+ fetchImpl?: typeof fetch;
17
+ }
18
+ export interface UpdateResult {
19
+ targetDir: string;
20
+ version: string;
21
+ updated: string[];
22
+ merged: string[];
23
+ added: string[];
24
+ removed: string[];
25
+ current: string[];
26
+ review: Array<{
27
+ path: string;
28
+ reason: string;
29
+ }>;
30
+ skipped: Array<{
31
+ path: string;
32
+ reason: string;
33
+ }>;
34
+ /** Per file left under review or skipped, the template tests importing it that stay at the site's version (#8704). */
35
+ heldBack: Array<{
36
+ file: string;
37
+ tests: string[];
38
+ }>;
39
+ backfilled?: {
40
+ version: string;
41
+ files: number;
42
+ };
43
+ }
44
+ export declare function updateSite(opts: UpdateSiteOptions): Promise<UpdateResult>;
45
+ /** The printed report (D9): one section per non-empty list, in order. */
46
+ export declare function formatUpdateReport(result: UpdateResult): string;
47
+ export interface UpdateArgs {
48
+ target: string | undefined;
49
+ help: boolean;
50
+ }
51
+ export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file whose template copy changed is\noverwritten. An edited file whose template copy also changed is three-way\nmerged with `git` (git must be on PATH); a conflict leaves the file alone\nand is listed under \"Needs review\". Every template test that imports a file\nleft under \"Needs review\" or \"Skipped\" (by a relative import) is held at the\nsite's version with it, its record entry is not advanced, and it is listed\nunder \"Held back with <file>\" \u2014 reconcile the file, then run update again to\nbring its tests forward. Every other file in the run still updates.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. Commit before running, and review\nthe diff after.\n";
52
+ /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
53
+ export declare function parseUpdateArgs(argv: string[]): UpdateArgs;