handoff-app 2.0.0-8 → 2.0.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 (322) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +816 -393
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/catalog/normalize.js +3 -0
  55. package/dist/catalog/previews.js +5 -4
  56. package/dist/catalog/types.d.ts +8 -0
  57. package/dist/cli/create.js +1 -2
  58. package/dist/commands/build/index.d.ts +1 -1
  59. package/dist/commands/build/index.js +1 -1
  60. package/dist/commands/utils.d.ts +3 -3
  61. package/dist/config/defaults.js +7 -4
  62. package/dist/config/from-env.d.ts +4 -0
  63. package/dist/config/from-env.js +38 -1
  64. package/dist/config/helpers.js +2 -2
  65. package/dist/config/index.d.ts +0 -1
  66. package/dist/config/index.js +1 -4
  67. package/dist/config/loader.js +28 -2
  68. package/dist/config/resolve-env.d.ts +2 -1
  69. package/dist/config/resolve-env.js +28 -12
  70. package/dist/config/runtime.js +9 -11
  71. package/dist/handlebars.d.ts +10 -2
  72. package/dist/index.d.ts +12 -7
  73. package/dist/index.js +18 -22
  74. package/dist/nav/index.d.ts +2 -2
  75. package/dist/nav/registry.d.ts +3 -3
  76. package/dist/nav/registry.js +1 -1
  77. package/dist/nav/workspace.d.ts +3 -3
  78. package/dist/nav/workspace.js +1 -1
  79. package/dist/pipeline/figma.d.ts +13 -2
  80. package/dist/pipeline/figma.js +86 -46
  81. package/dist/pipeline/styles.js +1 -1
  82. package/dist/pipeline/validation.d.ts +1 -1
  83. package/dist/pipeline/validation.js +3 -3
  84. package/dist/providers.d.ts +10 -0
  85. package/dist/providers.js +13 -0
  86. package/dist/registry/asset-storage/define.d.ts +5 -5
  87. package/dist/registry/asset-storage/define.js +4 -4
  88. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  89. package/dist/registry/asset-storage/resolve.js +33 -21
  90. package/dist/registry/asset-storage/types.d.ts +37 -17
  91. package/dist/registry/asset-storage/types.js +5 -5
  92. package/dist/registry/auth/ai-keys.d.ts +32 -0
  93. package/dist/registry/auth/ai-keys.js +69 -0
  94. package/dist/registry/auth/crypto.d.ts +17 -0
  95. package/dist/registry/auth/crypto.js +61 -1
  96. package/dist/registry/auth/index.d.ts +2 -1
  97. package/dist/registry/auth/index.js +11 -2
  98. package/dist/registry/checkout/index.d.ts +2 -1
  99. package/dist/registry/checkout/index.js +57 -127
  100. package/dist/registry/client.js +53 -17
  101. package/dist/registry/db/driver.js +3 -3
  102. package/dist/registry/db/schema.d.ts +261 -3
  103. package/dist/registry/db/schema.js +29 -4
  104. package/dist/registry/email/define.d.ts +10 -0
  105. package/dist/registry/email/define.js +13 -0
  106. package/dist/registry/email/providers/smtp.d.ts +10 -0
  107. package/dist/registry/email/providers/smtp.js +73 -0
  108. package/dist/registry/email/resolve.d.ts +28 -0
  109. package/dist/registry/email/resolve.js +74 -0
  110. package/dist/registry/email/types.d.ts +24 -0
  111. package/dist/registry/email/types.js +6 -0
  112. package/dist/registry/path.d.ts +2 -0
  113. package/dist/registry/path.js +11 -1
  114. package/dist/registry/publish/package.js +8 -0
  115. package/dist/registry/transfer.d.ts +4 -0
  116. package/dist/store/registry.d.ts +3 -3
  117. package/dist/store/registry.js +4 -4
  118. package/dist/transformers/plugins/ssr-render.js +40 -15
  119. package/dist/transformers/preview/component/builder.d.ts +1 -9
  120. package/dist/transformers/preview/component/builder.js +17 -17
  121. package/dist/transformers/preview/component/css.d.ts +3 -1
  122. package/dist/transformers/preview/component/css.js +92 -51
  123. package/dist/transformers/preview/component/html.d.ts +3 -1
  124. package/dist/transformers/preview/component/html.js +6 -2
  125. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  126. package/dist/transformers/preview/component/javascript.js +10 -4
  127. package/dist/transformers/preview/pattern/html.js +3 -1
  128. package/dist/transformers/preview/types.d.ts +13 -0
  129. package/dist/transformers/utils/build.d.ts +24 -0
  130. package/dist/transformers/utils/build.js +100 -1
  131. package/dist/transformers/utils/module.js +2 -1
  132. package/dist/types/config.d.ts +135 -18
  133. package/dist/utils/path.d.ts +1 -5
  134. package/dist/utils/path.js +6 -23
  135. package/docs/api.md +6 -4
  136. package/docs/cli.md +275 -54
  137. package/drizzle/0007_add_ai_keys.sql +11 -0
  138. package/drizzle/0008_add_source_dir.sql +2 -0
  139. package/drizzle/meta/0007_snapshot.json +2013 -0
  140. package/drizzle/meta/0008_snapshot.json +2025 -0
  141. package/drizzle/meta/_journal.json +14 -0
  142. package/package.json +19 -8
  143. package/src/ai/README.md +42 -0
  144. package/src/ai/connections.ts +189 -0
  145. package/src/ai/define.ts +15 -0
  146. package/src/ai/types.ts +30 -0
  147. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  148. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  149. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  150. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  151. package/src/app/components/AiAssistant/dockState.ts +86 -0
  152. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  153. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  154. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  155. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  156. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  157. package/src/app/components/Auth/AuthShell.tsx +27 -33
  158. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  159. package/src/app/components/Auth/installStatus.ts +29 -0
  160. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  161. package/src/app/components/Component/Preview.tsx +383 -189
  162. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  163. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  164. package/src/app/components/Layout/Header.tsx +21 -15
  165. package/src/app/components/Layout/Main.tsx +2 -2
  166. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  167. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  168. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  169. package/src/app/components/Navigation/SideNav.tsx +1 -15
  170. package/src/app/components/context/NavProvider.tsx +7 -3
  171. package/src/app/components/ui/attachment.tsx +50 -0
  172. package/src/app/components/ui/bubble.tsx +57 -0
  173. package/src/app/components/ui/button.tsx +3 -1
  174. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  175. package/src/app/components/ui/dialog.tsx +7 -2
  176. package/src/app/components/ui/marker.tsx +56 -0
  177. package/src/app/components/ui/sidebar.tsx +3 -1
  178. package/src/app/components/util/index.ts +3 -12
  179. package/src/app/css/index.css +64 -0
  180. package/src/app/lib/ai/auth.ts +33 -0
  181. package/src/app/lib/ai/context.ts +99 -0
  182. package/src/app/lib/ai/model.ts +79 -0
  183. package/src/app/lib/ai/prompt.ts +36 -0
  184. package/src/app/lib/ai/resolve.ts +97 -0
  185. package/src/app/lib/ai/tools.ts +120 -0
  186. package/src/app/lib/api/methods.ts +13 -0
  187. package/src/app/lib/asset-storage.ts +35 -36
  188. package/src/app/lib/auth/api.ts +4 -12
  189. package/src/app/lib/auth/email.ts +96 -23
  190. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  191. package/src/app/lib/docs-api/registry.ts +2 -3
  192. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  193. package/src/app/lib/mcp/components.ts +523 -0
  194. package/src/app/lib/mcp/page-url.ts +24 -0
  195. package/src/app/lib/mcp/pages.ts +84 -0
  196. package/src/app/lib/mcp/result.ts +33 -0
  197. package/src/app/lib/mcp/server.ts +39 -318
  198. package/src/app/lib/mcp/tokens.ts +334 -0
  199. package/src/app/lib/mcp/tool.ts +19 -0
  200. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  201. package/src/app/lib/registry-api/errors.ts +2 -0
  202. package/src/app/lib/registry-api/handler.ts +7 -1
  203. package/src/app/lib/registry-api/transfer.ts +28 -6
  204. package/src/app/lib/server-module.ts +34 -0
  205. package/src/app/lib/utils.ts +20 -0
  206. package/src/app/next.config.mjs +26 -24
  207. package/src/app/pages/[...slug]/index.tsx +2 -1
  208. package/src/app/pages/_app.tsx +6 -2
  209. package/src/app/pages/_document.tsx +6 -0
  210. package/src/app/pages/account/ai.tsx +183 -0
  211. package/src/app/pages/account/index.tsx +68 -68
  212. package/src/app/pages/account/tokens.tsx +37 -7
  213. package/src/app/pages/account/users.tsx +75 -15
  214. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  215. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  216. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  217. package/src/app/pages/api/admin/users/index.ts +2 -2
  218. package/src/app/pages/api/ai/chat.ts +115 -0
  219. package/src/app/pages/api/ai/connections.ts +23 -0
  220. package/src/app/pages/api/auth/request-reset.ts +14 -16
  221. package/src/app/pages/api/docs/nav.json.ts +1 -1
  222. package/src/app/pages/api/install.ts +3 -3
  223. package/src/app/pages/api/mcp.ts +6 -0
  224. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  225. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  226. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  227. package/src/app/pages/cli/device.tsx +10 -5
  228. package/src/app/pages/foundations/colors.tsx +9 -8
  229. package/src/app/pages/foundations/index.tsx +3 -3
  230. package/src/app/pages/index.tsx +2 -2
  231. package/src/app/pages/install.tsx +7 -2
  232. package/src/app/pages/login.tsx +32 -27
  233. package/src/app/pages/reset-password.tsx +21 -20
  234. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  235. package/src/app/pages/system/index.tsx +11 -11
  236. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  237. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  238. package/src/app-builder/README.md +2 -1
  239. package/src/app-builder/build.ts +218 -73
  240. package/src/app-builder/client-config.ts +3 -2
  241. package/src/app-builder/paths.ts +3 -22
  242. package/src/app-builder/static-export.ts +1 -1
  243. package/src/app-builder/vercel-output.ts +5 -4
  244. package/src/cache/README.md +4 -1
  245. package/src/cache/build-cache.ts +128 -43
  246. package/src/cache/build-inputs.ts +28 -0
  247. package/src/cache/index.ts +4 -0
  248. package/src/catalog/README.md +3 -0
  249. package/src/catalog/normalize.ts +2 -0
  250. package/src/catalog/previews.ts +6 -3
  251. package/src/catalog/types.ts +8 -0
  252. package/src/cli/create.ts +1 -2
  253. package/src/commands/build/index.ts +1 -1
  254. package/src/config/README.md +10 -9
  255. package/src/config/defaults.ts +7 -4
  256. package/src/config/from-env.ts +33 -0
  257. package/src/config/helpers.ts +1 -3
  258. package/src/config/index.ts +0 -3
  259. package/src/config/loader.ts +27 -3
  260. package/src/config/resolve-env.ts +28 -15
  261. package/src/config/runtime.ts +10 -5
  262. package/src/handlebars.ts +8 -2
  263. package/src/index.ts +33 -29
  264. package/src/nav/index.ts +2 -2
  265. package/src/nav/registry.ts +3 -3
  266. package/src/nav/workspace.ts +3 -3
  267. package/src/pipeline/README.md +1 -1
  268. package/src/pipeline/figma.ts +98 -49
  269. package/src/pipeline/styles.ts +1 -1
  270. package/src/pipeline/validation.ts +3 -3
  271. package/src/providers.ts +18 -0
  272. package/src/registry/asset-storage/define.ts +6 -6
  273. package/src/registry/asset-storage/resolve.ts +38 -37
  274. package/src/registry/asset-storage/types.ts +39 -17
  275. package/src/registry/auth/ai-keys.ts +62 -0
  276. package/src/registry/auth/crypto.ts +63 -1
  277. package/src/registry/auth/index.ts +11 -1
  278. package/src/registry/checkout/index.ts +60 -138
  279. package/src/registry/client.ts +60 -20
  280. package/src/registry/db/driver.ts +2 -2
  281. package/src/registry/db/schema.ts +49 -22
  282. package/src/registry/email/define.ts +15 -0
  283. package/src/registry/email/providers/smtp.ts +39 -0
  284. package/src/registry/email/resolve.ts +94 -0
  285. package/src/registry/email/types.ts +28 -0
  286. package/src/registry/path.ts +11 -0
  287. package/src/registry/publish/package.ts +9 -0
  288. package/src/registry/transfer.ts +4 -0
  289. package/src/store/registry.ts +7 -7
  290. package/src/transformers/plugins/ssr-render.ts +53 -16
  291. package/src/transformers/preview/component/builder.ts +19 -25
  292. package/src/transformers/preview/component/css.ts +101 -53
  293. package/src/transformers/preview/component/html.ts +6 -2
  294. package/src/transformers/preview/component/javascript.ts +14 -4
  295. package/src/transformers/preview/pattern/html.ts +3 -1
  296. package/src/transformers/preview/types.ts +14 -0
  297. package/src/transformers/utils/build.ts +93 -0
  298. package/src/transformers/utils/module.ts +4 -2
  299. package/src/types/config.ts +148 -20
  300. package/src/utils/README.md +1 -1
  301. package/src/utils/path.ts +6 -29
  302. package/templates/default/handoff.config.js.tpl +10 -2
  303. package/templates/default/handoff.config.ts.tpl +10 -2
  304. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  305. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  306. package/templates/default/package.json.tpl +2 -2
  307. package/.editorconfig +0 -19
  308. package/.eslintrc.json +0 -4
  309. package/.prettierignore +0 -0
  310. package/.prettierrc.json +0 -8
  311. package/.vscode/settings.json +0 -17
  312. package/dist/config/validator.d.ts +0 -9
  313. package/dist/config/validator.js +0 -24
  314. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  315. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  316. package/src/app/components/old/Header.tsx +0 -89
  317. package/src/app/lib/mcp/shape.ts +0 -596
  318. package/src/app/pages/assets/fonts.tsx +0 -129
  319. package/src/app/pages/assets/index.tsx +0 -91
  320. package/src/app/pages/assets/logos.tsx +0 -75
  321. package/src/config/validator.ts +0 -22
  322. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -1,13 +1,17 @@
1
1
  import * as p from '@clack/prompts';
2
+ import { nodeFileTrace } from '@vercel/nft';
2
3
  import spawn from 'cross-spawn';
3
4
  import fs from 'fs-extra';
4
5
  import path from 'path';
5
6
  import Handoff from '..';
7
+ import { resolveAiFromConfig, type AiSettings } from '../ai/connections';
6
8
  import { isMcpEnabled } from '../config';
9
+ import { envReferenceNames } from '../config/from-env';
7
10
  import { buildComponents } from '../pipeline/components';
8
11
  import { buildPatterns } from '../pipeline/patterns';
9
- import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
12
+ import { resolveAssetStorageFromConfig, type ResolvedAssetStorage } from '../registry/asset-storage/resolve';
10
13
  import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
14
+ import { resolveEmailFromConfig, type EmailSettings } from '../registry/email/resolve';
11
15
  import processComponents from '../transformers/preview/component/builder';
12
16
  import { buildMainCss } from '../transformers/preview/component/css';
13
17
  import { buildMainJS } from '../transformers/preview/component/javascript';
@@ -44,7 +48,7 @@ export const DEFAULT_BUILD_TARGET: BuildTarget = 'static';
44
48
  /**
45
49
  * Resolved packaging axis — orthogonal to {@link BuildTarget}. It
46
50
  * selects *how* the build is packaged, not *what* is built:
47
- * - `standalone` → the existing sites-directory deliverable (`out/<projectId>` for static, the
51
+ * - `standalone` → the sites-directory deliverable (`out/static` for static, the
48
52
  * Node standalone bundle `out/registry` for registry).
49
53
  * - `vercel` → the Vercel Build Output API directory (`.vercel/output`) at the repo root.
50
54
  *
@@ -71,7 +75,7 @@ export class HandoffBuildError extends Error {
71
75
  * Resolve and validate the effective `(target, package)` pair.
72
76
  *
73
77
  * `--package` is optional and never implies a target. When omitted the effective package is
74
- * `standalone` for both targets, preserving today's `out/<projectId>` / `out/registry` deliverables.
78
+ * `standalone` for both targets (`out/static` / `out/registry`).
75
79
  * The one rejected combination is `static + standalone`: a static snapshot has no server to package
76
80
  * as a Node standalone bundle.
77
81
  */
@@ -162,16 +166,51 @@ const runNextBuild = async (appPath: string, target: BuildTarget, errorLabel: st
162
166
  });
163
167
  };
164
168
 
165
- /**
166
- * Output directory for the packaged registry app. Lives under the configurable sites output
167
- * directory (`sitesOutputDirectory`, default `out`) alongside the static target's `out/<projectId>`
168
- * deliverable — both build targets emit to one discoverable, gitignored output root, keeping the
169
- * package's `.handoff` for internal build/staging only. The registry artifact is a single
170
- * deployable, so it is not keyed by project id.
171
- */
169
+ /** Fixed names, not project ids, so deploy paths are the same on every machine. */
170
+ export const getStaticBuildOutputPath = (handoff: Handoff): string => path.resolve(handoff.workingPath, handoff.sitesDirectory, 'static');
171
+
172
172
  export const getRegistryBuildOutputPath = (handoff: Handoff): string =>
173
173
  path.resolve(handoff.workingPath, handoff.sitesDirectory, 'registry');
174
174
 
175
+ const commonAncestorPath = (paths: string[]): string => {
176
+ const [first, ...rest] = paths.map((entry) => path.resolve(entry).split(path.sep));
177
+ let shared = first;
178
+ for (const candidate of rest) {
179
+ const next: string[] = [];
180
+ for (let index = 0; index < Math.min(shared.length, candidate.length) && shared[index] === candidate[index]; index += 1) {
181
+ next.push(shared[index]);
182
+ }
183
+ shared = next;
184
+ }
185
+ return shared.join(path.sep) || path.sep;
186
+ };
187
+
188
+ /**
189
+ * Root for Next's file tracing. Every traced path in the standalone bundle is relative to this root.
190
+ *
191
+ * The tracer copies only the files inside this root. It must contain the staged app (see
192
+ * {@link getAppPath}), or Next emits no standalone bundle. It must also contain every
193
+ * `node_modules` that the required runtime dependencies resolve from, or the bundle cannot start
194
+ * (see {@link assertRegistryRuntimeDepsTraced}).
195
+ *
196
+ * `workingPath` satisfies both conditions only when handoff-app is installed in the same package as
197
+ * the config, and nothing is hoisted above it.
198
+ */
199
+ export const resolveRegistryTracingRoot = (handoff: Handoff): string => {
200
+ const anchors = [path.resolve(handoff.workingPath), path.resolve(handoff.modulePath)];
201
+ // Resolve each module instead of assuming its location. A hoisted or linked dependency otherwise
202
+ // drops out of the trace.
203
+ for (const moduleName of getRequiredRegistryRuntimeModules(handoff)) {
204
+ try {
205
+ const resolved = require.resolve(`${moduleName}/package.json`, { paths: [handoff.modulePath, handoff.workingPath] });
206
+ anchors.push(fs.realpathSync(path.dirname(resolved)));
207
+ } catch {
208
+ // The assertion after assembly reports a module that does not resolve.
209
+ }
210
+ }
211
+ return commonAncestorPath(anchors);
212
+ };
213
+
175
214
  /**
176
215
  * Performs cleanup of the application directory by removing the existing app directory if it exists.
177
216
  */
@@ -243,7 +282,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
243
282
  const handoffAppBasePath = handoff.config.app.base_path ?? '';
244
283
  const handoffWorkingPath = path.resolve(handoff.workingPath);
245
284
  const handoffModulePath = path.resolve(handoff.modulePath);
246
- const handoffExportPath = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId());
285
+ const handoffExportPath = handoff.getOutputPath();
247
286
  const nextConfigPath = path.resolve(srcPath, 'next.config.mjs');
248
287
  const targetPath = path.resolve(appPath, 'next.config.mjs');
249
288
  const handoffWebsocketPort = handoff.config.app.ports?.websocket ?? 3001;
@@ -251,6 +290,8 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
251
290
  const escapedProjectId = escapeForSingleQuotedJsString(handoffProjectId);
252
291
  const escapedWorkingPath = escapeForSingleQuotedJsString(handoffWorkingPath);
253
292
  const escapedModulePath = escapeForSingleQuotedJsString(handoffModulePath);
293
+ // Baked here so the template and the assembly step use the same root.
294
+ const escapedTracingRoot = escapeForSingleQuotedJsString(resolveRegistryTracingRoot(handoff));
254
295
  const escapedExportPath = escapeForSingleQuotedJsString(handoffExportPath);
255
296
  const escapedWebsocketPort = escapeForSingleQuotedJsString(String(handoffWebsocketPort));
256
297
  // Resolved runtime mode + registry connection inputs (names only) baked into the Next bundle so the
@@ -259,29 +300,47 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
259
300
  const escapedMcpEnabled = escapeForSingleQuotedJsString(String(isMcpEnabled(handoff.config)));
260
301
  const escapedRegistryDriver = escapeForSingleQuotedJsString(resolveRegistryDriver(handoff.config));
261
302
  const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
262
- // Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
303
+ // Asset storage selection baked (provider + module + options JSON with `{ $env }` references).
263
304
  const assetStorage = resolveAssetStorageFromConfig(handoff.config);
264
- const escapedAssetStorageAdapter = escapeForSingleQuotedJsString(assetStorage.adapterKind);
305
+ const escapedAssetStorageProvider = escapeForSingleQuotedJsString(assetStorage.provider);
265
306
  const escapedAssetStorageModule = escapeForSingleQuotedJsString(assetStorage.module ?? '');
266
- const escapedAssetStorageTokenEnv = escapeForSingleQuotedJsString(assetStorage.tokenEnv);
267
307
  const escapedAssetStorageMaxInline = escapeForSingleQuotedJsString(String(assetStorage.maxInlineBytes));
268
308
  const escapedAssetStorageOptions = escapeForSingleQuotedJsString(JSON.stringify(assetStorage.options ?? {}));
309
+ const email = resolveEmailFromConfig(handoff.config);
310
+ const escapedEmailProvider = escapeForSingleQuotedJsString(email.provider ?? '');
311
+ const escapedEmailFrom = escapeForSingleQuotedJsString(email.from ?? '');
312
+ const escapedEmailModule = escapeForSingleQuotedJsString(email.module ?? '');
313
+ const escapedEmailOptions = escapeForSingleQuotedJsString(JSON.stringify(email.options));
314
+ // AI assistant selection baked (enabled flag + declared connections + default model). Connections
315
+ // carry env-var names and non-secret options only; keys are read from their named env var at
316
+ // request time, and a deployment can extend or replace the list through HANDOFF_AI_CONNECTIONS.
317
+ const ai = resolveAiFromConfig(handoff.config);
318
+ const escapedAiEnabled = escapeForSingleQuotedJsString(String(ai.enabled));
319
+ const escapedAiConnections = escapeForSingleQuotedJsString(JSON.stringify(ai.connections));
320
+ const escapedAiDefaultModel = escapeForSingleQuotedJsString(ai.defaultModel ?? '');
269
321
  const placeholderValues: Record<string, string> = {
270
322
  '%HANDOFF_PROJECT_ID%': escapedProjectId,
271
323
  '%HANDOFF_APP_BASE_PATH%': escapedAppBasePath,
272
324
  '%HANDOFF_WORKING_PATH%': escapedWorkingPath,
273
325
  '%HANDOFF_MODULE_PATH%': escapedModulePath,
326
+ '%HANDOFF_TRACING_ROOT%': escapedTracingRoot,
274
327
  '%HANDOFF_EXPORT_PATH%': escapedExportPath,
275
328
  '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
276
329
  '%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
277
330
  '%HANDOFF_MCP_ENABLED%': escapedMcpEnabled,
278
331
  '%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
279
332
  '%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
280
- '%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
333
+ '%HANDOFF_ASSET_STORAGE_PROVIDER%': escapedAssetStorageProvider,
281
334
  '%HANDOFF_ASSET_STORAGE_MODULE%': escapedAssetStorageModule,
282
- '%HANDOFF_ASSET_STORAGE_TOKEN_ENV%': escapedAssetStorageTokenEnv,
283
335
  '%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%': escapedAssetStorageMaxInline,
284
336
  '%HANDOFF_ASSET_STORAGE_OPTIONS%': escapedAssetStorageOptions,
337
+ '%HANDOFF_EMAIL_PROVIDER%': escapedEmailProvider,
338
+ '%HANDOFF_EMAIL_FROM%': escapedEmailFrom,
339
+ '%HANDOFF_EMAIL_MODULE%': escapedEmailModule,
340
+ '%HANDOFF_EMAIL_OPTIONS%': escapedEmailOptions,
341
+ '%HANDOFF_AI_ENABLED%': escapedAiEnabled,
342
+ '%HANDOFF_AI_BAKED_CONNECTIONS%': escapedAiConnections,
343
+ '%HANDOFF_AI_DEFAULT_MODEL%': escapedAiDefaultModel,
285
344
  };
286
345
  let nextConfigContent = await fs.readFile(nextConfigPath, 'utf-8');
287
346
  for (const [placeholder, value] of Object.entries(placeholderValues)) {
@@ -350,23 +409,14 @@ const buildApp = async (
350
409
 
351
410
  // Final assembly branches on the resolved package. `vercel` lays the materialized export
352
411
  // under `.vercel/output/static/` at the repo root (not the sites directory) — a hard Vercel
353
- // constraint; `standalone` keeps writing the `out/<projectId>` export.
412
+ // constraint; `standalone` keeps writing the `out/static` export.
354
413
  if (resolvedPackage === 'vercel') {
355
414
  await writeStaticVercelOutput(handoff, exportDir);
356
415
  return;
357
416
  }
358
417
 
359
- // Ensure output root directory exists
360
- const outputRoot = path.resolve(handoff.workingPath, handoff.sitesDirectory);
361
- await fs.ensureDir(outputRoot);
362
-
363
- // Clean the project output directory (if exists)
364
- const output = path.resolve(outputRoot, handoff.getProjectId());
365
- if (fs.existsSync(output)) {
366
- await fs.remove(output);
367
- }
368
-
369
- // Copy the build files into the project output directory
418
+ const output = getStaticBuildOutputPath(handoff);
419
+ await fs.remove(output);
370
420
  await fs.copy(exportDir, output);
371
421
  };
372
422
 
@@ -402,8 +452,36 @@ const findStandaloneServerDir = (root: string): string | null => {
402
452
  const writeRegistryDeploymentReadme = async (
403
453
  outputRoot: string,
404
454
  entryRelativePath: string,
405
- databaseUrlEnv: string
455
+ databaseUrlEnv: string,
456
+ ai: AiSettings,
457
+ email: EmailSettings,
458
+ assetStorage: ResolvedAssetStorage
406
459
  ): Promise<void> => {
460
+ const serviceKeyEnvs = [...new Set(ai.connections.map((connection) => connection.apiKeyEnv).filter(Boolean))];
461
+ const aiOptionEnvs = [...new Set(ai.connections.flatMap((connection) => envReferenceNames(connection.options ?? {})))];
462
+ const needsKeySecret = ai.connections.some((connection) => connection.credential === 'user');
463
+ const aiSection = !ai.enabled
464
+ ? ''
465
+ : `
466
+ ## AI assistant
467
+
468
+ This build serves the docs assistant at \`/api/ai/*\`.
469
+
470
+ ${serviceKeyEnvs.map((name) => `- \`${name}\` — API key for a declared AI connection, read at request time.`).join('\n') || '- No service-key connection is declared, so no provider key is required here.'}
471
+ ${aiOptionEnvs.map((name) => `- \`${name}\` — read by a custom AI provider at request time.\n`).join('')}${needsKeySecret ? '- `HANDOFF_AI_KEY_SECRET` — a long, random secret that encrypts the API keys readers save for themselves. Readers cannot save a key without it.\n' : ''}
472
+ \`HANDOFF_AI_CONNECTIONS\` optionally carries a JSON array of connections, read at request time and
473
+ merged over the list this build baked in, keyed by \`id\`. Use it to add or repoint a provider
474
+ without rebuilding.
475
+ `;
476
+
477
+ const assetStorageEnvs = assetStorage.provider === 'custom' ? envReferenceNames(assetStorage.options) : [];
478
+ const emailEnvs = envReferenceNames(email.options);
479
+ const emailSection = !email.from
480
+ ? 'Email delivery is off because `runtime.registry.email.from` is not set. Invitation links are shown once to an administrator for manual delivery.'
481
+ : `Invitation and password-reset emails are sent from \`${email.from}\` through the \`${email.provider}\` provider.${
482
+ emailEnvs.length ? ` The provider reads ${emailEnvs.map((name) => `\`${name}\``).join(', ')} at request time.` : ''
483
+ } Until the provider's settings have values, invitation links are shown once to an administrator for manual delivery.`;
484
+
407
485
  const readme = `# Handoff Registry App
408
486
 
409
487
  A self-contained, deployable Next.js **standalone** build produced by
@@ -420,10 +498,9 @@ and \`.next/static/\` already copied alongside so the server serves them.
420
498
  - \`${databaseUrlEnv}\` — PostgreSQL/Neon connection string, read at request time.
421
499
  - \`AUTH_SECRET\` — a long, random secret used to sign browser sessions.
422
500
  - \`AUTH_URL\` — the canonical public registry URL, including the configured base path.
423
-
424
- Optional email delivery uses \`RESEND_API_KEY\` and \`AUTH_FROM_EMAIL\`. Without them, invitation
425
- links are shown once to an administrator for manual delivery.
426
-
501
+ ${assetStorageEnvs.map((name) => `- \`${name}\` — read by the custom asset storage provider at request time.\n`).join('')}
502
+ ${emailSection}
503
+ ${aiSection}
427
504
  ## Database migrations
428
505
 
429
506
  Apply migrations from the \`handoff-app\` CLI as a controlled release step (e.g. in your CI/CD
@@ -492,8 +569,8 @@ For multi-instance self-hosting, front \`/_next/static\` with a CDN and configur
492
569
 
493
570
  ## Deploying to Vercel
494
571
 
495
- Vercel does **not** use \`output: 'standalone'\` — it ignores that setting and builds with its own
496
- adapter, so this pre-built bundle is **not** the artifact you deploy there. For Vercel, deploy the
572
+ Vercel does **not** use \`output: 'standalone'\` — it ignores that setting and runs its
573
+ own build, so this pre-built bundle is **not** the artifact you deploy there. For Vercel, deploy the
497
574
  registry app via Vercel's Git/CLI source build (it produces and serves the dynamic app itself) and
498
575
  supply the same environment variables above as project env vars. Use this standalone bundle for
499
576
  containers, custom Node servers, and other non-Vercel hosts.
@@ -505,29 +582,25 @@ containers, custom Node servers, and other non-Vercel hosts.
505
582
  * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
506
583
  * runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
507
584
  * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the MCP SDK
508
- * (and its `zod` peer) is only needed when `/api/mcp/` is enabled; the driver package is
509
- * driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
585
+ * is only needed when `/api/mcp/` is enabled; the driver package is
586
+ * driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport); the
587
+ * SMTP email provider needs `nodemailer`.
510
588
  */
511
589
  const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
512
590
  const base = ['next', 'next-auth', 'react', 'react-dom', 'drizzle-orm'];
513
- if (isMcpEnabled(handoff.config)) {
514
- base.push('@modelcontextprotocol/sdk', 'zod');
591
+ const ai = resolveAiFromConfig(handoff.config);
592
+ // The assistant runs the MCP tools in-process, so it needs the MCP SDK whether or not the HTTP
593
+ // endpoint is served.
594
+ if (isMcpEnabled(handoff.config) || ai.enabled) {
595
+ base.push('@modelcontextprotocol/sdk');
596
+ }
597
+ if (ai.enabled) {
598
+ base.push('ai', '@ai-sdk/openai-compatible');
515
599
  }
516
600
  const driver = resolveRegistryDriver(handoff.config);
517
601
  const driverModules = driver === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
518
- // Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
519
- // Vercel Blob adapter needs `@vercel/blob`; a custom adapter may declare its installed SDK
520
- // package(s) via `assetStorage.options.sdkModules` so the trace/assertion covers them.
521
- const assetStorage = resolveAssetStorageFromConfig(handoff.config);
522
- const storage: string[] = [];
523
- if (assetStorage.adapterKind === 'vercel-blob') {
524
- storage.push('@vercel/blob');
525
- }
526
- const sdkModules = assetStorage.options?.sdkModules;
527
- if (Array.isArray(sdkModules)) {
528
- storage.push(...sdkModules.filter((mod): mod is string => typeof mod === 'string'));
529
- }
530
- return [...base, ...driverModules, ...storage];
602
+ const email = resolveEmailFromConfig(handoff.config).provider === 'smtp' ? ['nodemailer'] : [];
603
+ return [...base, ...driverModules, ...email];
531
604
  };
532
605
 
533
606
  /**
@@ -558,6 +631,64 @@ const assertRegistryRuntimeDepsTraced = (handoff: Handoff, entryDir: string): vo
558
631
  );
559
632
  };
560
633
 
634
+ const getCustomServerModules = (handoff: Handoff): string[] => {
635
+ const assetStorage = resolveAssetStorageFromConfig(handoff.config);
636
+ const email = resolveEmailFromConfig(handoff.config);
637
+ const modules = [
638
+ assetStorage.provider === 'custom' ? assetStorage.module : undefined,
639
+ email.provider === 'custom' ? email.module : undefined,
640
+ ...resolveAiFromConfig(handoff.config).connections.map((connection) => connection.module),
641
+ ];
642
+ return [...new Set(modules.filter((module): module is string => Boolean(module)))];
643
+ };
644
+
645
+ /**
646
+ * Copy the custom server modules and every file they import into the bundle. The Next trace cannot
647
+ * follow their variable dynamic import.
648
+ */
649
+ const copyCustomServerModules = async (handoff: Handoff, tracingRoot: string, entryDir: string): Promise<void> => {
650
+ const modules = getCustomServerModules(handoff);
651
+ if (modules.length === 0) return;
652
+
653
+ const missing = modules.filter((module) => !fs.existsSync(path.resolve(handoff.workingPath, module)));
654
+ if (missing.length > 0) {
655
+ throw new HandoffBuildError(`A custom server module does not exist: ${missing.map((module) => `"${module}"`).join(', ')}.`);
656
+ }
657
+
658
+ const files = new Set<string>();
659
+ for (const module of modules) {
660
+ const { fileList } = await nodeFileTrace([path.resolve(handoff.workingPath, module)], { base: tracingRoot });
661
+ const outside = [...fileList].filter((file) => file.startsWith('..'));
662
+ if (outside.length > 0) {
663
+ throw new HandoffBuildError(
664
+ `The custom server module "${module}" imports files outside the build's tracing root "${tracingRoot}": ${outside.join(', ')}. ` +
665
+ `Install the packages it imports so that they resolve from "${handoff.workingPath}".`
666
+ );
667
+ }
668
+ if ([...fileList].some((file) => file.split(path.sep).join('/').endsWith('node_modules/handoff-app/dist/index.js'))) {
669
+ throw new HandoffBuildError(
670
+ `The custom server module "${module}" imports "handoff-app", which loads the whole CLI. Import the define helpers from "handoff-app/providers".`
671
+ );
672
+ }
673
+ fileList.forEach((file) => files.add(file));
674
+ }
675
+
676
+ for (const file of files) {
677
+ const source = path.join(tracingRoot, file);
678
+ const target = path.join(entryDir, file);
679
+ if (await fs.lstat(target).then(() => true, () => false)) continue;
680
+ const stat = await fs.lstat(source);
681
+ await fs.ensureDir(path.dirname(target));
682
+ if (stat.isSymbolicLink()) {
683
+ // Point the link at the bundle copy of its target, not at the build machine.
684
+ const linked = path.join(entryDir, path.relative(tracingRoot, await fs.realpath(source)));
685
+ await fs.symlink(path.relative(path.dirname(target), linked), target);
686
+ } else if (stat.isFile()) {
687
+ await fs.copy(source, target);
688
+ }
689
+ }
690
+ };
691
+
561
692
  /**
562
693
  * Lay the Next `standalone` build out as the documented standalone layout under `entryDir`: copy the
563
694
  * traced bundle, flatten the package-rooted server entry up to `entryDir`, and copy the untraced
@@ -567,22 +698,14 @@ const assertRegistryRuntimeDepsTraced = (handoff: Handoff, entryDir: string): vo
567
698
  *
568
699
  * @returns the absolute path of the server entry (`<entryDir>/server.js`).
569
700
  */
570
- const assembleRegistryStandalone = async (
571
- handoff: Handoff,
572
- appPath: string,
573
- standaloneRoot: string,
574
- entryDir: string
575
- ): Promise<string> => {
701
+ const assembleRegistryStandalone = async (handoff: Handoff, appPath: string, standaloneRoot: string, entryDir: string): Promise<string> => {
576
702
  await fs.copy(standaloneRoot, entryDir, { overwrite: true });
577
703
 
578
- // Locate the server entry within the assembled package. Standalone tracing is rooted at the
579
- // consumer project root (`workingPath`, see next.config.mjs), so every traced file is laid out at
580
- // its path *relative to `workingPath`*. The staged app lives at `<workingPath>/node_modules/
581
- // handoff-app/.handoff/<projectId>`, so the server entry lands nested at that same relative path
582
- // *underneath* the traced `node_modules`. Resolve it deterministically; the recursive fallback
583
- // skips `node_modules` (so it cannot find this nested entry) and only covers a hypothetical
584
- // non-nested layout.
585
- const relAppDir = path.relative(handoff.workingPath, appPath);
704
+ // Locate the server entry in the assembled package. Traced files keep their path relative to the
705
+ // tracing root, so the server entry nests under the traced `node_modules`. The recursive fallback
706
+ // skips `node_modules`, so it covers only a non-nested layout.
707
+ const tracingRoot = resolveRegistryTracingRoot(handoff);
708
+ const relAppDir = path.relative(tracingRoot, appPath);
586
709
  let serverDir = path.resolve(entryDir, relAppDir);
587
710
  if (!fs.existsSync(path.join(serverDir, 'server.js'))) {
588
711
  const located = findStandaloneServerDir(entryDir);
@@ -605,7 +728,7 @@ const assembleRegistryStandalone = async (
605
728
  // so we must NOT remove the first path segment (`node_modules`) — that would delete every traced
606
729
  // dependency. Remove only the `.handoff` staging container (the parent of the staged app dir),
607
730
  // leaving `node_modules/` and the real `node_modules/handoff-app/` package intact.
608
- const stagingContainer = path.resolve(entryDir, path.relative(handoff.workingPath, path.dirname(appPath)));
731
+ const stagingContainer = path.resolve(entryDir, path.relative(tracingRoot, path.dirname(appPath)));
609
732
  if (path.basename(stagingContainer) === '.handoff' && fs.existsSync(stagingContainer)) {
610
733
  await fs.remove(stagingContainer);
611
734
  }
@@ -631,6 +754,19 @@ const assembleRegistryStandalone = async (
631
754
  }
632
755
  }
633
756
 
757
+ // Next copies the `package.json` of the tracing root into the bundle without change. The generated
758
+ // `server.js` is CommonJS, so an ESM manifest stops the bundle from starting. That manifest also
759
+ // names scripts and dev dependencies that the bundle does not ship.
760
+ const manifestPath = path.join(entryDir, 'package.json');
761
+ const inheritedManifest: Record<string, unknown> = (await fs.pathExists(manifestPath))
762
+ ? await fs.readJson(manifestPath).catch(() => ({}))
763
+ : {};
764
+ delete inheritedManifest.scripts;
765
+ delete inheritedManifest.devDependencies;
766
+ await fs.writeJson(manifestPath, { ...inheritedManifest, type: 'commonjs' }, { spaces: 2 });
767
+
768
+ await copyCustomServerModules(handoff, tracingRoot, entryDir);
769
+
634
770
  // Fail loudly at build time if the trace did not capture the runtime deps, rather than shipping a
635
771
  // broken artifact that only crashes on an isolated deploy. Covers both deliverables (this is the
636
772
  // shared assembly step for `standalone` and `vercel`).
@@ -710,8 +846,10 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
710
846
  const standaloneRoot = path.resolve(appPath, '.next', 'standalone');
711
847
  if (!fs.existsSync(standaloneRoot)) {
712
848
  throw new Error(
713
- `Registry build did not produce a standalone bundle at "${standaloneRoot}". Ensure the registry ` +
714
- 'build target sets Next `output: "standalone"`.'
849
+ `Registry build did not produce a standalone bundle at "${standaloneRoot}". Next emits one only ` +
850
+ `when the app being built sits inside \`outputFileTracingRoot\` (resolved here to ` +
851
+ `"${resolveRegistryTracingRoot(handoff)}"), so check that the staged app at "${appPath}" is ` +
852
+ 'inside that root, and that the registry build target still sets Next `output: "standalone"`.'
715
853
  );
716
854
  }
717
855
 
@@ -733,7 +871,14 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
733
871
  // never from the deployed bundle. See the generated README for the migrate-then-deploy flow.
734
872
 
735
873
  const entryRelativePath = path.relative(output, serverEntry).split(path.sep).join('/');
736
- await writeRegistryDeploymentReadme(output, entryRelativePath, resolveDatabaseUrlEnv(handoff.config));
874
+ await writeRegistryDeploymentReadme(
875
+ output,
876
+ entryRelativePath,
877
+ resolveDatabaseUrlEnv(handoff.config),
878
+ resolveAiFromConfig(handoff.config),
879
+ resolveEmailFromConfig(handoff.config),
880
+ resolveAssetStorageFromConfig(handoff.config)
881
+ );
737
882
 
738
883
  Logger.success(`Packaged registry app at ${output} (start: \`node ${entryRelativePath}\`, migrate: \`handoff-app db:migrate\`).`);
739
884
  };
@@ -744,11 +889,11 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
744
889
  */
745
890
  export const watchApp = async (handoff: Handoff): Promise<void> => {
746
891
  // Build the shared/global artifacts first so component/pattern preview HTML references them only
747
- // when present, then process components with caching enabled (which skips
748
- // rebuilding components whose source files haven't changed).
892
+ // when present, then process components. The build cache skips the components whose files did not
893
+ // change.
749
894
  await buildMainJS(handoff);
750
895
  await buildMainCss(handoff);
751
- await processComponents(handoff, undefined, undefined, { useCache: true });
896
+ await processComponents(handoff);
752
897
 
753
898
  // Build patterns after components are ready
754
899
  await buildPatterns(handoff);
@@ -5,6 +5,7 @@ import { getClientConfig, isMcpEnabled } from '../config';
5
5
  import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
6
6
  import { resolveAuthenticatedRegistryConnection } from '../registry/connection';
7
7
  import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
8
+ import { resolveEmailFromConfig } from '../registry/email/resolve';
8
9
  import type { ResolvedConfig, RuntimeMode } from '../types/config';
9
10
  import { getAppPath } from './paths';
10
11
 
@@ -63,12 +64,12 @@ const buildServerRuntimeConfig = (config: ResolvedConfig, modeOverride?: Runtime
63
64
  databaseUrlEnv: resolveDatabaseUrlEnv(config),
64
65
  },
65
66
  assetStorage: {
66
- adapter: assetStorage.adapterKind,
67
+ provider: assetStorage.provider,
67
68
  module: assetStorage.module,
68
- tokenEnv: assetStorage.tokenEnv,
69
69
  maxInlineBytes: assetStorage.maxInlineBytes,
70
70
  options: assetStorage.options,
71
71
  },
72
+ email: resolveEmailFromConfig(config),
72
73
  };
73
74
  };
74
75
 
@@ -2,30 +2,11 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import Handoff from '..';
4
4
 
5
- /**
6
- * Gets the working public directory path for a given handoff instance.
7
- * Checks for both project-specific and default public directories.
8
- *
9
- * @param handoff - The handoff instance containing working path and figma project configuration
10
- * @returns The resolved path to the public directory if it exists, null otherwise
11
- */
12
5
  export const getWorkingPublicPath = (handoff: Handoff): string | null => {
13
- const paths = [path.resolve(handoff.workingPath, `public-${handoff.getProjectId()}`), path.resolve(handoff.workingPath, `public`)];
14
-
15
- for (const p of paths) {
16
- if (fs.existsSync(p)) {
17
- return p;
18
- }
19
- }
20
-
21
- return null;
6
+ const publicPath = path.resolve(handoff.workingPath, 'public');
7
+ return fs.existsSync(publicPath) ? publicPath : null;
22
8
  };
23
9
 
24
- /**
25
- * Gets the application path for a given handoff instance.
26
- * @param handoff - The handoff instance containing module path and figma project configuration
27
- * @returns The resolved path to the application directory
28
- */
29
10
  export const getAppPath = (handoff: Handoff): string => {
30
11
  return path.resolve(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`);
31
12
  };
@@ -42,7 +23,7 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
42
23
  };
43
24
 
44
25
  /**
45
- * Copy the fetch-produced asset bundles (`exported/<id>/{collection}.zip`) into the app's public
26
+ * Copy the fetch-produced asset bundles (`exported/{collection}.zip`) into the app's public
46
27
  * root, where the docs read API serves them at `/api/docs/assets/{collection}/{collection}.zip`.
47
28
  * `fetch` writes these bundles, but `build`/`start` cleans and re-stages `.handoff` without them, so
48
29
  * the download links 404 until the next fetch. Copying them here keeps the route working in workspace
@@ -220,7 +220,7 @@ export const materializeDocsReadModel = async (handoff: Handoff, outDir: string)
220
220
  * Write the whole-collection asset bundles to the asset route
221
221
  * (`api/docs/assets/{collection}/{collection}.zip`), the same URL the docs read API serves in
222
222
  * registry mode, so the "Download" links work identically on a static host. The bundles come from the
223
- * fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them and the
223
+ * fetch output (`exported/{collection}.zip`); `next build` never re-creates them and the
224
224
  * fetch-time copy into `public/` is wiped by the build's clean+restage, so we copy them here again.
225
225
  * Projects with no logos/icons have no bundles, so those are skipped rather than treated as an error.
226
226
  */
@@ -40,7 +40,7 @@ const resolveBasePath = (rawBasePath: string | undefined): string => {
40
40
  * no functions, no database.
41
41
  *
42
42
  * @param exportDir Absolute path of the materialized static export (the same content the static
43
- * target writes to `out/<projectId>`).
43
+ * target writes to `out/static`).
44
44
  */
45
45
  export const writeStaticVercelOutput = async (handoff: Handoff, exportDir: string): Promise<void> => {
46
46
  const outputPath = getVercelOutputPath(handoff);
@@ -69,10 +69,11 @@ export const writeStaticVercelOutput = async (handoff: Handoff, exportDir: strin
69
69
  /**
70
70
  * Node.js runtime identifier baked into the registry function's `.vc-config.json`. The `pg` driver
71
71
  * (and the Neon serverless driver in its Node fallback) need the Node.js runtime, not the edge
72
- * runtime — this is the required baseline. Vercel resolves the patch
73
- * version within this major; pinning the major keeps the artifact stable.
72
+ * runtime. The major matches the package `engines` minimum, and the Vercel project's Node.js setting
73
+ * does not override it. Vercel resolves the patch version within this major; pinning the major keeps
74
+ * the artifact stable.
74
75
  */
75
- const VERCEL_NODE_RUNTIME = 'nodejs20.x';
76
+ const VERCEL_NODE_RUNTIME = 'nodejs22.x';
76
77
 
77
78
  /** Name (sans `.func`) of the single registry function. Served at `/` and the catch-all route. */
78
79
  const REGISTRY_FUNCTION_NAME = 'index';
@@ -2,10 +2,13 @@
2
2
 
3
3
  Incremental build cache for component previews. Tracks file states to skip rebuilding unchanged components.
4
4
 
5
+ Every full component build uses the cache (`start`, `build`, `build:components` without an id, and bulk `publish`), unless `--force` is set. The cache file is `<workingPath>/.handoff/.cache/build-cache.json`. A component entry holds its declared files and every file that its last build read: Rollup watch files from the Vite builds (Sass partials included) and the esbuild graph of each script entry. The lockfile state in the global dependencies covers the files under `node_modules`.
6
+
5
7
  ## Files
6
8
 
7
9
  | File | Purpose |
8
10
  |------|---------|
9
11
  | `file-state.ts` | `computeFileState()`, `computeDirectoryState()`, `statesMatch()`, `directoryStatesMatch()` — file-level change detection |
10
- | `build-cache.ts` | `loadBuildCache()`, `saveBuildCache()`, `hasComponentChanged()`, `computeGlobalDepsState()`, `haveGlobalDepsChanged()`, `computeComponentFileStates()` — component-level and global dependency cache management |
12
+ | `build-cache.ts` | `loadBuildCache()`, `saveBuildCache()`, `hasComponentChanged()`, `computeGlobalDepsState()`, `haveGlobalDepsChanged()`, `computeComponentFileStates()`, `computeBuildInputStates()` — component-level and global dependency cache management |
13
+ | `build-inputs.ts` | `createBuildInputs()`, `withBuildInputs()` — collects the files that the Vite builds of a component read |
11
14
  | `index.ts` | Barrel re-exports |