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
|
@@ -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 '
|
|
9
|
-
import Layout from '
|
|
10
|
-
import HeadersType from '
|
|
11
|
-
import { buttonVariants } from '
|
|
12
|
-
import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig, getTokens, isRegistryRuntime } from '
|
|
13
|
-
import { useSingleAsset, type DisplayableAsset } from '
|
|
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(() => {
|
package/src/app-builder/build.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
'%
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
447
|
-
const
|
|
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, ...
|
|
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
|
|
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
|
|
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
|
|
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' &&
|
|
644
|
+
if (buildPackage === 'vercel' && resolveRegistryDriver(handoff.config) === 'pg') {
|
|
606
645
|
Logger.warn(
|
|
607
|
-
'Registry Vercel build uses the "pg"
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
60
|
+
driver: resolveRegistryDriver(config),
|
|
61
61
|
databaseUrlEnv: resolveDatabaseUrlEnv(config),
|
|
62
62
|
apiTokenEnv: resolveApiTokenEnv(config),
|
|
63
63
|
},
|
package/src/app-builder/paths.ts
CHANGED
|
@@ -42,13 +42,11 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
89
|
-
//
|
|
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
|
-
*
|
|
221
|
-
* (`api/docs/assets/{collection}/{collection}.zip`)
|
|
222
|
-
* registry mode, so the "Download" links
|
|
223
|
-
*
|
|
224
|
-
* fetch-time copy into
|
|
225
|
-
*
|
|
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`
|
|
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,
|
|
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
|
*/
|
package/src/artifacts/url.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
100
|
-
*
|
|
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
|
|
118
|
-
* (`{basePath}/api/docs/assets/{collection}/{collection}.zip`)
|
|
119
|
-
* "Download" 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
|
-
|
|
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:
|
|
8
|
+
type: RegistryEntityKind;
|
|
12
9
|
id?: string;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
|
-
* `handoff-app checkout <
|
|
17
|
-
* into this workspace
|
|
18
|
-
*
|
|
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
|
|
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:
|
|
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);
|
|
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
|
-
|
|
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:
|
|
8
|
+
type: RegistryEntityKind;
|
|
12
9
|
id?: string;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
|
-
* `handoff-app publish <
|
|
17
|
-
*
|
|
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
|
|
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:
|
|
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);
|
|
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
|
-
|
|
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;
|
package/src/commands/utils.ts
CHANGED
|
@@ -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({
|