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
@@ -10,17 +10,41 @@
10
10
  * fingerprint is a known placeholder (the shape's own home, or a shipped
11
11
  * design's arc of it); any authored home is left alone.
12
12
  *
13
+ * #8661 D11 — a design may also carry `contact/<shape>.mdx`, a complete
14
+ * `ContactPage` seeded the same way, with two differences: its
15
+ * `{{SITE_NAME}}` is filled from the site's `siteIdentity.name`, and it
16
+ * replaces the page's `title:` line as well as its `sections:` value (and a
17
+ * placeholder `description:` line), so the page is a placeholder only while
18
+ * its title is one too. Every reader and
19
+ * planner below takes the seeded page as `page` (default `"home"`).
20
+ *
13
21
  * Pure apart from `readDesignHomeSeeds`/`designHomeSeeds`: every directory it
14
22
  * reads is passed in (no `import.meta.url` — Jest's CJS transform).
15
23
  */
16
24
  import { existsSync, readdirSync, readFileSync } from "node:fs";
17
25
  import { join } from "node:path";
26
+ import { buildScaffoldMarkers } from "./copy.js";
18
27
  import { DesignPackageError } from "./design-package.js";
19
28
  import { locatePageSections, pageTypeOf, } from "./design-presets.js";
20
29
  import { RECOGNIZED_SHAPES } from "./parse-args.js";
30
+ import { substituteMarkers } from "./substitute.js";
21
31
  import { STARTER_DESIGNS, TemplateError } from "./template-manifest.js";
22
32
  /** Where a site's home page lives, site-relative. */
23
33
  export const HOME_PAGE_PATH = "content/en/HomePage/home.mdx";
34
+ /** Where a site's contact page lives, site-relative (#8661 D11). */
35
+ export const CONTACT_PAGE_PATH = "content/en/ContactPage/contact.mdx";
36
+ /**
37
+ * The pages a design may seed (#8661 D11), keyed by the design-package
38
+ * directory that holds their seeds (`home/<shape>.mdx`, `contact/<shape>.mdx`).
39
+ */
40
+ export const DESIGN_SEED_PAGES = {
41
+ home: { pageType: "HomePage", path: HOME_PAGE_PATH, route: "/" },
42
+ contact: { pageType: "ContactPage", path: CONTACT_PAGE_PATH, route: "/contact" },
43
+ };
44
+ /** The marker a contact seed's heading and title carry for the site's name (#8661 D11, C8). */
45
+ const SITE_NAME_MARKER = "{{SITE_NAME}}";
46
+ /** The name a contact seed takes when the site records none (#8661 D11). */
47
+ const NO_SITE_NAME = "{Your Name}";
24
48
  // ── D1: reading a design's seeds ───────────────────────────────────────────
25
49
  /** Flow punctuation: whitespace beside it is layout, never content. */
26
50
  const FLOW_PUNCTUATION = new Set(["{", "}", "[", "]", ",", ":"]);
@@ -72,14 +96,18 @@ function rawEntry(text, sections, index, key) {
72
96
  return entry === undefined ? undefined : text.slice(entry.valueStart, entry.valueEnd);
73
97
  }
74
98
  /**
75
- * Read and validate `<dir>/home/*` (D1). Refuses with a `DesignPackageError`
76
- * naming the file, before any write: a basename that is not a recognized
77
- * shape, a page whose `type` is not `HomePage`, `sections` that cannot be
78
- * located (or are not in the flow form a carry can splice), a section type
79
- * the catalog does not carry, or a `logoCloud` whose `logos` is not `[]`.
99
+ * Read and validate `<dir>/home/*` (D1), or `<dir>/contact/*` for
100
+ * `page: "contact"` (#8661 D11). Refuses with a `DesignPackageError` naming
101
+ * the file, before any write: a basename that is not a recognized shape, a
102
+ * page whose `type` is not the page's (`HomePage`, `ContactPage`), `sections`
103
+ * that cannot be located (or are not in the flow form a carry can splice), a
104
+ * section type the catalog does not carry, or a `logoCloud` whose `logos` is
105
+ * not `[]`.
80
106
  */
81
107
  export function readDesignHomeSeeds(dir, opts) {
82
- const homeDir = join(dir, "home");
108
+ const page = opts.page ?? "home";
109
+ const expectedType = DESIGN_SEED_PAGES[page].pageType;
110
+ const homeDir = join(dir, page);
83
111
  if (!existsSync(homeDir))
84
112
  return [];
85
113
  const seeds = [];
@@ -87,7 +115,7 @@ export function readDesignHomeSeeds(dir, opts) {
87
115
  const file = join(homeDir, name);
88
116
  const shape = name.replace(/\.mdx$/, "");
89
117
  if (!name.endsWith(".mdx") || !RECOGNIZED_SHAPES.includes(shape)) {
90
- throw new DesignPackageError(file, "home", `is not named after a recognized shape (expected <shape>.mdx, one of ${RECOGNIZED_SHAPES.join(", ")})`);
118
+ throw new DesignPackageError(file, page, `is not named after a recognized shape (expected <shape>.mdx, one of ${RECOGNIZED_SHAPES.join(", ")})`);
91
119
  }
92
120
  const text = readFileSync(file, "utf8");
93
121
  let pageType;
@@ -101,8 +129,8 @@ export function readDesignHomeSeeds(dir, opts) {
101
129
  throw err;
102
130
  throw new DesignPackageError(file, "sections", `cannot be located (${err.message})`);
103
131
  }
104
- if (pageType !== "HomePage") {
105
- throw new DesignPackageError(file, "type", `must be "HomePage" (got ${JSON.stringify(pageType ?? null)})`);
132
+ if (pageType !== expectedType) {
133
+ throw new DesignPackageError(file, "type", `must be "${expectedType}" (got ${JSON.stringify(pageType ?? null)})`);
106
134
  }
107
135
  if (sections === undefined || sections.some((s) => s.form !== "flow")) {
108
136
  throw new DesignPackageError(file, "sections", "must be a flow sequence of sections");
@@ -127,12 +155,12 @@ export function readDesignHomeSeeds(dir, opts) {
127
155
  return seeds;
128
156
  }
129
157
  /**
130
- * The seeds a resolved design uses (D1): its own `home/`, else — a
131
- * byte-identical copy of a shipped design IS that design — the shipped
132
- * design's, else none.
158
+ * The seeds a resolved design uses (D1): its own `home/` (or `contact/`),
159
+ * else — a byte-identical copy of a shipped design IS that design — the
160
+ * shipped design's, else none.
133
161
  */
134
162
  export function designHomeSeeds(resolved, opts) {
135
- if (existsSync(join(resolved.dir, "home")))
163
+ if (existsSync(join(resolved.dir, opts.page ?? "home")))
136
164
  return readDesignHomeSeeds(resolved.dir, opts);
137
165
  if (STARTER_DESIGNS.includes(resolved.recorded)) {
138
166
  return readDesignHomeSeeds(join(opts.designsDir, resolved.recorded), opts);
@@ -143,10 +171,87 @@ export function designHomeSeeds(resolved, opts) {
143
171
  export function shippedHomeSeeds(opts) {
144
172
  return STARTER_DESIGNS.flatMap((name) => readDesignHomeSeeds(join(opts.designsDir, name), opts));
145
173
  }
146
- /** The shape's own home: its shape tree's, else the overlay's (spec § 1.4). */
174
+ /** The shape's own home (or contact page): its shape tree's, else the overlay's (spec § 1.4). */
147
175
  export function shapeHomePath(shape, opts) {
148
- const own = join(opts.siteTemplatesDir, "shapes", shape, ...HOME_PAGE_PATH.split("/"));
149
- return existsSync(own) ? own : join(opts.templatesDir, ...HOME_PAGE_PATH.split("/"));
176
+ const path = DESIGN_SEED_PAGES[opts.page ?? "home"].path.split("/");
177
+ const own = join(opts.siteTemplatesDir, "shapes", shape, ...path);
178
+ return existsSync(own) ? own : join(opts.templatesDir, ...path);
179
+ }
180
+ // ── #8661 D11: the contact page's name and title ───────────────────────────
181
+ /** The front matter's body, or undefined when the text opens with none. */
182
+ function frontMatterOf(text) {
183
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?$/m.exec(text);
184
+ return fm && fm.index === 0 ? fm[1] : undefined;
185
+ }
186
+ /** A one-line YAML scalar's string value: a double-quoted value is JSON-read, a single-quoted one unescaped. */
187
+ function unquote(raw) {
188
+ const value = raw.trim();
189
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
190
+ try {
191
+ return JSON.parse(value);
192
+ }
193
+ catch {
194
+ return value.slice(1, -1);
195
+ }
196
+ }
197
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
198
+ return value.slice(1, -1).replace(/''/g, "'");
199
+ return value;
200
+ }
201
+ /**
202
+ * A site's `siteIdentity.name` (#8661 D11, C8): the first `name:` line
203
+ * indented directly under `siteIdentity:` in `content/_site.mdx`'s front
204
+ * matter, quotes stripped. Undefined when there is no such line.
205
+ */
206
+ export function siteIdentityName(siteMdx) {
207
+ const fm = siteMdx === undefined ? undefined : frontMatterOf(siteMdx);
208
+ if (fm === undefined)
209
+ return undefined;
210
+ const lines = fm.split(/\r?\n/);
211
+ const start = lines.findIndex((line) => /^siteIdentity:[ \t]*$/.test(line));
212
+ if (start < 0)
213
+ return undefined;
214
+ let indent;
215
+ for (const line of lines.slice(start + 1)) {
216
+ if (line.trim() === "" || line.trimStart().startsWith("#"))
217
+ continue;
218
+ const lead = line.length - line.trimStart().length;
219
+ if (lead === 0)
220
+ return undefined;
221
+ indent ??= lead;
222
+ if (lead !== indent)
223
+ continue;
224
+ const m = /^[ \t]+name:[ \t]*(.+?)[ \t]*$/.exec(line);
225
+ if (m) {
226
+ const name = unquote(m[1]);
227
+ return name === "" ? undefined : name;
228
+ }
229
+ }
230
+ return undefined;
231
+ }
232
+ /** `seed` with its `{{SITE_NAME}}` filled, escaped for the double-quoted strings it sits in. */
233
+ function fillSiteName(seed, name) {
234
+ const escaped = JSON.stringify(name).slice(1, -1);
235
+ return { ...seed, text: seed.text.split(SITE_NAME_MARKER).join(escaped) };
236
+ }
237
+ /** The `<key>:` line's span in the front matter, or undefined. */
238
+ function metaLine(text, key) {
239
+ const fm = frontMatterOf(text);
240
+ if (fm === undefined)
241
+ return undefined;
242
+ const m = new RegExp(`^${key}:[ \\t]*(.*?)[ \\t]*$`, "m").exec(fm);
243
+ if (!m)
244
+ return undefined;
245
+ const offset = text.indexOf(fm);
246
+ return { start: offset + m.index, end: offset + m.index + m[0].length, value: unquote(m[1]) };
247
+ }
248
+ /** `page` with its `<key>:` line replaced by `from`'s (or left as it is when either has none). */
249
+ function spliceMeta(page, from, key) {
250
+ const at = metaLine(page, key);
251
+ const source = metaLine(from, key);
252
+ if (at === undefined || source === undefined)
253
+ return page;
254
+ return page.slice(0, at.start) + from.slice(source.start, source.end) + page.slice(at.end);
150
255
  }
151
256
  // ── D3: the content fingerprint ────────────────────────────────────────────
152
257
  function locate(text, file) {
@@ -219,16 +324,32 @@ export function applySeed(home, seed, file = "home.mdx") {
219
324
  /**
220
325
  * Plan a site home under a design (D3). Writes nothing: `text` is the new
221
326
  * home when the outcome is `seeded` or `restored`, and absent otherwise.
327
+ *
328
+ * `page: "contact"` (#8661 D11) plans the contact page instead. The seeds'
329
+ * `{{SITE_NAME}}` is filled with `siteName` (absent: `{Your Name}`) before
330
+ * anything is compared; the shape's own page is read with the scaffold
331
+ * markers substituted, as a scaffold installed it; the page is a placeholder
332
+ * only while its title is one too (the shape's own, or a seed's); and a write
333
+ * replaces the `title:` line as well as the sections, and the `description:`
334
+ * line while it is still a placeholder (the shape's own, or a seed's).
222
335
  */
223
336
  export function planDesignHome(opts) {
224
- const { current, shape, target, shapeHome, catalog } = opts;
337
+ const page = opts.page ?? "home";
338
+ const contact = page === "contact";
339
+ const pagePath = DESIGN_SEED_PAGES[page].path;
340
+ const { current, shape, catalog } = opts;
225
341
  if (current === undefined)
226
342
  return { outcome: "no-home" };
227
343
  if (shape === undefined)
228
344
  return { outcome: "no-shape" };
345
+ const name = opts.siteName ?? NO_SITE_NAME;
346
+ const fill = (seed) => (contact ? fillSiteName(seed, name) : seed);
347
+ const target = opts.target === undefined ? undefined : fill(opts.target);
348
+ // The scaffold's own markers carry no project-specific value a contact page uses.
349
+ const shapeHome = contact ? substituteMarkers(opts.shapeHome, buildScaffoldMarkers("")) : opts.shapeHome;
229
350
  let currentPrint;
230
351
  try {
231
- currentPrint = fingerprint(current, catalog, HOME_PAGE_PATH);
352
+ currentPrint = fingerprint(current, catalog, pagePath);
232
353
  }
233
354
  catch (err) {
234
355
  // A home whose sections cannot be read is not a placeholder: never write it.
@@ -236,21 +357,38 @@ export function planDesignHome(opts) {
236
357
  return { outcome: "authored" };
237
358
  throw err;
238
359
  }
239
- const seeds = [...opts.shippedSeeds.filter((s) => s.shape === shape), ...(target !== undefined ? [target] : [])];
360
+ const seeds = [
361
+ ...opts.shippedSeeds.filter((s) => s.shape === shape).map(fill),
362
+ ...(target !== undefined ? [target] : []),
363
+ ];
240
364
  const placeholders = new Set([
241
365
  fingerprint(shapeHome, catalog),
242
366
  ...seeds.map((seed) => fingerprint(spliceSections(shapeHome, applySeed(shapeHome, seed), "shape home"), catalog)),
243
367
  ]);
244
368
  if (!placeholders.has(currentPrint))
245
369
  return { outcome: "authored" };
370
+ // #8661 D11 — a contact page whose title was retitled is authored, whatever its sections.
371
+ const placeholderTexts = [shapeHome, ...seeds.map((seed) => seed.text)];
372
+ const placeholderMeta = (key) => new Set(placeholderTexts.map((text) => metaLine(text, key)?.value));
373
+ if (contact && !placeholderMeta("title").has(metaLine(current, "title")?.value))
374
+ return { outcome: "authored" };
246
375
  const wanted = target !== undefined
247
- ? applySeed(current, target, HOME_PAGE_PATH)
376
+ ? applySeed(current, target, pagePath)
248
377
  : (() => {
249
378
  const own = locate(shapeHome, "shape home");
250
379
  return shapeHome.slice(own.valueStart, own.valueEnd);
251
380
  })();
252
- const text = spliceSections(current, wanted, HOME_PAGE_PATH);
253
- if (fingerprint(text, catalog) === currentPrint)
381
+ let text = spliceSections(current, wanted, pagePath);
382
+ if (contact) {
383
+ const from = target?.text ?? shapeHome;
384
+ text = spliceMeta(text, from, "title");
385
+ // An edited description is the customer's: only a placeholder one is replaced.
386
+ if (placeholderMeta("description").has(metaLine(current, "description")?.value)) {
387
+ text = spliceMeta(text, from, "description");
388
+ }
389
+ }
390
+ const sameMeta = ["title", "description"].every((key) => metaLine(text, key)?.value === metaLine(current, key)?.value);
391
+ if (fingerprint(text, catalog) === currentPrint && sameMeta)
254
392
  return { outcome: "unchanged" };
255
393
  return { outcome: target !== undefined ? "seeded" : "restored", text };
256
394
  }
@@ -54,9 +54,14 @@ export type JsonLdDriftVerdict = {
54
54
  * returned as it is.
55
55
  */
56
56
  export declare function admitSeededPeopleSlide(before: ComposedPages, after: ComposedPages): ComposedPages;
57
+ /**
58
+ * `after` with the standard contact page put back when the seeded one differs
59
+ * only in `name` and `description` (#8661 D11).
60
+ */
61
+ export declare function admitSeededContactPage(before: ComposedPages, after: ComposedPages): ComposedPages;
57
62
  /**
58
63
  * Decide whether `apply-design <design>` left every page's JSON-LD unchanged
59
- * (logo excluded), the seeded people slide's Persons admitted.
64
+ * (logo excluded), the seeded people slide's Persons and contact page admitted.
60
65
  */
61
66
  export declare function assessJsonLdDrift(design: string, before: ComposedPages, after: ComposedPages): JsonLdDriftVerdict;
62
67
  export interface MeasureApplyDesignDriftOptions {
@@ -21,11 +21,14 @@
21
21
  * organization home's people slide adds one `Person` per seeded team member to
22
22
  * the home's graph. `admitSeededPeopleSlide` puts the standard home back only
23
23
  * when the seeded home is exactly that, so any other change still counts.
24
+ * Likewise a design that seeds the contact page renames and re-describes it
25
+ * (#8661 D11); `admitSeededContactPage` admits only those two fields.
24
26
  */
25
27
  import { execFileSync } from "node:child_process";
26
28
  import { writeFileSync } from "node:fs";
27
29
  import { join } from "node:path";
28
30
  import { isDeepStrictEqual } from "node:util";
31
+ import { CONTACT_PAGE_PATH } from "../design-home.js";
29
32
  import { TEAM_SEED_MEMBERS } from "../team-profiles.js";
30
33
  function describePair(row) {
31
34
  const measured = row.status === "unmeasurable" || row.ratio === null
@@ -140,18 +143,36 @@ export function admitSeededPeopleSlide(before, after) {
140
143
  const isPeopleSlide = isDeepStrictEqual(page, JSON.parse(standard.jsonld)) && isDeepStrictEqual(people, expected);
141
144
  return isPeopleSlide ? { ...after, [HOME_PAGE]: standard } : after;
142
145
  }
146
+ /**
147
+ * `after` with the standard contact page put back when the seeded one differs
148
+ * only in `name` and `description` (#8661 D11).
149
+ */
150
+ export function admitSeededContactPage(before, after) {
151
+ const standard = before[CONTACT_PAGE_PATH];
152
+ const seeded = after[CONTACT_PAGE_PATH];
153
+ if (standard === undefined || seeded === undefined || !seeded.sectionsValid)
154
+ return after;
155
+ const strip = (jsonld) => ({
156
+ ...JSON.parse(jsonld),
157
+ name: undefined,
158
+ description: undefined,
159
+ });
160
+ return isDeepStrictEqual(strip(seeded.jsonld), strip(standard.jsonld))
161
+ ? { ...after, [CONTACT_PAGE_PATH]: standard }
162
+ : after;
163
+ }
143
164
  /** How many changed nodes a drift reason lists before it stops. */
144
165
  const REASON_NODE_LIMIT = 10;
145
166
  /**
146
167
  * Decide whether `apply-design <design>` left every page's JSON-LD unchanged
147
- * (logo excluded), the seeded people slide's Persons admitted.
168
+ * (logo excluded), the seeded people slide's Persons and contact page admitted.
148
169
  */
149
170
  export function assessJsonLdDrift(design, before, after) {
150
171
  const pages = Object.keys(before).length;
151
172
  if (pages === 0) {
152
173
  return { ok: false, pages, changed: [], reason: `design ${design}: no page was composed` };
153
174
  }
154
- const changed = jsonLdDrift(before, admitSeededPeopleSlide(before, after));
175
+ const changed = jsonLdDrift(before, admitSeededContactPage(before, admitSeededPeopleSlide(before, after)));
155
176
  if (changed.length === 0)
156
177
  return { ok: true, pages };
157
178
  const listed = changed
@@ -0,0 +1,21 @@
1
+ export declare const DEFAULT_AILK_NPM_REGISTRY_URL = "https://registry.npmjs.org";
2
+ export declare class FetchTemplateSnapshotError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface FetchTemplateSnapshotOptions {
6
+ /** The create-ailk version to re-materialise, e.g. "0.19.1". */
7
+ version: string;
8
+ /** Registry root; defaults to `AILK_NPM_REGISTRY_URL`, else the public npm registry. */
9
+ registryUrl?: string;
10
+ /** Injected fetch for tests. Never a live network call in CI. */
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ /**
14
+ * Fetch and re-materialise create-ailk `version`'s `templates/apps/{web,api,mcp}/`
15
+ * tree from its published npm tarball.
16
+ *
17
+ * @throws FetchTemplateSnapshotError on an invalid version, an unreachable
18
+ * or malformed registry response, an integrity mismatch, or a symlink /
19
+ * hard-link entry under one of the three app prefixes.
20
+ */
21
+ export declare function fetchTemplateSnapshot(options: FetchTemplateSnapshotOptions): Promise<Map<string, Uint8Array>>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Re-materialise an old create-ailk's `templates/apps/{web,api,mcp}/` tree
3
+ * from its published npm tarball (#8522 D6) — the merge base
4
+ * `create-ailk update` needs for a recorded version no longer sitting in
5
+ * this package's own `templates/`.
6
+ *
7
+ * Reads the public, unauthenticated npm registry — no bearer token, unlike
8
+ * `fetch-template.ts` / `fetch-module.ts` — through the same safety guards
9
+ * those two share (`fetch-guard.ts`): an `https:` (or loopback, for the test
10
+ * mock) registry URL, a request timeout, and a declared-`Content-Length`
11
+ * cap before the body is ever buffered. The tarball's integrity is verified
12
+ * against the packument's `dist.integrity` before it is gunzipped, and the
13
+ * gunzip itself is capped at the same `MAX_TOTAL_SIZE_BYTES`
14
+ * `extract-module-bundle.ts` enforces for a module bundle, so the whole
15
+ * fetch is bounded in memory regardless of what the tarball's own headers
16
+ * claim. The gunzipped tar is parsed in memory with `tar`'s own `Parser` —
17
+ * never extracted to disk — and only regular files under the three app
18
+ * prefixes are kept.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { gunzipSync } from "node:zlib";
22
+ import * as tar from "tar";
23
+ import { templateDestination } from "../copy.js";
24
+ import { MAX_TOTAL_SIZE_BYTES } from "./extract-module-bundle.js";
25
+ import { assertContentLengthWithinLimit, assertHttpsRegistryUrl, fetchTimeoutSignal, } from "./fetch-guard.js";
26
+ export const DEFAULT_AILK_NPM_REGISTRY_URL = "https://registry.npmjs.org";
27
+ /** The same version shape `.ailk-template-files.json` rows are checked against (D1). */
28
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
29
+ /** The one tar path prefix a snapshot is read from, and the three app roots inside it. */
30
+ const TAR_ROOT = "package/templates/";
31
+ const APP_PREFIXES = ["apps/web/", "apps/api/", "apps/mcp/"];
32
+ export class FetchTemplateSnapshotError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = "FetchTemplateSnapshotError";
36
+ }
37
+ }
38
+ function detail(err) {
39
+ return err instanceof Error ? err.message : String(err);
40
+ }
41
+ /** The one version's packument: `GET <registry>/create-ailk/<version>`. */
42
+ async function fetchPackument(registryUrl, version, fetchImpl) {
43
+ const url = `${registryUrl.replace(/\/+$/, "")}/create-ailk/${encodeURIComponent(version)}`;
44
+ let response;
45
+ try {
46
+ response = await fetchImpl(url, {
47
+ method: "GET",
48
+ headers: { Accept: "application/json" },
49
+ redirect: "error",
50
+ signal: fetchTimeoutSignal(),
51
+ });
52
+ }
53
+ catch (err) {
54
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument unreachable: ${detail(err)}`);
55
+ }
56
+ if (!response.ok) {
57
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument request failed: ${response.status}`);
58
+ }
59
+ let doc;
60
+ try {
61
+ doc = await response.json();
62
+ }
63
+ catch (err) {
64
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument is not JSON: ${detail(err)}`);
65
+ }
66
+ if (typeof doc !== "object" || doc === null) {
67
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument is not an object`);
68
+ }
69
+ const record = doc;
70
+ if (record["version"] !== version) {
71
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument names a different version: ${JSON.stringify(record["version"])}`);
72
+ }
73
+ const dist = record["dist"];
74
+ if (typeof dist !== "object" || dist === null) {
75
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist"`);
76
+ }
77
+ const distRecord = dist;
78
+ const tarball = distRecord["tarball"];
79
+ if (typeof tarball !== "string" || tarball.length === 0) {
80
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist.tarball"`);
81
+ }
82
+ const integrity = distRecord["integrity"];
83
+ if (typeof integrity !== "string" || !integrity.startsWith("sha512-")) {
84
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} packument has no "dist.integrity" starting "sha512-"`);
85
+ }
86
+ return { version, tarball, integrity };
87
+ }
88
+ async function fetchTarball(tarballUrl, integrity, version, fetchImpl) {
89
+ assertHttpsRegistryUrl(tarballUrl, FetchTemplateSnapshotError);
90
+ let response;
91
+ try {
92
+ response = await fetchImpl(tarballUrl, {
93
+ method: "GET",
94
+ headers: { Accept: "application/octet-stream" },
95
+ redirect: "error",
96
+ signal: fetchTimeoutSignal(),
97
+ });
98
+ }
99
+ catch (err) {
100
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball unreachable: ${detail(err)}`);
101
+ }
102
+ if (!response.ok) {
103
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball request failed: ${response.status}`);
104
+ }
105
+ assertContentLengthWithinLimit(response, FetchTemplateSnapshotError);
106
+ const buf = Buffer.from(await response.arrayBuffer());
107
+ const digest = `sha512-${createHash("sha512").update(buf).digest("base64")}`;
108
+ if (digest !== integrity) {
109
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball failed its integrity check`);
110
+ }
111
+ return buf;
112
+ }
113
+ /** The app-relative path (no `package/templates/` prefix) for a tar entry path, or undefined outside the three app trees. */
114
+ function appRelativePath(tarPath) {
115
+ if (!tarPath.startsWith(TAR_ROOT))
116
+ return undefined;
117
+ const rel = tarPath.slice(TAR_ROOT.length);
118
+ if (!APP_PREFIXES.some((prefix) => rel.startsWith(prefix)))
119
+ return undefined;
120
+ if (rel.length === 0)
121
+ return undefined;
122
+ const segments = rel.split("/");
123
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
124
+ return undefined;
125
+ }
126
+ return rel;
127
+ }
128
+ /**
129
+ * Parse the gunzipped tar in memory and return every regular file under the
130
+ * three app trees, keyed by its destination path (D3's `templateDestination`
131
+ * — `_gitignore` restored to `.gitignore`, `.gitkeep` / `.npmignore` dropped).
132
+ * A symlink or hard link under one of those prefixes refuses the WHOLE
133
+ * snapshot, never just that entry.
134
+ */
135
+ function parseSnapshot(gunzipped, version) {
136
+ const files = new Map();
137
+ let refusal;
138
+ const parser = new tar.Parser({
139
+ strict: true,
140
+ filter: (tarPath, rawEntry) => {
141
+ if (refusal !== undefined)
142
+ return false;
143
+ const rel = appRelativePath(tarPath);
144
+ if (rel === undefined)
145
+ return false;
146
+ const entryType = rawEntry.type;
147
+ if (entryType === "SymbolicLink" || entryType === "Link") {
148
+ refusal = `create-ailk ${version} snapshot: "${tarPath}" is a ${entryType} (refused)`;
149
+ return false;
150
+ }
151
+ return entryType === "File";
152
+ },
153
+ onReadEntry: (entry) => {
154
+ const rel = appRelativePath(entry.path);
155
+ const dest = rel !== undefined ? templateDestination(rel) : undefined;
156
+ if (dest === undefined) {
157
+ entry.resume();
158
+ return;
159
+ }
160
+ const chunks = [];
161
+ entry.on("data", (chunk) => chunks.push(chunk));
162
+ entry.on("end", () => files.set(dest, Buffer.concat(chunks)));
163
+ },
164
+ });
165
+ return new Promise((resolvePromise, rejectPromise) => {
166
+ parser.on("error", (err) => rejectPromise(err));
167
+ parser.on("end", () => {
168
+ if (refusal !== undefined) {
169
+ rejectPromise(new FetchTemplateSnapshotError(refusal));
170
+ return;
171
+ }
172
+ resolvePromise(files);
173
+ });
174
+ parser.end(gunzipped);
175
+ });
176
+ }
177
+ /**
178
+ * Fetch and re-materialise create-ailk `version`'s `templates/apps/{web,api,mcp}/`
179
+ * tree from its published npm tarball.
180
+ *
181
+ * @throws FetchTemplateSnapshotError on an invalid version, an unreachable
182
+ * or malformed registry response, an integrity mismatch, or a symlink /
183
+ * hard-link entry under one of the three app prefixes.
184
+ */
185
+ export async function fetchTemplateSnapshot(options) {
186
+ const { version } = options;
187
+ if (!VERSION_PATTERN.test(version)) {
188
+ throw new FetchTemplateSnapshotError(`refusing to fetch create-ailk "${version}": not a valid version`);
189
+ }
190
+ const registryUrl = options.registryUrl ??
191
+ process.env["AILK_NPM_REGISTRY_URL"] ??
192
+ DEFAULT_AILK_NPM_REGISTRY_URL;
193
+ assertHttpsRegistryUrl(registryUrl, FetchTemplateSnapshotError);
194
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
195
+ const packument = await fetchPackument(registryUrl, version, fetchImpl);
196
+ const tarball = await fetchTarball(packument.tarball, packument.integrity, version, fetchImpl);
197
+ let gunzipped;
198
+ try {
199
+ gunzipped = gunzipSync(tarball, { maxOutputLength: MAX_TOTAL_SIZE_BYTES });
200
+ }
201
+ catch (err) {
202
+ throw new FetchTemplateSnapshotError(`create-ailk ${version} tarball could not be decompressed: ${detail(err)}`);
203
+ }
204
+ return parseSnapshot(gunzipped, version);
205
+ }
@@ -0,0 +1,31 @@
1
+ export interface MergeThreeWayOptions {
2
+ /** The site's current (edited) text. */
3
+ ours: string;
4
+ /** The recorded version's rendered copy. */
5
+ base: string;
6
+ /** The running version's rendered copy. */
7
+ theirs: string;
8
+ oursLabel: string;
9
+ baseLabel: string;
10
+ theirsLabel: string;
11
+ }
12
+ export type MergeThreeWayResult = {
13
+ status: "merged";
14
+ text: string;
15
+ } | {
16
+ status: "conflict";
17
+ } | {
18
+ status: "error";
19
+ reason: string;
20
+ };
21
+ /**
22
+ * Run `git merge-file -p` over the three texts in a `mkdtemp` directory, no
23
+ * shell, argv holding only temp-file paths and the three labels (#8522 D5).
24
+ *
25
+ * Exit 0 is a clean merge (`merged`). Exit 1-127 is a conflicting hunk
26
+ * (`conflict`) — the file is never written with conflict markers by this
27
+ * function; the caller decides what to do with a conflict, and nothing here
28
+ * ever applies one. Any other exit, or a spawn failure (no `git` on `PATH`),
29
+ * is `error`.
30
+ */
31
+ export declare function mergeThreeWay(opts: MergeThreeWayOptions): MergeThreeWayResult;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `git merge-file` wrapper — the three-way text merge `create-ailk update`
3
+ * runs for a copied app file the site owner edited and the template also
4
+ * changed (#8522 D5).
5
+ *
6
+ * `git merge-file` is the reference diff3 merge: every AILK owner already
7
+ * has `git` on `PATH` (it is how they cloned or will push this site), and
8
+ * using it adds no new npm dependency. The three versions are written to a
9
+ * fresh temp directory (never the site's own tree) and read back as text;
10
+ * nothing is left behind.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ /**
17
+ * Run `git merge-file -p` over the three texts in a `mkdtemp` directory, no
18
+ * shell, argv holding only temp-file paths and the three labels (#8522 D5).
19
+ *
20
+ * Exit 0 is a clean merge (`merged`). Exit 1-127 is a conflicting hunk
21
+ * (`conflict`) — the file is never written with conflict markers by this
22
+ * function; the caller decides what to do with a conflict, and nothing here
23
+ * ever applies one. Any other exit, or a spawn failure (no `git` on `PATH`),
24
+ * is `error`.
25
+ */
26
+ export function mergeThreeWay(opts) {
27
+ const dir = mkdtempSync(join(tmpdir(), "ailk-merge-"));
28
+ try {
29
+ const oursPath = join(dir, "ours");
30
+ const basePath = join(dir, "base");
31
+ const theirsPath = join(dir, "theirs");
32
+ writeFileSync(oursPath, opts.ours);
33
+ writeFileSync(basePath, opts.base);
34
+ writeFileSync(theirsPath, opts.theirs);
35
+ const result = spawnSync("git", [
36
+ "merge-file",
37
+ "-p",
38
+ "-L",
39
+ opts.oursLabel,
40
+ "-L",
41
+ opts.baseLabel,
42
+ "-L",
43
+ opts.theirsLabel,
44
+ oursPath,
45
+ basePath,
46
+ theirsPath,
47
+ ], { shell: false });
48
+ if (result.error !== undefined) {
49
+ return { status: "error", reason: result.error.message };
50
+ }
51
+ if (result.status === 0) {
52
+ return { status: "merged", text: result.stdout.toString("utf8") };
53
+ }
54
+ if (result.status !== null && result.status >= 1 && result.status <= 127) {
55
+ return { status: "conflict" };
56
+ }
57
+ return {
58
+ status: "error",
59
+ reason: `git merge-file exited ${result.status ?? `signal ${result.signal}`}`,
60
+ };
61
+ }
62
+ finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ }