handoff-app 2.0.0-9 → 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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  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/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -1,18 +1,20 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import Handoff from '../index';
4
+ import { collectModuleInputs } from '../transformers/utils/build';
4
5
  import { Logger } from '../utils/logger';
5
6
  import { normalizePathForCompare } from '../utils/path';
7
+ import { BuildInputs } from './build-inputs';
6
8
  import { computeDirectoryState, computeFileState, directoryStatesMatch, FileState, statesMatch } from './file-state';
7
9
 
8
10
  /** Current cache format version - bump when structure changes */
9
- const CACHE_VERSION = '1.0.0';
11
+ const CACHE_VERSION = '2.0.0';
10
12
 
11
13
  /**
12
14
  * Cache entry for a single component
13
15
  */
14
16
  export interface ComponentCacheEntry {
15
- /** File states for all source files of this component */
17
+ /** File states for the declared source files of this component and every file its last build read */
16
18
  files: Record<string, FileState>;
17
19
  /** States for template directory files (if templates is a directory) */
18
20
  templateDirFiles?: Record<string, FileState>;
@@ -30,8 +32,25 @@ export interface GlobalDepsState {
30
32
  globalScss?: FileState;
31
33
  /** Global JS entry file state */
32
34
  globalJs?: FileState;
35
+ /** Main config file state. The build hooks are declared there */
36
+ mainConfig?: FileState;
37
+ /** Profile config file state */
38
+ profileConfig?: FileState;
39
+ /** Selected profile. All profiles write to the same output, so a switch invalidates all of it */
40
+ profile?: string;
41
+ /** Lockfile state. It stands in for the files under `node_modules`, which the cache does not track */
42
+ lockfile?: FileState;
43
+ /** handoff-app version that built the output */
44
+ version?: string;
45
+ /** Base path written into the generated HTML */
46
+ basePath?: string;
33
47
  }
34
48
 
49
+ const GLOBAL_FILE_DEPS = ['tokens', 'globalScss', 'globalJs', 'mainConfig', 'profileConfig', 'lockfile'] as const;
50
+ const GLOBAL_VALUE_DEPS = ['profile', 'version', 'basePath'] as const;
51
+
52
+ const LOCKFILE_NAMES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
53
+
35
54
  /**
36
55
  * Complete build cache structure
37
56
  */
@@ -48,11 +67,9 @@ export interface BuildCache {
48
67
  * Gets the path to the build cache file
49
68
  */
50
69
  export function getCachePath(handoff: Handoff): string {
51
- // Two profiles share one project id and can build different output, so the profile has to name
52
- // the cache file. One shared cache would serve the other profile's components.
53
- const profile = handoff.getProfile();
54
- const fileName = profile ? `build-cache.${profile}.json` : 'build-cache.json';
55
- return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', fileName);
70
+ // The cache describes the output in the working directory, so it lives there too. A build deletes
71
+ // the staged app under the package, so a cache there would not survive.
72
+ return path.resolve(handoff.workingPath, '.handoff', '.cache', 'build-cache.json');
56
73
  }
57
74
 
58
75
  /**
@@ -128,29 +145,64 @@ export async function computeGlobalDepsState(handoff: Handoff): Promise<GlobalDe
128
145
  result.globalJs = (await computeFileState(handoff.runtimeConfig.entries.js)) ?? undefined;
129
146
  }
130
147
 
148
+ const mainConfigPath = handoff.getMainConfigFilePath();
149
+ if (mainConfigPath) {
150
+ result.mainConfig = (await computeFileState(mainConfigPath)) ?? undefined;
151
+ }
152
+
153
+ const profileConfigPath = handoff.getProfileConfigFilePath();
154
+ if (profileConfigPath) {
155
+ result.profileConfig = (await computeFileState(profileConfigPath)) ?? undefined;
156
+ }
157
+
158
+ result.profile = handoff.getProfile();
159
+
160
+ const lockfilePath = findLockfile(handoff.workingPath);
161
+ if (lockfilePath) {
162
+ result.lockfile = (await computeFileState(lockfilePath)) ?? undefined;
163
+ }
164
+
165
+ result.version = await readPackageVersion(handoff);
166
+ result.basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
167
+
131
168
  return result;
132
169
  }
133
170
 
171
+ /** The nearest lockfile, searched up from the working directory so that a monorepo root lockfile is found. */
172
+ function findLockfile(fromDirectory: string): string | undefined {
173
+ for (let directory = path.resolve(fromDirectory); ; directory = path.dirname(directory)) {
174
+ const lockfile = LOCKFILE_NAMES.map((name) => path.join(directory, name)).find((file) => fs.existsSync(file));
175
+ if (lockfile) return lockfile;
176
+ if (path.dirname(directory) === directory) return undefined;
177
+ }
178
+ }
179
+
180
+ async function readPackageVersion(handoff: Handoff): Promise<string | undefined> {
181
+ try {
182
+ return (await fs.readJson(path.resolve(handoff.modulePath, 'package.json'))).version;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+
134
188
  /**
135
189
  * Checks if global dependencies have changed
136
190
  */
137
191
  export function haveGlobalDepsChanged(cached: GlobalDepsState | null | undefined, current: GlobalDepsState): boolean {
138
192
  if (!cached) return true;
139
193
 
140
- // Check each global dependency
141
- if (!statesMatch(cached.tokens, current.tokens)) {
142
- Logger.debug('Global dependency changed: tokens.json');
143
- return true;
144
- }
145
-
146
- if (!statesMatch(cached.globalScss, current.globalScss)) {
147
- Logger.debug('Global dependency changed: global SCSS entry');
148
- return true;
194
+ for (const key of GLOBAL_FILE_DEPS) {
195
+ if (!statesMatch(cached[key], current[key])) {
196
+ Logger.debug(`Global dependency changed: ${key}`);
197
+ return true;
198
+ }
149
199
  }
150
200
 
151
- if (!statesMatch(cached.globalJs, current.globalJs)) {
152
- Logger.debug('Global dependency changed: global JS entry');
153
- return true;
201
+ for (const key of GLOBAL_VALUE_DEPS) {
202
+ if (cached[key] !== current[key]) {
203
+ Logger.debug(`Global dependency changed: ${key}`);
204
+ return true;
205
+ }
154
206
  }
155
207
 
156
208
  return false;
@@ -274,46 +326,78 @@ export async function computeComponentFileStates(
274
326
  }
275
327
 
276
328
  /**
277
- * Checks if a component needs to be rebuilt based on file states
329
+ * Checks if a component needs to be rebuilt based on file states. This also compares the files that
330
+ * the last build read with their current state on disk.
278
331
  */
279
- export function hasComponentChanged(
332
+ export async function hasComponentChanged(
280
333
  cached: ComponentCacheEntry | null | undefined,
281
334
  current: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
282
- ): boolean {
335
+ ): Promise<boolean> {
283
336
  if (!cached) {
284
337
  return true; // No cache entry means new component
285
338
  }
286
339
 
287
- // Check regular files
288
- const cachedFiles = Object.keys(cached.files);
289
- const currentFiles = Object.keys(current.files);
290
-
291
- // Check if file count changed
292
- if (cachedFiles.length !== currentFiles.length) {
293
- return true;
340
+ for (const [file, state] of Object.entries(current.files)) {
341
+ if (!statesMatch(cached.files[file], state)) {
342
+ return true;
343
+ }
294
344
  }
295
345
 
296
- // Check if any files were added or removed
297
- const cachedSet = new Set(cachedFiles);
298
- for (const file of currentFiles) {
299
- if (!cachedSet.has(file)) {
346
+ for (const [file, state] of Object.entries(cached.files)) {
347
+ if (!(file in current.files) && !statesMatch(state, await computeFileState(file))) {
300
348
  return true;
301
349
  }
302
350
  }
303
351
 
304
- // Check if any file states changed
305
- for (const file of cachedFiles) {
306
- if (!statesMatch(cached.files[file], current.files[file])) {
307
- return true;
352
+ return !directoryStatesMatch(cached.templateDirFiles, current.templateDirFiles);
353
+ }
354
+
355
+ const SCRIPT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts']);
356
+
357
+ /**
358
+ * File states for every file that the builds of a component read, or `null` when that list is
359
+ * incomplete and the component must not be cached.
360
+ *
361
+ * Previews compile their sources with esbuild inside a Vite plugin, so Rollup does not see those
362
+ * imports. The esbuild graph of each script entry supplies them. The lockfile covers the files under
363
+ * `node_modules`, so they are skipped, together with the generated files in the output directory.
364
+ */
365
+ export async function computeBuildInputStates(
366
+ handoff: Handoff,
367
+ componentId: string,
368
+ inputs: BuildInputs
369
+ ): Promise<Record<string, FileState> | null> {
370
+ if (inputs.failed) {
371
+ return null;
372
+ }
373
+
374
+ const files = new Set(inputs.files);
375
+ const entries = handoff.runtimeConfig?.entities.components[componentId]?.entries as Record<string, string | undefined> | undefined;
376
+ const scriptEntries = new Set(Object.values(entries ?? {}).filter((entry): entry is string => !!entry && SCRIPT_EXTENSIONS.has(path.extname(entry))));
377
+
378
+ for (const entry of scriptEntries) {
379
+ const moduleInputs = await collectModuleInputs(entry, handoff);
380
+ if (!moduleInputs) {
381
+ return null;
308
382
  }
383
+ for (const file of moduleInputs) files.add(file);
309
384
  }
310
385
 
311
- // Check template directory files if applicable
312
- if (!directoryStatesMatch(cached.templateDirFiles, current.templateDirFiles)) {
313
- return true;
386
+ const outputDirectory = path.resolve(handoff.workingPath, 'public/api/component');
387
+ const states: Record<string, FileState> = {};
388
+
389
+ for (const id of files) {
390
+ const file = id.split('?')[0];
391
+ if (!path.isAbsolute(file) || file.split(path.sep).includes('node_modules') || file.startsWith(outputDirectory + path.sep)) {
392
+ continue;
393
+ }
394
+ const state = await computeFileState(file);
395
+ if (state) {
396
+ states[path.resolve(file)] = state;
397
+ }
314
398
  }
315
399
 
316
- return false;
400
+ return states;
317
401
  }
318
402
 
319
403
  /**
@@ -369,10 +453,11 @@ export function createEmptyCache(): BuildCache {
369
453
  export function updateComponentCacheEntry(
370
454
  cache: BuildCache,
371
455
  componentId: string,
372
- fileStates: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
456
+ fileStates: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> },
457
+ inputStates: Record<string, FileState>
373
458
  ): void {
374
459
  cache.components[componentId] = {
375
- files: fileStates.files,
460
+ files: { ...inputStates, ...fileStates.files },
376
461
  templateDirFiles: fileStates.templateDirFiles,
377
462
  buildTimestamp: Date.now(),
378
463
  };
@@ -0,0 +1,28 @@
1
+ import type { InlineConfig, Plugin } from 'vite';
2
+
3
+ /**
4
+ * The files that the builds of one component read, so the build cache can tell when an imported
5
+ * file changes. A failed build can stop before it reads the file that broke it, so its list is
6
+ * incomplete and must not be cached.
7
+ */
8
+ export interface BuildInputs {
9
+ files: Set<string>;
10
+ failed: boolean;
11
+ }
12
+
13
+ export const createBuildInputs = (): BuildInputs => ({ files: new Set(), failed: false });
14
+
15
+ /**
16
+ * Records every file that a Vite build loaded, Sass partials included. The Vite CSS plugin registers
17
+ * partials with `addWatchFile`, and Rollup keeps this list in every build, not only in watch mode.
18
+ */
19
+ const collectBuildInputs = (inputs: BuildInputs): Plugin => ({
20
+ name: 'handoff:collect-build-inputs',
21
+ buildEnd() {
22
+ for (const file of this.getWatchFiles()) inputs.files.add(file);
23
+ },
24
+ });
25
+
26
+ /** Call after the consumer hooks, so that a hook that replaces `plugins` cannot drop the collector. */
27
+ export const withBuildInputs = (config: InlineConfig, inputs?: BuildInputs): InlineConfig =>
28
+ inputs ? { ...config, plugins: [...(config.plugins ?? []), collectBuildInputs(inputs)] } : config;
@@ -4,6 +4,7 @@ export { computeDirectoryState, computeFileState, directoryStatesMatch, statesMa
4
4
  // Build cache utilities
5
5
  export {
6
6
  checkOutputExists,
7
+ computeBuildInputStates,
7
8
  computeComponentFileStates,
8
9
  computeGlobalDepsState,
9
10
  createEmptyCache,
@@ -18,3 +19,6 @@ export {
18
19
  type ComponentCacheEntry,
19
20
  type GlobalDepsState,
20
21
  } from './build-cache';
22
+
23
+ // Build input collection
24
+ export { createBuildInputs, type BuildInputs } from './build-inputs';
package/src/cli/create.ts CHANGED
@@ -125,8 +125,7 @@ const renderTemplate = (content: string, data: CreateOptions): string => {
125
125
  };
126
126
 
127
127
  const getDependenciesToInstall = (options: CreateOptions): { dependencies: string[]; devDependencies: string[] } => {
128
- // TODO: switch back to `handoff-app@latest` before the 2.0 stable release.
129
- const dependencies = ['handoff-app@next'];
128
+ const dependencies = ['handoff-app@latest'];
130
129
  const devDependencies: string[] = [];
131
130
 
132
131
  if (options.includeExamples) {
@@ -17,7 +17,7 @@ export interface BuildArgs extends SharedArgs {
17
17
  * `handoff-app build [--target static|registry] [--package standalone|vercel]` — builds the
18
18
  * documentation site for the resolved target and packages it for the resolved deliverable.
19
19
  * `--target` selects *what* is built; the optional, additive `--package` selects *how* it is
20
- * packaged. Bare `build` resolves to the static target with the `out/<projectId>` export;
20
+ * packaged. Bare `build` resolves to the static target with the `out/static` export;
21
21
  * `--package` never implies a `--target`.
22
22
  */
23
23
  const command: CommandModule<{}, BuildArgs> = {
@@ -8,24 +8,22 @@ Handles loading, validation, and resolution of handoff configuration.
8
8
  |------|---------|
9
9
  | `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
10
10
  | `from-env.ts` | JSON-compatible environment markers and public types |
11
- | `resolve-env.ts` | Resolves merged environment references and guards reference-only properties |
11
+ | `resolve-env.ts` | Resolves merged environment references and guards reference-only properties, whose paths accept a `*` segment to cover list elements |
12
12
  | `errors.ts` | Shared `HandoffConfigError` |
13
13
  | `env.ts` | Reads `.env` at load, then `loadProfileEnv()` applies `.env.<profile>` on top |
14
14
  | `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order) plus the selected profile sidecar, warns on conflicts, and merges every layer |
15
15
  | `runtime.ts` | `initRuntimeConfig()` — one pass over every registered catalog directory: loads each declaration, normalizes it into a component or pattern record, skips a declaration with a taken id, resolves SCSS/JS paths and transformer options |
16
16
  | `catalog-include.ts` | `isIncluded()` / `addToCatalog()` — registers a directory under `catalog.include` |
17
- | `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
18
17
  | `helpers.ts` | `defineConfig()` — typed authoring; maps camelCase keys to the runtime keys |
19
18
  | `index.ts` | Barrel re-exports |
20
19
 
21
20
  ## Usage
22
21
 
23
22
  ```ts
24
- import { initConfig, initRuntimeConfig, validateConfig } from './config';
23
+ import { initConfig, initRuntimeConfig } from './config';
25
24
 
26
25
  const config = initConfig(overrides);
27
26
  const [runtimeConfig, configPaths] = initRuntimeConfig({ config, workingPath });
28
- const validated = validateConfig(config);
29
27
  ```
30
28
 
31
29
  ## Main Config Precedence
@@ -70,7 +68,7 @@ property path, variable name, and active profile. Invalid names and rejected lit
70
68
  `EnvValue<T>` accepts a literal or reference. `EnvSecret<T>` accepts only a reference and resolves
71
69
  at config load. Secret defaults can only be empty or null, for optional credentials.
72
70
  `RuntimeEnvReference<T>` accepts only a reference with no default. Its name survives config loading and
73
- is read in the deployed process. Database URLs and asset-storage tokens are deferred.
71
+ is read in the deployed process. Database URLs, asset-storage tokens, email secrets, and AI keys are deferred.
74
72
 
75
73
  The loader resolves references once after all layers merge, before other config guards.
76
74
  References replace whole values, including earlier references and their defaults. A profile can
@@ -83,10 +81,13 @@ numeric and boolean properties also retain their default's conversion when a ref
83
81
  fallback. Booleans are true only for `true`. Numbers must be finite. A lower-layer fallback never
84
82
  becomes the fallback for a replacement reference.
85
83
 
86
- `runtime.registry.assetStorage.options` is for non-secret values only. Its resolved contents are
87
- JSON-encoded into the bundle, so the loader rejects an environment reference anywhere inside it
88
- (`BAKED_PATHS`). For custom adapter secrets, put a variable name in options and read it through the
89
- adapter factory's `env` argument at runtime. A per-environment literal belongs in a profile.
84
+ `runtime.registry.email.options`, `runtime.registry.assetStorage.options`, and
85
+ `runtime.ai.connections.*.options` are deferred prefixes (`DEFERRED_PREFIXES`). A reference anywhere
86
+ inside them keeps its name, and the deployed app resolves it with `resolveEnvReferences()` before it
87
+ calls the module factory. Literals are baked. `email.options.apiKey` and `email.options.password` are
88
+ also `DEFERRED_PATHS`, so a literal secret is rejected for every email provider. Before the merge, a
89
+ layer that changes `email.provider` drops `options` and `module` from the lower layers, so the
90
+ settings of two providers never mix.
90
91
 
91
92
  `ResolvedConfig` narrows eager properties by hand. Adding an eager property requires its `Config`
92
93
  member and resolved narrowing. A secret also needs a `SECRET_PATHS` entry for untyped configs.
@@ -15,8 +15,12 @@ export const isMcpEnabled = (config: Pick<Config, 'runtime'> | Pick<ResolvedConf
15
15
  config?.runtime?.mcp !== false;
16
16
 
17
17
  export const defaultConfig = (): Config => ({
18
- dev_access_token: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
19
- figma_project_id: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
18
+ integrations: {
19
+ figma: {
20
+ projectId: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
21
+ accessToken: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
22
+ },
23
+ },
20
24
  runtime: {
21
25
  mode: 'workspace',
22
26
  registryConnection: {
@@ -24,8 +28,7 @@ export const defaultConfig = (): Config => ({
24
28
  accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN', { default: '' }),
25
29
  },
26
30
  registry: {
27
- databaseUrl: fromEnv('DATABASE_URL'),
28
- assetStorage: { token: fromEnv('BLOB_READ_WRITE_TOKEN') },
31
+ database: { url: fromEnv('DATABASE_URL') },
29
32
  },
30
33
  },
31
34
  exportsOutputDirectory: fromEnv('HANDOFF_OUTPUT_DIR', { default: 'exported' }),
@@ -24,3 +24,36 @@ export const fromEnv = <T = string>(name: string, options?: { default: T }): Env
24
24
 
25
25
  export const isEnvReference = (value: unknown): value is EnvReference<unknown> =>
26
26
  value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, '$env');
27
+
28
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
29
+ value !== null && typeof value === 'object' && !Array.isArray(value);
30
+
31
+ /** Replace each reference nested in `options` with its value from `env`. An unset or empty variable becomes `undefined`. */
32
+ export const resolveEnvReferences = (options: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown> => {
33
+ const resolve = (value: unknown): unknown => {
34
+ if (isEnvReference(value)) {
35
+ const resolved = typeof value.$env === 'string' ? env[value.$env]?.trim() : undefined;
36
+ return resolved || undefined;
37
+ }
38
+ if (Array.isArray(value)) return value.map(resolve);
39
+ if (isPlainRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolve(entry)]));
40
+ return value;
41
+ };
42
+ return resolve(options) as Record<string, unknown>;
43
+ };
44
+
45
+ /** The variable names that the references nested in `options` use, for deployment instructions. */
46
+ export const envReferenceNames = (options: Record<string, unknown>): string[] => {
47
+ const names = new Set<string>();
48
+ const collect = (value: unknown): void => {
49
+ if (isEnvReference(value)) {
50
+ if (typeof value.$env === 'string') names.add(value.$env);
51
+ } else if (Array.isArray(value)) {
52
+ value.forEach(collect);
53
+ } else if (isPlainRecord(value)) {
54
+ Object.values(value).forEach(collect);
55
+ }
56
+ };
57
+ collect(options);
58
+ return [...names];
59
+ };
@@ -25,12 +25,10 @@ const normalizeAppConfig = (app?: Config['app']): Config['app'] => {
25
25
  };
26
26
 
27
27
  export const normalizeConfig = (config: Config): Config => {
28
- const { devAccessToken, figmaProjectId, assetsZipLinks, app, ...rest } = config;
28
+ const { assetsZipLinks, app, ...rest } = config;
29
29
 
30
30
  return {
31
31
  ...(rest as Config),
32
- dev_access_token: devAccessToken !== undefined ? devAccessToken : config.dev_access_token,
33
- figma_project_id: figmaProjectId !== undefined ? figmaProjectId : config.figma_project_id,
34
32
  assets_zip_links: assetsZipLinks ?? config.assets_zip_links,
35
33
  app: normalizeAppConfig(app),
36
34
  };
@@ -15,9 +15,6 @@ export { defineConfig } from './helpers';
15
15
  // Runtime config resolution
16
16
  export { initRuntimeConfig } from './runtime';
17
17
 
18
- // Config validation
19
- export { validateConfig } from './validator';
20
-
21
18
  // Registering catalog item directories under `catalog.include`
22
19
  export { addToCatalog, isIncluded } from './catalog-include';
23
20
  export type { AddToCatalogResult } from './catalog-include';
@@ -2,9 +2,10 @@ import esbuild from 'esbuild';
2
2
  import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
3
3
  import fs from 'fs-extra';
4
4
  import mergeWith from 'lodash/mergeWith';
5
+ import omit from 'lodash/omit';
5
6
  import { createRequire } from 'module';
6
7
  import path from 'path';
7
- import { Config, ResolvedConfig } from '../types/config';
8
+ import { Config, HandoffEmailConfig, ResolvedConfig } from '../types/config';
8
9
  import { Logger } from '../utils/logger';
9
10
  import { resolveWorkingPath } from '../utils/path';
10
11
  import { defaultConfig } from './defaults';
@@ -65,7 +66,7 @@ const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string):
65
66
  write: false,
66
67
  platform: 'node',
67
68
  format: 'cjs',
68
- target: 'node16',
69
+ target: 'node22',
69
70
  logLevel: 'silent',
70
71
  external: ['handoff-app'],
71
72
  loader: STYLE_IMPORT_LOADERS,
@@ -233,6 +234,27 @@ const replaceValues = (destination: unknown, source: unknown) => {
233
234
  return Array.isArray(source) || isEnvReference(source) || isEnvReference(destination) ? source : undefined;
234
235
  };
235
236
 
237
+ /**
238
+ * A layer that changes `runtime.registry.email.provider` starts from fresh `options` and `module`, so
239
+ * the deep merge never mixes the settings of two providers.
240
+ */
241
+ const resetEmailOnProviderChange = (layers: Partial<Config>[]): Partial<Config>[] => {
242
+ let provider: string | undefined;
243
+ let start = 0;
244
+ layers.forEach((layer, index) => {
245
+ const next = layer.runtime?.registry?.email?.provider;
246
+ if (next !== undefined && next !== provider) {
247
+ provider = next;
248
+ start = index;
249
+ }
250
+ });
251
+ return layers.map((layer, index) => {
252
+ const email = layer.runtime?.registry?.email;
253
+ if (index >= start || !email) return layer;
254
+ return { ...layer, runtime: { ...layer.runtime, registry: { ...layer.runtime!.registry, email: omit(email, ['options', 'module']) as HandoffEmailConfig } } };
255
+ });
256
+ };
257
+
236
258
  /**
237
259
  * Loads the handoff configuration for the given working directory and returns metadata.
238
260
  */
@@ -264,7 +286,7 @@ export const initConfigWithMetadata = (configOverride?: Partial<Config>, context
264
286
  Logger.debug(`Config profile: ${profileLabel}`);
265
287
  Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
266
288
 
267
- const merged = mergeWith({}, ...layers, replaceValues) as Config;
289
+ const merged = mergeWith({}, ...resetEmailOnProviderChange(layers), replaceValues) as Config;
268
290
  const config = resolveConfigEnv(merged, profileSelection?.name, layers[0]);
269
291
 
270
292
  // Guards run on the resolved config so a profile cannot slip a removed setting past them.
@@ -4,11 +4,32 @@ import { Logger } from '../utils/logger';
4
4
  import { isEnvReference } from './from-env';
5
5
  import { HandoffConfigError } from './errors';
6
6
 
7
- export const DEFERRED_PATHS = new Set(['runtime.registry.databaseUrl', 'runtime.registry.assetStorage.token']);
8
- const SECRET_PATHS = new Set(['dev_access_token', 'devAccessToken', 'runtime.registryConnection.accessToken']);
9
- /** Path prefixes whose resolved contents are JSON-baked into the bundle, so a reference would bake its value. */
10
- const BAKED_PATHS = ['runtime.registry.assetStorage.options'];
11
- const isBaked = (path: string): boolean => BAKED_PATHS.some((baked) => path === baked || path.startsWith(`${baked}.`));
7
+ /**
8
+ * Match a dot path against a pattern. A `*` segment matches exactly one segment, so one pattern
9
+ * covers every element of a list. With `prefix`, the pattern also matches anything nested under it.
10
+ */
11
+ const pathMatches = (pattern: string, path: string, prefix = false): boolean => {
12
+ const expected = pattern.split('.');
13
+ const actual = path.split('.');
14
+ if (prefix ? expected.length > actual.length : expected.length !== actual.length) return false;
15
+ return expected.every((segment, index) => segment === '*' || segment === actual[index]);
16
+ };
17
+
18
+ /** Paths whose value the deployed app reads at request time, so only the variable name is stored. */
19
+ export const DEFERRED_PATHS = [
20
+ 'runtime.registry.database.url',
21
+ 'runtime.registry.email.options.apiKey',
22
+ 'runtime.registry.email.options.password',
23
+ 'runtime.ai.connections.*.apiKey',
24
+ ];
25
+ /**
26
+ * Path prefixes whose nested references the deployed app reads at request time. Literals below them
27
+ * are baked. `DEFERRED_PATHS` entries below them still reject literals.
28
+ */
29
+ const DEFERRED_PREFIXES = ['runtime.registry.email.options', 'runtime.registry.assetStorage.options', 'runtime.ai.connections.*.options'];
30
+ const SECRET_PATHS = new Set(['integrations.figma.accessToken', 'runtime.registryConnection.accessToken']);
31
+ const isDeferred = (path: string): boolean => DEFERRED_PATHS.some((deferred) => pathMatches(deferred, path));
32
+ const isUnderDeferredPrefix = (path: string): boolean => DEFERRED_PREFIXES.some((prefix) => pathMatches(`${prefix}.*`, path, true));
12
33
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
34
  const ALIASES: Record<string, string> = {
14
35
  HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
@@ -40,20 +61,12 @@ export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Co
40
61
  throw new HandoffConfigError(`Config "${path}" (profile "${profile ?? 'default'}"): ${message}`);
41
62
  };
42
63
  const walk = (value: unknown, path: string, seed?: unknown): unknown => {
43
- const deferred = DEFERRED_PATHS.has(path);
64
+ const deferred = isDeferred(path);
44
65
  const secret = SECRET_PATHS.has(path);
45
66
  if ((secret || deferred) && value !== undefined && !isEnvReference(value)) {
46
67
  fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
47
68
  }
48
69
  if (isEnvReference(value)) {
49
- // Checked before the name, so the message names the supported pattern instead of the reference shape.
50
- if (isBaked(path)) {
51
- fail(
52
- path,
53
- 'Environment references are not allowed here because the resolved value is baked into the build. ' +
54
- "Put the variable name in options and read it through the adapter factory's `env` argument."
55
- );
56
- }
57
70
  if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
58
71
  fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
59
72
  }
@@ -62,7 +75,7 @@ export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Co
62
75
  if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
63
76
  fail(path, `Environment variable "${name}" has an invalid reference shape.`);
64
77
  }
65
- if (deferred) {
78
+ if (deferred || isUnderDeferredPrefix(path)) {
66
79
  if ('default' in value) fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
67
80
  return { $env: name };
68
81
  }
@@ -72,7 +72,7 @@ const evaluateDeclaration = (filePath: string, handoffModulePath: string): any =
72
72
  write: false,
73
73
  platform: 'node',
74
74
  format: 'cjs',
75
- target: 'node16',
75
+ target: 'node22',
76
76
  logLevel: 'silent',
77
77
  jsx: 'automatic',
78
78
  external: ['react', 'react-dom', 'handoff-app'],