handoff-app 2.0.0-0 → 2.0.0-2

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 (91) hide show
  1. package/README.md +39 -17
  2. package/config/docs/foundations/colors/guidelines.md +9 -0
  3. package/config/docs/foundations/effects/guidelines.md +9 -0
  4. package/config/docs/foundations/effects/resources.md +9 -0
  5. package/config/docs/foundations/grid.md +10 -0
  6. package/config/docs/foundations/icons/guidelines.md +9 -0
  7. package/config/docs/foundations/images/guidelines.md +9 -0
  8. package/config/docs/foundations/images/resources.md +9 -0
  9. package/config/docs/foundations/images.md +10 -0
  10. package/config/docs/foundations/logo/resources.md +9 -0
  11. package/config/docs/foundations/typography/guidelines.md +9 -0
  12. package/config/docs/foundations/typography/resources.md +9 -0
  13. package/config/docs/foundations.md +5 -3
  14. package/dist/app-builder/build.js +89 -46
  15. package/dist/app-builder/client-config.js +5 -5
  16. package/dist/app-builder/paths.js +23 -1
  17. package/dist/app-builder/static-export.js +25 -0
  18. package/dist/app-builder/vercel-output.js +2 -2
  19. package/dist/artifacts/index.d.ts +1 -1
  20. package/dist/artifacts/index.js +4 -1
  21. package/dist/artifacts/url.d.ts +18 -0
  22. package/dist/artifacts/url.js +31 -1
  23. package/dist/cli/create.js +48 -3
  24. package/dist/commands/checkout/index.d.ts +6 -6
  25. package/dist/commands/checkout/index.js +8 -12
  26. package/dist/commands/publish/index.d.ts +6 -5
  27. package/dist/commands/publish/index.js +8 -11
  28. package/dist/commands/utils.d.ts +6 -0
  29. package/dist/commands/utils.js +9 -1
  30. package/dist/index.d.ts +12 -8
  31. package/dist/index.js +26 -10
  32. package/dist/registry/asset-storage/resolve.d.ts +1 -1
  33. package/dist/registry/asset-storage/resolve.js +1 -1
  34. package/dist/registry/checkout/index.d.ts +10 -5
  35. package/dist/registry/checkout/index.js +62 -11
  36. package/dist/registry/client.d.ts +2 -1
  37. package/dist/registry/client.js +8 -0
  38. package/dist/registry/db/client.d.ts +9 -9
  39. package/dist/registry/db/client.js +7 -7
  40. package/dist/registry/db/{adapter.d.ts → driver.d.ts} +16 -14
  41. package/dist/registry/db/{adapter.js → driver.js} +17 -15
  42. package/dist/registry/db/index.d.ts +1 -1
  43. package/dist/registry/db/index.js +9 -9
  44. package/dist/registry/db/migrate.d.ts +1 -1
  45. package/dist/registry/db/migrate.js +5 -5
  46. package/dist/registry/publish/index.d.ts +8 -1
  47. package/dist/registry/publish/index.js +88 -2
  48. package/dist/registry/transfer.d.ts +13 -0
  49. package/dist/types/config.d.ts +12 -8
  50. package/drizzle.config.ts +1 -1
  51. package/package.json +1 -1
  52. package/src/app/components/Foundations/DisplayLogo.tsx +8 -2
  53. package/src/app/lib/docs-api/runtime-config.ts +10 -10
  54. package/src/app/lib/registry-api/transfer.ts +29 -0
  55. package/src/app/lib/registry-connection.ts +2 -2
  56. package/src/app/next.config.mjs +1 -1
  57. package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
  58. package/src/app/pages/assets/logos.tsx +4 -1
  59. package/src/app/pages/foundations/icons/index.tsx +7 -5
  60. package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +8 -8
  61. package/src/app/pages/foundations/logo.tsx +5 -4
  62. package/src/app-builder/build.ts +75 -55
  63. package/src/app-builder/client-config.ts +3 -3
  64. package/src/app-builder/paths.ts +25 -1
  65. package/src/app-builder/static-export.ts +27 -0
  66. package/src/app-builder/vercel-output.ts +2 -2
  67. package/src/artifacts/index.ts +8 -1
  68. package/src/artifacts/url.ts +32 -0
  69. package/src/cli/create.ts +58 -3
  70. package/src/commands/checkout/index.ts +10 -15
  71. package/src/commands/publish/index.ts +10 -14
  72. package/src/commands/utils.ts +12 -0
  73. package/src/index.ts +26 -12
  74. package/src/registry/asset-storage/resolve.ts +1 -1
  75. package/src/registry/checkout/index.ts +73 -11
  76. package/src/registry/client.ts +7 -1
  77. package/src/registry/db/client.ts +12 -12
  78. package/src/registry/db/{adapter.ts → driver.ts} +19 -17
  79. package/src/registry/db/index.ts +4 -4
  80. package/src/registry/db/migrate.ts +5 -5
  81. package/src/registry/publish/index.ts +100 -2
  82. package/src/registry/transfer.ts +14 -0
  83. package/src/types/config.ts +12 -8
  84. package/templates/default/README.md.tpl +40 -1
  85. package/templates/default/gitignore.tpl +18 -3
  86. package/templates/default/handoff.config.js.tpl +14 -0
  87. package/templates/default/handoff.config.ts.tpl +14 -0
  88. package/templates/default/handoff.config.with-examples.js.tpl +14 -0
  89. package/templates/default/handoff.config.with-examples.ts.tpl +14 -0
  90. package/templates/default/package.json.tpl +2 -1
  91. package/templates/default/vercel.json.tpl +4 -0
@@ -432,6 +432,35 @@ const upsertBuildMetadata = async (db: RegistryDatabase, kind: TransferEntityKin
432
432
  .onConflictDoUpdate({ target: [buildMetadata.entityKind, buildMetadata.entityId], set: values as any });
433
433
  };
434
434
 
435
+ /**
436
+ * Handles `GET /api/registry/transfer/{component|pattern|page}`. Lists summaries for one kind of
437
+ * entity: each id plus its build hashes and status. A connected workspace uses these to skip
438
+ * unchanged entities on a bulk publish and to enumerate published ids for a bulk checkout. The read
439
+ * is unauthenticated; only the registry-runtime and method guards apply.
440
+ */
441
+ export const handleEntitySummaryRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
442
+ handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
443
+ const table = ENTITY[kind].table;
444
+ const rows = await db
445
+ .select({
446
+ id: table.id,
447
+ status: buildMetadata.status,
448
+ artifactHash: buildMetadata.artifactHash,
449
+ sourceHash: buildMetadata.sourceHash,
450
+ })
451
+ .from(table)
452
+ .leftJoin(buildMetadata, and(eq(buildMetadata.entityKind, kind), eq(buildMetadata.entityId, table.id)));
453
+
454
+ const entities = rows.map((row) => ({
455
+ id: row.id,
456
+ kind,
457
+ status: row.status ?? undefined,
458
+ artifactHash: row.artifactHash ?? undefined,
459
+ sourceHash: row.sourceHash ?? undefined,
460
+ }));
461
+ sendRegistryData(res, 200, { entities }, buildMeta());
462
+ });
463
+
435
464
  /**
436
465
  * Handle `GET /api/registry/transfer/{component|pattern}/:id` — checkout read. Returns the
437
466
  * normalized record plus its registry-safe source files so a connected workspace can reconstruct
@@ -7,7 +7,7 @@ import { getServerRuntimeConfig } from './docs-api/runtime-config';
7
7
  * Both the registry-mode docs read API and the registry management API resolve their database
8
8
  * through this single helper so one connection pool backs the whole registry runtime rather than
9
9
  * one pool per consumer. The connection string is read from the configured env var *name* at
10
- * request time — never persisted in config — and the selected adapter comes from the server runtime
10
+ * request time — never persisted in config — and the selected driver comes from the server runtime
11
11
  * config baked at build time.
12
12
  *
13
13
  * Server-only: imported exclusively by registry-mode API route helpers, which are themselves only
@@ -48,7 +48,7 @@ export const getRegistryConnection = (): Promise<RegistryDbConnection> => {
48
48
  )
49
49
  );
50
50
  }
51
- connectionPromise = createRegistryDbConnection({ adapter: registry.adapter, connectionString }).catch((error) => {
51
+ connectionPromise = createRegistryDbConnection({ driver: registry.driver, connectionString }).catch((error) => {
52
52
  connectionPromise = null;
53
53
  // TODO: Route server errors through the shared logger.
54
54
  console.error('Registry database connection failed.', error);
@@ -95,7 +95,7 @@ const nextConfig = {
95
95
  // config-only — these are derived from `runtime.*`, not inferred from env at runtime. The DB
96
96
  // connection string itself is read from the named env var at request time (deployment-supplied).
97
97
  HANDOFF_RUNTIME_MODE: '%HANDOFF_RUNTIME_MODE%',
98
- HANDOFF_REGISTRY_ADAPTER: '%HANDOFF_REGISTRY_ADAPTER%',
98
+ HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
99
99
  HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
100
100
  HANDOFF_REGISTRY_API_TOKEN_ENV: '%HANDOFF_REGISTRY_API_TOKEN_ENV%',
101
101
  // Asset storage selection (provider + non-secret options + env-var names). Secret values (Blob
@@ -0,0 +1,21 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+ import { singleQueryValue } from '@/lib/api/query';
3
+ import { handleRegistryRoute, sendRegistryError } from '@/lib/registry-api';
4
+ import { handleEntitySummaryRoute } from '@/lib/registry-api/transfer';
5
+
6
+ /**
7
+ * `GET /api/registry/transfer/{component|pattern|page}`. Lists the registry's published entity
8
+ * summaries for one kind (id, build hashes, status) so a connected workspace can skip unchanged
9
+ * entities on a bulk publish and enumerate published ids for a bulk checkout. Registry-runtime only,
10
+ * enforced by the guard stack. The `tokens` and `assets` summaries have their own static routes,
11
+ * which Next resolves before this dynamic segment.
12
+ */
13
+ export default function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
14
+ const kind = singleQueryValue(req.query.kind);
15
+ if (kind !== 'component' && kind !== 'pattern' && kind !== 'page') {
16
+ return handleRegistryRoute(req, res, ['GET'], async () => {
17
+ sendRegistryError(res, 'not_found', `Unknown transfer entity kind "${kind ?? ''}".`);
18
+ });
19
+ }
20
+ return handleEntitySummaryRoute(req, res, kind);
21
+ }
@@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import Footer from '../../components/Footer';
8
8
  import { MarkdownComponents, remarkCodeMeta } from '../../components/Markdown/MarkdownComponents';
9
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
9
10
  import Header from '../../components/old/Header';
10
11
  import { DocumentationProps, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
11
12
 
@@ -52,7 +53,9 @@ const AssetsLogosPage = ({ content, menu, metadata, config }: DocumentationProps
52
53
  <h4>{config?.app?.client} Logo</h4>
53
54
  <p>Vector files of approved {config?.app?.client} logos.</p>
54
55
  <p>
55
- <a href={config?.assets_zip_links?.logos ?? '/logos.zip'}>Download Logos</a>
56
+ <a href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)} download>
57
+ Download Logos
58
+ </a>
56
59
  </p>
57
60
  </div>
58
61
  </div>
@@ -12,6 +12,7 @@ import { MarkdownComponents, remarkCodeMeta } from '../../../components/Markdown
12
12
  import HeadersType from '../../../components/Typography/Headers';
13
13
  import { buttonVariants } from '../../../components/ui/button';
14
14
  import { Input } from '../../../components/ui/input';
15
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
15
16
  import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../../components/util';
16
17
  import { useCollectionAssets, type DisplayableAsset } from '../../../components/util/useCollectionAssets';
17
18
 
@@ -35,13 +36,13 @@ export const DisplayIcon: React.FC<{ icon: DisplayableAsset }> = ({ icon }) => {
35
36
 
36
37
  return (
37
38
  <div className="flex flex-col gap-2">
38
- <Link href={`/foundations/icons/${icon.icon}`}>
39
+ <Link href={`/foundations/icons/library/${icon.icon}`}>
39
40
  <div className="flex flex-col items-center gap-6 rounded-lg border border-gray-100/80 bg-gray-100/80 py-12 transition-all hover:border-gray-300 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700 dark:hover:bg-gray-900">
40
41
  {/* Workspace/static: inline SVG body. Registry: the individual asset content URL. */}
41
42
  {icon.data ? <div dangerouslySetInnerHTML={{ __html: htmlData }} /> : <img className="o-icon h-6 w-6" src={icon.src} alt={icon.name} />}
42
43
  </div>
43
44
  </Link>
44
- <Link href={`/foundations/icons/${icon.icon}`}>
45
+ <Link href={`/foundations/icons/library/${icon.icon}`}>
45
46
  <p className="font-mono text-[11px]">{icon.icon}</p>
46
47
  </Link>
47
48
  </div>
@@ -94,12 +95,13 @@ const IconsPage = ({ content, menu, metadata, current, config, assets }: AssetDo
94
95
  <HeadersType.H1>{metadata.title}</HeadersType.H1>
95
96
  <p className="max-w-[800px] text-lg font-light text-gray-500 dark:text-gray-300">{metadata.description}</p>
96
97
  <div className="mt-3 flex flex-row gap-3">
97
- <Link
98
+ <a
98
99
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
99
- href={config?.assets_zip_links?.icons ?? '/icons.zip'}
100
+ href={config?.assets_zip_links?.icons ?? buildAssetDownloadUrl('icons', process.env.HANDOFF_APP_BASE_PATH)}
101
+ download
100
102
  >
101
103
  Download Icons <Download strokeWidth={1.5} />
102
- </Link>
104
+ </a>
103
105
  </div>
104
106
  </div>
105
107
  <hr className="mb-10" />
@@ -5,12 +5,12 @@ import { GetStaticProps } from 'next';
5
5
  import Link from 'next/link';
6
6
  import { useRouter } from 'next/router';
7
7
  import * as React from 'react';
8
- import Footer from '../../../../components/Footer';
9
- import Layout from '../../../../components/Layout/Main';
10
- import HeadersType from '../../../../components/Typography/Headers';
11
- import { buttonVariants } from '../../../../components/ui/button';
12
- import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig, getTokens, isRegistryRuntime } from '../../../../components/util';
13
- import { useSingleAsset, type DisplayableAsset } from '../../../../components/util/useCollectionAssets';
8
+ import Footer from '../../../../../components/Footer';
9
+ import Layout from '../../../../../components/Layout/Main';
10
+ import HeadersType from '../../../../../components/Typography/Headers';
11
+ import { buttonVariants } from '../../../../../components/ui/button';
12
+ import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig, getTokens, isRegistryRuntime } from '../../../../../components/util';
13
+ import { useSingleAsset, type DisplayableAsset } from '../../../../../components/util/useCollectionAssets';
14
14
 
15
15
  const DisplayIcon: React.FC<{ icon: DisplayableAsset }> = ({ icon }) => {
16
16
  const htmlData = React.useMemo(() => {
@@ -124,13 +124,13 @@ export default function SingleIcon({ menu, metadata, current, config, assets }:
124
124
  Copy SVG <Code strokeWidth={1.5} />
125
125
  </Link>
126
126
 
127
- <Link
127
+ <a
128
128
  href={icon.src ?? 'data:text/plain;charset=utf-8,' + encodeURIComponent(icon.data ?? '')}
129
129
  download={icon.name}
130
130
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
131
131
  >
132
132
  Download SVG <Download strokeWidth={1.5} />
133
- </Link>
133
+ </a>
134
134
  </div>
135
135
  </div>
136
136
 
@@ -1,6 +1,6 @@
1
1
  import type { GetStaticProps } from 'next';
2
- import Link from 'next/link';
3
2
 
3
+ import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
4
4
  import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
5
5
  import { useCollectionAssets } from '../../components/util/useCollectionAssets';
6
6
 
@@ -56,12 +56,13 @@ const LogoPage = ({ content, menu, metadata, current, config, assets }: AssetDoc
56
56
  <HeadersType.H1>{metadata.title}</HeadersType.H1>
57
57
  <p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{metadata.description}</p>
58
58
  <div className="mt-3 flex flex-row gap-3">
59
- <Link
59
+ <a
60
60
  className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
61
- href={config?.assets_zip_links?.logos ?? '/logos.zip'}
61
+ href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)}
62
+ download
62
63
  >
63
64
  Download Logos <Download strokeWidth={1.5} />
64
- </Link>
65
+ </a>
65
66
  </div>
66
67
  </div>
67
68
 
@@ -1,3 +1,4 @@
1
+ import * as p from '@clack/prompts';
1
2
  import spawn from 'cross-spawn';
2
3
  import fs from 'fs-extra';
3
4
  import path from 'path';
@@ -7,7 +8,7 @@ import { buildPatterns } from '../pipeline/patterns';
7
8
  import processComponents from '../transformers/preview/component/builder';
8
9
  import { buildMainCss } from '../transformers/preview/component/css';
9
10
  import { buildMainJS } from '../transformers/preview/component/javascript';
10
- import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryAdapter } from '../registry/db/adapter';
11
+ import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
11
12
  import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
12
13
  import type { RuntimeMode } from '../types/config';
13
14
  import { Logger } from '../utils/logger';
@@ -104,6 +105,63 @@ const assertStaticBuildAllowed = (handoff: Handoff): void => {
104
105
  }
105
106
  };
106
107
 
108
+ /**
109
+ * Run the Next.js production build while keeping its delayed output out of the successful CLI flow.
110
+ * Captured output is replayed only when the child fails so compiler diagnostics remain available.
111
+ */
112
+ const runNextBuild = async (appPath: string, target: BuildTarget, errorLabel: string): Promise<void> => {
113
+ const spinner = p.spinner();
114
+ spinner.start('Building Next.js app...');
115
+
116
+ const stdout: Buffer[] = [];
117
+ const stderr: Buffer[] = [];
118
+
119
+ await new Promise<void>((resolve, reject) => {
120
+ const buildProcess = spawn('npx', ['next', 'build'], {
121
+ cwd: appPath,
122
+ stdio: ['inherit', 'pipe', 'pipe'],
123
+ env: {
124
+ ...process.env,
125
+ NODE_ENV: 'production',
126
+ HANDOFF_BUILD_TARGET: target,
127
+ },
128
+ });
129
+
130
+ buildProcess.stdout?.on('data', (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
131
+ buildProcess.stderr?.on('data', (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
132
+
133
+ let settled = false;
134
+
135
+ const fail = (message: string): void => {
136
+ if (settled) return;
137
+ settled = true;
138
+
139
+ spinner.stop('Next.js app build failed');
140
+ Logger.childProcessBuffer(Buffer.concat(stdout));
141
+ Logger.childProcessBuffer(Buffer.concat(stderr));
142
+ reject(new Error(message));
143
+ };
144
+
145
+ buildProcess.once('error', (error) => {
146
+ fail(`${errorLabel} failed to start\nSpawn error: ${error.message}`);
147
+ });
148
+
149
+ buildProcess.once('close', (code, signal) => {
150
+ if (settled) return;
151
+
152
+ if (code === 0) {
153
+ settled = true;
154
+ spinner.stop('Next.js app built successfully');
155
+ resolve();
156
+ return;
157
+ }
158
+
159
+ const reason = signal ? ` because it was terminated by signal ${signal}` : ` with exit code ${code}`;
160
+ fail(`${errorLabel} failed${reason}`);
161
+ });
162
+ });
163
+ };
164
+
107
165
  /**
108
166
  * Output directory for the packaged registry app. Lives under the configurable sites output
109
167
  * directory (`sitesOutputDirectory`, default `out`) alongside the static target's `out/<projectId>`
@@ -198,7 +256,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
198
256
  // Resolved runtime mode + registry connection inputs (names only) baked into the Next bundle so the
199
257
  // deployed registry app resolves its mode/DB env-var name without any build-machine filesystem.
200
258
  const escapedRuntimeMode = escapeForSingleQuotedJsString(runtimeMode);
201
- const escapedRegistryAdapter = escapeForSingleQuotedJsString(resolveRegistryAdapter(handoff.config));
259
+ const escapedRegistryDriver = escapeForSingleQuotedJsString(resolveRegistryDriver(handoff.config));
202
260
  const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
203
261
  const escapedApiTokenEnv = escapeForSingleQuotedJsString(resolveApiTokenEnv(handoff.config));
204
262
  // Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
@@ -216,7 +274,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
216
274
  '%HANDOFF_EXPORT_PATH%': escapedExportPath,
217
275
  '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
218
276
  '%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
219
- '%HANDOFF_REGISTRY_ADAPTER%': escapedRegistryAdapter,
277
+ '%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
220
278
  '%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
221
279
  '%HANDOFF_REGISTRY_API_TOKEN_ENV%': escapedApiTokenEnv,
222
280
  '%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
@@ -283,26 +341,7 @@ const buildApp = async (
283
341
 
284
342
  // Build app. The static target drives Next's `output: 'export'` via HANDOFF_BUILD_TARGET so the
285
343
  // export gate is tied to the resolved target, not to NODE_ENV.
286
- const buildResult = spawn.sync('npx', ['next', 'build'], {
287
- cwd: appPath,
288
- stdio: ['inherit', 'pipe', 'pipe'],
289
- env: {
290
- ...process.env,
291
- NODE_ENV: 'production',
292
- HANDOFF_BUILD_TARGET: target,
293
- },
294
- });
295
-
296
- Logger.childProcessBuffer(buildResult.stdout);
297
- Logger.childProcessBuffer(buildResult.stderr);
298
-
299
- if (buildResult.status !== 0) {
300
- let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
301
- if (buildResult.error) {
302
- errorMsg += `\nSpawn error: ${buildResult.error.message}`;
303
- }
304
- throw new Error(errorMsg);
305
- }
344
+ await runNextBuild(appPath, target, 'Next.js build');
306
345
 
307
346
  // Reproduce the docs read API (`/api/docs/*`) as route-shaped static files in the export output,
308
347
  // since `output: 'export'` disables the live API routes.
@@ -390,7 +429,7 @@ pipeline) before deploying new code:
390
429
  ${databaseUrlEnv}="postgres://…" handoff-app db:migrate
391
430
  \`\`\`
392
431
 
393
- \`db:migrate\` reads your project config + DB env vars, resolves the same database adapter the app was
432
+ \`db:migrate\` reads your project config + DB env vars, resolves the same database driver the app was
394
433
  built with, and applies the package-owned migration set. It runs independently of \`build\` and of
395
434
  starting the server, so run it as a release/one-shot job.
396
435
 
@@ -437,14 +476,14 @@ containers, custom Node servers, and other non-Vercel hosts.
437
476
 
438
477
  /**
439
478
  * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
440
- * runtime, given the configured database adapter. `next`/`react`/`react-dom` run the server and React
479
+ * runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
441
480
  * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the driver
442
- * is adapter-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
481
+ * package is driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
443
482
  */
444
483
  const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
445
484
  const base = ['next', 'react', 'react-dom', 'drizzle-orm'];
446
- const adapter = resolveRegistryAdapter(handoff.config);
447
- const driver = adapter === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
485
+ const driver = resolveRegistryDriver(handoff.config);
486
+ const driverModules = driver === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
448
487
  // Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
449
488
  // Vercel Blob adapter needs `@vercel/blob`; a custom adapter may declare its installed SDK
450
489
  // package(s) via `assetStorage.options.sdkModules` so the trace/assertion covers them.
@@ -457,7 +496,7 @@ const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
457
496
  if (Array.isArray(sdkModules)) {
458
497
  storage.push(...sdkModules.filter((mod): mod is string => typeof mod === 'string'));
459
498
  }
460
- return [...base, ...driver, ...storage];
499
+ return [...base, ...driverModules, ...storage];
461
500
  };
462
501
 
463
502
  /**
@@ -584,7 +623,7 @@ const assembleRegistryStandalone = async (
584
623
  * - `vercel` → the Vercel Build Output API directory (`.vercel/output`) at the repo root: the
585
624
  * traced bundle wrapped as a Node function plus CDN static assets and a route table.
586
625
  *
587
- * Mode is forced to `registry`; the database adapter is honored from config (single-sourced so build
626
+ * Mode is forced to `registry`; the database driver is honored from config (single-sourced so build
588
627
  * and `db:migrate` never diverge). No connection-string value is ever baked — only env-var *names*.
589
628
  */
590
629
  const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = 'standalone'): Promise<void> => {
@@ -597,16 +636,16 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
597
636
  Logger.info(`Source runtime.mode is "${sourceMode}"; forcing "registry" mode in the packaged registry app.`);
598
637
  }
599
638
 
600
- // The adapter stays single-sourced from config (never flipped by the package flag), so build and
639
+ // The driver stays single-sourced from config (never flipped by the package flag), so build and
601
640
  // `db:migrate` always resolve the same one. `pg` uses long-lived TCP connections that pool poorly on
602
641
  // serverless; warn (non-fatally) so the operator points DATABASE_URL at a pooled endpoint or selects
603
- // the Neon adapter. The connection-string value is not available at build time, so this cannot be
642
+ // the Neon driver. The connection-string value is not available at build time, so this cannot be
604
643
  // validated — it is guidance only and never fails the build.
605
- if (buildPackage === 'vercel' && resolveRegistryAdapter(handoff.config) === 'pg') {
644
+ if (buildPackage === 'vercel' && resolveRegistryDriver(handoff.config) === 'pg') {
606
645
  Logger.warn(
607
- 'Registry Vercel build uses the "pg" adapter (standard TCP connections), which pools poorly on ' +
646
+ 'Registry Vercel build uses the "pg" driver (standard TCP connections), which pools poorly on ' +
608
647
  'serverless and can exhaust the database connection limit. Point DATABASE_URL at a pooled endpoint ' +
609
- '(PgBouncer / transaction pooling), or set runtime.registry.database.adapter: "neon" for a ' +
648
+ '(PgBouncer / transaction pooling), or set runtime.registry.database.driver: "neon" for a ' +
610
649
  'serverless-native HTTP/WebSocket driver. The build will continue.'
611
650
  );
612
651
  }
@@ -632,26 +671,7 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
632
671
 
633
672
  // Build the dynamic app. `output: 'standalone'` (driven by HANDOFF_BUILD_TARGET) traces the runtime
634
673
  // and the selected Postgres/Neon driver into a self-contained bundle — never a static export.
635
- const buildResult = spawn.sync('npx', ['next', 'build'], {
636
- cwd: appPath,
637
- stdio: ['inherit', 'pipe', 'pipe'],
638
- env: {
639
- ...process.env,
640
- NODE_ENV: 'production',
641
- HANDOFF_BUILD_TARGET: 'registry',
642
- },
643
- });
644
-
645
- Logger.childProcessBuffer(buildResult.stdout);
646
- Logger.childProcessBuffer(buildResult.stderr);
647
-
648
- if (buildResult.status !== 0) {
649
- let errorMsg = `Registry app build failed with exit code ${buildResult.status}`;
650
- if (buildResult.error) {
651
- errorMsg += `\nSpawn error: ${buildResult.error.message}`;
652
- }
653
- throw new Error(errorMsg);
654
- }
674
+ await runNextBuild(appPath, 'registry', 'Registry app build');
655
675
 
656
676
  // Assemble the deployable package. Standalone tracing is rooted at the package (modulePath), so the
657
677
  // app's traced files land under `<relAppDir>` inside the standalone tree; static assets and public
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import Handoff from '..';
4
4
  import { getClientConfig } from '../config';
5
5
  import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
6
- import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryAdapter } from '../registry/db/adapter';
6
+ import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
7
7
  import type { Config, RuntimeMode } from '../types/config';
8
8
  import { getAppPath } from './paths';
9
9
 
@@ -48,7 +48,7 @@ export const generateTokensApi = async (handoff: Handoff) => {
48
48
  * Build the server-only runtime config consumed by the docs read API.
49
49
  *
50
50
  * The browser-facing `client.config.json` carries only the resolved mode; the server additionally
51
- * needs the registry connection *inputs* (selected adapter + the *name* of the database-URL env
51
+ * needs the registry connection *inputs* (selected driver + the *name* of the database-URL env
52
52
  * var) to back the registry-mode docs read API. These are non-secret — the connection-string value
53
53
  * itself is never persisted, only resolved from the env var at request time.
54
54
  */
@@ -57,7 +57,7 @@ const buildServerRuntimeConfig = (config: Config, modeOverride?: RuntimeMode) =>
57
57
  return {
58
58
  mode: modeOverride ?? config?.runtime?.mode ?? 'workspace',
59
59
  registry: {
60
- adapter: resolveRegistryAdapter(config),
60
+ driver: resolveRegistryDriver(config),
61
61
  databaseUrlEnv: resolveDatabaseUrlEnv(config),
62
62
  apiTokenEnv: resolveApiTokenEnv(config),
63
63
  },
@@ -41,14 +41,34 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
41
41
  await fs.copy(sourcePath, destinationPath, { overwrite: true });
42
42
  };
43
43
 
44
+ /**
45
+ * Copy the fetch-produced asset bundles (`exported/<id>/{collection}.zip`) into the app's public
46
+ * root, where the docs read API serves them at `/api/docs/assets/{collection}/{collection}.zip`.
47
+ * `fetch` writes these bundles, but `build`/`start` cleans and re-stages `.handoff` without them, so
48
+ * the download links 404 until the next fetch. Copying them here keeps the route working in workspace
49
+ * dev and static builds. Projects with no logos/icons have no bundles, so those are skipped.
50
+ */
51
+ const stageAssetDownloadBundles = async (handoff: Handoff, destinationPublicPath: string): Promise<void> => {
52
+ const bundles: Array<{ src: string; name: string }> = [
53
+ { src: handoff.getLogosZipFilePath(), name: 'logos.zip' },
54
+ { src: handoff.getIconsZipFilePath(), name: 'icons.zip' },
55
+ ];
56
+ for (const { src, name } of bundles) {
57
+ if (fs.existsSync(src) && fs.statSync(src).isFile()) {
58
+ await fs.ensureDir(destinationPublicPath);
59
+ await fs.copy(src, path.resolve(destinationPublicPath, name), { overwrite: true });
60
+ }
61
+ }
62
+ };
63
+
44
64
  /**
45
65
  * Copy the public dir from the working dir to the module dir.
46
66
  */
47
67
  export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
48
68
  const appPath = getAppPath(handoff);
69
+ const destinationPublicPath = path.resolve(appPath, 'public');
49
70
  const workingPublicPath = getWorkingPublicPath(handoff);
50
71
  if (workingPublicPath) {
51
- const destinationPublicPath = path.resolve(appPath, 'public');
52
72
  const sourceApiPath = path.resolve(workingPublicPath, 'api');
53
73
  const destinationApiPath = path.resolve(destinationPublicPath, 'api');
54
74
 
@@ -62,4 +82,8 @@ export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
62
82
 
63
83
  await mirrorDirectory(sourceApiPath, destinationApiPath);
64
84
  }
85
+
86
+ // Stage the asset bundles even when the workspace has no `public/` dir; they come from the fetch
87
+ // output, not the workspace public tree.
88
+ await stageAssetDownloadBundles(handoff, destinationPublicPath);
65
89
  };
@@ -209,7 +209,34 @@ export const materializeDocsReadModel = async (handoff: Handoff, outDir: string)
209
209
  await fs.writeJson(path.join(docsRoot, 'patterns', `${pattern.id}.json`), detail);
210
210
  }
211
211
 
212
+ await materializeAssetDownloads(handoff, docsRoot);
213
+
212
214
  Logger.success(
213
215
  `Materialized docs read model (${components.length} component(s), ${patterns.length} pattern(s)) for static export.`
214
216
  );
217
+ }
218
+
219
+ /**
220
+ * Write the whole-collection asset bundles to the asset route
221
+ * (`api/docs/assets/{collection}/{collection}.zip`), the same URL the docs read API serves in
222
+ * registry mode, so the "Download" links work identically on a static host. The bundles come from the
223
+ * fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them and the
224
+ * fetch-time copy into `public/` is wiped by the build's clean+restage, so we copy them here again.
225
+ * Projects with no logos/icons have no bundles, so those are skipped rather than treated as an error.
226
+ */
227
+ const materializeAssetDownloads = async (handoff: Handoff, docsRoot: string): Promise<void> => {
228
+ const bundles: Array<{ collection: string; src: string }> = [
229
+ { collection: 'logos', src: handoff.getLogosZipFilePath() },
230
+ { collection: 'icons', src: handoff.getIconsZipFilePath() },
231
+ ];
232
+ let copied = 0;
233
+ for (const { collection, src } of bundles) {
234
+ if (fs.existsSync(src) && fs.statSync(src).isFile()) {
235
+ await fs.copy(src, path.join(docsRoot, 'assets', collection, `${collection}.zip`), { overwrite: true });
236
+ copied += 1;
237
+ }
238
+ }
239
+ if (copied > 0) {
240
+ Logger.info(`Materialized ${copied} asset download bundle(s) for static export.`);
241
+ }
215
242
  };
@@ -66,7 +66,7 @@ export const writeStaticVercelOutput = async (handoff: Handoff, exportDir: strin
66
66
  };
67
67
 
68
68
  /**
69
- * Node.js runtime identifier baked into the registry function's `.vc-config.json`. The `pg` adapter
69
+ * Node.js runtime identifier baked into the registry function's `.vc-config.json`. The `pg` driver
70
70
  * (and the Neon serverless driver in its Node fallback) need the Node.js runtime, not the edge
71
71
  * runtime — this is the required baseline. Vercel resolves the patch
72
72
  * version within this major; pinning the major keeps the artifact stable.
@@ -98,7 +98,7 @@ const MUTABLE_REGISTRY_PAGE_SRC_ROUTE = '/[...slug]';
98
98
  * data routes resolve the same way they do under the standalone server. `minimalMode:false` keeps
99
99
  * the function doing full in-process routing (the catch-all route forwards everything here).
100
100
  *
101
- * Runtime mode, adapter, and the DB env-var *name* are already baked into the resolved config the
101
+ * Runtime mode, driver, and the DB env-var *name* are already baked into the resolved config the
102
102
  * standalone build wrote to `.next/required-server-files.json`; the connection-string value is still
103
103
  * read from the named env var at request time, never baked.
104
104
  */
@@ -14,4 +14,11 @@ export {
14
14
  type ArtifactSizeDiagnostics,
15
15
  } from './types';
16
16
 
17
- export { ARTIFACTS_ROUTE_SEGMENT, buildArtifactUrl, normalizeBasePath } from './url';
17
+ export {
18
+ ARTIFACTS_ROUTE_SEGMENT,
19
+ ASSETS_ROUTE_SEGMENT,
20
+ buildArtifactUrl,
21
+ buildAssetDownloadUrl,
22
+ buildAssetUrl,
23
+ normalizeBasePath,
24
+ } from './url';
@@ -20,6 +20,9 @@ export const ARTIFACTS_ROUTE_SEGMENT = 'api/docs/artifacts';
20
20
  /** Route prefix for docs read API metadata (`.json`) reads. */
21
21
  export const DOCS_ROUTE_SEGMENT = 'api/docs';
22
22
 
23
+ /** Route prefix all asset content URLs are served under, regardless of runtime mode. */
24
+ export const ASSETS_ROUTE_SEGMENT = 'api/docs/assets';
25
+
23
26
  /**
24
27
  * Normalize a configured basePath to either an empty string or a single-leading-slash,
25
28
  * no-trailing-slash form. Mirrors the Next app's `resolveBasePath` so builder output composes
@@ -88,3 +91,32 @@ export const buildComponentDetailUrl = (id: string, basePath?: string | null): s
88
91
  */
89
92
  export const buildPatternDetailUrl = (id: string, basePath?: string | null): string =>
90
93
  `${normalizeBasePath(basePath)}/${DOCS_ROUTE_SEGMENT}/patterns/${encodeURIComponent(id)}.json`;
94
+
95
+ /**
96
+ * Build the basePath-aware content URL for a single asset in a collection
97
+ * (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
98
+ * serves in every runtime mode: from the database in registry mode, and from statically materialized
99
+ * files in a static export. Each segment is encoded on its own and traversal segments are rejected,
100
+ * matching {@link buildArtifactUrl}.
101
+ *
102
+ * @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
103
+ */
104
+ export const buildAssetUrl = (collection: string, logicalPath: string, basePath?: string | null): string => {
105
+ const segments = [...toArtifactSegments(collection), ...toArtifactSegments(logicalPath)];
106
+ if (segments.length < 2) {
107
+ throw new Error(`Cannot build asset URL: empty collection ("${collection}") or path ("${logicalPath}").`);
108
+ }
109
+ if (segments.some(isTraversalSegment)) {
110
+ throw new Error(`Cannot build asset URL: path traversal is not allowed in "${collection}/${logicalPath}".`);
111
+ }
112
+ const encoded = segments.map((segment) => encodeURIComponent(segment)).join('/');
113
+ return `${normalizeBasePath(basePath)}/${ASSETS_ROUTE_SEGMENT}/${encoded}`;
114
+ };
115
+
116
+ /**
117
+ * Build the download URL for a collection's zip bundle
118
+ * (`{basePath}/api/docs/assets/{collection}/{collection}.zip`). This is the default target for the
119
+ * docs "Download" links and can be overridden via `assets_zip_links`.
120
+ */
121
+ export const buildAssetDownloadUrl = (collection: string, basePath?: string | null): string =>
122
+ buildAssetUrl(collection, `${collection}.zip`, basePath);