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.
- package/README.md +39 -17
- package/config/docs/foundations/colors/guidelines.md +9 -0
- package/config/docs/foundations/effects/guidelines.md +9 -0
- package/config/docs/foundations/effects/resources.md +9 -0
- package/config/docs/foundations/grid.md +10 -0
- package/config/docs/foundations/icons/guidelines.md +9 -0
- package/config/docs/foundations/images/guidelines.md +9 -0
- package/config/docs/foundations/images/resources.md +9 -0
- package/config/docs/foundations/images.md +10 -0
- package/config/docs/foundations/logo/resources.md +9 -0
- package/config/docs/foundations/typography/guidelines.md +9 -0
- package/config/docs/foundations/typography/resources.md +9 -0
- package/config/docs/foundations.md +5 -3
- package/dist/app-builder/build.js +89 -46
- package/dist/app-builder/client-config.js +5 -5
- package/dist/app-builder/paths.js +7 -9
- package/dist/app-builder/static-export.js +6 -6
- package/dist/app-builder/vercel-output.js +2 -2
- package/dist/artifacts/url.d.ts +7 -7
- package/dist/artifacts/url.js +7 -7
- package/dist/cli/create.js +48 -3
- package/dist/commands/checkout/index.d.ts +6 -6
- package/dist/commands/checkout/index.js +8 -12
- package/dist/commands/publish/index.d.ts +6 -5
- package/dist/commands/publish/index.js +8 -11
- package/dist/commands/utils.d.ts +6 -0
- package/dist/commands/utils.js +9 -1
- package/dist/index.d.ts +12 -8
- package/dist/index.js +26 -10
- package/dist/registry/asset-storage/resolve.d.ts +1 -1
- package/dist/registry/asset-storage/resolve.js +1 -1
- package/dist/registry/checkout/index.d.ts +10 -5
- package/dist/registry/checkout/index.js +62 -11
- package/dist/registry/client.d.ts +2 -1
- package/dist/registry/client.js +8 -0
- package/dist/registry/db/client.d.ts +9 -9
- package/dist/registry/db/client.js +7 -7
- package/dist/registry/db/{adapter.d.ts → driver.d.ts} +16 -14
- package/dist/registry/db/{adapter.js → driver.js} +17 -15
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +9 -9
- package/dist/registry/db/migrate.d.ts +1 -1
- package/dist/registry/db/migrate.js +5 -5
- package/dist/registry/publish/index.d.ts +8 -1
- package/dist/registry/publish/index.js +88 -2
- package/dist/registry/transfer.d.ts +13 -0
- package/dist/types/config.d.ts +7 -3
- package/drizzle.config.ts +1 -1
- package/package.json +1 -1
- package/src/app/components/Foundations/DisplayLogo.tsx +3 -3
- package/src/app/lib/docs-api/runtime-config.ts +10 -10
- package/src/app/lib/registry-api/transfer.ts +29 -0
- package/src/app/lib/registry-connection.ts +2 -2
- package/src/app/next.config.mjs +1 -1
- package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
- package/src/app/pages/foundations/icons/index.tsx +2 -2
- package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +6 -6
- package/src/app-builder/build.ts +75 -55
- package/src/app-builder/client-config.ts +3 -3
- package/src/app-builder/paths.ts +7 -9
- package/src/app-builder/static-export.ts +6 -6
- package/src/app-builder/vercel-output.ts +2 -2
- package/src/artifacts/url.ts +7 -7
- package/src/cli/create.ts +58 -3
- package/src/commands/checkout/index.ts +10 -15
- package/src/commands/publish/index.ts +10 -14
- package/src/commands/utils.ts +12 -0
- package/src/index.ts +26 -12
- package/src/registry/asset-storage/resolve.ts +1 -1
- package/src/registry/checkout/index.ts +73 -11
- package/src/registry/client.ts +7 -1
- package/src/registry/db/client.ts +12 -12
- package/src/registry/db/{adapter.ts → driver.ts} +19 -17
- package/src/registry/db/index.ts +4 -4
- package/src/registry/db/migrate.ts +5 -5
- package/src/registry/publish/index.ts +100 -2
- package/src/registry/transfer.ts +14 -0
- package/src/types/config.ts +7 -3
- package/templates/default/README.md.tpl +40 -1
- package/templates/default/gitignore.tpl +18 -3
- package/templates/default/handoff.config.js.tpl +14 -0
- package/templates/default/handoff.config.ts.tpl +14 -0
- package/templates/default/handoff.config.with-examples.js.tpl +14 -0
- package/templates/default/handoff.config.with-examples.ts.tpl +14 -0
- package/templates/default/package.json.tpl +2 -1
- 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
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
148
|
+
async publish(kind: 'component' | 'pattern' | 'page', id?: string): Promise<Handoff> {
|
|
147
149
|
this.preRunner();
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
155
|
-
*
|
|
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
|
|
166
|
+
async checkout(kind: 'component' | 'pattern' | 'page', id?: string): Promise<Handoff> {
|
|
158
167
|
this.preRunner();
|
|
159
|
-
|
|
160
|
-
|
|
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/
|
|
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 <
|
|
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
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* messaging on any precondition or fetch
|
|
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
|
-
|
|
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 ${
|
|
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,
|
|
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
|
+
};
|
package/src/registry/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
31
|
+
driver: RegistryDatabaseDriver;
|
|
32
32
|
connectionString: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Create a registry database connection for the resolved
|
|
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
|
-
|
|
41
|
+
driver,
|
|
42
42
|
connectionString,
|
|
43
43
|
}: CreateRegistryDbConnectionParams): Promise<RegistryDbConnection> => {
|
|
44
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2
|
+
* Registry database driver resolution.
|
|
3
3
|
*
|
|
4
|
-
* `runtime.registry.database.
|
|
5
|
-
* `handoff-app` package — `pg` (default) and `neon` —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
14
|
-
export type
|
|
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
|
|
23
|
-
export const
|
|
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
|
|
28
|
-
|
|
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
|
|
52
|
-
export const
|
|
53
|
-
return config?.runtime?.registry?.database?.
|
|
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
|
|
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 {
|
|
75
|
+
return { driver, databaseUrlEnv, connectionString };
|
|
74
76
|
};
|
package/src/registry/db/index.ts
CHANGED
|
@@ -15,15 +15,15 @@ export type { ArtifactBuildStatus as RegistryBuildStatus } from '../../artifacts
|
|
|
15
15
|
|
|
16
16
|
export {
|
|
17
17
|
DEFAULT_DATABASE_URL_ENV,
|
|
18
|
-
|
|
18
|
+
DEFAULT_REGISTRY_DRIVER,
|
|
19
19
|
DEFAULT_REGISTRY_API_TOKEN_ENV,
|
|
20
20
|
resolveApiTokenEnv,
|
|
21
21
|
resolveDatabaseUrlEnv,
|
|
22
|
-
|
|
22
|
+
resolveRegistryDriver,
|
|
23
23
|
resolveRegistryDatabase,
|
|
24
|
-
type
|
|
24
|
+
type RegistryDatabaseDriver,
|
|
25
25
|
type ResolvedRegistryDatabase,
|
|
26
|
-
} from './
|
|
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.
|
|
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 './
|
|
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 {
|
|
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 (
|
|
47
|
+
Logger.info(`Running registry migrations (driver: "${driver}", database url from "${databaseUrlEnv}").`);
|
|
48
48
|
|
|
49
|
-
const connection = await createRegistryDbConnection({
|
|
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 <
|
|
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
|
+
};
|
package/src/registry/transfer.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
142
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
3
5
|
dist/
|
|
4
|
-
.next/
|
|
5
6
|
out/
|
|
6
|
-
.
|
|
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
|