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
@@ -0,0 +1,334 @@
1
+ import type { TokenArtifactResource } from '@handoff/store';
2
+ import type { DocsBackend, TokenSetDetail } from '../docs-api/backend';
3
+ import { fail, ok, read } from './result';
4
+ import { defineTool, type McpTool } from './tool';
5
+
6
+ /** The token tool, and the reshaping that turns stored token sets into variables an agent can emit. */
7
+
8
+ type TokenFormat = 'css' | 'scss' | 'styleDictionary' | 'types';
9
+
10
+ export const tokenTools: McpTool[] = [
11
+ defineTool<{ set?: string; kind?: 'foundation' | 'component'; format?: TokenFormat }>(
12
+ {
13
+ name: 'handoff_get_tokens',
14
+ title: 'Get design tokens',
15
+ description:
16
+ 'Design tokens. With no arguments: the list of token sets, plus all foundation tokens (colors, ' +
17
+ 'typography, effects). With `set`: one set. With `set` and `format`: the generated file for ' +
18
+ 'that set. A foundation token has `css` and `value`, or `cssPrefix` and `properties` (each ' +
19
+ 'variable is `{cssPrefix}-{property}`). A component set has `variants`: the axis values of each ' +
20
+ 'variant and the variables it sets. Use the CSS variable, not the literal value, unless no ' +
21
+ 'stylesheet is available.',
22
+ annotations: { readOnlyHint: true, openWorldHint: false },
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ set: { type: 'string', description: 'Token set id from the list, for example "foundation/colors".' },
27
+ kind: {
28
+ type: 'string',
29
+ enum: ['foundation', 'component'],
30
+ description: 'Without `set`: list only sets of this kind. `component` leaves out the foundation tokens.',
31
+ },
32
+ format: {
33
+ type: 'string',
34
+ enum: ['css', 'scss', 'styleDictionary', 'types'] satisfies TokenFormat[],
35
+ description: 'With `set`: return the generated file in this format.',
36
+ },
37
+ },
38
+ },
39
+ },
40
+ async ({ set, kind, format }) =>
41
+ read(set ? `token set "${set}"` : 'the token catalog', async (backend) => {
42
+ if (!set) {
43
+ if (format) {
44
+ return fail('`format` needs a `set`. Call handoff_get_tokens with no arguments to list the available sets.');
45
+ }
46
+ return ok(await listTokens(backend, kind));
47
+ }
48
+
49
+ const detail = await backend.getTokenSetDetail(set);
50
+ if (!detail) {
51
+ return fail(`Token set "${set}" was not found. Call handoff_get_tokens with no arguments to list the available sets.`);
52
+ }
53
+ if (!format) {
54
+ return ok({ id: detail.id, kind: detail.kind, formats: availableFormats(detail.artifacts), ...tokenPayload(detail) });
55
+ }
56
+
57
+ const artifact = detail.artifacts.find((candidate) => candidate.format === format);
58
+ if (!artifact) {
59
+ const formats = availableFormats(detail.artifacts);
60
+ return fail(
61
+ `Token set "${set}" has no "${format}" output. ${formats.length ? `Available formats: ${formats.join(', ')}.` : 'It has no generated output.'}`
62
+ );
63
+ }
64
+ return ok({ id: detail.id, kind: detail.kind, format, path: artifact.path, content: artifact.content });
65
+ })
66
+ ),
67
+ ];
68
+
69
+ /**
70
+ * A token set's payload, under a key naming which of the four forms it is, so the agent never has to
71
+ * guess: `tokens` for a foundation, `variants` for a component, or one of the two fallbacks.
72
+ *
73
+ * When neither reshape is possible we degrade rather than fail. The generated `stylesheet` still
74
+ * lets an agent write correct code, and the raw `record` is the last resort, which is what a
75
+ * workspace with unbuilt tokens gets.
76
+ */
77
+ const tokenPayload = (detail: TokenSetDetail): Record<string, unknown> => {
78
+ const reshaped =
79
+ detail.kind === 'foundation'
80
+ ? synthesizeFoundationTokens(detail.record, detail.artifacts)
81
+ : synthesizeComponentTokens(detail.artifacts);
82
+ if (reshaped) {
83
+ return detail.kind === 'foundation' ? { tokens: reshaped } : { variants: reshaped };
84
+ }
85
+ const stylesheet = detail.artifacts.find((a) => a.format === 'css') ?? detail.artifacts.find((a) => a.format === 'scss');
86
+ if (stylesheet) {
87
+ return { format: stylesheet.format, stylesheet: stylesheet.content };
88
+ }
89
+ return { record: stripFigmaIds(detail.record) };
90
+ };
91
+
92
+ /**
93
+ * The token catalog. Foundation tokens are inlined because they are what an agent needs before it
94
+ * writes anything. Component sets are listed by id and fetched one at a time, since inlining them
95
+ * all would be most of the design system in one response.
96
+ */
97
+ const listTokens = async (backend: DocsBackend, kind?: 'foundation' | 'component') => {
98
+ const sets = (await backend.listTokenSets()).filter((candidate) => !kind || candidate.kind === kind);
99
+ if (kind === 'component') {
100
+ return { sets };
101
+ }
102
+
103
+ const foundations: Record<string, unknown> = {};
104
+ for (const candidate of sets.filter((entry) => entry.kind === 'foundation')) {
105
+ const detail = await backend.getTokenSetDetail(candidate.id);
106
+ if (detail) {
107
+ foundations[candidate.id] = tokenPayload(detail);
108
+ }
109
+ }
110
+ return { sets, foundations };
111
+ };
112
+
113
+ /**
114
+ * Drop the Figma node id from every token record.
115
+ *
116
+ * Colors, typography, effects and component instances each carry an `id` that is a Figma node
117
+ * reference, which an agent cannot use. Everything else (`sass`, `reference`, `machineName`,
118
+ * `values`, `parts`, `variantProperties`) survives, so nothing that affects generated code is lost.
119
+ */
120
+ export const stripFigmaIds = (record: unknown): unknown => {
121
+ if (Array.isArray(record)) return record.map(stripFigmaIds);
122
+ if (record && typeof record === 'object') {
123
+ const { id: _id, ...rest } = record as Record<string, unknown>;
124
+ return Object.fromEntries(Object.entries(rest).map(([key, value]) => [key, stripFigmaIds(value)]));
125
+ }
126
+ return record;
127
+ };
128
+
129
+ /** The generated formats available for a token set, deduped and in artifact order. */
130
+ export const availableFormats = (artifacts: TokenArtifactResource[]): string[] =>
131
+ Array.from(new Set(artifacts.map((artifact) => artifact.format)));
132
+
133
+ /**
134
+ * Foundation tokens, reshaped into what an agent needs to write code.
135
+ *
136
+ * Neither the stored record nor the generated stylesheet is usable on its own. The record says a type
137
+ * style is called `Heading 1` but nothing in it yields the variable to emit: its `reference` is
138
+ * `typography--heading-1` while the real variables are `--typography-heading-1-font-size` and
139
+ * friends, and it carries raw Figma fields (`textAutoResize`, three competing `lineHeight*`
140
+ * encodings) that never become CSS. The stylesheet has the right names and resolved values but has
141
+ * flattened away the human name and the grouping.
142
+ *
143
+ * So the two are joined. Names and values come from the generated CSS, token identity from the
144
+ * generated `types` list, and `name`/`group` from the record. Nothing is reconstructed from a naming
145
+ * rule of our own, since the rule differs per set and typography would come out wrong.
146
+ *
147
+ * Foundations only. Component sets are multi-axis (part x state x theme x property) and their
148
+ * `types` artifact lists axes rather than token names, so there is no honest join to make.
149
+ */
150
+
151
+ /** One foundation token: a single value, or a bundle of properties under a shared variable prefix. */
152
+ export interface FoundationToken {
153
+ /** Human-facing name from the record, when it could be matched unambiguously. */
154
+ name?: string;
155
+ /** Record group (e.g. `primary`, `shadow`), when set. */
156
+ group?: string;
157
+ /** The CSS custom property, for a single-value token. */
158
+ css?: string;
159
+ /** Resolved value, for a single-value token. */
160
+ value?: string;
161
+ /** Shared variable prefix, for a bundled token: each property is `{cssPrefix}-{property}`. */
162
+ cssPrefix?: string;
163
+ /** Resolved values keyed by CSS property, for a bundled token. */
164
+ properties?: Record<string, string>;
165
+ }
166
+
167
+ /** One `--name: value;` line of a generated stylesheet. */
168
+ const CSS_DECLARATION = /^\s*--([\w-]+)\s*:\s*(.+?);\s*$/;
169
+
170
+ /** Parse `--name: value;` declarations out of a generated stylesheet. */
171
+ export const cssVariables = (content: string): Record<string, string> => {
172
+ const variables: Record<string, string> = {};
173
+ for (const line of content.split('\n')) {
174
+ const match = CSS_DECLARATION.exec(line);
175
+ if (match) {
176
+ variables[match[1]] = match[2].trim();
177
+ }
178
+ }
179
+ return variables;
180
+ };
181
+
182
+ /**
183
+ * Token names from a generated `types` artifact, read out of its quoted string list (`$color-names`,
184
+ * `$type-sizes`, `$effects`). Without it there is no telling whether `--color-primary-blue-darker`
185
+ * is a `darker` property of `primary-blue` or a token in its own right. It is the latter, and only
186
+ * this list says so.
187
+ */
188
+ export const tokenNamesFromTypes = (content: string): string[] =>
189
+ // `[^"]*` not `[^"]+`: the list can carry an empty entry (`$color-groups` ends with `""`), and a
190
+ // pattern that cannot match it pairs that entry's closing quote with the next entry's opening one,
191
+ // knocking every later name out of alignment. Empty names are dropped after matching, not during.
192
+ Array.from(new Set(Array.from(content.matchAll(/"([^"]*)"/g), (match) => match[1]))).filter(Boolean);
193
+
194
+ /**
195
+ * Assign a CSS variable to the longest token name that matches, returning the leftover as a property.
196
+ * Longest first is what keeps `primary-blue-darker` from being read as `primary-blue` + `darker`.
197
+ */
198
+ const claimVariable = (variable: string, namesByLength: string[]): { token: string; property?: string } | null => {
199
+ for (const token of namesByLength) {
200
+ if (variable === token || variable.endsWith(`-${token}`)) {
201
+ return { token };
202
+ }
203
+ const at = variable.indexOf(`-${token}-`);
204
+ if (at >= 0) {
205
+ return { token, property: variable.slice(at + token.length + 2) };
206
+ }
207
+ if (variable.startsWith(`${token}-`)) {
208
+ return { token, property: variable.slice(token.length + 1) };
209
+ }
210
+ }
211
+ return null;
212
+ };
213
+
214
+ /** A foundation record, as far as the join cares. */
215
+ type FoundationRecord = { name?: string; group?: string; reference?: string; machineName?: string; machine_name?: string };
216
+
217
+ /**
218
+ * The record describing one token. Matched on `reference`, which ends with the token name across all
219
+ * three foundation kinds. More than one candidate means it is ambiguous, so nothing is attached: a
220
+ * token without its label beats a token with the wrong one.
221
+ */
222
+ const recordFor = (token: string, records: FoundationRecord[]): FoundationRecord | null => {
223
+ const matches = records.filter(
224
+ (record) =>
225
+ record?.reference === token ||
226
+ record?.reference?.endsWith(`-${token}`) ||
227
+ record?.machineName === token ||
228
+ record?.machine_name === token
229
+ );
230
+ return matches.length === 1 ? matches[0] : null;
231
+ };
232
+
233
+ /**
234
+ * Reshape a foundation set. Returns `null` when the inputs are missing (no CSS or `types` artifact,
235
+ * or nothing parsed out of them) so the caller can fall back to the stylesheet or the record.
236
+ */
237
+ export const synthesizeFoundationTokens = (record: unknown, artifacts: TokenArtifactResource[]): Record<string, FoundationToken> | null => {
238
+ const css = artifacts.find((artifact) => artifact.format === 'css');
239
+ const types = artifacts.find((artifact) => artifact.format === 'types');
240
+ if (!css || !types) {
241
+ return null;
242
+ }
243
+
244
+ const variables = cssVariables(css.content);
245
+ const namesByLength = tokenNamesFromTypes(types.content).sort((a, b) => b.length - a.length);
246
+ if (Object.keys(variables).length === 0 || namesByLength.length === 0) {
247
+ return null;
248
+ }
249
+
250
+ const records: FoundationRecord[] = Array.isArray(record) ? record : [];
251
+ const tokens: Record<string, FoundationToken> = {};
252
+
253
+ for (const [variable, value] of Object.entries(variables)) {
254
+ const claim = claimVariable(variable, namesByLength);
255
+ if (!claim) {
256
+ continue;
257
+ }
258
+ const entry = (tokens[claim.token] ??= {});
259
+ if (!entry.name) {
260
+ const matched = recordFor(claim.token, records);
261
+ if (matched?.name) entry.name = matched.name;
262
+ if (matched?.group) entry.group = matched.group;
263
+ }
264
+ if (claim.property) {
265
+ entry.cssPrefix = `--${variable.slice(0, variable.length - claim.property.length - 1)}`;
266
+ (entry.properties ??= {})[claim.property] = value;
267
+ } else {
268
+ entry.css = `--${variable}`;
269
+ entry.value = value;
270
+ }
271
+ }
272
+
273
+ return Object.keys(tokens).length > 0 ? tokens : null;
274
+ };
275
+
276
+ /** One variant of a component: the axis values it applies to, and the variables it sets. */
277
+ export interface ComponentTokenVariant {
278
+ /** Axis values, e.g. `{ state: 'disabled', theme: 'dark' }`. Empty for a component with no variants. */
279
+ variant: Record<string, string>;
280
+ /** Full CSS custom property names to resolved values. */
281
+ variables: Record<string, string>;
282
+ }
283
+
284
+ /**
285
+ * Component tokens, grouped by the variant they belong to.
286
+ *
287
+ * A component set is keyed by part x variant x property and the variable name runs them together
288
+ * (`--select-additional-disabled-dark-border-color`) with no boundary that can be split on safely.
289
+ * The generated stylesheet already labels each group, though: `getComponentCommentBlock` emits
290
+ * `/* Select, state: disabled, theme: dark *\/` ahead of every block, built from the instance's
291
+ * `variantProperties`. So the grouping is read off the generator's own output and variables are kept
292
+ * whole rather than split by a guessed rule.
293
+ *
294
+ * No axes are advertised and nothing is resolved per brand or scheme. The variants already exist in
295
+ * the build output; this only groups them.
296
+ */
297
+ export const synthesizeComponentTokens = (artifacts: TokenArtifactResource[]): ComponentTokenVariant[] | null => {
298
+ const css = artifacts.find((artifact) => artifact.format === 'css');
299
+ if (!css) {
300
+ return null;
301
+ }
302
+
303
+ const variants: ComponentTokenVariant[] = [];
304
+ let current: ComponentTokenVariant | null = null;
305
+
306
+ for (const line of css.content.split('\n')) {
307
+ const comment = /^\s*\/\*\s*(.+?)\s*\*\/\s*$/.exec(line);
308
+ if (comment) {
309
+ // The first comma-separated part is the component name; the rest are `key: value` axis pairs.
310
+ const variant: Record<string, string> = {};
311
+ for (const part of comment[1].split(',').slice(1)) {
312
+ const [key, ...rest] = part.split(':');
313
+ if (rest.length > 0) {
314
+ variant[key.trim().toLowerCase()] = rest.join(':').trim();
315
+ }
316
+ }
317
+ current = { variant, variables: {} };
318
+ variants.push(current);
319
+ continue;
320
+ }
321
+ const declaration = CSS_DECLARATION.exec(line);
322
+ if (declaration) {
323
+ // A component with no variants emits no header, so open an unlabelled group for it.
324
+ if (!current) {
325
+ current = { variant: {}, variables: {} };
326
+ variants.push(current);
327
+ }
328
+ current.variables[`--${declaration[1]}`] = declaration[2].trim();
329
+ }
330
+ }
331
+
332
+ const populated = variants.filter((entry) => Object.keys(entry.variables).length > 0);
333
+ return populated.length > 0 ? populated : null;
334
+ };
@@ -0,0 +1,19 @@
1
+ import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
2
+
3
+ /**
4
+ * One MCP tool: its listing, with a plain JSON Schema for its input, and its handler.
5
+ *
6
+ * Plain JSON Schema keeps Zod out of the app. The Next build type-checks this code against the
7
+ * consumer's `node_modules`, where a second Zod copy makes the SDK's Zod types incompatible with ours.
8
+ */
9
+ export interface McpTool {
10
+ definition: Tool;
11
+ /** Runs with arguments already validated against `definition.inputSchema`. */
12
+ run: (args: unknown) => Promise<CallToolResult>;
13
+ }
14
+
15
+ /** `Args` must match `definition.inputSchema`; the server validates against the schema before `run`. */
16
+ export const defineTool = <Args>(definition: Tool, run: (args: Args) => Promise<CallToolResult>): McpTool => ({
17
+ definition,
18
+ run: run as McpTool['run'],
19
+ });
@@ -1,12 +1,14 @@
1
1
  import crypto from 'crypto';
2
+ import type { Readable } from 'stream';
2
3
  import type { NextApiRequest, NextApiResponse } from 'next';
3
4
  import { eq, inArray } from 'drizzle-orm';
4
5
  import type { RegistryDatabase } from '@handoff/registry/db/client';
5
6
  import { assetBlobs, assetCollections, assets } from '@handoff/registry/db/schema';
6
7
  import { isAssetCollection, type AssetCollection } from '@handoff/registry/assets/sets';
7
8
  import type { AssetCollectionTransferPackage, AssetManifestEntry } from '@handoff/registry/assets/transfer';
9
+ import type { StorageProvider } from '@handoff/registry/asset-storage/types';
8
10
  import { singleQueryValue } from '../api/query';
9
- import { getActiveAssetStorage, getActiveAssetStorageAdapter, getAssetStorageAdapter } from '../asset-storage';
11
+ import { getActiveAssetStorage, getActiveStorageProvider, getStorageProvider } from '../asset-storage';
10
12
  import { sendRegistryError } from './errors';
11
13
  import { buildMeta } from './meta';
12
14
  import { handleRegistryRoute, sendRegistryData } from './handler';
@@ -36,6 +38,10 @@ import {
36
38
  * - `PUT /api/registry/transfer/assets/blobs/:hash` stores one content-addressed blob (binary body)
37
39
  * through the active storage provider (inline `bytea` by default). Idempotent by hash.
38
40
  * - `GET /api/registry/transfer/assets/blobs/:hash` returns/redirects to one blob's bytes.
41
+ * - `POST /api/registry/transfer/assets/blobs/:hash/upload` returns a signed URL for a direct upload
42
+ * to storage, or `null` when the active provider cannot take one (the client then uses `PUT`).
43
+ * - `POST /api/registry/transfer/assets/blobs/:hash/complete` records a directly uploaded blob after
44
+ * it confirms the stored object exists.
39
45
  *
40
46
  * Runs behind {@link handleRegistryRoute}: runtime-mode, method, bearer-token (on mutations), and
41
47
  * database guards apply before any of this executes.
@@ -312,7 +318,7 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
312
318
  'application/octet-stream';
313
319
  const active = getActiveAssetStorage();
314
320
 
315
- let storageProvider = active.provider;
321
+ const storageProvider = active.provider;
316
322
  let content: Buffer | null = null;
317
323
  let storageRef: string | null = null;
318
324
  if (active.provider === 'database') {
@@ -326,13 +332,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
326
332
  }
327
333
  content = bytes;
328
334
  } else {
329
- const adapter = await getActiveAssetStorageAdapter();
330
- if (!adapter) {
331
- sendRegistryError(res, 'unexpected_error', `No storage adapter is available for provider "${active.provider}".`);
335
+ const storage = await getActiveStorageProvider();
336
+ if (!storage) {
337
+ sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${active.provider}".`);
332
338
  return;
333
339
  }
334
- ({ storageRef } = await adapter.put({ hash, bytes, contentType, size: bytes.length }));
335
- storageProvider = active.provider;
340
+ ({ storageRef } = await storage.put({ hash, bytes, contentType, size: bytes.length }));
336
341
  }
337
342
 
338
343
  await db
@@ -371,12 +376,12 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
371
376
  sendRegistryError(res, 'not_found', `Blob "${hash}" has no resolvable content.`);
372
377
  return;
373
378
  }
374
- const adapter = await getAssetStorageAdapter(blob.storageProvider);
375
- if (!adapter) {
376
- sendRegistryError(res, 'unexpected_error', `No storage adapter is available for provider "${blob.storageProvider}".`);
379
+ const storage = await getStorageProvider(blob.storageProvider);
380
+ if (!storage) {
381
+ sendRegistryError(res, 'unexpected_error', `No asset storage is available for provider "${blob.storageProvider}".`);
377
382
  return;
378
383
  }
379
- const result = await adapter.get(blob.storageRef);
384
+ const result = await storage.get(blob.storageRef);
380
385
  if (result.kind === 'redirect') {
381
386
  res.redirect(302, result.url);
382
387
  return;
@@ -388,3 +393,106 @@ export const handleAssetBlobRoute = (req: NextApiRequest, res: NextApiResponse):
388
393
  }
389
394
  result.stream.pipe(res);
390
395
  });
396
+
397
+ /** The active provider when it supports direct uploads, otherwise `null` (the client then uses `PUT`). */
398
+ const getDirectUploadProvider = async (): Promise<StorageProvider | null> => {
399
+ if (getActiveAssetStorage().provider === 'database') {
400
+ return null;
401
+ }
402
+ const storage = await getActiveStorageProvider();
403
+ return storage?.createUpload ? storage : null;
404
+ };
405
+
406
+ const readDirectUploadRequest = (req: NextApiRequest, res: NextApiResponse): { hash: string; size: number; contentType: string } | null => {
407
+ const hash = (singleQueryValue(req.query.hash) ?? '').toLowerCase();
408
+ if (!isSha256Hash(hash)) {
409
+ sendRegistryError(res, 'bad_request', 'Blob hash must be a SHA-256 hex string.');
410
+ return null;
411
+ }
412
+ const body = isPlainObject(req.body) ? req.body : {};
413
+ const size = body.size;
414
+ if (typeof size !== 'number' || !Number.isInteger(size) || size < 0) {
415
+ sendRegistryError(res, 'bad_request', '`size` must be a non-negative integer.', { rejectedFields: ['size'] });
416
+ return null;
417
+ }
418
+ return { hash, size, contentType: asString(body.contentType) || 'application/octet-stream' };
419
+ };
420
+
421
+ /**
422
+ * Throw unless `storageRef` resolves to a stored object. A redirect is checked with a one-byte
423
+ * ranged `GET`, because a signed read URL usually rejects `HEAD`.
424
+ */
425
+ const assertStored = async (storage: StorageProvider, storageRef: string, size: number): Promise<void> => {
426
+ const result = await storage.get(storageRef);
427
+ if (result.kind === 'bytes') {
428
+ if (result.bytes.length !== size) {
429
+ throw new Error(`stored size ${result.bytes.length} does not match ${size}`);
430
+ }
431
+ return;
432
+ }
433
+ if (result.kind === 'stream') {
434
+ (result.stream as Readable).destroy?.();
435
+ return;
436
+ }
437
+ const response = await fetch(result.url, { headers: { Range: 'bytes=0-0' } });
438
+ await response.body?.cancel();
439
+ if (!response.ok) {
440
+ throw new Error(`storage answered ${response.status}`);
441
+ }
442
+ };
443
+
444
+ /** `POST /api/registry/transfer/assets/blobs/:hash/upload`: a signed URL for a direct upload to storage. */
445
+ export const handleAssetBlobUploadRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
446
+ handleRegistryRoute(req, res, ['POST'], async ({ db }) => {
447
+ const input = readDirectUploadRequest(req, res);
448
+ if (!input) return;
449
+
450
+ const existing = await db.select({ hash: assetBlobs.hash }).from(assetBlobs).where(eq(assetBlobs.hash, input.hash)).limit(1);
451
+ if (existing.length > 0) {
452
+ sendRegistryData(res, 200, { upload: null, stored: true }, buildMeta());
453
+ return;
454
+ }
455
+ const storage = await getDirectUploadProvider();
456
+ const upload = storage?.createUpload ? await storage.createUpload(input) : null;
457
+ sendRegistryData(res, 200, { upload }, buildMeta());
458
+ });
459
+
460
+ /** `POST /api/registry/transfer/assets/blobs/:hash/complete`: record a directly uploaded blob once it is stored. */
461
+ export const handleAssetBlobCompleteRoute = (req: NextApiRequest, res: NextApiResponse): Promise<void> =>
462
+ handleRegistryRoute(req, res, ['POST'], async ({ db }) => {
463
+ const input = readDirectUploadRequest(req, res);
464
+ if (!input) return;
465
+ const storageRef = asString(isPlainObject(req.body) ? req.body.storageRef : undefined)?.trim();
466
+ if (!storageRef) {
467
+ sendRegistryError(res, 'bad_request', '`storageRef` is required.', { rejectedFields: ['storageRef'] });
468
+ return;
469
+ }
470
+
471
+ const storage = await getDirectUploadProvider();
472
+ if (!storage) {
473
+ sendRegistryError(res, 'bad_request', 'The active asset storage provider does not accept direct uploads.');
474
+ return;
475
+ }
476
+ try {
477
+ await assertStored(storage, storageRef, input.size);
478
+ } catch (error) {
479
+ const reason = error instanceof Error ? error.message : String(error);
480
+ sendRegistryError(res, 'bad_request', `Blob "${input.hash}" was not found in storage (${reason}).`, {
481
+ rejectedFields: ['storageRef'],
482
+ });
483
+ return;
484
+ }
485
+
486
+ await db
487
+ .insert(assetBlobs)
488
+ .values({
489
+ hash: input.hash,
490
+ storageProvider: getActiveAssetStorage().provider,
491
+ content: null,
492
+ storageRef,
493
+ contentType: input.contentType,
494
+ size: input.size,
495
+ } as any)
496
+ .onConflictDoNothing({ target: assetBlobs.hash });
497
+ sendRegistryData(res, 200, { hash: input.hash, stored: true }, buildMeta());
498
+ });
@@ -16,6 +16,7 @@ export type RegistryErrorCode =
16
16
  | 'method_not_allowed'
17
17
  | 'runtime_mode_conflict'
18
18
  | 'database_unavailable'
19
+ | 'storage_unavailable'
19
20
  | 'unexpected_error';
20
21
 
21
22
  const STATUS_BY_CODE: Record<RegistryErrorCode, number> = {
@@ -26,6 +27,7 @@ const STATUS_BY_CODE: Record<RegistryErrorCode, number> = {
26
27
  method_not_allowed: 405,
27
28
  runtime_mode_conflict: 409,
28
29
  database_unavailable: 503,
30
+ storage_unavailable: 503,
29
31
  unexpected_error: 500,
30
32
  };
31
33
 
@@ -1,6 +1,7 @@
1
1
  import type { NextApiRequest, NextApiResponse } from 'next';
2
2
  import type { RegistryDatabase } from '@handoff/registry/db/client';
3
3
  import type { RegistryPrincipal } from '@handoff/registry/auth';
4
+ import { AssetStorageError } from '../asset-storage';
4
5
  import { getServerRuntimeConfig } from '../docs-api/runtime-config';
5
6
  import { getRegistryConnection, RegistryConnectionError } from '../registry-connection';
6
7
  import { authorizeRegistryRequest, requiredScopeForMethod } from './auth';
@@ -15,7 +16,8 @@ import { buildMeta, type RegistryMeta } from './meta';
15
16
  * (`405 method_not_allowed`); database resolution (`503 database_unavailable`); and scoped credential
16
17
  * authorization on every request: reads require `registry:read`, mutations require `registry:write`
17
18
  * (`400 bad_request` / `401 unauthorized` / `403 forbidden`). The route body then runs against a live
18
- * connection, with any thrown failure mapped to `unexpected_error`.
19
+ * connection. A thrown asset storage configuration problem maps to `503 storage_unavailable` with
20
+ * its message, and any other thrown failure maps to `unexpected_error`.
19
21
  *
20
22
  * The database is resolved before authorization because that is the only guard needing a connection,
21
23
  * so a request that would fail auth against a registry with a down database reports
@@ -94,6 +96,10 @@ export const handleRegistryRoute = async (
94
96
  await body({ req, res, db, method, principal });
95
97
  } catch (error) {
96
98
  console.error('Registry API request failed.', error);
99
+ if (error instanceof AssetStorageError) {
100
+ sendRegistryError(res, 'storage_unavailable', error.message);
101
+ return;
102
+ }
97
103
  sendRegistryError(res, 'unexpected_error', 'Unexpected registry API error.');
98
104
  }
99
105
  };
@@ -10,7 +10,7 @@ import {
10
10
  patternFiles,
11
11
  patterns,
12
12
  } from '@handoff/registry/db/schema';
13
- import { isSafePathSegment, isSafeRelativePath } from '@handoff/registry/path';
13
+ import { isSafePathSegment, isSafeRelativePath, isWorkspaceRelativePath } from '@handoff/registry/path';
14
14
  import type { TransferArtifact, TransferBuild, TransferEntityKind, TransferFile, TransferPackage } from '@handoff/registry/transfer';
15
15
  import { and, eq, inArray } from 'drizzle-orm';
16
16
  import type { NextApiRequest, NextApiResponse } from 'next';
@@ -90,6 +90,14 @@ const validatePackage = (body: unknown, kind: TransferEntityKind, id: string): P
90
90
  return invalid(`Package item id "${itemId}" does not match the publish target "${id}".`, { rejectedFields: ['item.id'] });
91
91
  }
92
92
 
93
+ if (body.sourceDir !== undefined && (kind === 'page' || !isWorkspaceRelativePath(body.sourceDir))) {
94
+ return invalid(
95
+ kind === 'page' ? 'Page publishes cannot contain `sourceDir`.' : '`sourceDir` must be a normalized workspace-relative path.',
96
+ { rejectedFields: ['sourceDir'] }
97
+ );
98
+ }
99
+ const sourceDir = body.sourceDir as string | undefined;
100
+
93
101
  const rawFiles = body.files ?? [];
94
102
  if (!Array.isArray(rawFiles)) {
95
103
  return invalid('`files` must be an array.', { rejectedFields: ['files'] });
@@ -275,7 +283,7 @@ const validatePackage = (body: unknown, kind: TransferEntityKind, id: string): P
275
283
  return invalid(buildValidation.message, buildValidation.details);
276
284
  }
277
285
 
278
- return { ok: true, value: { item, files, artifacts, build: buildValidation.value } };
286
+ return { ok: true, value: { item, sourceDir, files, artifacts, build: buildValidation.value } };
279
287
  };
280
288
 
281
289
  /**
@@ -321,7 +329,8 @@ const upsertEntityRecord = async (
321
329
  db: RegistryDatabase,
322
330
  kind: TransferEntityKind,
323
331
  id: string,
324
- item: Record<string, unknown>
332
+ item: Record<string, unknown>,
333
+ sourceDir: string | undefined
325
334
  ): Promise<void> => {
326
335
  const spec = ENTITY[kind];
327
336
  const record = { ...item, id };
@@ -346,9 +355,15 @@ const upsertEntityRecord = async (
346
355
  // Drizzle omits an `undefined` column from the UPDATE, so only null clears a source format the item no longer has.
347
356
  sourceFormat: asString(item.sourceFormat) ?? null,
348
357
  categories: asStringArray(item.categories),
358
+ sourceDir: sourceDir ?? null,
349
359
  }
350
360
  : kind === 'pattern'
351
- ? { ...base, tags: asStringArray(item.tags), components: Array.isArray(item.components) ? item.components : [] }
361
+ ? {
362
+ ...base,
363
+ tags: asStringArray(item.tags),
364
+ components: Array.isArray(item.components) ? item.components : [],
365
+ sourceDir: sourceDir ?? null,
366
+ }
352
367
  : { ...base, weight: typeof item.weight === 'number' ? item.weight : null };
353
368
 
354
369
  if (existing[0]) {
@@ -473,6 +488,12 @@ export const handleEntitySummaryRoute = (req: NextApiRequest, res: NextApiRespon
473
488
  sendRegistryData(res, 200, { entities }, buildMeta());
474
489
  });
475
490
 
491
+ const readSourceDir = async (db: RegistryDatabase, kind: 'component' | 'pattern', id: string): Promise<string | undefined> => {
492
+ const table = ENTITY[kind].table;
493
+ const rows = await db.select({ sourceDir: table.sourceDir }).from(table).where(eq(table.id, id)).limit(1);
494
+ return rows[0]?.sourceDir ?? undefined;
495
+ };
496
+
476
497
  /**
477
498
  * Handle `GET /api/registry/transfer/{component|pattern}/:id` — checkout read. Returns the
478
499
  * normalized record plus its registry-safe source files so a connected workspace can reconstruct
@@ -504,8 +525,9 @@ export const handleCheckoutRoute = (req: NextApiRequest, res: NextApiResponse, k
504
525
  // Declarations are workspace-only and are never persisted as registry file records (their kind
505
526
  // is excluded from `RegistryTextFileKind`), so the stored files are already declaration-free.
506
527
  const files = await listEntityFiles(db, kind, id);
528
+ const sourceDir = kind === 'page' ? undefined : await readSourceDir(db, kind, id);
507
529
 
508
- sendRegistryData(res, 200, { kind, item: entity.data, files }, buildMeta(entity.build));
530
+ sendRegistryData(res, 200, { kind, item: entity.data, sourceDir, files }, buildMeta(entity.build));
509
531
  });
510
532
 
511
533
  /**
@@ -536,7 +558,7 @@ export const applyEntityPackage = async (
536
558
 
537
559
  await db.transaction(async (tx) => {
538
560
  const transactionalDb = tx as unknown as RegistryDatabase;
539
- await upsertEntityRecord(transactionalDb, kind, id, pkg.item);
561
+ await upsertEntityRecord(transactionalDb, kind, id, pkg.item, pkg.sourceDir);
540
562
  await replaceEntityFiles(transactionalDb, kind, id, pkg.files);
541
563
  await ingestArtifacts(transactionalDb, kind, id, pkg.artifacts);
542
564
  await upsertBuildMetadata(transactionalDb, kind, id, pkg.build);