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.
- package/{Changelog.md → CHANGELOG.md} +84 -0
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +760 -801
- package/RECIPES.md +975 -0
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/define.d.ts +1 -1
- package/dist/ai/define.js +1 -1
- package/dist/ai/types.d.ts +1 -1
- package/dist/ai/types.js +1 -1
- package/dist/app-builder/build.js +14 -14
- package/dist/app-builder/client-config.js +1 -1
- package/dist/config/loader.js +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/providers.d.ts +10 -0
- package/dist/{define.js → providers.js} +4 -4
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +8 -11
- package/dist/registry/asset-storage/resolve.js +12 -13
- package/dist/registry/asset-storage/types.d.ts +12 -12
- package/dist/registry/asset-storage/types.js +2 -2
- package/dist/registry/db/schema.d.ts +2 -2
- package/dist/registry/db/schema.js +2 -2
- package/dist/registry/email/define.d.ts +1 -1
- package/dist/registry/email/define.js +1 -1
- package/dist/registry/email/resolve.d.ts +7 -8
- package/dist/registry/email/resolve.js +21 -23
- package/dist/registry/email/types.d.ts +1 -1
- package/dist/registry/email/types.js +1 -1
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/types/config.d.ts +4 -13
- package/docs/cli.md +275 -54
- package/package.json +7 -7
- package/src/ai/define.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/app/components/Auth/AccountLayout.tsx +1 -1
- package/src/app/components/Auth/AuthShell.tsx +10 -4
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +0 -2
- package/src/app/components/util/index.ts +0 -11
- package/src/app/lib/asset-storage.ts +25 -25
- package/src/app/lib/auth/email.ts +4 -5
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +8 -8
- package/src/app/lib/registry-api/asset-transfer.ts +25 -25
- package/src/app/lib/server-module.ts +1 -1
- package/src/app/lib/utils.ts +7 -0
- package/src/app/next.config.mjs +2 -2
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/account/ai.tsx +1 -1
- package/src/app/pages/account/index.tsx +1 -1
- package/src/app/pages/account/tokens.tsx +1 -1
- package/src/app/pages/account/users.tsx +1 -1
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +1 -1
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/login.tsx +10 -5
- package/src/app/pages/reset-password.tsx +7 -2
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/build.ts +10 -10
- package/src/app-builder/client-config.ts +1 -1
- package/src/config/loader.ts +1 -2
- package/src/index.ts +6 -6
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/{define.ts → providers.ts} +8 -8
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +16 -20
- package/src/registry/asset-storage/types.ts +12 -12
- package/src/registry/db/schema.ts +3 -3
- package/src/registry/email/define.ts +1 -1
- package/src/registry/email/resolve.ts +23 -23
- package/src/registry/email/types.ts +1 -1
- package/src/store/registry.ts +7 -7
- package/src/types/config.ts +4 -14
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/define.d.ts +0 -10
- package/dist/registry/email/adapters/resend.d.ts +0 -7
- package/dist/registry/email/adapters/resend.js +0 -35
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/registry/email/adapters/resend.ts +0 -22
- /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
- /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
- /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/
|
|
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
|
-
|
|
11
|
-
documentation and distributable artifacts.
|
|
12
|
-
or a shared
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
- npm 10 or newer
|
|
29
|
-
- A paid Figma account
|
|
30
|
-
- PostgreSQL
|
|
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
|
-
|
|
87
|
+
### New project
|
|
33
88
|
|
|
34
|
-
|
|
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
|
|
42
|
-
setup, and optional Figma credentials
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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
|
-
|
|
134
|
+
### Adding Handoff to an existing application
|
|
84
135
|
|
|
85
|
-
Declarations sit
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
is used instead.
|
|
186
|
+
1. Install `handoff-app` in the application package:
|
|
124
187
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
//
|
|
255
|
+
// handoff.config.ts
|
|
256
|
+
import { defineConfig, fromEnv } from 'handoff-app';
|
|
257
|
+
|
|
131
258
|
export default defineConfig({
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
`
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
415
|
+
Keep the name `HANDOFF_OUTPUT_DIR`. The documentation app also reads this
|
|
416
|
+
variable directly.
|
|
168
417
|
|
|
169
|
-
|
|
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
|
-
###
|
|
420
|
+
### Catalog items
|
|
174
421
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
324
|
-
|
|
520
|
+
`catalog.include` in the config registers the catalog directories. Each path is
|
|
521
|
+
one of these:
|
|
325
522
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
import { defineConfig } from 'handoff-app';
|
|
523
|
+
- An item directory.
|
|
524
|
+
- A collection directory. Each subdirectory is an item.
|
|
329
525
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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/`.
|
|
345
|
-
|
|
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
|
-
|
|
543
|
+
### Figma foundations
|
|
348
544
|
|
|
349
|
-
Figma integration is optional.
|
|
350
|
-
`init`,
|
|
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
|
-
|
|
354
|
-
be added or updated in `.env`:
|
|
548
|
+
To add or change the Figma source, set these values in `.env`:
|
|
355
549
|
|
|
356
|
-
```
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
```
|
|
366
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
```
|
|
572
|
+
After a design change, publish the Figma library again. Then run `fetch` again
|
|
573
|
+
to get the latest foundations.
|
|
384
574
|
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
##
|
|
588
|
+
## Running the workspace
|
|
395
589
|
|
|
396
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
600
|
+
Open http://localhost:3000 and make sure that:
|
|
409
601
|
|
|
410
|
-
-
|
|
411
|
-
-
|
|
412
|
-
- component
|
|
413
|
-
-
|
|
414
|
-
- foundation pages
|
|
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
|
-
##
|
|
608
|
+
## Building
|
|
417
609
|
|
|
418
|
-
|
|
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
|
-
|
|
425
|
-
static
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
component and every file that its last build read, Sass partials
|
|
430
|
-
|
|
431
|
-
stylesheet loads through `url()
|
|
432
|
-
again
|
|
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
|
-
|
|
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
|
|
445
|
-
|
|
446
|
-
workspace source
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
659
|
+
```bash
|
|
660
|
+
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/handoff?schema=public" \
|
|
661
|
+
npm run db:migrate
|
|
662
|
+
```
|
|
607
663
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
678
|
+
4. Start `out/registry/server.js` with these environment variables:
|
|
630
679
|
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
kind.
|
|
698
|
+
> **Recipes:** [Local registry](RECIPES.md#local-registry) ·
|
|
699
|
+
> [Vercel and Neon](RECIPES.md#vercel-and-neon)
|
|
649
700
|
|
|
650
|
-
|
|
651
|
-
documented Figma credentials must be available.
|
|
701
|
+
### Database
|
|
652
702
|
|
|
653
|
-
|
|
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
|
-
```
|
|
656
|
-
|
|
706
|
+
```ts
|
|
707
|
+
runtime: {
|
|
708
|
+
registry: {
|
|
709
|
+
database: {
|
|
710
|
+
url: fromEnv('DATABASE_URL'),
|
|
711
|
+
driver: 'neon',
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
},
|
|
657
715
|
```
|
|
658
716
|
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
739
|
+
The module default-exports a `defineStorageProvider()` result with these
|
|
740
|
+
methods:
|
|
694
741
|
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
709
|
-
|
|
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
|
-
|
|
778
|
+
> **Recipes:** [S3-compatible storage](RECIPES.md#s3-compatible-storage) ·
|
|
779
|
+
> [Vercel Blob](RECIPES.md#vercel-blob)
|
|
712
780
|
|
|
713
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
811
|
+
> **Recipes:** [SMTP](RECIPES.md#smtp) · [Resend](RECIPES.md#resend) ·
|
|
812
|
+
> [Custom email provider](RECIPES.md#custom-email-provider)
|
|
735
813
|
|
|
736
|
-
|
|
737
|
-
different service. The module default-exports a `defineAssetStorage()` adapter
|
|
738
|
-
with these methods:
|
|
814
|
+
### Custom server modules
|
|
739
815
|
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
751
|
-
|
|
827
|
+
The factory gets `options` with each `fromEnv()` value resolved at request
|
|
828
|
+
time.
|
|
752
829
|
|
|
753
|
-
|
|
830
|
+
## Connecting a workspace to a registry
|
|
754
831
|
|
|
755
|
-
|
|
756
|
-
it
|
|
757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
766
|
-
`maxInlineBytes` limit.
|
|
840
|
+
Start the authorization from the workspace:
|
|
767
841
|
|
|
768
|
-
|
|
842
|
+
```bash
|
|
843
|
+
npm run login -- --url http://localhost:4000
|
|
844
|
+
```
|
|
769
845
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
const pathnameFor = (hash) => `assets/${hash}`;
|
|
902
|
+
Publish every kind of content in dependency order:
|
|
859
903
|
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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
|
-
|
|
930
|
-
`options.apiKey` names a different variable:
|
|
924
|
+
Add one or more ids to publish only those entities:
|
|
931
925
|
|
|
932
|
-
```
|
|
933
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
with `fromEnv()` values resolved:
|
|
941
|
+
After a publish, reload the registry to see the published items and
|
|
942
|
+
foundations.
|
|
958
943
|
|
|
959
|
-
|
|
960
|
-
import { defineEmailProvider } from 'handoff-app/define';
|
|
944
|
+
### Checkout
|
|
961
945
|
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
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
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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/` |
|
|
996
|
-
| Registry (standalone or Vercel) | `<registry url>/api/mcp/` |
|
|
997
|
-
| Static
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
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
|
|
1034
|
-
catalog and links to the pages and components it read. Open
|
|
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
|
|
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'
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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()`,
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1094
|
-
and `.env`
|
|
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
|
|
1099
|
-
change it without a rebuild. `HANDOFF_AI_CONNECTIONS` holds a JSON array
|
|
1100
|
-
at request time and
|
|
1101
|
-
|
|
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
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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` |
|
|
1120
|
-
| `npm run fetch` |
|
|
1121
|
-
| `npm run start` |
|
|
1122
|
-
| `npm run dev` |
|
|
1123
|
-
| `npm run build -- [--target static\|registry]` |
|
|
1124
|
-
| `npm run db:migrate` |
|
|
1125
|
-
| `npm run
|
|
1126
|
-
| `npm run
|
|
1127
|
-
| `npm run
|
|
1128
|
-
| `npm run
|
|
1129
|
-
| `
|
|
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`
|
|
1090
|
+
and `-f, --force`. `publish` and `checkout` also accept `--dry-run`, and
|
|
1133
1091
|
`publish` accepts `--no-build`.
|
|
1134
1092
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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](
|
|
1116
|
+
[MIT](LICENSE.md) ©Convertiv
|