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
package/src/index.ts CHANGED
@@ -138,26 +138,40 @@ class Handoff {
138
138
  }
139
139
 
140
140
  /**
141
- * Publish a single component, pattern, or page from this connected workspace to the configured
142
- * remote registry. Components and patterns receive a fresh targeted build before upload. Pages
143
- * upload their record and markdown source because they render at runtime. The publish module is
144
- * loaded lazily so the registry client and build code never enter the docs app bundle.
141
+ * Publish components, patterns, or pages from this connected workspace to the configured remote
142
+ * registry. With `id` it publishes that one entity after a targeted build; without `id` it builds
143
+ * the kind once and publishes every declared entity, skipping ones whose content is unchanged on
144
+ * the registry (`--force` re-uploads all). Pages upload their record and markdown source because
145
+ * they render at runtime. The publish module is loaded lazily so the registry client and build code
146
+ * never enter the docs app bundle.
145
147
  */
146
- async publish(kind: 'component' | 'pattern' | 'page', id: string): Promise<Handoff> {
148
+ async publish(kind: 'component' | 'pattern' | 'page', id?: string): Promise<Handoff> {
147
149
  this.preRunner();
148
- const { publishEntity } = await import('./registry/publish');
149
- await publishEntity(this, kind, id);
150
+ if (id) {
151
+ const { publishEntity } = await import('./registry/publish');
152
+ await publishEntity(this, kind, id);
153
+ } else {
154
+ const { publishEntities } = await import('./registry/publish');
155
+ await publishEntities(this, kind);
156
+ }
150
157
  return this;
151
158
  }
152
159
 
153
160
  /**
154
- * Checkout a single component, pattern, or page from the connected remote registry. The checkout
155
- * module is loaded lazily so the registry client never enters the docs app bundle.
161
+ * Checkout components, patterns, or pages from the connected remote registry into this workspace.
162
+ * With `id` it checks out that one entity; without `id` it checks out every published entity of the
163
+ * kind. Overwriting existing local files requires `--force` or an interactive confirmation. The
164
+ * checkout module is loaded lazily so the registry client never enters the docs app bundle.
156
165
  */
157
- async checkout(kind: 'component' | 'pattern' | 'page', id: string): Promise<Handoff> {
166
+ async checkout(kind: 'component' | 'pattern' | 'page', id?: string): Promise<Handoff> {
158
167
  this.preRunner();
159
- const { checkoutEntity } = await import('./registry/checkout');
160
- await checkoutEntity(this, kind, id);
168
+ if (id) {
169
+ const { checkoutEntity } = await import('./registry/checkout');
170
+ await checkoutEntity(this, kind, id);
171
+ } else {
172
+ const { checkoutEntities } = await import('./registry/checkout');
173
+ await checkoutEntities(this, kind);
174
+ }
161
175
  return this;
162
176
  }
163
177
 
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `runtime.registry.assetStorage` selects the active provider for **new** uploads: the built-in
5
5
  * `database` inline default, the pre-packaged `vercel-blob` adapter, or a `custom` module. Mirrors
6
- * {@link import('../db/adapter')}: only provider selection, module location, non-secret options, and
6
+ * {@link import('../db/driver')}: only provider selection, module location, non-secret options, and
7
7
  * env-var *names* are ever persisted; secret *values* are read from `process.env` at request time.
8
8
  */
9
9
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Connected-workspace checkout orchestration.
3
3
  *
4
- * `checkout <component|pattern> <id>` reads a normalized registry record and its registry-safe
4
+ * `checkout <components|patterns> <id>` reads a normalized registry record and its registry-safe
5
5
  * source files from the connected registry through the shared registry client, then writes them
6
6
  * into the local workspace in standard authoring form. The local **declaration is synthesized
7
7
  * locally** in the configured `runtime.workspace.declarationFormat` — declarations are never read
@@ -20,7 +20,7 @@ import { resolveComponentDeclaration } from '../../config/runtime';
20
20
  import Handoff from '../../index';
21
21
  import type { DeclarationFormat } from '../../types/config';
22
22
  import { Logger } from '../../utils/logger';
23
- import { createRegistryClient, RegistryClientError } from '../client';
23
+ import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
24
24
  import { resolveRegistryConnection } from '../connection';
25
25
  import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
26
26
  import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
@@ -530,26 +530,31 @@ const checkoutPage = async (handoff: Handoff, id: string, payload: CheckoutPaylo
530
530
  };
531
531
 
532
532
  /**
533
- * Checkout a single component or pattern from a connected workspace: precondition checks → fetch the
534
- * normalized record + source files → resolve the local target → explicit-overwrite guard → write
535
- * source files and synthesize the local declaration. Throws {@link CheckoutError} with actionable
536
- * messaging on any precondition or fetch failure.
533
+ * Checkout one entity through an already-resolved client: precondition checks → fetch the normalized
534
+ * record + source files → resolve the local target → explicit-overwrite guard → write source files
535
+ * and synthesize the local declaration. Shared by the single-id and bulk entry points so both behave
536
+ * identically. Throws {@link CheckoutError} with actionable messaging on any precondition or fetch
537
+ * failure.
537
538
  */
538
- export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
539
+ const checkoutSingle = async (
540
+ handoff: Handoff,
541
+ kind: TransferEntityKind,
542
+ id: string,
543
+ client: RegistryClient,
544
+ registryUrl: string
545
+ ): Promise<void> => {
539
546
  const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
540
547
  if (!validId) {
541
548
  throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
542
549
  }
543
- const connection = resolveConnectionOrThrow(handoff);
544
550
 
545
- Logger.info(`Fetching ${kind} "${id}" from ${connection.url}…`);
546
- const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
551
+ Logger.info(`Fetching ${kind} "${id}" from ${registryUrl}…`);
547
552
  let payload: CheckoutPayload;
548
553
  try {
549
554
  payload = await client.checkout(kind, id);
550
555
  } catch (error) {
551
556
  if (error instanceof RegistryClientError) {
552
- throw new CheckoutError(describeFetchFailure(error, kind, id, connection.url));
557
+ throw new CheckoutError(describeFetchFailure(error, kind, id, registryUrl));
553
558
  }
554
559
  throw error;
555
560
  }
@@ -594,3 +599,60 @@ export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind,
594
599
  `(${payload.files.length} source file(s) + ${declarationFileName}).`
595
600
  );
596
601
  };
602
+
603
+ /**
604
+ * Checkout a single component, pattern, or page from the connected registry into this workspace.
605
+ * Overwriting existing local files requires `--force` or an interactive confirmation.
606
+ */
607
+ export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
608
+ const connection = resolveConnectionOrThrow(handoff);
609
+ const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
610
+ await checkoutSingle(handoff, kind, id, client, connection.url);
611
+ };
612
+
613
+ /**
614
+ * Checkout every published component, pattern, or page of the kind into this workspace. Enumerates
615
+ * the registry's published ids, then checks each out through the shared client. A per-entity failure
616
+ * is collected and never aborts the rest; the run throws at the end if any entity failed. Overwrite
617
+ * prompting is per entity (skipped under `--force`).
618
+ */
619
+ export const checkoutEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
620
+ const connection = resolveConnectionOrThrow(handoff);
621
+ const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
622
+
623
+ let summaries;
624
+ try {
625
+ summaries = await client.listEntities(kind);
626
+ } catch (error) {
627
+ if (error instanceof RegistryClientError) {
628
+ throw new CheckoutError(`Could not list ${kind}s from the registry at ${connection.url}: ${error.message}`);
629
+ }
630
+ throw error;
631
+ }
632
+
633
+ if (summaries.length === 0) {
634
+ Logger.success(`No ${kind}s are published in the registry; nothing to checkout.`);
635
+ return;
636
+ }
637
+
638
+ let checkedOut = 0;
639
+ const failed: { id: string; message: string }[] = [];
640
+ for (const { id } of summaries) {
641
+ try {
642
+ await checkoutSingle(handoff, kind, id, client, connection.url);
643
+ checkedOut += 1;
644
+ } catch (error) {
645
+ failed.push({ id, message: error instanceof Error ? error.message : String(error) });
646
+ }
647
+ }
648
+
649
+ Logger.success(
650
+ `${kind[0].toUpperCase()}${kind.slice(1)}s checkout complete — ${checkedOut} checked out${failed.length ? `, ${failed.length} failed` : ''}.`
651
+ );
652
+ if (failed.length > 0) {
653
+ for (const failure of failed) {
654
+ Logger.error(` - ${failure.id}: ${failure.message}`);
655
+ }
656
+ throw new CheckoutError(`${failed.length} ${kind}(s) failed to checkout.`);
657
+ }
658
+ };
@@ -8,7 +8,7 @@
8
8
  * and the browser both provide it), so this module stays runtime-agnostic.
9
9
  */
10
10
 
11
- import type { CheckoutPayload, TransferEntityKind, TransferPackage } from './transfer';
11
+ import type { CheckoutPayload, EntitySummary, TransferEntityKind, TransferPackage } from './transfer';
12
12
  import type { TokenSetCheckoutPayload, TokenSetSummary, TokenSetTransferPackage } from './tokens/transfer';
13
13
  import type { AssetCollectionCheckoutPayload, AssetCollectionSummary, AssetCollectionTransferPackage } from './assets/transfer';
14
14
 
@@ -55,6 +55,7 @@ export interface RegistryClientOptions {
55
55
  export interface RegistryClient {
56
56
  publish(kind: TransferEntityKind, id: string, pkg: TransferPackage): Promise<RegistryEnvelope>;
57
57
  checkout(kind: TransferEntityKind, id: string): Promise<CheckoutPayload>;
58
+ listEntities(kind: TransferEntityKind): Promise<EntitySummary[]>;
58
59
  listTokenSets(): Promise<TokenSetSummary[]>;
59
60
  publishTokens(pkg: TokenSetTransferPackage): Promise<RegistryEnvelope>;
60
61
  checkoutTokens(id: string): Promise<TokenSetCheckoutPayload>;
@@ -154,6 +155,11 @@ export const createRegistryClient = ({ baseUrl, accessToken }: RegistryClientOpt
154
155
  }
155
156
  return envelope.data;
156
157
  },
158
+ /** List the registry's entities of one kind (id + build hashes) for skip-unchanged / bulk checkout. */
159
+ async listEntities(kind: TransferEntityKind): Promise<EntitySummary[]> {
160
+ const envelope = await request<{ entities: EntitySummary[] }>('GET', `/api/registry/transfer/${kind}`);
161
+ return envelope.data?.entities ?? [];
162
+ },
157
163
  /** List the registry's token sets (id + kind + source hash) for skip-unchanged / bulk checkout. */
158
164
  async listTokenSets(): Promise<TokenSetSummary[]> {
159
165
  const envelope = await request<{ sets: TokenSetSummary[] }>('GET', '/api/registry/transfer/tokens');
@@ -1,24 +1,24 @@
1
1
  /**
2
2
  * Bundled Drizzle client + migrator for the registry database.
3
3
  *
4
- * The package ships both built-in adapters (`pg` and `neon`) and the Drizzle client/migrator, so a
4
+ * The package ships both built-in drivers (`pg` and `neon`) and the Drizzle client/migrator, so a
5
5
  * consuming project needs no global Drizzle install or separate CLI. Driver modules are loaded
6
- * lazily (dynamic import) for the selected adapter only, so workspace dev/build never pulls the
6
+ * lazily (dynamic import) for the selected driver only, so workspace dev/build never pulls the
7
7
  * Postgres/Neon drivers into memory when the registry is not in use.
8
8
  */
9
9
 
10
10
  import type { NeonDatabase } from 'drizzle-orm/neon-serverless';
11
11
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
12
- import type { RegistryDatabaseAdapter } from './adapter';
12
+ import type { RegistryDatabaseDriver } from './driver';
13
13
  import * as schema from './schema';
14
14
 
15
- /** Typed Drizzle database over the registry schema, regardless of which adapter backs it. */
15
+ /** Typed Drizzle database over the registry schema, regardless of which driver backs it. */
16
16
  export type RegistryDatabase = NodePgDatabase<typeof schema> | NeonDatabase<typeof schema>;
17
17
 
18
18
  /** A live registry database connection plus its lifecycle operations. */
19
19
  export interface RegistryDbConnection {
20
- /** Selected adapter that produced this connection. */
21
- adapter: RegistryDatabaseAdapter;
20
+ /** Selected driver that produced this connection. */
21
+ driver: RegistryDatabaseDriver;
22
22
  /** Typed Drizzle database. */
23
23
  db: RegistryDatabase;
24
24
  /** Apply the package-owned migrations found in `migrationsFolder`. */
@@ -28,20 +28,20 @@ export interface RegistryDbConnection {
28
28
  }
29
29
 
30
30
  export interface CreateRegistryDbConnectionParams {
31
- adapter: RegistryDatabaseAdapter;
31
+ driver: RegistryDatabaseDriver;
32
32
  connectionString: string;
33
33
  }
34
34
 
35
35
  /**
36
- * Create a registry database connection for the resolved adapter. Both adapters target the
36
+ * Create a registry database connection for the resolved driver. Both drivers target the
37
37
  * Postgres dialect and share the same schema, so the returned connection is interchangeable for
38
38
  * migration and (later) the registry store.
39
39
  */
40
40
  export const createRegistryDbConnection = async ({
41
- adapter,
41
+ driver,
42
42
  connectionString,
43
43
  }: CreateRegistryDbConnectionParams): Promise<RegistryDbConnection> => {
44
- if (adapter === 'neon') {
44
+ if (driver === 'neon') {
45
45
  const { Pool, neonConfig } = await import('@neondatabase/serverless');
46
46
  // Neon's serverless driver needs a WebSocket constructor in Node; reuse the bundled `ws`.
47
47
  const ws = await import('ws');
@@ -59,7 +59,7 @@ export const createRegistryDbConnection = async ({
59
59
  const db = drizzle(pool, { schema });
60
60
 
61
61
  return {
62
- adapter,
62
+ driver,
63
63
  db,
64
64
  migrate: (migrationsFolder: string) => migrate(db, { migrationsFolder }),
65
65
  close: () => pool.end(),
@@ -80,7 +80,7 @@ export const createRegistryDbConnection = async ({
80
80
  const db = drizzle(pool, { schema });
81
81
 
82
82
  return {
83
- adapter,
83
+ driver,
84
84
  db,
85
85
  migrate: (migrationsFolder: string) => migrate(db, { migrationsFolder }),
86
86
  close: () => pool.end(),
@@ -1,17 +1,19 @@
1
1
  /**
2
- * Registry database adapter resolution.
2
+ * Registry database driver resolution.
3
3
  *
4
- * `runtime.registry.database.adapter` selects one of two built-in adapters shipped with the
5
- * `handoff-app` package — `pg` (default) and `neon` — both targeting the Postgres dialect.
6
- * `build --target registry` and `db:migrate` resolve the **same** adapter so build and migration
7
- * never diverge. Connection credentials are referenced by environment-variable name only and
8
- * resolved to a value at runtime; the value is never stored in config.
4
+ * PostgreSQL is the supported database. `runtime.registry.database.driver` selects one of two
5
+ * built-in connection drivers shipped with the `handoff-app` package — `pg` (default) and `neon` —
6
+ * both targeting the same Postgres dialect over one package-owned schema and migration set. The
7
+ * driver picks *how* to connect, not *which* database engine to use. `build --target registry` and
8
+ * `db:migrate` resolve the **same** driver so build and migration never diverge. Connection
9
+ * credentials are referenced by environment-variable name only and resolved to a value at runtime;
10
+ * the value is never stored in config.
9
11
  */
10
12
 
11
13
  import type { Config } from '../../types/config';
12
14
 
13
- /** Built-in registry database adapter. Both ship with the package and target Postgres. */
14
- export type RegistryDatabaseAdapter = 'pg' | 'neon';
15
+ /** Built-in registry database connection driver. Both ship with the package and target Postgres. */
16
+ export type RegistryDatabaseDriver = 'pg' | 'neon';
15
17
 
16
18
  /** Default env-var name holding the database connection string. */
17
19
  export const DEFAULT_DATABASE_URL_ENV = 'DATABASE_URL';
@@ -19,13 +21,13 @@ export const DEFAULT_DATABASE_URL_ENV = 'DATABASE_URL';
19
21
  /** Default env-var name holding the registry management API bearer token. */
20
22
  export const DEFAULT_REGISTRY_API_TOKEN_ENV = 'HANDOFF_REGISTRY_API_TOKEN';
21
23
 
22
- /** Default registry database adapter when none is configured. */
23
- export const DEFAULT_REGISTRY_ADAPTER: RegistryDatabaseAdapter = 'pg';
24
+ /** Default registry database driver when none is configured. */
25
+ export const DEFAULT_REGISTRY_DRIVER: RegistryDatabaseDriver = 'pg';
24
26
 
25
27
  /** Fully resolved registry database connection inputs. */
26
28
  export interface ResolvedRegistryDatabase {
27
- /** Selected built-in adapter. */
28
- adapter: RegistryDatabaseAdapter;
29
+ /** Selected built-in driver. */
30
+ driver: RegistryDatabaseDriver;
29
31
  /** Name of the env var the connection string was read from. */
30
32
  databaseUrlEnv: string;
31
33
  /** Resolved connection string (never persisted in config). */
@@ -48,9 +50,9 @@ export const resolveApiTokenEnv = (config: Config | null | undefined): string =>
48
50
  return configured || DEFAULT_REGISTRY_API_TOKEN_ENV;
49
51
  };
50
52
 
51
- /** Resolve the configured registry adapter (defaults to `pg`). */
52
- export const resolveRegistryAdapter = (config: Config | null | undefined): RegistryDatabaseAdapter => {
53
- return config?.runtime?.registry?.database?.adapter ?? DEFAULT_REGISTRY_ADAPTER;
53
+ /** Resolve the configured registry database driver (defaults to `pg`). */
54
+ export const resolveRegistryDriver = (config: Config | null | undefined): RegistryDatabaseDriver => {
55
+ return config?.runtime?.registry?.database?.driver ?? DEFAULT_REGISTRY_DRIVER;
54
56
  };
55
57
 
56
58
  /**
@@ -59,7 +61,7 @@ export const resolveRegistryAdapter = (config: Config | null | undefined): Regis
59
61
  * message instead of failing deep inside the driver.
60
62
  */
61
63
  export const resolveRegistryDatabase = (config: Config | null | undefined): ResolvedRegistryDatabase => {
62
- const adapter = resolveRegistryAdapter(config);
64
+ const driver = resolveRegistryDriver(config);
63
65
  const databaseUrlEnv = resolveDatabaseUrlEnv(config);
64
66
  const connectionString = process.env[databaseUrlEnv]?.trim();
65
67
 
@@ -70,5 +72,5 @@ export const resolveRegistryDatabase = (config: Config | null | undefined): Reso
70
72
  );
71
73
  }
72
74
 
73
- return { adapter, databaseUrlEnv, connectionString };
75
+ return { driver, databaseUrlEnv, connectionString };
74
76
  };
@@ -15,15 +15,15 @@ export type { ArtifactBuildStatus as RegistryBuildStatus } from '../../artifacts
15
15
 
16
16
  export {
17
17
  DEFAULT_DATABASE_URL_ENV,
18
- DEFAULT_REGISTRY_ADAPTER,
18
+ DEFAULT_REGISTRY_DRIVER,
19
19
  DEFAULT_REGISTRY_API_TOKEN_ENV,
20
20
  resolveApiTokenEnv,
21
21
  resolveDatabaseUrlEnv,
22
- resolveRegistryAdapter,
22
+ resolveRegistryDriver,
23
23
  resolveRegistryDatabase,
24
- type RegistryDatabaseAdapter,
24
+ type RegistryDatabaseDriver,
25
25
  type ResolvedRegistryDatabase,
26
- } from './adapter';
26
+ } from './driver';
27
27
 
28
28
  export {
29
29
  createRegistryDbConnection,
@@ -2,7 +2,7 @@
2
2
  * Registry migration runner.
3
3
  *
4
4
  * `handoff-app db:migrate` reads the consuming project's config + DB env vars, resolves the same
5
- * `runtime.registry.database.adapter` used at runtime/build, and applies the package-owned
5
+ * `runtime.registry.database.driver` used at runtime/build, and applies the package-owned
6
6
  * migration set with the bundled Drizzle migrator. Migrations are resolved relative to the
7
7
  * installed package (not the consuming project), so the schema is versioned with the tool. This
8
8
  * runs independently of `build`.
@@ -12,7 +12,7 @@ import fs from 'fs-extra';
12
12
  import path from 'path';
13
13
  import type Handoff from '../../';
14
14
  import { Logger } from '../../utils/logger';
15
- import { resolveRegistryDatabase } from './adapter';
15
+ import { resolveRegistryDatabase } from './driver';
16
16
  import { createRegistryDbConnection } from './client';
17
17
 
18
18
  /**
@@ -33,7 +33,7 @@ export const runRegistryMigrations = async (handoff: Handoff): Promise<void> =>
33
33
  throw new Error('Handoff is not initialized; cannot resolve registry database configuration.');
34
34
  }
35
35
 
36
- const { adapter, databaseUrlEnv, connectionString } = resolveRegistryDatabase(handoff.config);
36
+ const { driver, databaseUrlEnv, connectionString } = resolveRegistryDatabase(handoff.config);
37
37
 
38
38
  const migrationsFolder = getMigrationsFolder(handoff);
39
39
  const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
@@ -44,9 +44,9 @@ export const runRegistryMigrations = async (handoff: Handoff): Promise<void> =>
44
44
  );
45
45
  }
46
46
 
47
- Logger.info(`Running registry migrations (adapter: "${adapter}", database url from "${databaseUrlEnv}").`);
47
+ Logger.info(`Running registry migrations (driver: "${driver}", database url from "${databaseUrlEnv}").`);
48
48
 
49
- const connection = await createRegistryDbConnection({ adapter, connectionString });
49
+ const connection = await createRegistryDbConnection({ driver, connectionString });
50
50
  try {
51
51
  await connection.migrate(migrationsFolder);
52
52
  Logger.success('Registry migrations applied successfully.');
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Connected-workspace publish orchestration.
3
3
  *
4
- * `publish <component|pattern|page> <id>` prepares one entity and uploads its record, source files,
4
+ * `publish <components|patterns|pages> <id>` prepares one entity and uploads its record, source files,
5
5
  * applicable rendered artifacts, and build metadata through the shared registry client.
6
6
  *
7
7
  * The build is targeted: a component builds the global artifacts + that component; a pattern builds
@@ -17,7 +17,7 @@ import { processPatterns } from '../../transformers/preview/pattern/builder';
17
17
  import { Logger } from '../../utils/logger';
18
18
  import { createRegistryClient, RegistryClientError } from '../client';
19
19
  import { resolveRegistryConnection } from '../connection';
20
- import type { TransferEntityKind } from '../transfer';
20
+ import type { EntitySummary, TransferEntityKind } from '../transfer';
21
21
  import { describeUploadFailure } from './errors';
22
22
  import { assertRequiredArtifactsPresent, buildPublishPackage, PublishPackageError } from './package';
23
23
 
@@ -126,3 +126,101 @@ export const publishEntity = async (handoff: Handoff, kind: TransferEntityKind,
126
126
 
127
127
  Logger.success(`Published ${kind} "${id}" to the registry.`);
128
128
  };
129
+
130
+ /** The published-content hash a bulk publish compares for skip-unchanged (artifact for rendered kinds, source for pages). */
131
+ const entityHash = (value: { artifactHash?: string; sourceHash?: string }): string | undefined => value.artifactHash ?? value.sourceHash;
132
+
133
+ /**
134
+ * Run one build covering every entity of the kind, so a bulk publish builds once instead of per id.
135
+ * Pages carry no rendered artifacts. Components need the global artifacts + all components; patterns
136
+ * additionally need all patterns composed (each references pre-built component HTML).
137
+ */
138
+ const runBulkBuild = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
139
+ if (kind === 'page') {
140
+ return;
141
+ }
142
+ await buildMainJS(handoff);
143
+ await buildMainCss(handoff);
144
+ await processComponents(handoff);
145
+ if (kind === 'pattern') {
146
+ await processPatterns(handoff);
147
+ }
148
+ };
149
+
150
+ /** List every declared entity id of the kind from the workspace store. */
151
+ const listWorkspaceEntityIds = async (handoff: Handoff, kind: TransferEntityKind): Promise<string[]> => {
152
+ const store = kind === 'component' ? handoff.store.components : kind === 'pattern' ? handoff.store.patterns : handoff.store.pages;
153
+ return (await store.list()).map((entity) => entity.id);
154
+ };
155
+
156
+ /**
157
+ * Publish every component, pattern, or page declared in this connected workspace. Builds the kind
158
+ * once, assembles each entity's package, skips entities whose content hash already matches the
159
+ * registry (unless `--force`), and reports published/unchanged/failed counts. A per-entity failure is
160
+ * collected and never aborts the rest; the run throws at the end if any entity failed.
161
+ */
162
+ export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
163
+ const connection = resolveConnectionOrThrow(handoff);
164
+
165
+ Logger.info(`Building ${kind}s for publish…`);
166
+ await runBulkBuild(handoff, kind);
167
+
168
+ const ids = await listWorkspaceEntityIds(handoff, kind);
169
+ if (ids.length === 0) {
170
+ Logger.success(`No ${kind}s are declared in this workspace; nothing to publish.`);
171
+ return;
172
+ }
173
+
174
+ const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
175
+ let remote: EntitySummary[] = [];
176
+ if (!handoff.force) {
177
+ try {
178
+ remote = await client.listEntities(kind);
179
+ } catch {
180
+ // If the summary listing fails we simply publish everything (the server still overwrites in place).
181
+ Logger.info(`Could not read current registry ${kind}s; publishing all.`);
182
+ }
183
+ }
184
+ const remoteHashById = new Map(remote.map((entry) => [entry.id, entityHash(entry)]));
185
+
186
+ let published = 0;
187
+ let unchanged = 0;
188
+ const failed: { id: string; message: string }[] = [];
189
+
190
+ for (const id of ids) {
191
+ try {
192
+ const pkg = await buildPublishPackage(handoff, kind, id);
193
+ assertRequiredArtifactsPresent(pkg);
194
+
195
+ const remoteHash = remoteHashById.get(id);
196
+ const localHash = entityHash(pkg.build);
197
+ if (!handoff.force && remoteHash && localHash && remoteHash === localHash) {
198
+ unchanged += 1;
199
+ Logger.info(`Unchanged: ${id}`);
200
+ continue;
201
+ }
202
+
203
+ await client.publish(kind, id, pkg);
204
+ published += 1;
205
+ Logger.info(`Published: ${id} (${pkg.artifacts.length} artifact(s), ${pkg.files.length} source file(s))`);
206
+ } catch (error) {
207
+ const message =
208
+ error instanceof RegistryClientError
209
+ ? describeUploadFailure(error, connection.url, 'package')
210
+ : error instanceof Error
211
+ ? error.message
212
+ : String(error);
213
+ failed.push({ id, message });
214
+ }
215
+ }
216
+
217
+ Logger.success(
218
+ `${kind[0].toUpperCase()}${kind.slice(1)}s publish complete — ${published} published, ${unchanged} unchanged${failed.length ? `, ${failed.length} failed` : ''}.`
219
+ );
220
+ if (failed.length > 0) {
221
+ for (const failure of failed) {
222
+ Logger.error(` - ${failure.id}: ${failure.message}`);
223
+ }
224
+ throw new PublishError(`${failed.length} ${kind}(s) failed to publish.`);
225
+ }
226
+ };
@@ -80,3 +80,17 @@ export interface CheckoutPayload {
80
80
  item: Record<string, unknown>;
81
81
  files: TransferFile[];
82
82
  }
83
+
84
+ /**
85
+ * A published entity's summary from `GET /api/registry/transfer/{component|pattern|page}`. Carries
86
+ * the build hashes so a connected workspace can skip unchanged entities on a bulk publish and
87
+ * enumerate published ids for a bulk checkout. Components/patterns key on `artifactHash`; pages key
88
+ * on `sourceHash`.
89
+ */
90
+ export interface EntitySummary {
91
+ id: string;
92
+ kind: TransferEntityKind;
93
+ status?: string;
94
+ artifactHash?: string;
95
+ sourceHash?: string;
96
+ }
@@ -138,8 +138,12 @@ export interface HandoffRuntimeConfig {
138
138
  /** Name of the env var holding the registry API token. @default "HANDOFF_REGISTRY_API_TOKEN" */
139
139
  apiTokenEnv?: string;
140
140
  database?: {
141
- /** @default "pg" */
142
- adapter?: 'pg' | 'neon';
141
+ /**
142
+ * PostgreSQL is the supported database. `driver` selects the built-in connection driver — how
143
+ * to connect, not which engine — over the same package-owned Postgres schema and migrations.
144
+ * @default "pg"
145
+ */
146
+ driver?: 'pg' | 'neon';
143
147
  };
144
148
  /**
145
149
  * Where published asset blobs (icons/logos/fonts/sprites/archives) are stored. Defaults to the
@@ -214,7 +218,7 @@ export interface Config {
214
218
  */
215
219
  entries?: ConfigEntries;
216
220
  /**
217
- * Override URLs for the asset zip download links. When unset, each link defaults to the canonical,
221
+ * Override URLs for the asset zip download links. When unset, each link defaults to the
218
222
  * basePath-aware asset route `{basePath}/api/docs/assets/{collection}/{collection}.zip`, served by
219
223
  * the docs read API in every runtime mode (from the DB in registry mode, from statically
220
224
  * materialized files in a static export).
@@ -12,4 +12,43 @@ A design system documentation site powered by Handoff App.
12
12
 
13
13
  - `npm run fetch` - Fetch design data from Figma
14
14
  - `npm run start` - Start the documentation server
15
- - `npm run build` - Build for production
15
+ - `npm run dev` - Start the server in watch mode
16
+ - `npm run build` - Build the documentation site
17
+ - `npm run db:migrate` - Apply registry database migrations
18
+
19
+ ## Deploying
20
+
21
+ Handoff builds two deployment shapes:
22
+
23
+ - **Static** — plain files served by any static file server or CDN. Build with
24
+ `handoff-app build --target static`. The simplest to deploy and run.
25
+ - **Registry** — a live, database-backed Node app that serves a shared catalog.
26
+ Build with `handoff-app build --target registry`, run it on any Node host, and
27
+ provide a Postgres `DATABASE_URL`. The more capable, actively evolving option,
28
+ and the recommended path for a design system you plan to grow.
29
+
30
+ Add `--package vercel` to either build to produce a `.vercel/output` bundle. If
31
+ you scaffolded a `vercel.json`, it runs the build for the deployment you chose;
32
+ edit its `buildCommand` to switch.
33
+
34
+ ## Registry mode
35
+
36
+ New projects run in **workspace mode**, which needs no database and is the default
37
+ for building and iterating locally. The database-backed **registry mode** lets you
38
+ publish and share a design system across projects and is where Handoff's
39
+ capabilities are expanding, so it's the recommended path as your design system
40
+ grows. See the Handoff docs for setting up the database and publishing.
41
+
42
+ ### Database migrations
43
+
44
+ A registry deployment stores its catalog in Postgres, so its schema has to be
45
+ migrated before you deploy new code. Run this against the target database:
46
+
47
+ ```bash
48
+ npm run db:migrate
49
+ ```
50
+
51
+ It reads `DATABASE_URL` from the environment and applies the migrations bundled
52
+ with Handoff. It runs on its own, separate from build and start, so use it as a
53
+ release step (for example in CI/CD). Static deployments don't use a database and
54
+ can skip it.
@@ -1,7 +1,22 @@
1
+ # Dependencies
1
2
  node_modules/
2
- .env
3
+
4
+ # Build output
3
5
  dist/
4
- .next/
5
6
  out/
6
- .DS_Store
7
+ .next/
8
+
9
+ # Generated build artifacts
10
+ public/api
11
+
12
+ # Environment
13
+ .env
14
+
15
+ # Deployment
16
+ .vercel
17
+
18
+ # Logs
7
19
  *.log
20
+
21
+ # OS files
22
+ .DS_Store