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
package/src/index.ts CHANGED
@@ -12,10 +12,16 @@ import {
12
12
  initRuntimeConfig,
13
13
  loadProfileEnv,
14
14
  resolveProfileSelection,
15
- validateConfig,
16
15
  } from './config';
17
16
  import pipeline, { buildComponents, buildPatterns } from './pipeline';
18
- import { ALL_KIND_ORDER, ENTITY_WIRE_KIND, isRegistryEntityKind, REGISTRY_ENTITY_KINDS, type RegistryEntityKind } from './registry/content-kinds';
17
+ import { resolveFigmaConnection } from './pipeline/figma';
18
+ import {
19
+ ALL_KIND_ORDER,
20
+ ENTITY_WIRE_KIND,
21
+ isRegistryEntityKind,
22
+ REGISTRY_ENTITY_KINDS,
23
+ type RegistryEntityKind,
24
+ } from './registry/content-kinds';
19
25
  import type { TransferEntityKind } from './registry/transfer';
20
26
  import { createFilesystemStore, type HandoffStore } from './store';
21
27
  import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
@@ -231,7 +237,7 @@ class Handoff {
231
237
  this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
232
238
  this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
233
239
  [this.runtimeConfig, this._configFilePaths, this._configFileIndex] = initRuntimeConfig(this);
234
- if(this.config.app.base_path && !process.env.HANDOFF_APP_BASE_PATH) {
240
+ if (this.config.app.base_path && !process.env.HANDOFF_APP_BASE_PATH) {
235
241
  process.env.HANDOFF_APP_BASE_PATH = this.config.app.base_path ?? '';
236
242
  }
237
243
  return this;
@@ -242,13 +248,10 @@ class Handoff {
242
248
  return this;
243
249
  }
244
250
 
245
- preRunner(validate?: boolean): Handoff {
251
+ preRunner(): Handoff {
246
252
  if (!this.config) {
247
253
  throw Error('Handoff not initialized');
248
254
  }
249
- if (validate) {
250
- this.config = validateConfig(this.config);
251
- }
252
255
  return this;
253
256
  }
254
257
 
@@ -523,13 +526,8 @@ class Handoff {
523
526
  return this._handoffRunner;
524
527
  }
525
528
 
526
- const apiCredentials = {
527
- projectId: this.config.figma_project_id,
528
- accessToken: this.config.dev_access_token,
529
- };
530
-
531
- // Initialize the provider
532
- const provider = Providers.RestApiProvider(apiCredentials);
529
+ const { projectId, accessToken } = resolveFigmaConnection(this.config);
530
+ const provider = Providers.RestApiProvider({ projectId, accessToken });
533
531
 
534
532
  this._handoffRunner = HandoffRunner(
535
533
  provider,
@@ -557,15 +555,8 @@ class Handoff {
557
555
  return this._handoffRunner;
558
556
  }
559
557
 
560
- /**
561
- * Gets the project ID, falling back to filesystem-safe working path if figma_project_id is missing
562
- * @returns {string} The project ID to use for path construction
563
- */
558
+ /** Keys `.handoff/<projectId>`, so workspaces that share one installed package stage apart. */
564
559
  getProjectId(): string {
565
- if (this.config?.figma_project_id) {
566
- return this.config.figma_project_id;
567
- }
568
- // Fallback to filesystem-safe transformation of working path
569
560
  return generateFilesystemSafeId(this.workingPath);
570
561
  }
571
562
 
@@ -574,7 +565,7 @@ class Handoff {
574
565
  * @returns {string} The absolute path to the output directory
575
566
  */
576
567
  getOutputPath(): string {
577
- return path.resolve(this.workingPath, this.exportsDirectory, this.getProjectId());
568
+ return path.resolve(this.workingPath, this.exportsDirectory);
578
569
  }
579
570
 
580
571
  /**
@@ -642,6 +633,13 @@ class Handoff {
642
633
  return this._mainConfigFilePath;
643
634
  }
644
635
 
636
+ /**
637
+ * Gets the selected profile config file path if one exists.
638
+ */
639
+ getProfileConfigFilePath(): string | undefined {
640
+ return this._profileConfigFilePath;
641
+ }
642
+
645
643
  getProfile(): string | undefined {
646
644
  return this._profile;
647
645
  }
@@ -699,14 +697,20 @@ export type {
699
697
  SourceFormat,
700
698
  RendererKind,
701
699
  } from './catalog';
702
- export { defineAssetStorage } from './registry/asset-storage/define';
700
+ export { defineStorageProvider } from './registry/asset-storage/define';
703
701
  export type {
704
- AssetStorage,
705
- AssetStorageContext,
706
- AssetStorageFactory,
707
- AssetStorageInput,
708
- AssetStorageReadResult,
702
+ StorageProvider,
703
+ StorageProviderContext,
704
+ StorageProviderFactory,
705
+ StorageInput,
706
+ StorageReadResult,
707
+ AssetUpload,
708
+ AssetUploadInput,
709
709
  } from './registry/asset-storage/types';
710
+ export { defineEmailProvider } from './registry/email/define';
711
+ export type { EmailMessage, EmailProvider, EmailProviderContext, EmailProviderFactory } from './registry/email/types';
712
+ export { defineAiProvider } from './ai/define';
713
+ export type { AiProvider, AiProviderContext, AiProviderFactory } from './ai/types';
710
714
 
711
715
  // Export transformers and types from handoff-core
712
716
  export { Transformers as CoreTransformers, TransformerUtils as CoreTransformerUtils, Types as CoreTypes } from 'handoff-core';
package/src/nav/index.ts CHANGED
@@ -89,6 +89,6 @@ export interface NavData {
89
89
  export { getNavData } from './resolver';
90
90
  export type { GetNavDataInput } from './resolver';
91
91
  export { getWorkspaceNavData } from './workspace';
92
- export type { WorkspaceNavAdapterOptions, WorkspaceNavLoaders } from './workspace';
92
+ export type { WorkspaceNavOptions, WorkspaceNavLoaders } from './workspace';
93
93
  export { getRegistryNavData } from './registry';
94
- export type { RegistryNavAdapterOptions, RegistryNavRecords } from './registry';
94
+ export type { RegistryNavOptions, RegistryNavRecords } from './registry';
@@ -9,7 +9,7 @@ export interface RegistryNavRecords {
9
9
  tokenSets: NavTokenSet[];
10
10
  }
11
11
 
12
- export interface RegistryNavAdapterOptions {
12
+ export interface RegistryNavOptions {
13
13
  shell: SectionLink[];
14
14
  load: NavLoad;
15
15
  basePath?: string;
@@ -18,8 +18,8 @@ export interface RegistryNavAdapterOptions {
18
18
 
19
19
  const emptyRecords = (): RegistryNavRecords => ({ components: [], patterns: [], pages: [], tokenSets: [] });
20
20
 
21
- /** Registry adapter: shell-only initial load, or one best-effort runtime-record fetch on refresh. */
22
- export const getRegistryNavData = async (options: RegistryNavAdapterOptions): Promise<NavData> => {
21
+ /** Registry nav data: shell-only initial load, or one best-effort runtime-record fetch on refresh. */
22
+ export const getRegistryNavData = async (options: RegistryNavOptions): Promise<NavData> => {
23
23
  let records = emptyRecords();
24
24
  if (options.load === 'refresh') {
25
25
  try {
@@ -14,7 +14,7 @@ export interface WorkspaceNavLoaders {
14
14
  tokenSets: () => Awaitable<NavTokenSet[]>;
15
15
  }
16
16
 
17
- export interface WorkspaceNavAdapterOptions {
17
+ export interface WorkspaceNavOptions {
18
18
  docRoot: string;
19
19
  workingPagesDir?: string;
20
20
  basePath?: string;
@@ -43,8 +43,8 @@ const markdownTitle = (id: string, docRoot: string, workingPagesDir?: string): s
43
43
  return undefined;
44
44
  };
45
45
 
46
- /** Workspace/static adapter: live filesystem shell plus best-effort local snapshot loaders. */
47
- export const getWorkspaceNavData = async (options: WorkspaceNavAdapterOptions): Promise<NavData> => {
46
+ /** Workspace/static nav data: live filesystem shell plus best-effort local snapshot loaders. */
47
+ export const getWorkspaceNavData = async (options: WorkspaceNavOptions): Promise<NavData> => {
48
48
  const [loadedComponents, patterns, pages, loadedTokenSets] = await Promise.all([
49
49
  safeLoad(options.loaders.components),
50
50
  safeLoad(options.loaders.patterns),
@@ -7,7 +7,7 @@ Handles the end-to-end Figma data pipeline: authentication, extraction, token ge
7
7
  | File | Purpose |
8
8
  |------|---------|
9
9
  | `index.ts` | Main `pipeline()` orchestrator and barrel re-exports |
10
- | `figma.ts` | `validateFigmaAuth()` — interactive credential prompting; `figmaExtract()` — Figma data extraction |
10
+ | `figma.ts` | `resolveFigmaConnection()` — Figma values from `integrations.figma` and their variable names; `validateFigmaAuth()` — in a terminal, asks for an empty value and can save it to the profile env file, otherwise throws `HandoffConfigError`; `figmaExtract()` — Figma data extraction |
11
11
  | `styles.ts` | `buildStyles()` — design token transformers; `buildCustomFonts()` — font zipping |
12
12
  | `components.ts` | `buildComponents()` — component preview generation |
13
13
  | `documentation.ts` | `createDocumentationObject()` — Figma data extraction into a documentation object with assets and SVG sprites |
@@ -4,29 +4,74 @@ import { Types as HandoffTypes } from 'handoff-core';
4
4
  import * as stream from 'node:stream';
5
5
  import path from 'path';
6
6
  import Handoff from '..';
7
+ import { HandoffConfigError } from '../config/errors';
8
+ import { configEnvName } from '../config/resolve-env';
9
+ import type { ResolvedConfig } from '../types/config';
7
10
  import { Logger } from '../utils/logger';
8
11
  import { zipAssets } from './archive';
9
12
  import { createDocumentationObject } from './documentation';
10
13
 
14
+ /** Figma values resolved at config load, with the variable names they come from. */
15
+ export type FigmaConnection = {
16
+ projectId: string;
17
+ accessToken: string;
18
+ /** Unset when the config gives a literal file ID. */
19
+ projectIdEnv?: string;
20
+ accessTokenEnv?: string;
21
+ };
22
+
23
+ export const resolveFigmaConnection = (config: ResolvedConfig | null | undefined): FigmaConnection => {
24
+ const figma = config?.integrations?.figma;
25
+ return {
26
+ projectId: figma?.projectId?.trim() || '',
27
+ accessToken: figma?.accessToken?.trim() || '',
28
+ projectIdEnv: configEnvName(config, 'integrations.figma.projectId'),
29
+ accessTokenEnv: configEnvName(config, 'integrations.figma.accessToken'),
30
+ };
31
+ };
32
+
33
+ const describeSource = (envName: string | undefined, configPath: string): string =>
34
+ envName ? `${envName} (${configPath})` : configPath;
35
+
36
+ /** Replaces each variable that the file already sets and appends the others. */
37
+ const saveEnvValues = async (filePath: string, values: Record<string, string>): Promise<void> => {
38
+ const lines = fs.existsSync(filePath) ? (await fs.readFile(filePath, 'utf8')).split(/\r?\n/) : [];
39
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
40
+ for (const [name, value] of Object.entries(values)) {
41
+ const line = `${name}="${value}"`;
42
+ const index = lines.findIndex((existing) => new RegExp(`^\\s*(export\\s+)?${name}\\s*=`).test(existing));
43
+ if (index === -1) lines.push(line);
44
+ else lines[index] = line;
45
+ }
46
+ await fs.writeFile(filePath, `${lines.join('\n')}\n`);
47
+ };
48
+
11
49
  /**
12
- * Validate the figma auth tokens.
13
- * Prompts the user interactively if credentials are missing.
50
+ * Makes sure that `fetch` has a Figma file ID and access token. In a terminal, it asks for an empty
51
+ * value and can save it to the env file of the selected profile. Without a terminal, it throws
52
+ * `HandoffConfigError`.
14
53
  */
15
54
  export const validateFigmaAuth = async (handoff: Handoff): Promise<void> => {
16
- let DEV_ACCESS_TOKEN = handoff.config.dev_access_token;
17
- let FIGMA_PROJECT_ID = handoff.config.figma_project_id;
55
+ const connection = resolveFigmaConnection(handoff.config);
56
+ let { projectId, accessToken } = connection;
18
57
 
19
- if (DEV_ACCESS_TOKEN && FIGMA_PROJECT_ID) {
58
+ if (projectId && accessToken) {
20
59
  return;
21
60
  }
22
61
 
23
- let missingEnvVars = false;
62
+ const profile = handoff.getProfile();
63
+ if (!process.stdin.isTTY) {
64
+ const missing = [
65
+ !accessToken && describeSource(connection.accessTokenEnv, 'integrations.figma.accessToken'),
66
+ !projectId && describeSource(connection.projectIdEnv, 'integrations.figma.projectId'),
67
+ ].filter(Boolean);
68
+ throw new HandoffConfigError(`Figma is not configured (profile "${profile ?? 'default'}"). Set ${missing.join(' and ')}.`);
69
+ }
24
70
 
25
- if (!DEV_ACCESS_TOKEN) {
26
- missingEnvVars = true;
71
+ if (!accessToken) {
27
72
  p.log.warn(
28
- `Figma developer access token not found. You can supply it as an environment variable or .env file at HANDOFF_DEV_ACCESS_TOKEN.\n` +
29
- `Use these instructions to generate them: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens`
73
+ `Figma access token not found. Set ${describeSource(connection.accessTokenEnv, 'integrations.figma.accessToken')}.\n` +
74
+ `Use these instructions to generate one: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens`
30
75
  );
31
76
  const token = await p.password({
32
77
  message: 'Figma Developer Key:',
@@ -35,68 +80,55 @@ export const validateFigmaAuth = async (handoff: Handoff): Promise<void> => {
35
80
  p.cancel('Authentication cancelled.');
36
81
  process.exit(0);
37
82
  }
38
- DEV_ACCESS_TOKEN = token as string;
83
+ accessToken = (token as string).trim();
39
84
  }
40
85
 
41
- if (!FIGMA_PROJECT_ID) {
42
- missingEnvVars = true;
86
+ if (!projectId) {
43
87
  p.log.warn(
44
- `Figma project ID not found. Provide HANDOFF_FIGMA_PROJECT_ID via environment variable or .env file.\n` +
45
- `Find it in your Figma file URL (e.g., figma.com/file/{PROJECT_ID}/...).`
88
+ `Figma project ID not found. Set ${describeSource(connection.projectIdEnv, 'integrations.figma.projectId')}.\n` +
89
+ `Find it in your Figma file URL (e.g., figma.com/file/{PROJECT_ID}/...).`
46
90
  );
47
- const projectId = await p.text({
91
+ const value = await p.text({
48
92
  message: 'Figma Project Id:',
49
93
  validate: (value) => {
50
94
  if (!value.trim()) return 'Project ID is required';
51
95
  },
52
96
  });
53
- if (p.isCancel(projectId)) {
97
+ if (p.isCancel(value)) {
54
98
  p.cancel('Authentication cancelled.');
55
99
  process.exit(0);
56
100
  }
57
- FIGMA_PROJECT_ID = projectId as string;
101
+ projectId = (value as string).trim();
58
102
  }
59
103
 
60
- if (missingEnvVars) {
61
- p.log.info(`To simplify future runs, we can save these variables to a local .env file.`);
104
+ const values: Record<string, string> = {};
105
+ if (!connection.accessToken && connection.accessTokenEnv) values[connection.accessTokenEnv] = accessToken;
106
+ if (!connection.projectId && connection.projectIdEnv) values[connection.projectIdEnv] = projectId;
62
107
 
63
- const writeEnvFile = await p.confirm({
64
- message: 'Write environment variables to .env file?',
108
+ const envFileName = profile ? `.env.${profile}` : '.env';
109
+ if (Object.keys(values).length) {
110
+ const save = await p.confirm({
111
+ message: `Save ${Object.keys(values).join(' and ')} to ${envFileName} for future runs?`,
65
112
  initialValue: true,
66
113
  });
67
114
 
68
- if (p.isCancel(writeEnvFile) || writeEnvFile === false) {
69
- p.log.info(`Skipped .env file creation. Please provide these variables manually.`);
115
+ if (p.isCancel(save) || save === false) {
116
+ p.log.info(`Skipped saving. Set these variables before the next run.`);
70
117
  } else {
71
- const envFilePath = path.resolve(handoff.workingPath, '.env');
72
- const envFileContent = `
73
- HANDOFF_DEV_ACCESS_TOKEN="${DEV_ACCESS_TOKEN}"
74
- HANDOFF_FIGMA_PROJECT_ID="${FIGMA_PROJECT_ID}"
75
- `;
76
-
77
118
  try {
78
- const fileExists = await fs
79
- .access(envFilePath)
80
- .then(() => true)
81
- .catch(() => false);
82
-
83
- if (fileExists) {
84
- await fs.appendFile(envFilePath, envFileContent);
85
- p.log.success(
86
- `The .env file was found and updated with new content. Since these are sensitive variables, please do not commit this file.`
87
- );
88
- } else {
89
- await fs.writeFile(envFilePath, envFileContent.replace(/^\s*[\r\n]/gm, ''));
90
- p.log.success(`Created .env file. Do not commit sensitive variables.`);
91
- }
119
+ await saveEnvValues(path.resolve(handoff.workingPath, envFileName), values);
120
+ const secretNote = connection.accessTokenEnv && values[connection.accessTokenEnv] ? ' It contains a secret, so do not commit it.' : '';
121
+ p.log.success(`Saved to ${envFileName}.${secretNote}`);
92
122
  } catch (error) {
93
- Logger.error('Error handling the .env file:', error);
123
+ Logger.error(`Could not save ${envFileName}:`, error);
94
124
  }
95
125
  }
96
126
  }
97
127
 
98
- handoff.config.dev_access_token = DEV_ACCESS_TOKEN;
99
- handoff.config.figma_project_id = FIGMA_PROJECT_ID;
128
+ handoff.config.integrations = {
129
+ ...handoff.config.integrations,
130
+ figma: { ...handoff.config.integrations?.figma, projectId, accessToken },
131
+ };
100
132
  };
101
133
 
102
134
  /**
@@ -105,13 +137,26 @@ HANDOFF_FIGMA_PROJECT_ID="${FIGMA_PROJECT_ID}"
105
137
  export const figmaExtract = async (handoff: Handoff): Promise<HandoffTypes.IDocumentationObject> => {
106
138
  Logger.success(`Starting Figma data extraction.`);
107
139
 
108
- await fs.emptyDir(handoff.getOutputPath());
140
+ // The export directory can hold other files, so remove only what fetch writes.
141
+ const outputPath = handoff.getOutputPath();
142
+ await Promise.all(
143
+ [
144
+ handoff.getTokensFilePath(),
145
+ handoff.getVariablesFilePath(),
146
+ handoff.getIconsZipFilePath(),
147
+ handoff.getLogosZipFilePath(),
148
+ path.join(outputPath, 'tokens-map.json'),
149
+ path.join(outputPath, 'fonts'),
150
+ ].map((entry) => fs.remove(entry))
151
+ );
152
+ await fs.ensureDir(outputPath);
109
153
 
110
154
  const documentationObject = await createDocumentationObject(handoff);
155
+ const createZipFiles = process.env.HANDOFF_CREATE_ASSETS_ZIP_FILES !== 'false';
111
156
 
112
157
  await Promise.all([
113
158
  fs.writeJSON(handoff.getTokensFilePath(), documentationObject, { spaces: 2 }),
114
- ...(!process.env.HANDOFF_CREATE_ASSETS_ZIP_FILES || process.env.HANDOFF_CREATE_ASSETS_ZIP_FILES !== 'false'
159
+ ...(createZipFiles
115
160
  ? [
116
161
  zipAssets(documentationObject.assets.icons, fs.createWriteStream(handoff.getIconsZipFilePath())).then((writeStream) =>
117
162
  stream.promises.finished(writeStream)
@@ -123,6 +168,10 @@ export const figmaExtract = async (handoff: Handoff): Promise<HandoffTypes.IDocu
123
168
  : []),
124
169
  ]);
125
170
 
171
+ if (!createZipFiles) {
172
+ return documentationObject;
173
+ }
174
+
126
175
  // define the output folder
127
176
  const outputFolder = path.resolve(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`, 'public');
128
177
 
@@ -29,7 +29,7 @@ export const buildCustomFonts = async (handoff: Handoff, documentationObject: Ha
29
29
  if (fs.existsSync(fontDirName)) {
30
30
  const stream = fs.createWriteStream(path.join(fontLocation, `${name}.zip`));
31
31
  await zip(fontDirName, stream);
32
- const fontsFolder = path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId(), 'fonts');
32
+ const fontsFolder = path.join(handoff.getOutputPath(), 'fonts');
33
33
  if (!fs.existsSync(fontsFolder)) {
34
34
  fs.mkdirSync(fontsFolder);
35
35
  }
@@ -3,13 +3,13 @@ import { Logger } from '../utils/logger';
3
3
  /**
4
4
  * Validates that the Node.js runtime meets the minimum version requirements.
5
5
  *
6
- * @throws {Error} If Node.js version is below 16.
6
+ * @throws {Error} If Node.js version is below 22.
7
7
  */
8
8
  export const validateHandoffRequirements = async () => {
9
9
  let requirements = false;
10
10
  const result = process.versions;
11
11
  if (result && result.node) {
12
- if (parseInt(result.node) >= 16) {
12
+ if (parseInt(result.node) >= 22) {
13
13
  requirements = true;
14
14
  }
15
15
  } else {
@@ -18,7 +18,7 @@ export const validateHandoffRequirements = async () => {
18
18
  if (!requirements) {
19
19
  Logger.error('Handoff installation failed.');
20
20
  Logger.warn(
21
- '- Please update node to at least Node 16 https://nodejs.org/en/download. \n- You can read more about installing handoff at https://www.handoff.com/docs/'
21
+ '- Please update node to at least Node 22 https://nodejs.org/en/download. \n- You can read more about installing handoff at https://www.handoff.com/docs/'
22
22
  );
23
23
  throw new Error('Could not run handoff');
24
24
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Define helpers for custom server modules: storage, email and AI providers. This entry point has no
3
+ * runtime dependencies, so a module that imports it does not pull the CLI into the registry bundle.
4
+ */
5
+ export { defineAiProvider } from './ai/define';
6
+ export type { AiProvider, AiProviderContext, AiProviderFactory } from './ai/types';
7
+ export { defineStorageProvider } from './registry/asset-storage/define';
8
+ export type {
9
+ StorageProvider,
10
+ StorageProviderContext,
11
+ StorageProviderFactory,
12
+ StorageInput,
13
+ StorageReadResult,
14
+ AssetUpload,
15
+ AssetUploadInput,
16
+ } from './registry/asset-storage/types';
17
+ export { defineEmailProvider } from './registry/email/define';
18
+ export type { EmailMessage, EmailProvider, EmailProviderContext, EmailProviderFactory } from './registry/email/types';
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Typed identity helper for custom asset storage adapters (mirrors `defineCatalogItem`): a consumer
2
+ * Typed identity helper for custom asset storage providers (mirrors `defineCatalogItem`): a consumer
3
3
  * default-exports its result from a **server-only** module and points
4
4
  * `runtime.registry.assetStorage.module` at it. It must be its own module (not an inline
5
5
  * `defineConfig` function) so the build can trace it into the deployed registry. The default export
6
- * may be the adapter object, or a factory `(context) => AssetStorage` when it needs its options/env.
6
+ * may be the provider object, or a factory `(context) => StorageProvider` when it needs its options/env.
7
7
  */
8
8
 
9
- import type { AssetStorage, AssetStorageFactory } from './types';
9
+ import type { StorageProvider, StorageProviderFactory } from './types';
10
10
 
11
- export function defineAssetStorage(adapter: AssetStorage): AssetStorage;
12
- export function defineAssetStorage(factory: AssetStorageFactory): AssetStorageFactory;
13
- export function defineAssetStorage(input: AssetStorage | AssetStorageFactory): AssetStorage | AssetStorageFactory {
11
+ export function defineStorageProvider(provider: StorageProvider): StorageProvider;
12
+ export function defineStorageProvider(factory: StorageProviderFactory): StorageProviderFactory;
13
+ export function defineStorageProvider(input: StorageProvider | StorageProviderFactory): StorageProvider | StorageProviderFactory {
14
14
  return input;
15
15
  }
@@ -2,74 +2,75 @@
2
2
  * Asset storage settings resolution.
3
3
  *
4
4
  * `runtime.registry.assetStorage` selects the active provider for **new** uploads: the built-in
5
- * `database` inline default, the pre-packaged `vercel-blob` adapter, or a `custom` module. Mirrors
6
- * {@link import('../db/driver')}: only provider selection, module location, non-secret options, and
7
- * env-var *names* are ever persisted; secret *values* are read from `process.env` at request time.
5
+ * `database` inline default or a `custom` module. The build bakes the provider, the module location,
6
+ * and the options. An option literal is baked as it is. An environment reference stays `{ $env }`
7
+ * and is resolved at request time, before the custom provider factory runs.
8
8
  */
9
9
 
10
+ import { HandoffConfigError } from '../../config/errors';
10
11
  import type { ResolvedConfig } from '../../types/config';
11
12
  import type { AssetStorageProvider } from '../db/schema';
12
13
 
13
- /** Which adapter implementation is active. */
14
- export type AssetStorageAdapterKind = 'database' | 'vercel-blob' | 'custom';
15
-
16
14
  /** The raw, authored settings block (a structural copy of the config type, resolvable from bake). */
17
15
  export interface AssetStorageSettings {
18
- adapter?: AssetStorageAdapterKind;
16
+ provider?: AssetStorageProvider;
19
17
  module?: string;
20
- tokenEnv?: string;
21
18
  maxInlineBytes?: number;
22
19
  options?: Record<string, unknown>;
23
20
  }
24
21
 
25
- /** Default adapter when none is configured. */
26
- export const DEFAULT_ASSET_STORAGE_ADAPTER: AssetStorageAdapterKind = 'database';
22
+ const PROVIDERS: readonly AssetStorageProvider[] = ['database', 'custom'];
27
23
 
28
- /** Default env-var name holding the Vercel Blob read/write token. */
29
- export const DEFAULT_BLOB_TOKEN_ENV = 'BLOB_READ_WRITE_TOKEN';
24
+ export const DEFAULT_ASSET_STORAGE_PROVIDER: AssetStorageProvider = 'database';
30
25
 
31
26
  /** Default max bytes kept inline in Postgres `bytea` (4 MB, under Vercel's ~4.5 MB function limit). */
32
27
  export const DEFAULT_MAX_INLINE_BYTES = 4 * 1024 * 1024;
33
28
 
34
29
  /** Fully resolved asset storage settings. */
35
30
  export interface ResolvedAssetStorage {
36
- /** The active adapter implementation. */
37
- adapterKind: AssetStorageAdapterKind;
38
- /** Provider id recorded on blob rows written by the active adapter (drives read resolution). */
31
+ /** Recorded on each new blob row as its storage provider. */
39
32
  provider: AssetStorageProvider;
40
- /** For `custom`: the server-only adapter module path. */
33
+ /** For `custom`: the server-only provider module path. */
41
34
  module?: string;
42
- /** For `vercel-blob`: the env-var name holding the token. */
43
- tokenEnv: string;
44
35
  /** For `database`: the inline-content size ceiling. */
45
36
  maxInlineBytes: number;
46
- /** Non-secret adapter options. */
37
+ /** Provider options: literals and `{ $env }` references, never secret values. */
47
38
  options: Record<string, unknown>;
48
39
  }
49
40
 
50
- /** The stable provider id recorded on blobs for a custom adapter (single custom provider at a time). */
51
- const customProviderId = (options: Record<string, unknown>): AssetStorageProvider => {
52
- const id = typeof options.providerId === 'string' && options.providerId.trim() ? options.providerId.trim() : 'custom';
53
- return id;
54
- };
55
-
56
41
  /** Resolve raw asset storage settings into a fully-defaulted shape. */
57
42
  export const resolveAssetStorageSettings = (settings: AssetStorageSettings | null | undefined): ResolvedAssetStorage => {
58
- const adapterKind = settings?.adapter ?? DEFAULT_ASSET_STORAGE_ADAPTER;
43
+ const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
59
44
  const options = settings?.options ?? {};
60
- const tokenEnv = settings?.tokenEnv?.trim() || DEFAULT_BLOB_TOKEN_ENV;
61
45
  const maxInlineBytes =
62
46
  typeof settings?.maxInlineBytes === 'number' && settings.maxInlineBytes > 0 ? settings.maxInlineBytes : DEFAULT_MAX_INLINE_BYTES;
63
47
 
64
- const provider: AssetStorageProvider =
65
- adapterKind === 'vercel-blob' ? 'vercel-blob' : adapterKind === 'custom' ? customProviderId(options) : 'database';
66
-
67
- return { adapterKind, provider, module: settings?.module?.trim() || undefined, tokenEnv, maxInlineBytes, options };
48
+ return { provider, module: settings?.module?.trim() || undefined, maxInlineBytes, options };
68
49
  };
69
50
 
70
- /** Resolve the active asset storage settings from a loaded config (CLI/build side). */
71
- export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage =>
72
- resolveAssetStorageSettings({
73
- ...config?.runtime?.registry?.assetStorage,
74
- tokenEnv: config?.runtime?.registry?.assetStorage?.token?.$env,
75
- });
51
+ /** Resolve and validate the asset storage block from a loaded config (CLI/build side). An unusable block fails the build. */
52
+ export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage => {
53
+ const settings = config?.runtime?.registry?.assetStorage as Record<string, unknown> | undefined;
54
+ const fail = (problem: string): never => {
55
+ throw new HandoffConfigError(`Config "runtime.registry.assetStorage": ${problem}`);
56
+ };
57
+
58
+ const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
59
+ if (!PROVIDERS.includes(provider as AssetStorageProvider)) {
60
+ fail(`unknown provider ${JSON.stringify(provider)}. Use "database" or "custom".`);
61
+ }
62
+ const options = settings?.options;
63
+ if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) {
64
+ fail('"options" must be an object.');
65
+ }
66
+ const module = settings?.module;
67
+ if (provider === 'custom' && (typeof module !== 'string' || !module.trim())) {
68
+ fail('provider "custom" needs a "module" that default-exports a defineStorageProvider() result.');
69
+ }
70
+ const maxInlineBytes = settings?.maxInlineBytes;
71
+ if (maxInlineBytes !== undefined && (typeof maxInlineBytes !== 'number' || !Number.isInteger(maxInlineBytes) || maxInlineBytes <= 0)) {
72
+ fail('"maxInlineBytes" must be a positive whole number.');
73
+ }
74
+
75
+ return resolveAssetStorageSettings(settings as AssetStorageSettings | undefined);
76
+ };