handoff-app 2.0.0-12 → 2.0.0-13

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 (136) hide show
  1. package/{Changelog.md → CHANGELOG.md} +84 -0
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +760 -801
  4. package/RECIPES.md +975 -0
  5. package/components.json +20 -20
  6. package/config/docs/foundations/colors/guidelines.md +0 -1
  7. package/config/docs/foundations/colors.md +0 -1
  8. package/config/docs/foundations/effects/guidelines.md +0 -1
  9. package/config/docs/foundations/effects/resources.md +0 -1
  10. package/config/docs/foundations/effects.md +0 -1
  11. package/config/docs/foundations/fonts.md +0 -1
  12. package/config/docs/foundations/grid.md +0 -1
  13. package/config/docs/foundations/icon.md +0 -1
  14. package/config/docs/foundations/icons/guidelines.md +0 -1
  15. package/config/docs/foundations/icons.md +0 -1
  16. package/config/docs/foundations/images/guidelines.md +0 -1
  17. package/config/docs/foundations/images/resources.md +0 -1
  18. package/config/docs/foundations/images.md +0 -1
  19. package/config/docs/foundations/logo/resources.md +0 -1
  20. package/config/docs/foundations/logo.md +0 -1
  21. package/config/docs/foundations/typography/guidelines.md +0 -1
  22. package/config/docs/foundations/typography/resources.md +0 -1
  23. package/config/docs/foundations/typography.md +0 -1
  24. package/config/docs/foundations.md +0 -1
  25. package/config/docs/guidelines.md +0 -1
  26. package/config/docs/index.md +0 -1
  27. package/config/docs/system/tokens/foundations/colors.md +0 -4
  28. package/config/docs/system/tokens/foundations/effects.md +0 -4
  29. package/config/docs/system/tokens/foundations/typography.md +0 -2
  30. package/config/docs/system.md +0 -2
  31. package/config/templates/page.md +2 -1
  32. package/config/templates/page.mdx +2 -1
  33. package/dist/ai/define.d.ts +1 -1
  34. package/dist/ai/define.js +1 -1
  35. package/dist/ai/types.d.ts +1 -1
  36. package/dist/ai/types.js +1 -1
  37. package/dist/app-builder/build.js +14 -14
  38. package/dist/app-builder/client-config.js +1 -1
  39. package/dist/config/loader.js +1 -2
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +2 -2
  42. package/dist/nav/index.d.ts +2 -2
  43. package/dist/nav/registry.d.ts +3 -3
  44. package/dist/nav/registry.js +1 -1
  45. package/dist/nav/workspace.d.ts +3 -3
  46. package/dist/nav/workspace.js +1 -1
  47. package/dist/providers.d.ts +10 -0
  48. package/dist/{define.js → providers.js} +4 -4
  49. package/dist/registry/asset-storage/define.d.ts +5 -5
  50. package/dist/registry/asset-storage/define.js +4 -4
  51. package/dist/registry/asset-storage/resolve.d.ts +8 -11
  52. package/dist/registry/asset-storage/resolve.js +12 -13
  53. package/dist/registry/asset-storage/types.d.ts +12 -12
  54. package/dist/registry/asset-storage/types.js +2 -2
  55. package/dist/registry/db/schema.d.ts +2 -2
  56. package/dist/registry/db/schema.js +2 -2
  57. package/dist/registry/email/define.d.ts +1 -1
  58. package/dist/registry/email/define.js +1 -1
  59. package/dist/registry/email/resolve.d.ts +7 -8
  60. package/dist/registry/email/resolve.js +21 -23
  61. package/dist/registry/email/types.d.ts +1 -1
  62. package/dist/registry/email/types.js +1 -1
  63. package/dist/store/registry.d.ts +3 -3
  64. package/dist/store/registry.js +4 -4
  65. package/dist/types/config.d.ts +4 -13
  66. package/docs/cli.md +275 -54
  67. package/package.json +7 -7
  68. package/src/ai/define.ts +1 -1
  69. package/src/ai/types.ts +1 -1
  70. package/src/app/components/Auth/AccountLayout.tsx +1 -1
  71. package/src/app/components/Auth/AuthShell.tsx +10 -4
  72. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  73. package/src/app/components/Layout/Main.tsx +2 -2
  74. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  75. package/src/app/components/Navigation/MobileNav.tsx +0 -2
  76. package/src/app/components/util/index.ts +0 -11
  77. package/src/app/lib/asset-storage.ts +25 -25
  78. package/src/app/lib/auth/email.ts +4 -5
  79. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  80. package/src/app/lib/docs-api/registry.ts +2 -3
  81. package/src/app/lib/docs-api/runtime-config.ts +8 -8
  82. package/src/app/lib/registry-api/asset-transfer.ts +25 -25
  83. package/src/app/lib/server-module.ts +1 -1
  84. package/src/app/lib/utils.ts +7 -0
  85. package/src/app/next.config.mjs +2 -2
  86. package/src/app/pages/[...slug]/index.tsx +2 -1
  87. package/src/app/pages/account/ai.tsx +1 -1
  88. package/src/app/pages/account/index.tsx +1 -1
  89. package/src/app/pages/account/tokens.tsx +1 -1
  90. package/src/app/pages/account/users.tsx +1 -1
  91. package/src/app/pages/api/docs/nav.json.ts +1 -1
  92. package/src/app/pages/api/mcp.ts +6 -0
  93. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  94. package/src/app/pages/cli/device.tsx +1 -1
  95. package/src/app/pages/foundations/colors.tsx +9 -8
  96. package/src/app/pages/foundations/index.tsx +3 -3
  97. package/src/app/pages/index.tsx +2 -2
  98. package/src/app/pages/login.tsx +10 -5
  99. package/src/app/pages/reset-password.tsx +7 -2
  100. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  101. package/src/app/pages/system/index.tsx +11 -11
  102. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  103. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  104. package/src/app-builder/build.ts +10 -10
  105. package/src/app-builder/client-config.ts +1 -1
  106. package/src/config/loader.ts +1 -2
  107. package/src/index.ts +6 -6
  108. package/src/nav/index.ts +2 -2
  109. package/src/nav/registry.ts +3 -3
  110. package/src/nav/workspace.ts +3 -3
  111. package/src/{define.ts → providers.ts} +8 -8
  112. package/src/registry/asset-storage/define.ts +6 -6
  113. package/src/registry/asset-storage/resolve.ts +16 -20
  114. package/src/registry/asset-storage/types.ts +12 -12
  115. package/src/registry/db/schema.ts +3 -3
  116. package/src/registry/email/define.ts +1 -1
  117. package/src/registry/email/resolve.ts +23 -23
  118. package/src/registry/email/types.ts +1 -1
  119. package/src/store/registry.ts +7 -7
  120. package/src/types/config.ts +4 -14
  121. package/.editorconfig +0 -19
  122. package/.eslintrc.json +0 -4
  123. package/.prettierignore +0 -0
  124. package/.prettierrc.json +0 -8
  125. package/.vscode/settings.json +0 -17
  126. package/dist/define.d.ts +0 -10
  127. package/dist/registry/email/adapters/resend.d.ts +0 -7
  128. package/dist/registry/email/adapters/resend.js +0 -35
  129. package/src/app/components/old/Header.tsx +0 -89
  130. package/src/app/pages/assets/fonts.tsx +0 -129
  131. package/src/app/pages/assets/index.tsx +0 -91
  132. package/src/app/pages/assets/logos.tsx +0 -75
  133. package/src/registry/email/adapters/resend.ts +0 -22
  134. /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
  135. /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
  136. /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
package/README.md CHANGED
@@ -3,65 +3,116 @@
3
3
  <a aria-label="NPM version" href="https://www.npmjs.com/package/handoff-app">
4
4
  <img alt="" src="https://img.shields.io/npm/v/handoff-app?style=for-the-badge&labelColor=000000">
5
5
  </a>
6
- <a aria-label="License" href="https://github.com/convertiv/handoff-app/blob/main/License.md">
6
+ <a aria-label="License" href="https://github.com/convertiv/handoff-app/blob/main/LICENSE.md">
7
7
  <img alt="" src="https://img.shields.io/npm/l/handoff-app?style=for-the-badge&labelColor=000000">
8
8
  </a>
9
9
 
10
- Design tokens, components, and patterns are turned by Handoff into working
11
- documentation and distributable artifacts. A local workspace, a static site,
12
- or a shared PostgreSQL-backed registry can be used.
10
+ Handoff turns design tokens, components, and patterns into working
11
+ documentation and distributable artifacts. You can serve the documentation
12
+ from a local workspace, a static site, or a shared registry with a PostgreSQL
13
+ database.
13
14
 
14
- ## Runtime modes
15
+ > **Recipes:** [RECIPES.md](RECIPES.md) has complete examples for project
16
+ > layouts, catalog items, profiles, registry hosting, asset storage, email, and
17
+ > the AI assistant.
15
18
 
16
- - **Workspace** is the default. Local files are the source of truth and no
17
- database is required.
18
- - **Registry** is a deployed catalog backed by PostgreSQL.
19
- - A **connected workspace** remains in workspace mode but can publish to and
20
- check out from a registry.
19
+ > **Upgrade from version 1.x.x:** read the
20
+ > [migration guide](UPGRADE.md#version-1xx-to-version-2xx) for the breaking
21
+ > changes and the migration steps.
21
22
 
22
- Workspace mode is used by local projects for authoring. A separate
23
- registry-mode application is created automatically by a registry build.
23
+ ## Contents
24
+
25
+ - [How Handoff works](#how-handoff-works)
26
+ - [Requirements](#requirements)
27
+ - [Installation](#installation)
28
+ - [Configuration](#configuration)
29
+ - [Authoring](#authoring)
30
+ - [Running the workspace](#running-the-workspace)
31
+ - [Building](#building)
32
+ - [Registry](#registry)
33
+ - [Connecting a workspace to a registry](#connecting-a-workspace-to-a-registry)
34
+ - [MCP](#mcp)
35
+ - [AI assistant](#ai-assistant)
36
+ - [CLI reference](#cli-reference)
37
+
38
+ ## How Handoff works
39
+
40
+ ### Runtime modes
41
+
42
+ - **Workspace** is the default mode. Local files are the source of truth, and
43
+ no database is necessary. You use it to author content.
44
+ - **Registry** is a deployed catalog with a PostgreSQL database. It stores and
45
+ serves only the content that a workspace publishes to it.
46
+ - A **connected workspace** is a workspace that can publish to a registry and
47
+ check out from it. It is not a third mode.
48
+
49
+ The `runtime.mode` value in the config sets the mode. A registry build sets
50
+ registry mode automatically, so the base config stays in workspace mode.
51
+
52
+ ### Workflow
53
+
54
+ ```text
55
+ Figma ── fetch ──▶ exported/ tokens, CSS, Sass, assets
56
+ catalog, pages ── start ──▶ local documentation at http://localhost:3000
57
+ ── build ──▶ out/static (static site) or out/registry (registry server)
58
+ workspace ── publish ─▶ registry ── checkout ─▶ workspace
59
+ ```
60
+
61
+ The workspace is the only place that builds content. A registry never builds
62
+ and never reads workspace source.
63
+
64
+ ### Terms
65
+
66
+ - **Working directory:** the directory that holds `handoff.config.ts`. Every
67
+ command runs against it.
68
+ - **Catalog item:** one documented UI entry. It has a declaration file, a
69
+ stable id, and previews.
70
+ - **Component:** a catalog item with an implementation (React, Handlebars, or
71
+ CSF).
72
+ - **Pattern:** a catalog item that is a composition of other catalog items.
73
+ - **Foundations:** the tokens and assets that `fetch` gets from Figma.
74
+ - **Page:** a Markdown documentation page under `pages/`.
75
+ - **Profile:** a config file that merges onto the base config for one
76
+ environment.
24
77
 
25
78
  ## Requirements
26
79
 
27
- - Node.js 22 or newer; Node.js 24 LTS is recommended
28
- - npm 10 or newer
29
- - A paid Figma account when fetching a Figma library
30
- - PostgreSQL when running a registry
80
+ - Node.js 22 or newer. We recommend Node.js 24 LTS.
81
+ - npm 10 or newer.
82
+ - A paid Figma account, to fetch a Figma library.
83
+ - PostgreSQL, to run a registry.
84
+
85
+ ## Installation
31
86
 
32
- ## Quick start
87
+ ### New project
33
88
 
34
- A project is created with the interactive wizard:
89
+ Create a project with the interactive wizard:
35
90
 
36
91
  ```bash
37
92
  npx handoff-app init
38
93
  cd my-handoff-project
39
94
  ```
40
95
 
41
- The project name, sample content, JavaScript or TypeScript, optional Vercel
42
- setup, and optional Figma credentials are requested by the wizard. Starter
43
- files are created and `handoff-app` is installed locally. Generated npm scripts
44
- are used for project operations so the pinned project version is shared by
45
- local development and CI:
96
+ The wizard asks for the project name, sample content, JavaScript or
97
+ TypeScript, an optional Vercel setup, and optional Figma credentials. It
98
+ creates the starter files and installs `handoff-app` in the project.
99
+
100
+ Use the generated npm scripts. They run the project version of `handoff-app`,
101
+ so local development and CI use the same version:
46
102
 
47
103
  ```bash
48
104
  npm run fetch # optional: fetch Figma foundations
49
105
  npm run start # local server with Handoff file watchers
50
106
  npm run dev # local development server without Handoff file watchers
51
107
  npm run build # static build by default
108
+ npm run publish # publish to a registry
109
+ npm run checkout # check out from a registry
110
+ npm run login # sign in to a registry
111
+ npm run logout # sign out of a registry
52
112
  npm run db:migrate # registry database migrations
53
- npm run validate
54
113
  ```
55
114
 
56
- ## Upgrade from version 1.x.x to version 2.x.x
57
-
58
- See the [version 1.x.x to version 2.x.x migration guide](UPGRADE.md#version-1xx-to-version-2xx)
59
- for breaking changes, migration steps, and upgrade verification.
60
-
61
- ## Project layout
62
-
63
- Every command runs against a working directory: the directory holding
64
- `handoff.config.ts`. Generated output is written there.
115
+ ### Project layout
65
116
 
66
117
  ```text
67
118
  <working directory>/
@@ -77,104 +128,311 @@ Every command runs against a working directory: the directory holding
77
128
  └─ .handoff/ # local Handoff state; gitignored
78
129
  ```
79
130
 
80
- In a repository of its own, which is what `init` scaffolds, the working
81
- directory is the repository root.
131
+ In a project that `init` creates, the working directory is the repository
132
+ root. `HANDOFF_WORKING_PATH` sets a different working directory.
82
133
 
83
- ## Adding Handoff to an existing application
134
+ ### Adding Handoff to an existing application
84
135
 
85
- Declarations sit beside the components they document, so the catalog points
86
- into the application source:
136
+ Declarations sit next to the components that they document. Thus the catalog
137
+ points into the application source:
87
138
 
88
139
  ```ts
89
140
  catalog: { include: ['src/components', 'src/blocks'] },
90
141
  ```
91
142
 
92
- **`handoff-app` must be a dependency of the package holding those
93
- `*.handoff.ts` files.** They import `handoff-app/react`, which resolves from the
94
- directory of the declaration, not from the directory of the config. Under npm a
95
- missing dependency is usually hidden by hoisting. Under pnpm it is not: the
96
- affected items are skipped with `Could not resolve "handoff-app/react"` and the
97
- build still exits 0.
143
+ Two rules apply to every layout.
98
144
 
99
- Two `tsconfig.json` files do different jobs:
145
+ **`handoff-app` must be a dependency of the package that holds the
146
+ `*.handoff.ts` files.** A declaration imports `handoff-app/react`. Handoff
147
+ resolves that import from the directory of the declaration, not from the
148
+ directory of the config. With npm, hoisting usually hides a missing dependency.
149
+ With pnpm, Handoff skips the affected items with
150
+ `Could not resolve "handoff-app/react"`, and the build still exits with code 0.
151
+ In a workspace-package layout, the application package and the `handoff/`
152
+ package must both declare `handoff-app`.
100
153
 
101
- - The config nearest the declarations type checks them. For files in `src/`
102
- that is the one at the project root. An editor reads the nearest config only,
103
- so a config in another directory that includes `src/` does not count, and
104
- neither the editor nor the application build reports errors in a declaration.
105
- - The config at the working directory generates the property tables. Without
106
- it, Handoff warns `TypeScript config not found` and every table is empty.
154
+ **Two `tsconfig.json` files do different jobs:**
107
155
 
108
- Both files are needed when the working directory is not the project root.
156
+ - The config nearest to the declarations type checks them. An editor reads
157
+ only the nearest config. A config in a different directory that includes
158
+ `src/` does not count.
159
+ - The config in the working directory makes the property tables. Without it,
160
+ Handoff shows `TypeScript config not found`, and every table is empty.
109
161
 
110
- Three placements work, all keeping one dependency tree so the catalog renders
111
- the real components against the same React instance. A separate install with its
112
- own `node_modules` outside that tree is not supported.
162
+ If the working directory is not the project root, you need both files.
113
163
 
114
- **Handoff in a subdirectory — recommended.** Nothing in the application changes.
115
- `handoff-app` is a dependency of the application package, and
116
- `HANDOFF_WORKING_PATH` keeps the generated directories in `handoff/`:
164
+ Three layouts work. Each keeps one dependency tree, so the catalog renders the
165
+ real components with the same React instance. Handoff does not support a
166
+ separate install with its own `node_modules` outside that tree.
117
167
 
118
- ```json
119
- { "scripts": { "handoff:dev": "HANDOFF_WORKING_PATH=handoff handoff-app start" } }
120
- ```
168
+ | Layout | Working directory | Use when |
169
+ | --- | --- | --- |
170
+ | Subdirectory (recommended) | `handoff/`, set by `HANDOFF_WORKING_PATH` | The application must not change |
171
+ | Project root | The project root | The bundler can move its `public/` directory (not Next.js) |
172
+ | Workspace package | `handoff/`, with its own `package.json` | The repository is already a workspace |
173
+
174
+ In the project-root layout, Handoff writes the generated documentation API to
175
+ `public/api`. Thus a bundler that serves `public/` copies it into the
176
+ production build of the application. Move the bundler to a different
177
+ directory. A Next.js application cannot move its `public` directory, so it
178
+ cannot use this layout.
179
+
180
+ With pnpm, `build --target registry` cannot follow the pnpm symlinks. Set
181
+ `nodeLinker: hoisted` in `pnpm-workspace.yaml`. The other builds work with the
182
+ default pnpm layout.
183
+
184
+ To add Handoff in the recommended subdirectory layout:
121
185
 
122
- An inline variable does not work in an npm script on Windows, where `cross-env`
123
- is used instead.
186
+ 1. Install `handoff-app` in the application package:
124
187
 
125
- **Handoff in the project root.** The working directory is the project root, so
126
- one `tsconfig.json` does both jobs. Handoff writes to `public/api`, so a bundler
127
- already serving static files from `public/` must be pointed elsewhere:
188
+ ```bash
189
+ npm install handoff-app
190
+ ```
191
+
192
+ 2. Create `handoff/handoff.config.ts`. Point the catalog at the application
193
+ source, and load the global stylesheet of the application into the
194
+ previews:
195
+
196
+ ```ts
197
+ import { defineConfig } from 'handoff-app';
198
+
199
+ export default defineConfig({
200
+ app: { title: 'Acme Design System' },
201
+ catalog: { include: ['../src/components'] },
202
+ entries: { scss: '../src/styles/main.scss' },
203
+ });
204
+ ```
205
+
206
+ Paths are relative to the working directory. `npx handoff-app eject:config`
207
+ writes the full default config instead.
208
+
209
+ 3. Create `handoff/tsconfig.json`, so that Handoff can make the property
210
+ tables:
211
+
212
+ ```json
213
+ { "extends": "../tsconfig.json", "include": ["../src", "handoff.config.ts"] }
214
+ ```
215
+
216
+ 4. Add the scripts to the `package.json` of the application:
217
+
218
+ ```json
219
+ {
220
+ "scripts": {
221
+ "handoff:dev": "HANDOFF_WORKING_PATH=handoff handoff-app start",
222
+ "handoff:build": "HANDOFF_WORKING_PATH=handoff handoff-app build"
223
+ }
224
+ }
225
+ ```
226
+
227
+ 5. Write a declaration next to a component, for example
228
+ `src/components/button/button.handoff.ts`. The
229
+ [Catalog items](#catalog-items) section shows the format.
230
+
231
+ 6. Start the documentation:
232
+
233
+ ```bash
234
+ npm run handoff:dev
235
+ ```
236
+
237
+ 7. Add `handoff/public/api`, `handoff/out`, `handoff/.handoff`, and
238
+ `handoff/.vercel` to `.gitignore`. Commit `handoff/exported` if you fetch
239
+ from Figma.
240
+
241
+ > **Recipes:** [Subdirectory](RECIPES.md#handoff-in-a-subdirectory) ·
242
+ > [Project root](RECIPES.md#handoff-in-the-project-root) ·
243
+ > [Workspace package](RECIPES.md#handoff-as-a-workspace-package) ·
244
+ > [pnpm](RECIPES.md#pnpm) · [npm scripts on Windows](RECIPES.md#npm-scripts-on-windows)
245
+
246
+ ## Configuration
247
+
248
+ ### Config file
249
+
250
+ Handoff reads `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in that order.
251
+ `-c, --config <file>` loads one specific file instead. `defineConfig` gives
252
+ typed authoring:
128
253
 
129
254
  ```ts
130
- // vite.config.ts
255
+ // handoff.config.ts
256
+ import { defineConfig, fromEnv } from 'handoff-app';
257
+
131
258
  export default defineConfig({
132
- publicDir: 'static',
259
+ app: { title: 'Acme Design System' },
260
+ catalog: { include: ['components', 'patterns'] },
261
+ runtime: {
262
+ mode: 'workspace',
263
+ workspace: { declarationFormat: 'ts' },
264
+ registry: {
265
+ database: { url: fromEnv('DATABASE_URL'), driver: 'pg' },
266
+ },
267
+ },
133
268
  });
134
269
  ```
135
270
 
136
- Without that change the generated documentation API is copied into the
137
- production build of the application. A Next.js application cannot use this
138
- placement: `sitesOutputDirectory` moves `out/` away from its export directory,
139
- but its `public` directory is a fixed convention with no setting to move it.
271
+ Values merge onto the defaults. Plain objects merge recursively. Arrays,
272
+ scalars, `null`, and functions replace the default.
140
273
 
141
- **Handoff as a workspace package.** For a repository that is already a
142
- workspace. `handoff/` gets its own `package.json` and scripts, generated output
143
- lands in `handoff/`, and the root forwards to it:
274
+ ### Main settings
144
275
 
145
- ```json
146
- {
147
- "workspaces": ["handoff"],
148
- "scripts": { "handoff:dev": "npm run dev --workspace handoff" }
149
- }
276
+ | Setting | Purpose |
277
+ | --- | --- |
278
+ | `app.title`, `app.client` | Name of the documentation site and of the client |
279
+ | `app.theme` | Theme of the documentation app. `npx handoff-app eject:theme` copies it so that you can change it. |
280
+ | `app.basePath` | URL path of the site when you serve it under a sub-path, for example `/design-system` |
281
+ | `app.breakpoints` | Preview widths. A declared block merges onto the defaults. |
282
+ | `app.ports` | Workspace server ports, `{ app, websocket }`. The defaults are 3000 and 3001. |
283
+ | `catalog.include` | Catalog directories. See [Registration](#registration). |
284
+ | `entries.scss`, `entries.js` | Global stylesheet and script that Handoff builds and loads into every preview |
285
+ | `integrations.figma` | Figma file and token. See [Figma foundations](#figma-foundations). |
286
+ | `exportsOutputDirectory` | Directory for fetched foundations. The default is `exported`. |
287
+ | `sitesOutputDirectory` | Directory for build output. The default is `out`. |
288
+ | `runtime.mode` | `workspace` (default) or `registry` |
289
+ | `runtime.workspace.declarationFormat` | Format of the declarations that `checkout` writes: `ts`, `js`, or `cjs` |
290
+ | `runtime.registryConnection` | Registry URL and token of a connected workspace |
291
+ | `runtime.registry` | Registry database, asset storage, and email. See [Registry](#registry). |
292
+ | `runtime.mcp` | MCP endpoint on or off. See [MCP](#mcp). |
293
+ | `runtime.ai` | AI assistant. See [AI assistant](#ai-assistant). |
294
+ | `hooks` | Build hooks, documented in [docs/api.md](docs/api.md) |
295
+
296
+ The `Config` type documents every setting. Several settings read an environment
297
+ variable by default. [Environment variables](#environment-variables) lists them.
298
+
299
+ ### Build-time and runtime values
300
+
301
+ `fromEnv('NAME')` refers to an environment variable. It does not copy the
302
+ value into the config.
303
+
304
+ - A literal value is baked into the build.
305
+ - For a `fromEnv()` value, the build keeps only the variable name. A deployed
306
+ registry reads the value at request time. Thus you can change it without a
307
+ rebuild.
308
+ - Secrets must use `fromEnv()`.
309
+
310
+ Some settings change the shape of the build. These settings are baked, so a
311
+ change to a deployed registry needs a rebuild:
312
+
313
+ - `runtime.mode`
314
+ - `runtime.mcp`
315
+ - `runtime.ai.enabled`
316
+ - The database driver
317
+ - The asset storage provider and the email provider
318
+ - Every custom server module
319
+
320
+ ### Profiles
321
+
322
+ A profile is a config file that merges onto the base config. It holds only what
323
+ changes between environments. Thus a project keeps one shared config, not a
324
+ second full copy.
325
+
326
+ - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. When no
327
+ profile is selected, Handoff loads only this file.
328
+ - A profile file is `handoff.config.<profile>.*`, with the same four
329
+ extensions.
330
+ - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
331
+ `--profile` has priority over `HANDOFF_PROFILE`.
332
+ - Profile names are not predefined. A name can contain lowercase letters,
333
+ numbers, and hyphens.
334
+ - A selected profile must exist. If no `handoff.config.<name>.*` file exists,
335
+ the command stops with an error.
336
+ - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
337
+ only a saved registry login. For these commands, the profile selects the
338
+ registry. Every other command needs the config file.
339
+ - Layers apply in this order: defaults, base config, profile, then
340
+ programmatic config. The [merge rules](#config-file) apply to each layer.
341
+ - A selected profile also reads `.env.<profile>` on top of `.env`, if that file
342
+ exists. Handoff reads both files from the directory where the command runs. A
343
+ variable that is already in the environment has priority over both files.
344
+
345
+ ```bash
346
+ npx handoff-app build --target registry --profile registry
150
347
  ```
151
348
 
152
- The dependency rule is easiest to get wrong here: the declarations in `src/`
153
- belong to the application package, not to the workspace, so both packages
154
- declare `handoff-app`.
349
+ A profile is a good place for the registry build settings: the database
350
+ driver, the database variable name, the asset storage, the email provider, and
351
+ MCP. `build --target registry` always builds a registry-mode application, so
352
+ the profile does not set `runtime.mode`. A hosting provider or a CI job selects
353
+ the profile with `HANDOFF_PROFILE`.
155
354
 
156
- ### pnpm
355
+ `defineConfig` types a profile and a base config the same way. The generated
356
+ `.gitignore` lists `handoff.config.local.*`. Thus `local` is the usual name for
357
+ a profile that stays on one machine.
157
358
 
158
- `build` and `build --package vercel` work with the default pnpm layout.
159
- `build --target registry` cannot follow pnpm symlinks when it traces its runtime
160
- dependencies into a standalone bundle, so it needs a flat layout:
359
+ Arrays replace. Thus a profile that declares `catalog.include` also controls
360
+ what `make:component` and `checkout` register.
161
361
 
162
- ```yaml
163
- # pnpm-workspace.yaml
164
- nodeLinker: hoisted
165
- ```
362
+ Handoff reads `.env.<profile>` once at startup and does not watch it. It cannot
363
+ set `HANDOFF_WORKING_PATH`, because Handoff finds the working path before it
364
+ knows the profile.
365
+
366
+ > **Recipes:** [Local profile](RECIPES.md#local-profile) ·
367
+ > [Registry build profile](RECIPES.md#registry-build-profile) ·
368
+ > [One workspace, several registries](RECIPES.md#one-workspace-several-registries)
369
+
370
+ ### Environment variables
371
+
372
+ Handoff reads two kinds of variables:
373
+
374
+ - **Fixed variables.** Handoff always reads these names.
375
+ - **Setting defaults.** Each one is the default `fromEnv()` name of a setting.
376
+ To use a different name, set the setting to `fromEnv('YOUR_NAME')`.
377
+
378
+ This section does not list the variables that you name in the config. Examples
379
+ are the keys of a custom asset storage provider or of an AI connection. A
380
+ standalone registry build writes `out/registry/README.md`, which lists every
381
+ variable that the build needs, with the names from your config.
382
+
383
+ **Fixed variables**
384
+
385
+ | Variable | Purpose |
386
+ | --- | --- |
387
+ | `HANDOFF_PROFILE` | Config profile merged onto the base config |
388
+ | `HANDOFF_WORKING_PATH` | Directory that holds `handoff.config.ts`. The default is the current directory. |
389
+ | `HANDOFF_LOG_LEVEL` | `debug`, `info`, `warn`, `error`, or `silent`. The default is `info`. |
390
+ | `HANDOFF_LOG_SCOPES` | Comma-separated log sources: `handoff`, `vite`, `next`. The default is all three. |
391
+ | `HANDOFF_LOGIN_NO_BROWSER` | Set to `true` to stop `login` from opening a browser, the same as `--no-browser` |
392
+ | `HANDOFF_CREATE_ASSETS_ZIP_FILES` | Set to `false` to skip the icon and logo zip files in `fetch` |
393
+ | `HANDOFF_SYNC_SECRET` | Optional deployment-wide credential with read and write access. The registry accepts it. A workspace uses it only when it has no token and no login. |
394
+ | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters. Required by a registry. |
395
+ | `AUTH_URL` | Canonical public registry URL, with the base path. Required by a registry. |
396
+ | `PORT` | Standalone registry server port |
397
+ | `HOSTNAME` | Standalone registry server bind hostname |
398
+ | `HANDOFF_AI_KEY_SECRET` | Encrypts the AI provider keys of readers, at least 32 characters |
399
+ | `HANDOFF_AI_CONNECTIONS` | JSON array of AI connections, merged over the built-in list by `id` |
400
+
401
+ **Setting defaults**
402
+
403
+ | Variable | Setting | Purpose |
404
+ | --- | --- | --- |
405
+ | `HANDOFF_FIGMA_PROJECT_ID` | `integrations.figma.projectId` | Figma file ID that `fetch` uses |
406
+ | `HANDOFF_DEV_ACCESS_TOKEN` | `integrations.figma.accessToken` | Figma personal access token that `fetch` uses |
407
+ | `HANDOFF_REGISTRY_URL` | `runtime.registryConnection.url` | Registry URL of a connected workspace |
408
+ | `HANDOFF_REGISTRY_ACCESS_TOKEN` | `runtime.registryConnection.accessToken` | Registry access token of a connected workspace |
409
+ | `DATABASE_URL` | `runtime.registry.database.url` | PostgreSQL connection string. Required by a registry. |
410
+ | `HANDOFF_APP_PORT` | `app.ports.app` | Workspace documentation server port. The default is 3000. |
411
+ | `HANDOFF_WEBSOCKET_PORT` | `app.ports.websocket` | Workspace live-reload server port. The default is 3001. |
412
+ | `HANDOFF_OUTPUT_DIR` | `exportsOutputDirectory` | Fetched output directory. The default is `exported`. |
413
+ | `HANDOFF_SITES_DIR` | `sitesOutputDirectory` | Build output directory. The default is `out`. |
166
414
 
167
- ## Catalog items
415
+ Keep the name `HANDOFF_OUTPUT_DIR`. The documentation app also reads this
416
+ variable directly.
168
417
 
169
- Every documented UI entry is a catalog item. Each item declares either an implementation or a composition of other items.
170
- An implementation names its renderer. `defineCatalogItem` comes from the module for that renderer.
171
- The declaration supplies stable identity, documentation metadata, source entries, and previews.
418
+ ## Authoring
172
419
 
173
- ### React
420
+ ### Catalog items
174
421
 
175
- Handoff finds the implementation file from its import, so you do not need to repeat the path, and
176
- builds the item's CSS from the stylesheets that import chain pulls in, descendants included.
177
- Previews are named exports. `Preview<typeof item>` gives them the implementation's argument type.
422
+ Every documented UI entry is a catalog item. An item declares one of these:
423
+
424
+ - An `implementation`, which names a renderer: React, Handlebars, or CSF.
425
+ The registry stores the item as a component.
426
+ - A `composition` of other items. The registry stores the item as a pattern.
427
+
428
+ An item never declares both. `defineCatalogItem` comes from the module of the
429
+ renderer. The declaration gives the stable id, the documentation metadata, the
430
+ source entries, and the previews.
431
+
432
+ **React.** Handoff finds the implementation file from its import, so you do not
433
+ repeat the path. Handoff builds the CSS of the item from the stylesheets that
434
+ the import chain loads, descendants included. Previews are named exports.
435
+ `Preview<typeof item>` gives them the argument type of the implementation.
178
436
 
179
437
  ```tsx
180
438
  // components/example/Component.tsx
@@ -210,88 +468,23 @@ export const Default = {
210
468
  } satisfies ComponentPreview;
211
469
  ```
212
470
 
213
- The export name is the preview name. Use `name` to set a different display title.
471
+ The export name is the preview name. `name` sets a different display title.
472
+ Handoff serializes the arguments. Thus a preview that needs an icon, a
473
+ callback, JSX children, or its own state declares a `render` function. A
474
+ declaration that holds JSX must use the `.handoff.tsx` extension.
214
475
 
215
- Arguments are serialized, so a preview that needs an icon, a callback, JSX children, or its own
216
- state declares `render` instead. It renders as a component, so a capitalized name lets it use hooks, and the item keeps
217
- documenting the implementation rather than a wrapper. Name a declaration that holds JSX
218
- `example.handoff.tsx`.
219
-
220
- ```tsx
221
- export const Interactive = {
222
- render: function Interactive() {
223
- const [label, setLabel] = useState('Example');
224
- return <Component label={label} onSelect={() => setLabel('Selected')} />;
225
- },
226
- } satisfies ComponentPreview;
227
- ```
476
+ **Handlebars.** The implementation is the path to the template:
477
+ `implementation: './Badge.hbs'`, from `handoff-app/handlebars`.
228
478
 
229
- ### Handlebars
479
+ **CSF.** A React item uses an existing story file with
480
+ `implementation: fromCSF('./Card.stories.tsx')`. The story file does not
481
+ change, and its named exports are the previews. The server renders a CSF
482
+ preview, and the browser does not hydrate it. The browser hydrates a direct
483
+ React implementation.
230
484
 
231
- The implementation is the path to the template. Arguments are `Record<string, unknown>` by default;
232
- an explicit argument type is supplied through `defineCatalogItem<BadgeArgs>`.
233
-
234
- ```ts
235
- // components/badge/badge.handoff.ts
236
- import { defineCatalogItem, type Preview } from 'handoff-app/handlebars';
237
-
238
- const item = defineCatalogItem({
239
- id: 'badge',
240
- name: 'Badge',
241
- implementation: './Badge.hbs',
242
- });
243
-
244
- export default item;
245
-
246
- type BadgePreview = Preview<typeof item>;
247
-
248
- export const Primary = {
249
- args: { variant: 'primary', children: 'New' },
250
- } satisfies BadgePreview;
251
- ```
252
-
253
- ### CSF
254
-
255
- CSF is a source format. A story file uses the renderer from the module that declares it. A React item references an existing CSF file with `fromCSF`.
256
- The CSF file stays unchanged and defines its previews through named exports. Each story's `args` merge with and override `meta.args`.
257
- Handoff preserves story names, `argTypes`, and `render` functions.
258
- It finds the React component through `meta.component`, then documents and publishes it with the story file.
259
-
260
- ```ts
261
- // components/card/card.handoff.ts
262
- import { defineCatalogItem, fromCSF } from 'handoff-app/react';
263
-
264
- export default defineCatalogItem({
265
- id: 'card',
266
- name: 'Card',
267
- implementation: fromCSF('./Card.stories.tsx'),
268
- });
269
- ```
270
-
271
- `Meta` and `StoryObj` are exported from `handoff-app/react`, so a story file type-checks without a
272
- Storybook dependency. A project that already has Storybook keeps using its own types.
273
-
274
- ```tsx
275
- // components/card/Card.stories.tsx
276
- import { type Meta, type StoryObj } from 'handoff-app/react';
277
- import Card from './Card';
278
-
279
- const meta = { component: Card } satisfies Meta<typeof Card>;
280
- export default meta;
281
-
282
- export const Primary: StoryObj<typeof meta> = {
283
- args: { title: 'Example' },
284
- };
285
- ```
286
-
287
- A CSF preview is rendered on the server and is not hydrated. A direct React implementation is
288
- hydrated in the browser.
289
-
290
- ### Compositions
291
-
292
- A composition references other items by stable id. A named preview can be selected and its arguments
293
- can be overridden by each reference. A composition needs no renderer, so it is declared with
294
- `defineCatalogItem` from `handoff-app/pattern`.
485
+ **Compositions.** A composition references other items by stable id. Each
486
+ reference can select a named preview and override its arguments. A composition
487
+ needs no renderer, so it uses `defineCatalogItem` from `handoff-app/pattern`:
295
488
 
296
489
  ```ts
297
490
  // patterns/example/example.handoff.ts
@@ -309,754 +502,510 @@ export default defineCatalogItem({
309
502
  });
310
503
  ```
311
504
 
312
- An item declares `implementation` or `composition`, never both. Terms such as atom, element, and
313
- block stay optional classification metadata on `type` and `categories`.
505
+ **Ids.** The `id` addresses the item everywhere: its artifacts, its
506
+ documentation URL, and the `publish` and `checkout` commands. Components and
507
+ patterns share one namespace, so each id must be unique across both. If two
508
+ declarations use one id, Handoff keeps the first, skips the second, and names
509
+ both files in a warning. `publish` does not run until each id is unique.
314
510
 
315
- The `id` addresses the item everywhere: its artifacts, its documentation URL, and its publish and
316
- checkout commands. Components and patterns share one namespace, so each id must be unique across
317
- both.
511
+ Terms such as atom, element, and block are optional classification metadata on
512
+ `type` and `categories`.
513
+
514
+ > **Recipes:** [React preview with a render function](RECIPES.md#react-preview-with-a-render-function) ·
515
+ > [Handlebars item](RECIPES.md#handlebars-item) · [CSF stories](RECIPES.md#csf-stories) ·
516
+ > [Composition with preview overrides](RECIPES.md#composition-with-preview-overrides)
318
517
 
319
- If two declarations claim one id, Handoff keeps the first and skips the second. The warning names
320
- both files. `publish` refuses to run until each id is unique.
321
518
  ### Registration
322
519
 
323
- Catalog directories are registered in one place. A path is either an item directory or a collection
324
- directory whose subdirectories are each treated as an item.
520
+ `catalog.include` in the config registers the catalog directories. Each path is
521
+ one of these:
325
522
 
326
- ```ts
327
- // handoff.config.ts
328
- import { defineConfig } from 'handoff-app';
523
+ - An item directory.
524
+ - A collection directory. Each subdirectory is an item.
329
525
 
330
- export default defineConfig({
331
- catalog: {
332
- include: ['components', 'patterns'],
333
- },
334
- runtime: {
335
- workspace: {
336
- declarationFormat: 'ts',
337
- },
338
- },
339
- });
526
+ ```ts
527
+ catalog: {
528
+ include: ['components', 'patterns'],
529
+ },
340
530
  ```
341
531
 
532
+ `npx handoff-app make:component <name>` creates a Handlebars item and adds its
533
+ directory to `catalog.include` when necessary.
534
+
342
535
  ### Pages
343
536
 
344
- Custom documentation pages are Markdown files under `pages/`. Their relative
345
- paths become their routes and registry IDs.
537
+ Custom documentation pages are Markdown files under `pages/`. The relative
538
+ path of a page is its route and its registry id.
539
+ `npx handoff-app make:page <name> [parent]` creates a page.
540
+ `npx handoff-app eject:pages` copies the default pages so that you can change
541
+ them.
346
542
 
347
- ## Figma foundations
543
+ ### Figma foundations
348
544
 
349
- Figma integration is optional. When Figma credentials are entered during
350
- `init`, they are written to `.env` by the wizard and the fetch command can be
351
- run directly.
545
+ The Figma integration is optional. If you enter Figma credentials during
546
+ `init`, the wizard writes them to `.env`, and `fetch` is ready to run.
352
547
 
353
- When that step is skipped or the Figma source must be changed, these values can
354
- be added or updated in `.env`:
548
+ To add or change the Figma source, set these values in `.env`:
355
549
 
356
- ```bash
550
+ ```dotenv
357
551
  HANDOFF_FIGMA_PROJECT_ID=figma-file-id
358
552
  HANDOFF_DEV_ACCESS_TOKEN=figma-personal-access-token
359
553
  ```
360
554
 
361
- These are the defaults of `integrations.figma` in `handoff.config`. Set the block
362
- only to use other variable names, to commit the file ID, or to change the file
363
- for a profile. The access token accepts only an environment reference:
555
+ The personal access token must have the `file_content:read` and
556
+ `library_content:read` scopes. Fetch the library:
364
557
 
365
- ```ts
366
- integrations: {
367
- figma: {
368
- projectId: fromEnv("HANDOFF_FIGMA_PROJECT_ID", { default: null }),
369
- accessToken: fromEnv("HANDOFF_DEV_ACCESS_TOKEN", { default: null }),
370
- },
371
- },
558
+ ```bash
559
+ npm run fetch
372
560
  ```
373
561
 
374
- If a value is empty, `fetch` asks for it in a terminal and can save it to `.env`,
375
- or to `.env.<profile>` when a profile is selected. Without a terminal, such as
376
- in CI, `fetch` stops with an error that names the missing variable.
562
+ `fetch` writes tokens, CSS, Sass, and the available assets to `exported/`.
563
+ Commit `exported/` so that local builds and CI builds use the same inputs. To
564
+ make sure that the fetch worked, look for `exported/tokens.json`. An empty
565
+ foundation page does not prove that the fetch worked.
377
566
 
378
- The `file_content:read` and `library_content:read` scopes must be granted to the
379
- personal access token. The configured library is fetched with:
567
+ If a value is empty, `fetch` asks for it in the terminal. It can save the value
568
+ to `.env`, or to `.env.<profile>` when a profile is selected. Without a
569
+ terminal, for example in CI, `fetch` stops with an error that names the
570
+ missing variable.
380
571
 
381
- ```bash
382
- npm run fetch
383
- ```
572
+ After a design change, publish the Figma library again. Then run `fetch` again
573
+ to get the latest foundations.
384
574
 
385
- After design changes, the Figma library should be republished and the command
386
- should be run again so the latest foundations are pulled.
575
+ These variable names are the defaults of `integrations.figma`. Set the block
576
+ only to use different variable names, to commit the file ID, or to change the
577
+ file for a profile. The access token accepts only an environment reference:
387
578
 
388
- Generated tokens, CSS, Sass, and available assets are written below
389
- `exported/`. The `exported/` directory should be committed so the
390
- same inputs are used by local and CI builds. A successful fetch can be verified
391
- by checking for generated token data such as `exported/tokens.json`;
392
- an empty foundation route does not prove that the fetch succeeded.
579
+ ```ts
580
+ integrations: {
581
+ figma: {
582
+ projectId: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
583
+ accessToken: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
584
+ },
585
+ },
586
+ ```
393
587
 
394
- ## Workspace
588
+ ## Running the workspace
395
589
 
396
- Components, patterns, pages, styles, and configuration are authored as local
397
- workspace files. For day-to-day authoring, the documentation server and file
398
- watchers are started with:
590
+ Start the documentation server and the Handoff file watchers:
399
591
 
400
592
  ```bash
401
593
  npm run start
402
594
  ```
403
595
 
404
- Changes are rebuilt and reflected in the documentation while the command is
405
- running. When Handoff file watchers are not required, the development server
406
- can be started with `npm run dev` instead.
596
+ While the command runs, Handoff rebuilds changed files and updates the
597
+ documentation. If you do not need the file watchers, run `npm run dev`
598
+ instead.
407
599
 
408
- The following should be visible at http://localhost:3000:
600
+ Open http://localhost:3000 and make sure that:
409
601
 
410
- - the app reports `Workspace` as the active runtime mode;
411
- - components appear in the component list;
412
- - component detail pages render every configured preview;
413
- - patterns render their referenced components; and
414
- - foundation pages contain the fetched values.
602
+ - The app shows `Workspace` as the active runtime mode.
603
+ - The component list shows your components.
604
+ - Each component page renders all of its previews.
605
+ - Each pattern renders the components that it references.
606
+ - The foundation pages show the fetched values.
415
607
 
416
- ## Build outputs
608
+ ## Building
417
609
 
418
- A static documentation site is built with:
610
+ **Static site.** Build the documentation as a static site:
419
611
 
420
612
  ```bash
421
613
  npm run build
422
614
  ```
423
615
 
424
- Static output is written below `out/static` and can be served by any
425
- static file server or CDN.
616
+ The output goes to `out/static`. A static file server or a CDN can serve it. A
617
+ static site has no API routes. Thus it has no MCP endpoint and no AI
618
+ assistant.
426
619
 
427
- Components whose files have not changed since the last build are not built
428
- again. The build cache in `.handoff/.cache/` records the files of each
429
- component and every file that its last build read, Sass partials included.
430
- Files under `node_modules` are covered by the lockfile instead. Assets that a
431
- stylesheet loads through `url()` are not tracked. Every component is built
432
- again with:
620
+ **Build cache.** Handoff does not build a component again if its files did not
621
+ change since the last build. The cache in `.handoff/.cache/` records the files
622
+ of each component and every file that its last build read, Sass partials
623
+ included. The lockfile covers files under `node_modules`. The cache does not
624
+ track assets that a stylesheet loads through `url()`. Build every component
625
+ again:
433
626
 
434
627
  ```bash
435
628
  npm run build -- --force
436
629
  ```
437
630
 
438
- A standalone registry application is built with:
631
+ **Registry server.** Build a standalone registry application:
439
632
 
440
633
  ```bash
441
634
  npm run build -- --target registry
442
635
  ```
443
636
 
444
- The generated application is automatically configured for registry mode by the
445
- command. The database-backed Next.js bundle is written to `out/registry`, and
446
- workspace source is not compiled or served. A Vercel Build Output bundle can be
447
- produced for either target by adding `--package vercel`.
448
-
449
- ## Configuration
450
-
451
- Configuration is read from `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in
452
- that order. `defineConfig` provides typed authoring. Values merge onto the
453
- defaults: plain objects merge recursively, and arrays, scalars, `null`, and
454
- functions replace.
455
-
456
- Useful environment variables:
457
-
458
- | Variable | Purpose |
459
- | --- | --- |
460
- | `HANDOFF_PROFILE` | Config profile merged onto the base config |
461
- | `HANDOFF_FIGMA_PROJECT_ID` | Figma file ID used by `fetch` |
462
- | `HANDOFF_DEV_ACCESS_TOKEN` | Figma personal access token used by `fetch` |
463
- | `HANDOFF_REGISTRY_URL` | Connected workspace registry URL |
464
- | `HANDOFF_REGISTRY_ACCESS_TOKEN` | Registry access token used by a connected workspace |
465
- | `HANDOFF_SYNC_SECRET` | Optional deployment-wide registry credential |
466
- | `DATABASE_URL` | Registry PostgreSQL connection string |
467
- | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters |
468
- | `AUTH_URL` | Canonical public registry URL |
469
- | `PORT` | Standalone registry server port |
470
- | `HOSTNAME` | Standalone registry bind hostname |
471
- | `HANDOFF_AI_KEY_SECRET` | Encrypts reader-supplied AI provider keys, at least 32 characters |
472
- | `HANDOFF_AI_CONNECTIONS` | JSON array of AI connections, merged over the baked list by `id` |
473
- | `HANDOFF_OUTPUT_DIR` | Override the fetched output directory |
474
- | `HANDOFF_CREATE_ASSETS_ZIP_FILES` | Set to `false` to skip the icon and logo zip files in `fetch` |
475
- | `HANDOFF_SITES_DIR` | Override the build output directory |
476
- | `HANDOFF_WORKING_PATH` | Directory holding `handoff.config.ts`; defaults to the current directory |
477
- | `HANDOFF_APP_PORT` | Workspace documentation server port |
478
- | `HANDOFF_WEBSOCKET_PORT` | Workspace live-reload server port |
479
-
480
- ## Profiles
637
+ The command sets registry mode in the generated application. It writes the
638
+ Next.js server bundle to `out/registry`. It does not compile or serve the
639
+ workspace source. The [Registry](#registry) section explains how to deploy it.
481
640
 
482
- A profile is a sidecar config file that merges onto the base config. It holds
483
- only what changes between environments, so a project keeps one shared config
484
- instead of a second complete copy of it.
485
-
486
- - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. It is the
487
- only config file loaded when no profile is selected.
488
- - A profile file is `handoff.config.<profile>.*`, with the same four
489
- extensions.
490
- - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
491
- `--profile` takes precedence over `HANDOFF_PROFILE`.
492
- - Profile names are not predefined. A name can contain lowercase letters,
493
- numbers, and hyphens.
494
- - A selected profile must exist. If no `handoff.config.<name>.*` file is found,
495
- the command stops with an error.
496
- - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
497
- only a saved registry login, because there the profile selects the registry.
498
- Every other command still needs the config file.
499
- - Layers resolve as defaults, base config, profile, then programmatic config,
500
- with the merge rules described under [Configuration](#configuration).
501
- - A selected profile also reads `.env.<profile>` on top of `.env`, when that
502
- file exists. Both are read from the directory the command runs in, and a
503
- variable already set in the environment beats both files.
641
+ **Vercel.** Add `--package vercel` to either target to make a Vercel Build
642
+ Output bundle in `.vercel/output`:
504
643
 
505
644
  ```bash
506
- npx handoff-app build --target registry --profile registry
507
- ```
508
-
509
- `build --target registry` always packages a registry-mode application, so a
510
- profile does not need to set `runtime.mode`. This makes a profile a good place
511
- for the registry build settings: the database driver, the database
512
- environment-variable name, the asset storage, and whether MCP is enabled. A
513
- hosting provider or a CI job selects the profile through `HANDOFF_PROFILE`.
514
-
515
- `defineConfig` types a profile as well as a base config. The generated
516
- `.gitignore` lists `handoff.config.local.*`, which makes `local` the usual name
517
- for a profile that stays on one machine.
518
-
519
- Arrays replace, so a profile that declares `catalog.include` also decides what
520
- `make` and `checkout` register.
521
-
522
- `.env.<profile>` is read once at startup rather than watched.
523
- `HANDOFF_WORKING_PATH` cannot be set from it, because the working path is
524
- resolved before any profile is known.
525
-
526
- ## Registry setup
527
-
528
- ### 1. Database migrations
529
-
530
- Migrations are run from the source workspace or CI checkout, where the project
531
- dependency and configuration are available:
532
-
533
- ```bash
534
- DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public" \
535
- npm run db:migrate
536
- ```
537
-
538
- Migrations are a controlled release step and are separate from the build,
539
- server startup, and browser installer.
540
-
541
- ### 2. Registry runtime
542
-
543
- The generated registry bundle uses these environment variables:
544
-
545
- ```dotenv
546
- PORT=4000
547
- HOSTNAME=0.0.0.0
548
- DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public"
549
- AUTH_SECRET="replace-with-at-least-32-random-characters"
550
- AUTH_URL="http://localhost:4000"
551
- ```
552
-
553
- `DATABASE_URL`, `AUTH_SECRET`, and `AUTH_URL` are required. The standalone
554
- server also reads `PORT` and `HOSTNAME`. The generated entrypoint is
555
- `out/registry/server.js`; how it is started and hosted depends on the deployment
556
- environment.
557
-
558
- The defaults work without more configuration: asset blobs are stored in
559
- PostgreSQL, and email is not sent. Other providers are baked into the build, so
560
- they must be selected under [Registry providers](#registry-providers) before
561
- the registry is built.
562
-
563
- ### 3. Installation
564
-
565
- The installer is opened at http://localhost:4000/install, where the first
566
- administrator is created. The deployment is verified by the installer, but
567
- migrations are never run by it.
568
-
569
- An uninstalled registry can be claimed by the first visitor. A new deployment
570
- should not be left unattended before installation is complete.
571
-
572
- ### 4. Workspace authorization
573
-
574
- A workspace reaches a registry with a registry URL and an access token. There
575
- are two ways to supply them. Both use the same kind of token, and both are
576
- listed and revoked under Account → Access tokens in the registry.
577
-
578
- #### Device login
579
-
580
- CLI authorization is started from the source workspace with:
581
-
582
- ```bash
583
- npm run login -- --url http://localhost:4000
645
+ npm run build -- --target registry --package vercel
584
646
  ```
585
647
 
586
- Registry sign-in, entry of the displayed device code, and CLI approval are
587
- completed in the browser. The issued credential is saved in
588
- `.handoff/cli-auth.json` for that exact registry URL.
648
+ ## Registry
589
649
 
590
- One credential is saved per profile, so a workspace can stay signed in to
591
- several registries at the same time:
650
+ A registry is a deployed catalog with a PostgreSQL database. It serves only
651
+ the records that a workspace publishes. It never reads the workspace
652
+ directly.
592
653
 
593
- ```bash
594
- npm run login -- --profile staging --url https://staging.example.com
595
- npm run publish -- all --profile staging
596
- ```
654
+ ### Deploy a registry
597
655
 
598
- A login profile name is free. It needs no `handoff.config.<name>.*` file, and
599
- `login` is the only command that accepts a new name. A profile without its own
600
- login falls back to the default login, so one `npm run login` is still enough
601
- for a workspace with one registry.
602
-
603
- `npm run logout` revokes and removes the login of the selected profile. The
604
- `--all` option removes every saved login.
656
+ 1. Apply the database migrations from the workspace or a CI checkout, where the
657
+ project config is available:
605
658
 
606
- #### Environment variables
659
+ ```bash
660
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public" \
661
+ npm run db:migrate
662
+ ```
607
663
 
608
- A token is created in the registry under Account → Access tokens. Read and
609
- write access is required to publish; read access is enough to check out. The
610
- token and the registry URL are then set in `.env`:
664
+ Migrations are a controlled release step. The build, the server start, and
665
+ the installer never run them. Run `db:migrate` again each time you upgrade
666
+ `handoff-app`, before you deploy the new registry build.
611
667
 
612
- ```dotenv
613
- HANDOFF_REGISTRY_URL=http://localhost:4000
614
- HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_...
615
- ```
668
+ 2. Select the [database driver](#database), the [asset storage](#asset-storage),
669
+ and the [email provider](#email). The build bakes these choices, so set them
670
+ before you build. The defaults work without more configuration.
616
671
 
617
- No configuration file entry is needed, because these two variables are the
618
- defaults for `runtime.registryConnection`. A selected profile reads
619
- `.env.<profile>` on top of `.env`, so one workspace can address a different
620
- registry per profile. In CI, the job environment supplies the same two
621
- variables instead of a file.
672
+ 3. Build the registry:
622
673
 
623
- Environment values win over a saved device login, so a CI job stays
624
- deterministic on a machine where a developer is signed in. A token is only used
625
- for the registry URL it was issued for. If the environment names a different
626
- registry than the login of the selected profile, publish and checkout report
627
- which login was skipped and why.
674
+ ```bash
675
+ npm run build -- --target registry
676
+ ```
628
677
 
629
- ### 5. Content publishing
678
+ 4. Start `out/registry/server.js` with these environment variables:
630
679
 
631
- Every kind in the workspace is published in dependency order with:
680
+ ```dotenv
681
+ PORT=4000
682
+ HOSTNAME=0.0.0.0
683
+ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public"
684
+ AUTH_SECRET="replace-with-at-least-32-random-characters"
685
+ AUTH_URL="http://localhost:4000"
686
+ ```
632
687
 
633
- ```bash
634
- npm run publish -- all
635
- ```
688
+ `DATABASE_URL`, `AUTH_SECRET`, and `AUTH_URL` are required. The standalone
689
+ server also reads `PORT` and `HOSTNAME`. For Vercel, build with
690
+ `--package vercel` and set these variables in the deployment environment.
636
691
 
637
- A single kind is published on its own:
692
+ 5. Open http://localhost:4000/install and create the first administrator. The
693
+ installer examines the deployment, but it never runs migrations.
638
694
 
639
- ```bash
640
- npm run publish -- catalog
641
- npm run publish -- pages
642
- npm run publish -- tokens
643
- npm run publish -- assets
644
- ```
695
+ CAUTION: Complete the installation immediately after the deployment. The first
696
+ visitor can claim a registry that is not installed.
645
697
 
646
- `catalog` covers every catalog item. An item is stored as a component or as a
647
- pattern, and its declaration decides which. The command names items and never a
648
- kind.
698
+ > **Recipes:** [Local registry](RECIPES.md#local-registry) ·
699
+ > [Vercel and Neon](RECIPES.md#vercel-and-neon)
649
700
 
650
- Publishing tokens or assets runs the Figma data pipeline before upload, so the
651
- documented Figma credentials must be available.
701
+ ### Database
652
702
 
653
- One or more IDs can be appended to narrow a publish to those entities:
703
+ PostgreSQL is the supported database. `driver` selects how the registry
704
+ connects. The default is `pg`. Use `neon` for Neon PostgreSQL:
654
705
 
655
- ```bash
656
- npm run publish -- catalog item-id another-id
706
+ ```ts
707
+ runtime: {
708
+ registry: {
709
+ database: {
710
+ url: fromEnv('DATABASE_URL'),
711
+ driver: 'neon',
712
+ },
713
+ },
714
+ },
657
715
  ```
658
716
 
659
- `--dry-run` reports what would be uploaded and contacts no registry at all, so
660
- it needs neither a registry URL nor a token. It still runs the build, which
661
- refreshes generated output on disk; `--no-build` skips the build and publishes
662
- the existing output, and the two combine to leave the workspace untouched:
717
+ Both drivers use the same schema and the same `db:migrate` step.
663
718
 
664
- ```bash
665
- npm run publish -- all --dry-run
666
- npm run publish -- catalog --no-build
667
- ```
668
-
669
- `checkout` takes the same `all`, multi-ID, and `--dry-run` forms. A dry-run
670
- checkout reads from the registry, lists the files it would create or overwrite,
671
- and writes nothing.
719
+ ### Asset storage
672
720
 
673
- After the registry is reloaded, the published catalog items and foundations are
674
- visible. Published database records are read by registry pages; the local
675
- workspace is never read directly.
721
+ By default, the registry stores asset blobs in PostgreSQL. Each blob can be at
722
+ most 4 MB. `assetStorage.maxInlineBytes` changes this limit.
676
723
 
677
- For CI, supply `HANDOFF_REGISTRY_URL` and `HANDOFF_REGISTRY_ACCESS_TOKEN`
678
- through the job environment, as described under
679
- [Workspace authorization](#4-workspace-authorization). A connection block in
680
- `handoff.config.ts` is needed only to pin the URL in the repository, or to read
681
- the values from differently named variables (`fromEnv` is imported from
682
- `handoff-app`):
724
+ A [custom server module](#custom-server-modules) can store blobs in a
725
+ different service:
683
726
 
684
727
  ```ts
685
728
  runtime: {
686
- registryConnection: {
687
- url: 'https://registry.example.com',
688
- accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN'),
729
+ registry: {
730
+ assetStorage: {
731
+ provider: 'custom',
732
+ module: './server/storage/s3.mjs',
733
+ options: { bucket: 'handoff-assets', secretKey: fromEnv('S3_SECRET_KEY') },
734
+ },
689
735
  },
690
736
  },
691
737
  ```
692
738
 
693
- ## Registry providers
739
+ The module default-exports a `defineStorageProvider()` result with these
740
+ methods:
694
741
 
695
- ### Vercel and Neon
742
+ - `put` stores a blob and returns the `storageRef` that the registry records.
743
+ - `get` returns a blob by its `storageRef`, as `{ kind: 'stream', stream }`,
744
+ `{ kind: 'bytes', bytes }`, or `{ kind: 'redirect', url }`.
745
+ - `delete` removes a blob by its `storageRef`.
746
+ - `createUpload` is optional. It enables direct uploads.
696
747
 
697
- Vercel packaging and the Neon PostgreSQL driver are supported independently.
698
- Either integration can be used without the other.
748
+ **Upload paths.** `publish` uploads only the blobs that the registry does not
749
+ have. The active provider decides which of two paths each blob takes. No
750
+ setting changes the path.
699
751
 
700
- #### Vercel packaging
752
+ | Path | When | Hash check | Size limit |
753
+ | --- | --- | --- | --- |
754
+ | Through the registry | The `database` provider, or a custom provider without `createUpload` | The registry | The request limit of the host, about 4.5 MB on Vercel. The `database` provider also applies `maxInlineBytes`. |
755
+ | Direct to storage | A custom provider with `createUpload` | Storage, through the signed URL | The limit of the storage |
701
756
 
702
- A registry bundle that follows the Vercel Build Output API is produced with:
757
+ For a direct upload, the registry gives the CLI a signed URL, and the CLI sends
758
+ the blob to storage. The registry records the blob only after `get` finds the
759
+ stored object.
703
760
 
704
- ```bash
705
- npm run build -- --target registry --package vercel
706
- ```
761
+ - The machine that runs `publish` must be able to connect to the storage URL.
762
+ - If a direct upload fails, `publish` stops. It does not try again through the
763
+ registry.
764
+ - The signed URL must make storage reject bytes that do not match `hash`.
765
+ - The registry does not delete an uploaded object that it never records.
766
+
767
+ **Download paths.** The result of `get` controls how a reader receives a blob.
768
+ With `{ kind: 'redirect' }`, the registry sends the reader to storage. With
769
+ `bytes` or `stream`, the registry sends the blob itself.
707
770
 
708
- The generated deployment output is written to `.vercel/output`. The registry
709
- runtime variables listed above are supplied through the deployment environment.
771
+ **Changing the provider.** The registry records which provider stores each blob.
772
+ Blobs in PostgreSQL stay readable after you select a custom provider. A publish
773
+ does not move blobs. If you change the custom provider or its storage, copy the
774
+ stored objects first. The new provider must find each object by the
775
+ `storageRef` that the previous provider returned. After a change back to
776
+ `database`, the registry cannot read the blobs of the custom provider.
710
777
 
711
- #### Neon PostgreSQL
778
+ > **Recipes:** [S3-compatible storage](RECIPES.md#s3-compatible-storage) ·
779
+ > [Vercel Blob](RECIPES.md#vercel-blob)
712
780
 
713
- Import `fromEnv` from `handoff-app`. Select the Neon connection driver in `handoff.config.ts`:
781
+ ### Email
782
+
783
+ The registry sends invitation and password-reset emails when `email.from` is
784
+ set. Without it, an administrator sees each invitation link one time and sends
785
+ it manually, and password reset is not available.
714
786
 
715
787
  ```ts
716
788
  runtime: {
717
789
  registry: {
718
- database: {
719
- url: fromEnv('DATABASE_URL'),
720
- driver: 'neon',
790
+ email: {
791
+ from: 'Handoff <no-reply@example.com>',
792
+ provider: 'smtp',
793
+ options: { host: fromEnv('SMTP_HOST') },
721
794
  },
722
795
  },
723
796
  },
724
797
  ```
725
798
 
726
- A Neon PostgreSQL connection string is supplied through `DATABASE_URL`. The
727
- same explicit database migration step is required before deployment.
799
+ `provider` selects the delivery service. It is required when you set `email`.
728
800
 
729
- ### Asset storage
801
+ - `smtp` sends through an SMTP server, for example Amazon SES, SendGrid, or
802
+ Microsoft 365.
803
+ - `custom` names a [custom server module](#custom-server-modules) that
804
+ default-exports `defineEmailProvider()`. Use it for an HTTP API, for example
805
+ Resend or Postmark.
730
806
 
731
- The registry stores asset blobs in PostgreSQL by default, with a limit of 4 MB
732
- for each blob. `maxInlineBytes` changes this limit.
807
+ `apiKey` and `password` must use `fromEnv()`. A profile can set a different
808
+ sender or provider. When a profile changes the provider, it replaces
809
+ `options`.
733
810
 
734
- #### Custom adapter
811
+ > **Recipes:** [SMTP](RECIPES.md#smtp) · [Resend](RECIPES.md#resend) ·
812
+ > [Custom email provider](RECIPES.md#custom-email-provider)
735
813
 
736
- A [custom server module](#custom-server-modules) can store blobs in a
737
- different service. The module default-exports a `defineAssetStorage()` adapter
738
- with these methods:
814
+ ### Custom server modules
739
815
 
740
- - `put` stores a blob and returns the `storageRef` that the registry records.
741
- - `get` returns a blob by its `storageRef`, as `{ kind: 'stream', stream }`,
742
- `{ kind: 'bytes', bytes }`, or `{ kind: 'redirect', url }`.
743
- - `delete` removes a blob by its `storageRef`.
744
- - `createUpload` is optional. It enables [direct uploads](#direct-uploads).
816
+ Asset storage, email, and AI connections can name a custom server module. Each
817
+ module must obey these rules:
745
818
 
746
- Literals in `options` are baked into the build. The deployed registry reads
747
- each `fromEnv()` value at request time and gives the resolved `options` to the
748
- module. Thus secrets must use `fromEnv()`.
819
+ - The path is relative to the working directory.
820
+ - The file is `.js` or `.mjs`.
821
+ - The default export is the object, or a factory that returns it.
822
+ - The module imports its define helper from `handoff-app/providers`. The registry
823
+ build stops when a module imports `handoff-app`.
824
+ - The project installs the packages that the module imports. The registry
825
+ build copies them into the bundle.
749
826
 
750
- The S3 and Vercel Blob examples that follow are complete adapters with direct
751
- uploads.
827
+ The factory gets `options` with each `fromEnv()` value resolved at request
828
+ time.
752
829
 
753
- ##### Direct uploads
830
+ ## Connecting a workspace to a registry
754
831
 
755
- By default, `publish` sends each blob to the registry, and the registry sends
756
- it to storage. A serverless host limits the size of one request. Vercel permits
757
- about 4.5 MB, so a larger blob fails.
832
+ A connected workspace publishes content to a registry and checks content out
833
+ from it. It needs a registry URL and an access token. You can supply them with
834
+ a [device login](#device-login) or with [environment variables](#access-token).
835
+ Both use the same kind of token. You can see and revoke tokens under
836
+ Account → Access tokens in the registry.
758
837
 
759
- With `createUpload`, the registry gives the CLI a signed URL, and the CLI sends
760
- the blob directly to storage. The signed URL must make storage reject bytes
761
- that do not match `hash`. The registry records the blob only after `get` finds
762
- the stored object. The registry does not delete an uploaded object that it
763
- never records.
838
+ ### Device login
764
839
 
765
- The `database` provider always uploads through the registry, with its
766
- `maxInlineBytes` limit.
840
+ Start the authorization from the workspace:
767
841
 
768
- ##### S3-compatible storage
842
+ ```bash
843
+ npm run login -- --url http://localhost:4000
844
+ ```
769
845
 
770
- This adapter works with Amazon S3, Cloudflare R2, MinIO, SeaweedFS, and other
771
- services with an S3 API. It uses `@aws-sdk/client-s3` and
772
- `@aws-sdk/s3-request-presigner`.
846
+ In the browser, sign in to the registry, enter the device code, and approve the
847
+ CLI. The CLI saves the credential in `.handoff/cli-auth.json` for that exact
848
+ registry URL.
773
849
 
774
- ```ts
775
- runtime: {
776
- registry: {
777
- assetStorage: {
778
- adapter: 'custom',
779
- module: './server/storage/s3.mjs',
780
- options: {
781
- endpoint: 'http://127.0.0.1:8333',
782
- bucket: 'handoff-assets',
783
- accessKey: fromEnv('S3_ACCESS_KEY'),
784
- secretKey: fromEnv('S3_SECRET_KEY'),
785
- },
786
- },
787
- },
788
- },
850
+ The CLI saves one credential for each profile. Thus a workspace can stay signed
851
+ in to several registries:
852
+
853
+ ```bash
854
+ npm run login -- --profile staging --url https://staging.example.com
855
+ npm run publish -- all --profile staging
789
856
  ```
790
857
 
791
- ```js
792
- // server/storage/s3.mjs
793
- import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
794
- import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
795
- import { defineAssetStorage } from 'handoff-app/define';
796
-
797
- export default defineAssetStorage(({ options }) => {
798
- const client = new S3Client({
799
- endpoint: options.endpoint,
800
- region: 'us-east-1',
801
- forcePathStyle: true,
802
- credentials: { accessKeyId: options.accessKey, secretAccessKey: options.secretKey },
803
- });
804
- const Bucket = options.bucket;
805
- return {
806
- async put({ hash, bytes, contentType }) {
807
- await client.send(new PutObjectCommand({ Bucket, Key: hash, Body: bytes, ContentType: contentType }));
808
- return { storageRef: hash };
809
- },
810
- async get(storageRef) {
811
- const result = await client.send(new GetObjectCommand({ Bucket, Key: storageRef }));
812
- return { kind: 'stream', stream: result.Body, contentType: result.ContentType };
813
- },
814
- async delete(storageRef) {
815
- await client.send(new DeleteObjectCommand({ Bucket, Key: storageRef }));
816
- },
817
- async createUpload({ hash, contentType }) {
818
- const checksum = Buffer.from(hash, 'hex').toString('base64');
819
- const command = new PutObjectCommand({ Bucket, Key: hash, ContentType: contentType, ChecksumSHA256: checksum });
820
- const url = await getSignedUrl(client, command, {
821
- expiresIn: 900,
822
- unhoistableHeaders: new Set(['x-amz-checksum-sha256']),
823
- });
824
- return { url, headers: { 'Content-Type': contentType, 'x-amz-checksum-sha256': checksum }, storageRef: hash };
825
- },
826
- };
827
- });
858
+ A login profile name is free. It needs no `handoff.config.<name>.*` file, and
859
+ `login` is the only command that accepts a new name. A profile without its own
860
+ login uses the default login. Thus one `npm run login` is enough for a
861
+ workspace with one registry.
862
+
863
+ `npm run logout` revokes and removes the login of the selected profile.
864
+ `--all` removes every saved login.
865
+
866
+ ### Access token
867
+
868
+ Create a token in the registry under Account → Access tokens. To publish, the
869
+ token needs read and write access. To check out, read access is enough. Set the
870
+ token and the registry URL in `.env`:
871
+
872
+ ```dotenv
873
+ HANDOFF_REGISTRY_URL=http://localhost:4000
874
+ HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_...
828
875
  ```
829
876
 
830
- `createUpload` signs a SHA-256 checksum header. Thus storage rejects bytes
831
- that do not match `hash`.
877
+ These two variables are the defaults of `runtime.registryConnection`, so no
878
+ config entry is necessary. A selected profile reads `.env.<profile>` on top of
879
+ `.env`. Thus each profile can address a different registry. In CI, the job
880
+ environment supplies the same two variables.
832
881
 
833
- ##### Vercel Blob
882
+ Environment values have priority over a saved device login. Thus a CI job gives
883
+ the same result on a computer where a developer is signed in. A token works
884
+ only for the registry URL that issued it. If the environment names a different
885
+ registry than the login of the selected profile, `publish` and `checkout`
886
+ report which login they skipped and why.
834
887
 
835
- This adapter stores blobs in a private Vercel Blob store. It uses
836
- `@vercel/blob` 2.x. When the store is connected to the Vercel project, the SDK
837
- reads the store credentials from the deployment environment. Thus the adapter
838
- needs no `options`.
888
+ A config block is necessary only to keep the URL in the repository, or to read
889
+ the values from variables with different names:
839
890
 
840
891
  ```ts
841
892
  runtime: {
842
- registry: {
843
- assetStorage: {
844
- adapter: 'custom',
845
- module: './server/storage/vercel-blob.mjs',
846
- },
893
+ registryConnection: {
894
+ url: 'https://registry.example.com',
895
+ accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN'),
847
896
  },
848
897
  },
849
898
  ```
850
899
 
851
- ```js
852
- // server/storage/vercel-blob.mjs
853
- import { Readable } from 'node:stream';
854
- import { del, get, issueSignedToken, parseStoreIdFromDelegationToken, presignUrl, put } from '@vercel/blob';
855
- import { defineAssetStorage } from 'handoff-app/define';
900
+ ### Publishing
856
901
 
857
- const access = 'private';
858
- const pathnameFor = (hash) => `assets/${hash}`;
902
+ Publish every kind of content in dependency order:
859
903
 
860
- export default defineAssetStorage({
861
- async put({ hash, bytes, contentType }) {
862
- const blob = await put(pathnameFor(hash), bytes, { access, contentType, addRandomSuffix: false, allowOverwrite: true });
863
- return { storageRef: blob.pathname };
864
- },
865
- async get(storageRef) {
866
- const result = await get(storageRef, { access });
867
- if (!result?.stream) {
868
- throw new Error(`Blob "${storageRef}" does not exist.`);
869
- }
870
- return { kind: 'stream', stream: Readable.fromWeb(result.stream), contentType: result.blob.contentType };
871
- },
872
- async delete(storageRef) {
873
- await del(storageRef);
874
- },
875
- async createUpload({ hash, size, contentType }) {
876
- const pathname = pathnameFor(hash);
877
- const token = await issueSignedToken({
878
- pathname,
879
- operations: ['put'],
880
- validUntil: Date.now() + 15 * 60 * 1000,
881
- allowedContentTypes: [contentType],
882
- maximumSizeInBytes: size,
883
- });
884
- const { presignedUrl } = await presignUrl(token, { operation: 'put', pathname, access, allowOverwrite: true });
885
- return {
886
- url: presignedUrl,
887
- headers: {
888
- 'x-api-version': '12',
889
- 'x-vercel-blob-store-id': parseStoreIdFromDelegationToken(token.delegationToken),
890
- 'x-vercel-blob-access': access,
891
- 'x-content-type': contentType,
892
- },
893
- storageRef: pathname,
894
- };
895
- },
896
- });
904
+ ```bash
905
+ npm run publish -- all
897
906
  ```
898
907
 
899
- This adapter is different from the S3 example in these ways:
900
-
901
- - `get` converts the stream. The SDK returns a web stream, but the registry
902
- needs a Node stream.
903
- - A private blob has no public URL. Thus the registry sends each blob to the
904
- reader itself.
905
- - **Vercel Blob cannot reject bytes that do not match `hash`.** The signed URL
906
- limits only the path, the size, and the content type. Thus the registry does
907
- not verify a blob that the CLI uploads directly.
908
- - Without `createUpload`, each blob goes through the registry, which verifies
909
- its hash. The Vercel limit of about 4.5 MB then applies to each blob.
910
- - The CLI sends a plain `PUT` request. Thus `createUpload` returns the headers
911
- that the SDK sends for a presigned upload. These headers are not a documented
912
- API, so a new version of `@vercel/blob` can change them.
913
-
914
- #### Changing the provider
908
+ Publish one kind:
915
909
 
916
- The registry records on each blob whether PostgreSQL or the custom adapter
917
- stores it. Blobs in PostgreSQL stay readable after you select a custom adapter.
918
- A publish does not move blobs. Thus, if you change the custom adapter or its
919
- storage, copy the stored objects first. The new adapter must find each object
920
- by the `storageRef` that the previous adapter returned. After a change back to
921
- `database`, the registry cannot read the blobs of the custom adapter.
910
+ ```bash
911
+ npm run publish -- catalog
912
+ npm run publish -- pages
913
+ npm run publish -- tokens
914
+ npm run publish -- assets
915
+ ```
922
916
 
923
- ### Email delivery
917
+ `catalog` covers every catalog item. The declaration of each item decides if
918
+ the registry stores it as a component or as a pattern. Thus the command names
919
+ items, not component or pattern kinds.
924
920
 
925
- The registry sends invitation and password-reset emails when `email.from` is
926
- set. Without it, an administrator sees each invitation link once and delivers
927
- it manually, and password reset is not available.
921
+ `tokens` and `assets` run the Figma pipeline before the upload. Thus the
922
+ [Figma credentials](#figma-foundations) must be available.
928
923
 
929
- Resend is the default provider. It reads its key from `RESEND_API_KEY`, unless
930
- `options.apiKey` names a different variable:
924
+ Add one or more ids to publish only those entities:
931
925
 
932
- ```ts
933
- runtime: {
934
- registry: {
935
- email: { from: 'Handoff <no-reply@example.com>' },
936
- },
937
- },
926
+ ```bash
927
+ npm run publish -- catalog item-id another-id
938
928
  ```
939
929
 
940
- For an SMTP server, for example Amazon SES, SendGrid, or Microsoft 365:
930
+ `--dry-run` lists the content that a real publish uploads. It does not connect
931
+ to a registry. Thus it needs no registry URL and no token. It still runs the build,
932
+ which updates the generated output on disk. `--no-build` skips the build and
933
+ publishes the existing output. Together, the two options do not change the
934
+ workspace:
941
935
 
942
- ```ts
943
- email: {
944
- from: 'Handoff <no-reply@example.com>',
945
- provider: 'smtp',
946
- options: {
947
- host: fromEnv('SMTP_HOST'),
948
- port: 465, // default, uses TLS. 587 uses STARTTLS.
949
- user: fromEnv('SMTP_USER'),
950
- password: fromEnv('SMTP_PASSWORD'),
951
- },
952
- },
936
+ ```bash
937
+ npm run publish -- all --dry-run
938
+ npm run publish -- catalog --no-build
953
939
  ```
954
940
 
955
- For a different service, set `provider: 'custom'` and set `module` to a
956
- [custom server module](#custom-server-modules). The factory gets `options`
957
- with `fromEnv()` values resolved:
941
+ After a publish, reload the registry to see the published items and
942
+ foundations.
958
943
 
959
- ```ts
960
- import { defineEmailProvider } from 'handoff-app/define';
944
+ ### Checkout
961
945
 
962
- export default defineEmailProvider(({ options }) => ({
963
- async send({ from, to, subject, html, text }) {
964
- // Deliver the message. Throw an error if it is not accepted.
965
- },
966
- }));
967
- ```
946
+ `checkout` writes registry content into the workspace. It takes the same
947
+ `all`, kind, id, and `--dry-run` forms as `publish`:
968
948
 
969
- Literals in `options` are baked into the build. `fromEnv()` values are read at
970
- request time, so they can change without a rebuild. `apiKey` and `password`
971
- must use `fromEnv()`. A profile can set a different sender or provider. When a
972
- profile changes the provider, it replaces `options`.
949
+ ```bash
950
+ npm run checkout -- all
951
+ npm run checkout -- catalog item-id
952
+ npm run checkout -- all --dry-run
953
+ ```
973
954
 
974
- ### Custom server modules
955
+ A dry-run checkout reads from the registry and lists the files that a real
956
+ checkout creates or overwrites. It writes nothing. Checkout writes declarations in the
957
+ format that `runtime.workspace.declarationFormat` sets.
975
958
 
976
- Asset storage, email, and AI connections can name a custom server module. Each
977
- module obeys these rules:
978
-
979
- - The path is relative to the working directory.
980
- - The file is `.js` or `.mjs`.
981
- - The default export is the object, or a factory that returns it.
982
- - The module imports its define helper from `handoff-app/define`. The registry build stops when a module imports `handoff-app`.
983
- - The packages that the module imports are installed in the project. The registry build copies them into the bundle.
959
+ > **Recipes:** [One workspace, several registries](RECIPES.md#one-workspace-several-registries) ·
960
+ > [Publish from GitHub Actions](RECIPES.md#publish-from-github-actions)
984
961
 
985
962
  ## MCP
986
963
 
987
- The documentation app serves a Model Context Protocol endpoint at `/api/mcp/`, so
988
- coding agents can look up the pages, components and tokens that already exist instead
989
- of inventing markup and values. It is stateless Streamable HTTP over `POST`;
990
- there is nothing to start and no extra port. Keep the trailing slash: the app
991
- sets `trailingSlash: true`, so `/api/mcp` answers with a 308 redirect.
964
+ The documentation app serves a Model Context Protocol endpoint at `/api/mcp/`.
965
+ Coding agents use it to find the pages, components, and tokens that exist. Thus
966
+ they do not invent markup and values. The endpoint uses stateless Streamable
967
+ HTTP over `POST`. There is nothing to start and no extra port.
992
968
 
993
969
  | Target | Endpoint | Credential |
994
970
  | --- | --- | --- |
995
- | Workspace (`dev` / `start`) | `http://localhost:3000/api/mcp/` | none, as with `/api/docs/*` |
996
- | Registry (standalone or Vercel) | `<registry url>/api/mcp/` | an access token, read-only is enough |
997
- | Static export | not available, a static site has no API routes | n/a |
971
+ | Workspace (`dev` / `start`) | `http://localhost:3000/api/mcp/` | None, as with `/api/docs/*` |
972
+ | Registry (standalone or Vercel) | `<registry url>/api/mcp/` | An access token. Read access is enough. |
973
+ | Static site | Not available | Not applicable |
998
974
 
999
- Registry tokens come from `handoff-app login` or Account settings in the
1000
- registry itself, and are the same credentials the rest of the registry API takes.
975
+ Keep the trailing slash. The app sets `trailingSlash: true`, so `/api/mcp`
976
+ answers with a 308 redirect. Registry tokens come from `handoff-app login` or
977
+ from Account → Access tokens in the registry.
1001
978
 
1002
- The endpoint is served unless a project turns it off:
979
+ The documentation app shows its endpoint and a ready-made client config for
980
+ Claude Code, Cursor, and VS Code behind the plug icon in the header.
1003
981
 
1004
- ```ts
1005
- runtime: {
1006
- mcp: false,
1007
- },
1008
- ```
982
+ MCP is on by default. To turn it off, set `runtime.mcp: false`. The build bakes
983
+ this value. A build with MCP off answers 404 on the route, leaves the MCP SDK
984
+ out of the bundle, and shows no connect control.
1009
985
 
1010
- Like `runtime.mode`, this is config-only and baked at build time, so changing it
1011
- in a deployed registry means a rebuild. A disabled build answers 404 on the route,
1012
- drops the MCP SDK from the packaged bundle, and shows no connect affordance at all
1013
- rather than one that fails.
1014
-
1015
- The documentation app carries its own endpoint and a ready-made client config
1016
- for Claude Code, Cursor and VS Code behind the plug icon in the header. By hand:
1017
-
1018
- ```json
1019
- {
1020
- "mcpServers": {
1021
- "handoff": {
1022
- "type": "http",
1023
- "url": "https://registry.example.com/api/mcp/",
1024
- "headers": { "Authorization": "Bearer hnd_..." }
1025
- }
1026
- }
1027
- }
1028
- ```
986
+ > **Recipes:** [Connect a client by hand](RECIPES.md#connect-a-client-by-hand) ·
987
+ > [Turn off MCP](RECIPES.md#turn-off-mcp)
1029
988
 
1030
989
  ## AI assistant
1031
990
 
1032
991
  The documentation app can answer questions about the design system. The
1033
- assistant reads through the same MCP tools, so every answer comes from the
1034
- catalog and links to the pages and components it read. Open it from the search
1035
- control in the header, or with `⌘K`.
992
+ assistant reads through the same MCP tools. Thus each answer comes from the
993
+ catalog and links to the pages and components that it read. Open the assistant
994
+ from the search control in the header, or with `⌘K`.
1036
995
 
1037
- The deployment chooses the provider:
996
+ The `ai` block turns on the assistant, and the deployment selects the
997
+ provider:
1038
998
 
1039
999
  ```ts
1040
1000
  runtime: {
1041
1001
  ai: {
1042
1002
  connections: [
1043
- // Service key: the deployment pays for every reader.
1044
1003
  {
1045
1004
  id: 'gateway',
1046
1005
  label: 'Acme LiteLLM',
1047
1006
  baseUrl: 'https://llm.acme.internal/v1',
1048
1007
  apiKey: fromEnv('LITELLM_API_KEY'),
1049
- models: ['gpt-4o', 'claude-sonnet-4-5', 'grok-4'],
1050
- },
1051
- // No credential: the endpoint needs none.
1052
- { id: 'local', label: 'Ollama', baseUrl: 'http://127.0.0.1:11434/v1', models: ['llama3.1'] },
1053
- // User key: the config fixes the endpoint and models. Each reader adds their own key.
1054
- {
1055
- id: 'openai',
1056
- label: 'OpenAI',
1057
- baseUrl: 'https://api.openai.com/v1',
1058
- credential: 'user',
1059
- models: ['gpt-4o', 'o3'],
1008
+ models: ['gpt-4o', 'claude-sonnet-4-5'],
1060
1009
  },
1061
1010
  ],
1062
1011
  defaultModel: 'gateway/claude-sonnet-4-5',
@@ -1064,77 +1013,87 @@ runtime: {
1064
1013
  },
1065
1014
  ```
1066
1015
 
1067
- The `ai` block turns the assistant on, and `ai: { enabled: false }` turns it
1068
- back off. Like `runtime.mcp`, that flag is baked at build time, so a build
1069
- without it serves no chat route and shows no control.
1016
+ `ai: { enabled: false }` turns off the assistant. The build bakes this flag.
1017
+ A build without the assistant serves no chat route and shows no control.
1070
1018
 
1071
- **The declared connections are the whole surface.** A reader can only add a key
1072
- for a connection the config already declares, so the server never calls a URL a
1073
- reader chose. Without a `credential: 'user'` connection, there is no
1074
- reader-facing setting.
1019
+ Each connection uses one of these credentials:
1075
1020
 
1076
- Every connection speaks the OpenAI-compatible `/chat/completions` API. This does
1077
- not limit which models a reader can use. Ollama serves an OpenAI-compatible API
1078
- at `/v1`, and LiteLLM, OpenRouter, vLLM, LM Studio and Azure OpenAI each front
1079
- Anthropic, Google and xAI models. A provider that fits nothing else names a
1021
+ - **Service key:** `apiKey` names a deployment key. The deployment pays for
1022
+ every reader.
1023
+ - **No credential:** the endpoint needs no key, for example a local Ollama.
1024
+ - **Reader key:** `credential: 'user'` asks each reader for their own key.
1025
+
1026
+ **The declared connections are the full surface.** A reader can add a key only
1027
+ for a connection that the config declares. Thus the server never calls a URL
1028
+ that a reader selected. Without a `credential: 'user'` connection, readers have
1029
+ no AI setting.
1030
+
1031
+ Each connection uses the OpenAI-compatible `/chat/completions` API. This does
1032
+ not limit the models. Ollama serves an OpenAI-compatible API at `/v1`. LiteLLM,
1033
+ OpenRouter, vLLM, LM Studio, and Azure OpenAI each give access to Anthropic,
1034
+ Google, and xAI models. For a provider without this API, name a
1080
1035
  [custom server module](#custom-server-modules) that default-exports
1081
- `defineAiProvider()`, in place of `baseUrl`. The factory gets `options` with
1082
- `fromEnv()` values resolved. Literals in `options` are baked into the build, so
1083
- secrets must use `fromEnv()`.
1036
+ `defineAiProvider()`, instead of `baseUrl`.
1037
+
1038
+ The gateway controls cost. LiteLLM and OpenRouter both apply budgets and rate
1039
+ limits for each key. The assistant limits only the number of steps for one
1040
+ question.
1084
1041
 
1085
1042
  ### Reader keys
1086
1043
 
1087
- A connection marked `credential: 'user'` asks each reader for their own key,
1088
- under Account → AI providers. Keys are encrypted with `HANDOFF_AI_KEY_SECRET`,
1089
- not hashed, because the server must send them to the provider. Set that variable
1090
- to a random value of at least 32 characters. A key is write-only across the API:
1091
- after a save, a read reports only that a key is in place.
1044
+ A reader adds a key under Account → AI providers. The registry encrypts keys
1045
+ with `HANDOFF_AI_KEY_SECRET`. It does not hash them, because the server must
1046
+ send them to the provider. Set this variable to a random value of at least 32
1047
+ characters. A key is write-only through the API. After a save, a read reports
1048
+ only that a key exists.
1092
1049
 
1093
- This applies to registry mode only. A workspace has one user, whose config file
1094
- and `.env` are their user layer, so a workspace supplies a key through `apiKey`.
1050
+ Reader keys work only in registry mode. A workspace has one user, whose config
1051
+ file and `.env` hold their keys. Thus a workspace supplies a key through
1052
+ `apiKey`.
1095
1053
 
1096
1054
  ### Changing connections without a rebuild
1097
1055
 
1098
- The connection list is deployment data, not build shape, so a registry can
1099
- change it without a rebuild. `HANDOFF_AI_CONNECTIONS` holds a JSON array, read
1100
- at request time and merged over the baked list by `id`. It names each key
1101
- instead of holding its value:
1102
-
1103
- ```json
1104
- [{ "id": "gateway", "label": "Acme LiteLLM", "baseUrl": "https://llm.acme.internal/v1",
1105
- "apiKeyEnv": "LITELLM_API_KEY", "models": ["gpt-4o"] }]
1106
- ```
1056
+ The connection list is deployment data, not build shape. Thus a registry can
1057
+ change it without a rebuild. `HANDOFF_AI_CONNECTIONS` holds a JSON array. The
1058
+ registry reads it at request time and merges it over the built-in list by `id`.
1059
+ Each entry names its key variable with `apiKeyEnv`, not the key value.
1107
1060
 
1108
1061
  A connection in this list can use a `module` only if a connection in the config
1109
1062
  names the same module. The build copies only those modules into the registry.
1110
1063
 
1111
- Spend control belongs to the gateway. LiteLLM and OpenRouter both enforce
1112
- budgets and rate limits per key. The assistant caps only how many steps one
1113
- question can take.
1064
+ > **Recipes:** [Service key through a gateway](RECIPES.md#service-key-through-a-gateway) ·
1065
+ > [Local Ollama](RECIPES.md#local-ollama) · [Reader keys](RECIPES.md#reader-keys) ·
1066
+ > [Custom AI provider](RECIPES.md#custom-ai-provider) ·
1067
+ > [Change connections without a rebuild](RECIPES.md#change-connections-without-a-rebuild)
1114
1068
 
1115
1069
  ## CLI reference
1116
1070
 
1117
1071
  | Command | Description |
1118
1072
  | --- | --- |
1119
- | `npx handoff-app init` | A workspace is scaffolded with the interactive wizard |
1120
- | `npm run fetch` | Design tokens and assets are fetched from Figma |
1121
- | `npm run start` | The workspace server and Handoff file watchers are started |
1122
- | `npm run dev` | The workspace development server is started |
1123
- | `npm run build -- [--target static\|registry]` | The static site or standalone registry bundle is built |
1124
- | `npm run db:migrate` | Registry database migrations are applied |
1125
- | `npm run validate` | Configured components are validated |
1126
- | `npm run publish -- <kind\|all> [id...]` | Catalog items, pages, tokens, or assets are published |
1127
- | `npm run checkout -- <kind\|all> [id...]` | Published content is pulled into a workspace |
1128
- | `npm run login -- [--profile <name>] --url <url>` | The CLI is authorized through the registry device flow |
1129
- | `npm run logout -- [--profile <name>] [--all]` | Saved CLI credentials are revoked and removed |
1073
+ | `npx handoff-app init` | Create a project with the interactive wizard |
1074
+ | `npm run fetch` | Fetch design tokens and assets from Figma |
1075
+ | `npm run start` | Start the workspace server and the Handoff file watchers |
1076
+ | `npm run dev` | Start the workspace server without the file watchers |
1077
+ | `npm run build -- [--target static\|registry] [--package vercel]` | Build the static site or the registry server |
1078
+ | `npm run db:migrate` | Apply the registry database migrations |
1079
+ | `npm run publish -- <kind\|all> [id...]` | Publish catalog items, pages, tokens, or assets |
1080
+ | `npm run checkout -- <kind\|all> [id...]` | Write published content into the workspace |
1081
+ | `npm run login -- [--profile <name>] --url <url>` | Authorize the CLI through the registry device flow |
1082
+ | `npm run logout -- [--profile <name>] [--all]` | Revoke and remove saved CLI credentials |
1083
+ | `npx handoff-app validate:components` | Validate the catalog items |
1084
+ | `npx handoff-app make:component <name>` | Create a Handlebars catalog item |
1085
+ | `npx handoff-app make:page <name> [parent]` | Create a documentation page |
1086
+ | `npx handoff-app scaffold` | Create catalog item stubs for fetched Figma components |
1087
+ | `npx handoff-app eject:config` | Write the default config to the working directory |
1130
1088
 
1131
1089
  Every command except `init` accepts `-c, --config`, `--profile`, `-d, --debug`,
1132
- and `-f, --force`. `publish` and `checkout` additionally accept `--dry-run`, and
1090
+ and `-f, --force`. `publish` and `checkout` also accept `--dry-run`, and
1133
1091
  `publish` accepts `--no-build`.
1134
1092
 
1135
- Arguments after `--` are forwarded to the local CLI. Exact options can be shown
1136
- by adding `--help` after the separator. Advanced configuration and hooks are
1137
- documented in [docs/api.md](docs/api.md).
1093
+ npm forwards the arguments after `--` to the CLI. To see the options of a
1094
+ command, add `--help` after `--`. [docs/cli.md](docs/cli.md) lists every
1095
+ command and option. [docs/api.md](docs/api.md) documents the JavaScript API and
1096
+ the build hooks.
1138
1097
 
1139
1098
  ## Maintainers
1140
1099
 
@@ -1154,4 +1113,4 @@ Code of Conduct.
1154
1113
 
1155
1114
  ## License
1156
1115
 
1157
- [MIT](License.md) ©Convertiv
1116
+ [MIT](LICENSE.md) ©Convertiv