handoff-app 2.0.0-1 → 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 (86) 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 +7 -9
  17. package/dist/app-builder/static-export.js +6 -6
  18. package/dist/app-builder/vercel-output.js +2 -2
  19. package/dist/artifacts/url.d.ts +7 -7
  20. package/dist/artifacts/url.js +7 -7
  21. package/dist/cli/create.js +48 -3
  22. package/dist/commands/checkout/index.d.ts +6 -6
  23. package/dist/commands/checkout/index.js +8 -12
  24. package/dist/commands/publish/index.d.ts +6 -5
  25. package/dist/commands/publish/index.js +8 -11
  26. package/dist/commands/utils.d.ts +6 -0
  27. package/dist/commands/utils.js +9 -1
  28. package/dist/index.d.ts +12 -8
  29. package/dist/index.js +26 -10
  30. package/dist/registry/asset-storage/resolve.d.ts +1 -1
  31. package/dist/registry/asset-storage/resolve.js +1 -1
  32. package/dist/registry/checkout/index.d.ts +10 -5
  33. package/dist/registry/checkout/index.js +62 -11
  34. package/dist/registry/client.d.ts +2 -1
  35. package/dist/registry/client.js +8 -0
  36. package/dist/registry/db/client.d.ts +9 -9
  37. package/dist/registry/db/client.js +7 -7
  38. package/dist/registry/db/{adapter.d.ts → driver.d.ts} +16 -14
  39. package/dist/registry/db/{adapter.js → driver.js} +17 -15
  40. package/dist/registry/db/index.d.ts +1 -1
  41. package/dist/registry/db/index.js +9 -9
  42. package/dist/registry/db/migrate.d.ts +1 -1
  43. package/dist/registry/db/migrate.js +5 -5
  44. package/dist/registry/publish/index.d.ts +8 -1
  45. package/dist/registry/publish/index.js +88 -2
  46. package/dist/registry/transfer.d.ts +13 -0
  47. package/dist/types/config.d.ts +7 -3
  48. package/drizzle.config.ts +1 -1
  49. package/package.json +1 -1
  50. package/src/app/components/Foundations/DisplayLogo.tsx +3 -3
  51. package/src/app/lib/docs-api/runtime-config.ts +10 -10
  52. package/src/app/lib/registry-api/transfer.ts +29 -0
  53. package/src/app/lib/registry-connection.ts +2 -2
  54. package/src/app/next.config.mjs +1 -1
  55. package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
  56. package/src/app/pages/foundations/icons/index.tsx +2 -2
  57. package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +6 -6
  58. package/src/app-builder/build.ts +75 -55
  59. package/src/app-builder/client-config.ts +3 -3
  60. package/src/app-builder/paths.ts +7 -9
  61. package/src/app-builder/static-export.ts +6 -6
  62. package/src/app-builder/vercel-output.ts +2 -2
  63. package/src/artifacts/url.ts +7 -7
  64. package/src/cli/create.ts +58 -3
  65. package/src/commands/checkout/index.ts +10 -15
  66. package/src/commands/publish/index.ts +10 -14
  67. package/src/commands/utils.ts +12 -0
  68. package/src/index.ts +26 -12
  69. package/src/registry/asset-storage/resolve.ts +1 -1
  70. package/src/registry/checkout/index.ts +73 -11
  71. package/src/registry/client.ts +7 -1
  72. package/src/registry/db/client.ts +12 -12
  73. package/src/registry/db/{adapter.ts → driver.ts} +19 -17
  74. package/src/registry/db/index.ts +4 -4
  75. package/src/registry/db/migrate.ts +5 -5
  76. package/src/registry/publish/index.ts +100 -2
  77. package/src/registry/transfer.ts +14 -0
  78. package/src/types/config.ts +7 -3
  79. package/templates/default/README.md.tpl +40 -1
  80. package/templates/default/gitignore.tpl +18 -3
  81. package/templates/default/handoff.config.js.tpl +14 -0
  82. package/templates/default/handoff.config.ts.tpl +14 -0
  83. package/templates/default/handoff.config.with-examples.js.tpl +14 -0
  84. package/templates/default/handoff.config.with-examples.ts.tpl +14 -0
  85. package/templates/default/package.json.tpl +2 -1
  86. package/templates/default/vercel.json.tpl +4 -0
@@ -36,13 +36,13 @@ export const DisplayIcon: React.FC<{ icon: DisplayableAsset }> = ({ icon }) => {
36
36
 
37
37
  return (
38
38
  <div className="flex flex-col gap-2">
39
- <Link href={`/foundations/icons/${icon.icon}`}>
39
+ <Link href={`/foundations/icons/library/${icon.icon}`}>
40
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">
41
41
  {/* Workspace/static: inline SVG body. Registry: the individual asset content URL. */}
42
42
  {icon.data ? <div dangerouslySetInnerHTML={{ __html: htmlData }} /> : <img className="o-icon h-6 w-6" src={icon.src} alt={icon.name} />}
43
43
  </div>
44
44
  </Link>
45
- <Link href={`/foundations/icons/${icon.icon}`}>
45
+ <Link href={`/foundations/icons/library/${icon.icon}`}>
46
46
  <p className="font-mono text-[11px]">{icon.icon}</p>
47
47
  </Link>
48
48
  </div>
@@ -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(() => {
@@ -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
  },
@@ -42,13 +42,11 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
42
42
  };
43
43
 
44
44
  /**
45
- * Stage the fetch-produced asset download bundles (`exported/<id>/{collection}.zip`) into the app
46
- * public root, where the docs read API's workspace asset store serves them at
47
- * `/api/docs/assets/{collection}/{collection}.zip` (the canonical download URL). `fetch` writes these
48
- * bundles, but a plain `build`/`start` cleans and re-stages `.handoff` without carrying them, so the
49
- * download links would 404 until a fetch happened to run. Copying the committed export bundles here
50
- * makes the route resolve deterministically in workspace dev and static builds. Absent bundles (a
51
- * project with no logos/icons) are skipped.
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.
52
50
  */
53
51
  const stageAssetDownloadBundles = async (handoff: Handoff, destinationPublicPath: string): Promise<void> => {
54
52
  const bundles: Array<{ src: string; name: string }> = [
@@ -85,7 +83,7 @@ export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
85
83
  await mirrorDirectory(sourceApiPath, destinationApiPath);
86
84
  }
87
85
 
88
- // Stage the asset download bundles regardless of whether the workspace has a `public/` dir — they
89
- // come from the fetch output, not the workspace public tree.
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.
90
88
  await stageAssetDownloadBundles(handoff, destinationPublicPath);
91
89
  };
@@ -217,12 +217,12 @@ export const materializeDocsReadModel = async (handoff: Handoff, outDir: string)
217
217
  }
218
218
 
219
219
  /**
220
- * Materialize the whole-collection asset download bundles at the canonical 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 resolve identically on a static host. The bundles come from
223
- * the fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them, and the
224
- * fetch-time copy into the app `public/` is wiped by the build's clean+restage, so they are copied
225
- * here. Absent bundles (a project with no logos/icons) are skipped, not an error.
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
226
  */
227
227
  const materializeAssetDownloads = async (handoff: Handoff, docsRoot: string): Promise<void> => {
228
228
  const bundles: Array<{ collection: string; src: string }> = [
@@ -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
  */
@@ -20,7 +20,7 @@ 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
- /** Canonical route prefix all asset content URLs are served under (mode-independent). */
23
+ /** Route prefix all asset content URLs are served under, regardless of runtime mode. */
24
24
  export const ASSETS_ROUTE_SEGMENT = 'api/docs/assets';
25
25
 
26
26
  /**
@@ -93,11 +93,11 @@ export const buildPatternDetailUrl = (id: string, basePath?: string | null): str
93
93
  `${normalizeBasePath(basePath)}/${DOCS_ROUTE_SEGMENT}/patterns/${encodeURIComponent(id)}.json`;
94
94
 
95
95
  /**
96
- * Build the canonical, basePath-aware content URL for one asset within a collection
96
+ * Build the basePath-aware content URL for a single asset in a collection
97
97
  * (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
98
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 individually encoded and traversal segments are rejected,
100
- * mirroring {@link buildArtifactUrl}.
99
+ * files in a static export. Each segment is encoded on its own and traversal segments are rejected,
100
+ * matching {@link buildArtifactUrl}.
101
101
  *
102
102
  * @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
103
103
  */
@@ -114,9 +114,9 @@ export const buildAssetUrl = (collection: string, logicalPath: string, basePath?
114
114
  };
115
115
 
116
116
  /**
117
- * Build the canonical download URL for a collection's whole-collection zip bundle
118
- * (`{basePath}/api/docs/assets/{collection}/{collection}.zip`) — the default target for the docs
119
- * "Download" links, overridable via `assets_zip_links`.
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
120
  */
121
121
  export const buildAssetDownloadUrl = (collection: string, basePath?: string | null): string =>
122
122
  buildAssetUrl(collection, `${collection}.zip`, basePath);
package/src/cli/create.ts CHANGED
@@ -23,6 +23,8 @@ interface CreateOptions {
23
23
  figmaAccessToken: string;
24
24
  languagePreference: 'typescript' | 'javascript';
25
25
  includeExamples: boolean;
26
+ includeVercel: boolean;
27
+ vercelBuildCommand: string;
26
28
  [key: string]: string | boolean; // Allow dynamic access for template replacement
27
29
  }
28
30
 
@@ -67,7 +69,8 @@ interface TemplateConfig {
67
69
  */
68
70
  const getMandatoryTemplates = (
69
71
  includeExamples: boolean,
70
- languagePreference: 'typescript' | 'javascript'
72
+ languagePreference: 'typescript' | 'javascript',
73
+ includeVercel: boolean
71
74
  ): TemplateConfig[] => {
72
75
  const configFormat = languagePreference;
73
76
  const configTemplateBaseName = includeExamples ? 'handoff.config.with-examples' : 'handoff.config';
@@ -89,6 +92,10 @@ const getMandatoryTemplates = (
89
92
  templates.push({ source: 'tsconfig.json.tpl', destination: 'tsconfig.json' });
90
93
  }
91
94
 
95
+ if (includeVercel) {
96
+ templates.push({ source: 'vercel.json.tpl', destination: 'vercel.json' });
97
+ }
98
+
92
99
  return templates;
93
100
  };
94
101
 
@@ -118,7 +125,8 @@ const renderTemplate = (content: string, data: CreateOptions): string => {
118
125
  };
119
126
 
120
127
  const getDependenciesToInstall = (options: CreateOptions): { dependencies: string[]; devDependencies: string[] } => {
121
- const dependencies = ['handoff-app@latest'];
128
+ // TODO: switch back to `handoff-app@latest` before the 2.0 stable release.
129
+ const dependencies = ['handoff-app@next'];
122
130
  const devDependencies: string[] = [];
123
131
 
124
132
  if (options.includeExamples) {
@@ -263,6 +271,51 @@ const create = async (): Promise<void> => {
263
271
 
264
272
  const languagePreference = languagePreferenceSelection as 'typescript' | 'javascript';
265
273
 
274
+ const includeVercelSelection = await p.confirm({
275
+ message: 'Add a Vercel deployment config (vercel.json)?',
276
+ initialValue: true,
277
+ });
278
+
279
+ if (p.isCancel(includeVercelSelection)) {
280
+ p.cancel('Project creation cancelled.');
281
+ process.exit(0);
282
+ }
283
+
284
+ const includeVercel = includeVercelSelection as boolean;
285
+
286
+ // When a Vercel config is scaffolded, choose which deployment its build command produces. Both
287
+ // package to `.vercel/output`; they differ in what is served. Registry is the recommended,
288
+ // pre-selected option; static stays available as the simplest deploy.
289
+ let vercelBuildCommand = 'npm run build -- --target registry --package vercel';
290
+ if (includeVercel) {
291
+ const vercelDeploymentSelection = await p.select({
292
+ message: 'Which deployment should Vercel build?',
293
+ options: [
294
+ {
295
+ value: 'registry',
296
+ label: 'Registry (dynamic)',
297
+ hint: 'Live, database-backed app (needs a Postgres DATABASE_URL). The more capable, actively evolving option — recommended for a shared design system.',
298
+ },
299
+ {
300
+ value: 'static',
301
+ label: 'Static site',
302
+ hint: 'Prebuilt snapshot served as plain files. The simplest to deploy and run.',
303
+ },
304
+ ],
305
+ initialValue: 'registry',
306
+ });
307
+
308
+ if (p.isCancel(vercelDeploymentSelection)) {
309
+ p.cancel('Project creation cancelled.');
310
+ process.exit(0);
311
+ }
312
+
313
+ vercelBuildCommand =
314
+ vercelDeploymentSelection === 'registry'
315
+ ? 'npm run build -- --target registry --package vercel'
316
+ : 'npm run build -- --target static --package vercel';
317
+ }
318
+
266
319
  // Get Figma project ID
267
320
  p.log.step(chalk.blue('Figma Configuration'));
268
321
  const figmaProjectId = await p.text({
@@ -295,6 +348,8 @@ const create = async (): Promise<void> => {
295
348
  figmaAccessToken: ((figmaAccessToken as string) || '').trim(),
296
349
  languagePreference,
297
350
  includeExamples,
351
+ includeVercel,
352
+ vercelBuildCommand,
298
353
  };
299
354
 
300
355
  // Create files
@@ -309,7 +364,7 @@ const create = async (): Promise<void> => {
309
364
 
310
365
  // Combine mandatory templates with example templates based on user choice
311
366
  const templates = [
312
- ...getMandatoryTemplates(includeExamples, languagePreference),
367
+ ...getMandatoryTemplates(includeExamples, languagePreference, includeVercel),
313
368
  ...(includeExamples ? getExampleTemplates(languagePreference) : []),
314
369
  ];
315
370
 
@@ -2,20 +2,18 @@ import { CommandModule } from 'yargs';
2
2
  import Handoff from '../../';
3
3
  import { Logger } from '../../utils/logger';
4
4
  import { SharedArgs } from '../types';
5
- import { getSharedOptions } from '../utils';
6
-
7
- /** Entity kinds checkout-able from a connected workspace. */
8
- const ENTITY_KINDS = ['component', 'pattern', 'page', 'tokens', 'assets'] as const;
5
+ import { ENTITY_WIRE_KIND, getSharedOptions, REGISTRY_ENTITY_KINDS, RegistryEntityKind } from '../utils';
9
6
 
10
7
  export interface CheckoutArgs extends SharedArgs {
11
- type: (typeof ENTITY_KINDS)[number];
8
+ type: RegistryEntityKind;
12
9
  id?: string;
13
10
  }
14
11
 
15
12
  /**
16
- * `handoff-app checkout <component|pattern|page> <id>` — pull an entity from the connected registry
17
- * into this workspace: writes its source files in standard authoring form and synthesizes a local
18
- * declaration.
13
+ * `handoff-app checkout <components|patterns|pages> [id]` pulls entities from the connected registry
14
+ * into this workspace, writing their source files in standard authoring form and synthesizing local
15
+ * declarations. Pass an `id` to pull that one entity, or omit it to pull every published entity of
16
+ * that kind.
19
17
  *
20
18
  * `handoff-app checkout tokens [setId]` — pull every published token set (or only the named set) into
21
19
  * this workspace: reconstruct `tokens.json` and restore the generated token files.
@@ -28,16 +26,16 @@ export interface CheckoutArgs extends SharedArgs {
28
26
  */
29
27
  const command: CommandModule<{}, CheckoutArgs> = {
30
28
  command: 'checkout <type> [id]',
31
- describe: 'Pull a component, pattern, page, design tokens, or assets from the connected registry into this workspace',
29
+ describe: 'Pull components, patterns, pages, design tokens, or assets from the connected registry into this workspace',
32
30
  builder: (yargs) => {
33
31
  return getSharedOptions(yargs)
34
32
  .positional('type', {
35
33
  describe: 'The kind of entity to checkout',
36
- choices: ENTITY_KINDS,
34
+ choices: REGISTRY_ENTITY_KINDS,
37
35
  type: 'string',
38
36
  })
39
37
  .positional('id', {
40
- describe: 'The stable id of the entity (component/pattern/page id, token set id, or asset collection); optional for tokens/assets',
38
+ describe: 'The stable id of the entity (component/pattern/page id, token set id, or asset collection); omit to checkout all of that kind',
41
39
  type: 'string',
42
40
  });
43
41
  },
@@ -52,10 +50,7 @@ const command: CommandModule<{}, CheckoutArgs> = {
52
50
  await handoff.checkoutAssets(args.id);
53
51
  return;
54
52
  }
55
- if (!args.id) {
56
- throw new Error(`An id is required to checkout a ${args.type} (e.g. "handoff-app checkout ${args.type} <id>").`);
57
- }
58
- await handoff.checkout(args.type, args.id);
53
+ await handoff.checkout(ENTITY_WIRE_KIND[args.type], args.id);
59
54
  } catch (error) {
60
55
  Logger.error(error instanceof Error ? error.message : String(error));
61
56
  process.exitCode = 1;
@@ -2,19 +2,18 @@ import { CommandModule } from 'yargs';
2
2
  import Handoff from '../../';
3
3
  import { Logger } from '../../utils/logger';
4
4
  import { SharedArgs } from '../types';
5
- import { getSharedOptions } from '../utils';
6
-
7
- /** Entity kinds publishable from a connected workspace. */
8
- const ENTITY_KINDS = ['component', 'pattern', 'page', 'tokens', 'assets'] as const;
5
+ import { ENTITY_WIRE_KIND, getSharedOptions, REGISTRY_ENTITY_KINDS, RegistryEntityKind } from '../utils';
9
6
 
10
7
  export interface PublishArgs extends SharedArgs {
11
- type: (typeof ENTITY_KINDS)[number];
8
+ type: RegistryEntityKind;
12
9
  id?: string;
13
10
  }
14
11
 
15
12
  /**
16
- * `handoff-app publish <component|pattern|page> <id>` — fresh targeted local build + upload of the
17
- * selected entity to the connected registry.
13
+ * `handoff-app publish <components|patterns|pages> [id]` runs a fresh local build and uploads to the
14
+ * connected registry. Pass an `id` to build and upload that one entity, or omit it to build the kind
15
+ * once and upload every declared entity, skipping any whose content already matches the registry
16
+ * (pass `--force` to re-upload everything).
18
17
  *
19
18
  * `handoff-app publish tokens [setId]` — fresh token build + upload of every logical token set, or
20
19
  * only the named set (`foundation/colors`, `component/button`).
@@ -25,16 +24,16 @@ export interface PublishArgs extends SharedArgs {
25
24
  */
26
25
  const command: CommandModule<{}, PublishArgs> = {
27
26
  command: 'publish <type> [id]',
28
- describe: 'Build and publish a component, pattern, page, design tokens, or assets to the connected registry',
27
+ describe: 'Build and publish components, patterns, pages, design tokens, or assets to the connected registry',
29
28
  builder: (yargs) => {
30
29
  return getSharedOptions(yargs)
31
30
  .positional('type', {
32
31
  describe: 'The kind of entity to publish',
33
- choices: ENTITY_KINDS,
32
+ choices: REGISTRY_ENTITY_KINDS,
34
33
  type: 'string',
35
34
  })
36
35
  .positional('id', {
37
- describe: 'The stable id of the entity (component/pattern/page id, token set id, or asset collection); optional for tokens/assets',
36
+ describe: 'The stable id of the entity (component/pattern/page id, token set id, or asset collection); omit to publish all of that kind',
38
37
  type: 'string',
39
38
  });
40
39
  },
@@ -49,10 +48,7 @@ const command: CommandModule<{}, PublishArgs> = {
49
48
  await handoff.publishAssets(args.id);
50
49
  return;
51
50
  }
52
- if (!args.id) {
53
- throw new Error(`An id is required to publish a ${args.type} (e.g. "handoff-app publish ${args.type} <id>").`);
54
- }
55
- await handoff.publish(args.type, args.id);
51
+ await handoff.publish(ENTITY_WIRE_KIND[args.type], args.id);
56
52
  } catch (error) {
57
53
  Logger.error(error instanceof Error ? error.message : String(error));
58
54
  process.exitCode = 1;
@@ -1,4 +1,16 @@
1
1
  import { Argv } from 'yargs';
2
+ import type { TransferEntityKind } from '../registry/transfer';
3
+
4
+ /** Plural entity kinds accepted by the `publish`/`checkout` commands. */
5
+ export const REGISTRY_ENTITY_KINDS = ['components', 'patterns', 'pages', 'tokens', 'assets'] as const;
6
+ export type RegistryEntityKind = (typeof REGISTRY_ENTITY_KINDS)[number];
7
+
8
+ /** Map the plural CLI kind to the singular wire kind used by entity publish/checkout. */
9
+ export const ENTITY_WIRE_KIND: Record<'components' | 'patterns' | 'pages', TransferEntityKind> = {
10
+ components: 'component',
11
+ patterns: 'pattern',
12
+ pages: 'page',
13
+ };
2
14
 
3
15
  export const getSharedOptions = (yargs: Argv) => {
4
16
  return yargs.options({