handoff-app 2.0.0-9 → 2.0.0
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/{Changelog.md → CHANGELOG.md} +85 -1
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +813 -406
- package/RECIPES.md +975 -0
- package/UPGRADE.md +57 -9
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/connections.d.ts +66 -0
- package/dist/ai/connections.js +149 -0
- package/dist/ai/define.d.ts +10 -0
- package/dist/ai/define.js +13 -0
- package/dist/ai/types.d.ts +26 -0
- package/dist/ai/types.js +10 -0
- package/dist/app-builder/build.d.ts +14 -7
- package/dist/app-builder/build.js +195 -66
- package/dist/app-builder/client-config.js +3 -2
- package/dist/app-builder/paths.d.ts +0 -12
- package/dist/app-builder/paths.js +3 -20
- package/dist/app-builder/static-export.js +1 -1
- package/dist/app-builder/vercel-output.d.ts +1 -1
- package/dist/app-builder/vercel-output.js +5 -4
- package/dist/cache/build-cache.d.ts +27 -4
- package/dist/cache/build-cache.js +114 -47
- package/dist/cache/build-inputs.d.ts +13 -0
- package/dist/cache/build-inputs.js +19 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +5 -1
- package/dist/cli/create.js +1 -2
- package/dist/commands/build/index.d.ts +1 -1
- package/dist/commands/build/index.js +1 -1
- package/dist/commands/utils.d.ts +3 -3
- package/dist/config/defaults.js +7 -4
- package/dist/config/from-env.d.ts +4 -0
- package/dist/config/from-env.js +38 -1
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +1 -4
- package/dist/config/loader.js +26 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +1 -1
- package/dist/index.d.ts +12 -7
- package/dist/index.js +18 -22
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/pipeline/figma.d.ts +13 -2
- package/dist/pipeline/figma.js +86 -46
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/validation.d.ts +1 -1
- package/dist/pipeline/validation.js +3 -3
- package/dist/providers.d.ts +10 -0
- package/dist/providers.js +13 -0
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +9 -19
- package/dist/registry/asset-storage/resolve.js +33 -21
- package/dist/registry/asset-storage/types.d.ts +37 -17
- package/dist/registry/asset-storage/types.js +5 -5
- package/dist/registry/auth/ai-keys.d.ts +32 -0
- package/dist/registry/auth/ai-keys.js +69 -0
- package/dist/registry/auth/crypto.d.ts +17 -0
- package/dist/registry/auth/crypto.js +61 -1
- package/dist/registry/auth/index.d.ts +2 -1
- package/dist/registry/auth/index.js +11 -2
- package/dist/registry/checkout/index.d.ts +2 -1
- package/dist/registry/checkout/index.js +57 -127
- package/dist/registry/client.js +53 -17
- package/dist/registry/db/driver.js +3 -3
- package/dist/registry/db/schema.d.ts +261 -3
- package/dist/registry/db/schema.js +29 -4
- package/dist/registry/email/define.d.ts +10 -0
- package/dist/registry/email/define.js +13 -0
- package/dist/registry/email/providers/smtp.d.ts +10 -0
- package/dist/registry/email/providers/smtp.js +73 -0
- package/dist/registry/email/resolve.d.ts +28 -0
- package/dist/registry/email/resolve.js +74 -0
- package/dist/registry/email/types.d.ts +24 -0
- package/dist/registry/email/types.js +6 -0
- package/dist/registry/path.d.ts +2 -0
- package/dist/registry/path.js +11 -1
- package/dist/registry/publish/package.js +8 -0
- package/dist/registry/transfer.d.ts +4 -0
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/transformers/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +2 -1
- package/dist/transformers/preview/component/css.js +8 -5
- package/dist/transformers/preview/component/html.d.ts +3 -1
- package/dist/transformers/preview/component/html.js +6 -2
- package/dist/transformers/preview/component/javascript.d.ts +2 -1
- package/dist/transformers/preview/component/javascript.js +10 -4
- package/dist/transformers/utils/build.d.ts +5 -0
- package/dist/transformers/utils/build.js +21 -4
- package/dist/types/config.d.ts +129 -18
- package/dist/utils/path.d.ts +1 -5
- package/dist/utils/path.js +6 -23
- package/docs/api.md +6 -4
- package/docs/cli.md +275 -54
- package/drizzle/0007_add_ai_keys.sql +11 -0
- package/drizzle/0008_add_source_dir.sql +2 -0
- package/drizzle/meta/0007_snapshot.json +2013 -0
- package/drizzle/meta/0008_snapshot.json +2025 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +19 -8
- package/src/ai/README.md +42 -0
- package/src/ai/connections.ts +189 -0
- package/src/ai/define.ts +15 -0
- package/src/ai/types.ts +30 -0
- package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
- package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
- package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
- package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
- package/src/app/components/AiAssistant/dockState.ts +86 -0
- package/src/app/components/AiAssistant/toolLabel.ts +24 -0
- package/src/app/components/AiAssistant/useAiChat.ts +147 -0
- package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
- package/src/app/components/AiAssistant/usePageContext.ts +63 -0
- package/src/app/components/Auth/AccountLayout.tsx +58 -19
- package/src/app/components/Auth/AuthShell.tsx +27 -33
- package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
- package/src/app/components/Auth/installStatus.ts +29 -0
- package/src/app/components/Component/Preview.tsx +5 -2
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Header.tsx +21 -15
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +2 -2
- package/src/app/components/Navigation/SideNav.tsx +1 -15
- package/src/app/components/context/NavProvider.tsx +7 -3
- package/src/app/components/ui/attachment.tsx +50 -0
- package/src/app/components/ui/bubble.tsx +57 -0
- package/src/app/components/ui/button.tsx +3 -1
- package/src/app/components/ui/confirm-dialog.tsx +87 -0
- package/src/app/components/ui/dialog.tsx +7 -2
- package/src/app/components/ui/marker.tsx +56 -0
- package/src/app/components/ui/sidebar.tsx +3 -1
- package/src/app/components/util/index.ts +3 -12
- package/src/app/css/index.css +64 -0
- package/src/app/lib/ai/auth.ts +33 -0
- package/src/app/lib/ai/context.ts +99 -0
- package/src/app/lib/ai/model.ts +79 -0
- package/src/app/lib/ai/prompt.ts +36 -0
- package/src/app/lib/ai/resolve.ts +97 -0
- package/src/app/lib/ai/tools.ts +120 -0
- package/src/app/lib/api/methods.ts +13 -0
- package/src/app/lib/asset-storage.ts +35 -36
- package/src/app/lib/auth/api.ts +4 -12
- package/src/app/lib/auth/email.ts +96 -23
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +47 -21
- package/src/app/lib/mcp/components.ts +523 -0
- package/src/app/lib/mcp/page-url.ts +24 -0
- package/src/app/lib/mcp/pages.ts +84 -0
- package/src/app/lib/mcp/result.ts +33 -0
- package/src/app/lib/mcp/server.ts +39 -318
- package/src/app/lib/mcp/tokens.ts +334 -0
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +119 -11
- package/src/app/lib/registry-api/errors.ts +2 -0
- package/src/app/lib/registry-api/handler.ts +7 -1
- package/src/app/lib/registry-api/transfer.ts +28 -6
- package/src/app/lib/server-module.ts +34 -0
- package/src/app/lib/utils.ts +20 -0
- package/src/app/next.config.mjs +26 -24
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/_app.tsx +6 -2
- package/src/app/pages/_document.tsx +6 -0
- package/src/app/pages/account/ai.tsx +183 -0
- package/src/app/pages/account/index.tsx +68 -68
- package/src/app/pages/account/tokens.tsx +37 -7
- package/src/app/pages/account/users.tsx +75 -15
- package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
- package/src/app/pages/api/account/ai/keys/index.ts +20 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
- package/src/app/pages/api/admin/users/index.ts +2 -2
- package/src/app/pages/api/ai/chat.ts +115 -0
- package/src/app/pages/api/ai/connections.ts +23 -0
- package/src/app/pages/api/auth/request-reset.ts +14 -16
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/install.ts +3 -3
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +10 -5
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/install.tsx +7 -2
- package/src/app/pages/login.tsx +32 -27
- package/src/app/pages/reset-password.tsx +21 -20
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/README.md +2 -1
- package/src/app-builder/build.ts +218 -73
- package/src/app-builder/client-config.ts +3 -2
- package/src/app-builder/paths.ts +3 -22
- package/src/app-builder/static-export.ts +1 -1
- package/src/app-builder/vercel-output.ts +5 -4
- package/src/cache/README.md +4 -1
- package/src/cache/build-cache.ts +128 -43
- package/src/cache/build-inputs.ts +28 -0
- package/src/cache/index.ts +4 -0
- package/src/cli/create.ts +1 -2
- package/src/commands/build/index.ts +1 -1
- package/src/config/README.md +10 -9
- package/src/config/defaults.ts +7 -4
- package/src/config/from-env.ts +33 -0
- package/src/config/helpers.ts +1 -3
- package/src/config/index.ts +0 -3
- package/src/config/loader.ts +25 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +1 -1
- package/src/index.ts +33 -29
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/pipeline/README.md +1 -1
- package/src/pipeline/figma.ts +98 -49
- package/src/pipeline/styles.ts +1 -1
- package/src/pipeline/validation.ts +3 -3
- package/src/providers.ts +18 -0
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +38 -37
- package/src/registry/asset-storage/types.ts +39 -17
- package/src/registry/auth/ai-keys.ts +62 -0
- package/src/registry/auth/crypto.ts +63 -1
- package/src/registry/auth/index.ts +11 -1
- package/src/registry/checkout/index.ts +60 -138
- package/src/registry/client.ts +60 -20
- package/src/registry/db/driver.ts +2 -2
- package/src/registry/db/schema.ts +49 -22
- package/src/registry/email/define.ts +15 -0
- package/src/registry/email/providers/smtp.ts +39 -0
- package/src/registry/email/resolve.ts +94 -0
- package/src/registry/email/types.ts +28 -0
- package/src/registry/path.ts +11 -0
- package/src/registry/publish/package.ts +9 -0
- package/src/registry/transfer.ts +4 -0
- package/src/store/registry.ts +7 -7
- package/src/transformers/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +9 -4
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/utils/build.ts +16 -3
- package/src/types/config.ts +142 -20
- package/src/utils/README.md +1 -1
- package/src/utils/path.ts +6 -29
- package/templates/default/handoff.config.js.tpl +10 -2
- package/templates/default/handoff.config.ts.tpl +10 -2
- package/templates/default/handoff.config.with-examples.js.tpl +10 -2
- package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
- package/templates/default/package.json.tpl +2 -2
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/config/validator.d.ts +0 -9
- package/dist/config/validator.js +0 -24
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
- package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/lib/mcp/shape.ts +0 -596
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/config/validator.ts +0 -22
- package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
package/src/cache/build-cache.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import Handoff from '../index';
|
|
4
|
+
import { collectModuleInputs } from '../transformers/utils/build';
|
|
4
5
|
import { Logger } from '../utils/logger';
|
|
5
6
|
import { normalizePathForCompare } from '../utils/path';
|
|
7
|
+
import { BuildInputs } from './build-inputs';
|
|
6
8
|
import { computeDirectoryState, computeFileState, directoryStatesMatch, FileState, statesMatch } from './file-state';
|
|
7
9
|
|
|
8
10
|
/** Current cache format version - bump when structure changes */
|
|
9
|
-
const CACHE_VERSION = '
|
|
11
|
+
const CACHE_VERSION = '2.0.0';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Cache entry for a single component
|
|
13
15
|
*/
|
|
14
16
|
export interface ComponentCacheEntry {
|
|
15
|
-
/** File states for
|
|
17
|
+
/** File states for the declared source files of this component and every file its last build read */
|
|
16
18
|
files: Record<string, FileState>;
|
|
17
19
|
/** States for template directory files (if templates is a directory) */
|
|
18
20
|
templateDirFiles?: Record<string, FileState>;
|
|
@@ -30,8 +32,25 @@ export interface GlobalDepsState {
|
|
|
30
32
|
globalScss?: FileState;
|
|
31
33
|
/** Global JS entry file state */
|
|
32
34
|
globalJs?: FileState;
|
|
35
|
+
/** Main config file state. The build hooks are declared there */
|
|
36
|
+
mainConfig?: FileState;
|
|
37
|
+
/** Profile config file state */
|
|
38
|
+
profileConfig?: FileState;
|
|
39
|
+
/** Selected profile. All profiles write to the same output, so a switch invalidates all of it */
|
|
40
|
+
profile?: string;
|
|
41
|
+
/** Lockfile state. It stands in for the files under `node_modules`, which the cache does not track */
|
|
42
|
+
lockfile?: FileState;
|
|
43
|
+
/** handoff-app version that built the output */
|
|
44
|
+
version?: string;
|
|
45
|
+
/** Base path written into the generated HTML */
|
|
46
|
+
basePath?: string;
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
const GLOBAL_FILE_DEPS = ['tokens', 'globalScss', 'globalJs', 'mainConfig', 'profileConfig', 'lockfile'] as const;
|
|
50
|
+
const GLOBAL_VALUE_DEPS = ['profile', 'version', 'basePath'] as const;
|
|
51
|
+
|
|
52
|
+
const LOCKFILE_NAMES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
|
|
53
|
+
|
|
35
54
|
/**
|
|
36
55
|
* Complete build cache structure
|
|
37
56
|
*/
|
|
@@ -48,11 +67,9 @@ export interface BuildCache {
|
|
|
48
67
|
* Gets the path to the build cache file
|
|
49
68
|
*/
|
|
50
69
|
export function getCachePath(handoff: Handoff): string {
|
|
51
|
-
//
|
|
52
|
-
// the
|
|
53
|
-
|
|
54
|
-
const fileName = profile ? `build-cache.${profile}.json` : 'build-cache.json';
|
|
55
|
-
return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', fileName);
|
|
70
|
+
// The cache describes the output in the working directory, so it lives there too. A build deletes
|
|
71
|
+
// the staged app under the package, so a cache there would not survive.
|
|
72
|
+
return path.resolve(handoff.workingPath, '.handoff', '.cache', 'build-cache.json');
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
/**
|
|
@@ -128,29 +145,64 @@ export async function computeGlobalDepsState(handoff: Handoff): Promise<GlobalDe
|
|
|
128
145
|
result.globalJs = (await computeFileState(handoff.runtimeConfig.entries.js)) ?? undefined;
|
|
129
146
|
}
|
|
130
147
|
|
|
148
|
+
const mainConfigPath = handoff.getMainConfigFilePath();
|
|
149
|
+
if (mainConfigPath) {
|
|
150
|
+
result.mainConfig = (await computeFileState(mainConfigPath)) ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const profileConfigPath = handoff.getProfileConfigFilePath();
|
|
154
|
+
if (profileConfigPath) {
|
|
155
|
+
result.profileConfig = (await computeFileState(profileConfigPath)) ?? undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
result.profile = handoff.getProfile();
|
|
159
|
+
|
|
160
|
+
const lockfilePath = findLockfile(handoff.workingPath);
|
|
161
|
+
if (lockfilePath) {
|
|
162
|
+
result.lockfile = (await computeFileState(lockfilePath)) ?? undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
result.version = await readPackageVersion(handoff);
|
|
166
|
+
result.basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
|
|
167
|
+
|
|
131
168
|
return result;
|
|
132
169
|
}
|
|
133
170
|
|
|
171
|
+
/** The nearest lockfile, searched up from the working directory so that a monorepo root lockfile is found. */
|
|
172
|
+
function findLockfile(fromDirectory: string): string | undefined {
|
|
173
|
+
for (let directory = path.resolve(fromDirectory); ; directory = path.dirname(directory)) {
|
|
174
|
+
const lockfile = LOCKFILE_NAMES.map((name) => path.join(directory, name)).find((file) => fs.existsSync(file));
|
|
175
|
+
if (lockfile) return lockfile;
|
|
176
|
+
if (path.dirname(directory) === directory) return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function readPackageVersion(handoff: Handoff): Promise<string | undefined> {
|
|
181
|
+
try {
|
|
182
|
+
return (await fs.readJson(path.resolve(handoff.modulePath, 'package.json'))).version;
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
134
188
|
/**
|
|
135
189
|
* Checks if global dependencies have changed
|
|
136
190
|
*/
|
|
137
191
|
export function haveGlobalDepsChanged(cached: GlobalDepsState | null | undefined, current: GlobalDepsState): boolean {
|
|
138
192
|
if (!cached) return true;
|
|
139
193
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!statesMatch(cached.globalScss, current.globalScss)) {
|
|
147
|
-
Logger.debug('Global dependency changed: global SCSS entry');
|
|
148
|
-
return true;
|
|
194
|
+
for (const key of GLOBAL_FILE_DEPS) {
|
|
195
|
+
if (!statesMatch(cached[key], current[key])) {
|
|
196
|
+
Logger.debug(`Global dependency changed: ${key}`);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
149
199
|
}
|
|
150
200
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
201
|
+
for (const key of GLOBAL_VALUE_DEPS) {
|
|
202
|
+
if (cached[key] !== current[key]) {
|
|
203
|
+
Logger.debug(`Global dependency changed: ${key}`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
154
206
|
}
|
|
155
207
|
|
|
156
208
|
return false;
|
|
@@ -274,46 +326,78 @@ export async function computeComponentFileStates(
|
|
|
274
326
|
}
|
|
275
327
|
|
|
276
328
|
/**
|
|
277
|
-
* Checks if a component needs to be rebuilt based on file states
|
|
329
|
+
* Checks if a component needs to be rebuilt based on file states. This also compares the files that
|
|
330
|
+
* the last build read with their current state on disk.
|
|
278
331
|
*/
|
|
279
|
-
export function hasComponentChanged(
|
|
332
|
+
export async function hasComponentChanged(
|
|
280
333
|
cached: ComponentCacheEntry | null | undefined,
|
|
281
334
|
current: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
|
|
282
|
-
): boolean {
|
|
335
|
+
): Promise<boolean> {
|
|
283
336
|
if (!cached) {
|
|
284
337
|
return true; // No cache entry means new component
|
|
285
338
|
}
|
|
286
339
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// Check if file count changed
|
|
292
|
-
if (cachedFiles.length !== currentFiles.length) {
|
|
293
|
-
return true;
|
|
340
|
+
for (const [file, state] of Object.entries(current.files)) {
|
|
341
|
+
if (!statesMatch(cached.files[file], state)) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
294
344
|
}
|
|
295
345
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
for (const file of currentFiles) {
|
|
299
|
-
if (!cachedSet.has(file)) {
|
|
346
|
+
for (const [file, state] of Object.entries(cached.files)) {
|
|
347
|
+
if (!(file in current.files) && !statesMatch(state, await computeFileState(file))) {
|
|
300
348
|
return true;
|
|
301
349
|
}
|
|
302
350
|
}
|
|
303
351
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
352
|
+
return !directoryStatesMatch(cached.templateDirFiles, current.templateDirFiles);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const SCRIPT_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts']);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* File states for every file that the builds of a component read, or `null` when that list is
|
|
359
|
+
* incomplete and the component must not be cached.
|
|
360
|
+
*
|
|
361
|
+
* Previews compile their sources with esbuild inside a Vite plugin, so Rollup does not see those
|
|
362
|
+
* imports. The esbuild graph of each script entry supplies them. The lockfile covers the files under
|
|
363
|
+
* `node_modules`, so they are skipped, together with the generated files in the output directory.
|
|
364
|
+
*/
|
|
365
|
+
export async function computeBuildInputStates(
|
|
366
|
+
handoff: Handoff,
|
|
367
|
+
componentId: string,
|
|
368
|
+
inputs: BuildInputs
|
|
369
|
+
): Promise<Record<string, FileState> | null> {
|
|
370
|
+
if (inputs.failed) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const files = new Set(inputs.files);
|
|
375
|
+
const entries = handoff.runtimeConfig?.entities.components[componentId]?.entries as Record<string, string | undefined> | undefined;
|
|
376
|
+
const scriptEntries = new Set(Object.values(entries ?? {}).filter((entry): entry is string => !!entry && SCRIPT_EXTENSIONS.has(path.extname(entry))));
|
|
377
|
+
|
|
378
|
+
for (const entry of scriptEntries) {
|
|
379
|
+
const moduleInputs = await collectModuleInputs(entry, handoff);
|
|
380
|
+
if (!moduleInputs) {
|
|
381
|
+
return null;
|
|
308
382
|
}
|
|
383
|
+
for (const file of moduleInputs) files.add(file);
|
|
309
384
|
}
|
|
310
385
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
386
|
+
const outputDirectory = path.resolve(handoff.workingPath, 'public/api/component');
|
|
387
|
+
const states: Record<string, FileState> = {};
|
|
388
|
+
|
|
389
|
+
for (const id of files) {
|
|
390
|
+
const file = id.split('?')[0];
|
|
391
|
+
if (!path.isAbsolute(file) || file.split(path.sep).includes('node_modules') || file.startsWith(outputDirectory + path.sep)) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const state = await computeFileState(file);
|
|
395
|
+
if (state) {
|
|
396
|
+
states[path.resolve(file)] = state;
|
|
397
|
+
}
|
|
314
398
|
}
|
|
315
399
|
|
|
316
|
-
return
|
|
400
|
+
return states;
|
|
317
401
|
}
|
|
318
402
|
|
|
319
403
|
/**
|
|
@@ -369,10 +453,11 @@ export function createEmptyCache(): BuildCache {
|
|
|
369
453
|
export function updateComponentCacheEntry(
|
|
370
454
|
cache: BuildCache,
|
|
371
455
|
componentId: string,
|
|
372
|
-
fileStates: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> }
|
|
456
|
+
fileStates: { files: Record<string, FileState>; templateDirFiles?: Record<string, FileState> },
|
|
457
|
+
inputStates: Record<string, FileState>
|
|
373
458
|
): void {
|
|
374
459
|
cache.components[componentId] = {
|
|
375
|
-
files: fileStates.files,
|
|
460
|
+
files: { ...inputStates, ...fileStates.files },
|
|
376
461
|
templateDirFiles: fileStates.templateDirFiles,
|
|
377
462
|
buildTimestamp: Date.now(),
|
|
378
463
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InlineConfig, Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The files that the builds of one component read, so the build cache can tell when an imported
|
|
5
|
+
* file changes. A failed build can stop before it reads the file that broke it, so its list is
|
|
6
|
+
* incomplete and must not be cached.
|
|
7
|
+
*/
|
|
8
|
+
export interface BuildInputs {
|
|
9
|
+
files: Set<string>;
|
|
10
|
+
failed: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createBuildInputs = (): BuildInputs => ({ files: new Set(), failed: false });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Records every file that a Vite build loaded, Sass partials included. The Vite CSS plugin registers
|
|
17
|
+
* partials with `addWatchFile`, and Rollup keeps this list in every build, not only in watch mode.
|
|
18
|
+
*/
|
|
19
|
+
const collectBuildInputs = (inputs: BuildInputs): Plugin => ({
|
|
20
|
+
name: 'handoff:collect-build-inputs',
|
|
21
|
+
buildEnd() {
|
|
22
|
+
for (const file of this.getWatchFiles()) inputs.files.add(file);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Call after the consumer hooks, so that a hook that replaces `plugins` cannot drop the collector. */
|
|
27
|
+
export const withBuildInputs = (config: InlineConfig, inputs?: BuildInputs): InlineConfig =>
|
|
28
|
+
inputs ? { ...config, plugins: [...(config.plugins ?? []), collectBuildInputs(inputs)] } : config;
|
package/src/cache/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { computeDirectoryState, computeFileState, directoryStatesMatch, statesMa
|
|
|
4
4
|
// Build cache utilities
|
|
5
5
|
export {
|
|
6
6
|
checkOutputExists,
|
|
7
|
+
computeBuildInputStates,
|
|
7
8
|
computeComponentFileStates,
|
|
8
9
|
computeGlobalDepsState,
|
|
9
10
|
createEmptyCache,
|
|
@@ -18,3 +19,6 @@ export {
|
|
|
18
19
|
type ComponentCacheEntry,
|
|
19
20
|
type GlobalDepsState,
|
|
20
21
|
} from './build-cache';
|
|
22
|
+
|
|
23
|
+
// Build input collection
|
|
24
|
+
export { createBuildInputs, type BuildInputs } from './build-inputs';
|
package/src/cli/create.ts
CHANGED
|
@@ -125,8 +125,7 @@ const renderTemplate = (content: string, data: CreateOptions): string => {
|
|
|
125
125
|
};
|
|
126
126
|
|
|
127
127
|
const getDependenciesToInstall = (options: CreateOptions): { dependencies: string[]; devDependencies: string[] } => {
|
|
128
|
-
|
|
129
|
-
const dependencies = ['handoff-app@next'];
|
|
128
|
+
const dependencies = ['handoff-app@latest'];
|
|
130
129
|
const devDependencies: string[] = [];
|
|
131
130
|
|
|
132
131
|
if (options.includeExamples) {
|
|
@@ -17,7 +17,7 @@ export interface BuildArgs extends SharedArgs {
|
|
|
17
17
|
* `handoff-app build [--target static|registry] [--package standalone|vercel]` — builds the
|
|
18
18
|
* documentation site for the resolved target and packages it for the resolved deliverable.
|
|
19
19
|
* `--target` selects *what* is built; the optional, additive `--package` selects *how* it is
|
|
20
|
-
* packaged. Bare `build` resolves to the static target with the `out
|
|
20
|
+
* packaged. Bare `build` resolves to the static target with the `out/static` export;
|
|
21
21
|
* `--package` never implies a `--target`.
|
|
22
22
|
*/
|
|
23
23
|
const command: CommandModule<{}, BuildArgs> = {
|
package/src/config/README.md
CHANGED
|
@@ -8,24 +8,22 @@ Handles loading, validation, and resolution of handoff configuration.
|
|
|
8
8
|
|------|---------|
|
|
9
9
|
| `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
|
|
10
10
|
| `from-env.ts` | JSON-compatible environment markers and public types |
|
|
11
|
-
| `resolve-env.ts` | Resolves merged environment references and guards reference-only properties |
|
|
11
|
+
| `resolve-env.ts` | Resolves merged environment references and guards reference-only properties, whose paths accept a `*` segment to cover list elements |
|
|
12
12
|
| `errors.ts` | Shared `HandoffConfigError` |
|
|
13
13
|
| `env.ts` | Reads `.env` at load, then `loadProfileEnv()` applies `.env.<profile>` on top |
|
|
14
14
|
| `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order) plus the selected profile sidecar, warns on conflicts, and merges every layer |
|
|
15
15
|
| `runtime.ts` | `initRuntimeConfig()` — one pass over every registered catalog directory: loads each declaration, normalizes it into a component or pattern record, skips a declaration with a taken id, resolves SCSS/JS paths and transformer options |
|
|
16
16
|
| `catalog-include.ts` | `isIncluded()` / `addToCatalog()` — registers a directory under `catalog.include` |
|
|
17
|
-
| `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
|
|
18
17
|
| `helpers.ts` | `defineConfig()` — typed authoring; maps camelCase keys to the runtime keys |
|
|
19
18
|
| `index.ts` | Barrel re-exports |
|
|
20
19
|
|
|
21
20
|
## Usage
|
|
22
21
|
|
|
23
22
|
```ts
|
|
24
|
-
import { initConfig, initRuntimeConfig
|
|
23
|
+
import { initConfig, initRuntimeConfig } from './config';
|
|
25
24
|
|
|
26
25
|
const config = initConfig(overrides);
|
|
27
26
|
const [runtimeConfig, configPaths] = initRuntimeConfig({ config, workingPath });
|
|
28
|
-
const validated = validateConfig(config);
|
|
29
27
|
```
|
|
30
28
|
|
|
31
29
|
## Main Config Precedence
|
|
@@ -70,7 +68,7 @@ property path, variable name, and active profile. Invalid names and rejected lit
|
|
|
70
68
|
`EnvValue<T>` accepts a literal or reference. `EnvSecret<T>` accepts only a reference and resolves
|
|
71
69
|
at config load. Secret defaults can only be empty or null, for optional credentials.
|
|
72
70
|
`RuntimeEnvReference<T>` accepts only a reference with no default. Its name survives config loading and
|
|
73
|
-
is read in the deployed process. Database URLs
|
|
71
|
+
is read in the deployed process. Database URLs, asset-storage tokens, email secrets, and AI keys are deferred.
|
|
74
72
|
|
|
75
73
|
The loader resolves references once after all layers merge, before other config guards.
|
|
76
74
|
References replace whole values, including earlier references and their defaults. A profile can
|
|
@@ -83,10 +81,13 @@ numeric and boolean properties also retain their default's conversion when a ref
|
|
|
83
81
|
fallback. Booleans are true only for `true`. Numbers must be finite. A lower-layer fallback never
|
|
84
82
|
becomes the fallback for a replacement reference.
|
|
85
83
|
|
|
86
|
-
`runtime.registry.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
`runtime.registry.email.options`, `runtime.registry.assetStorage.options`, and
|
|
85
|
+
`runtime.ai.connections.*.options` are deferred prefixes (`DEFERRED_PREFIXES`). A reference anywhere
|
|
86
|
+
inside them keeps its name, and the deployed app resolves it with `resolveEnvReferences()` before it
|
|
87
|
+
calls the module factory. Literals are baked. `email.options.apiKey` and `email.options.password` are
|
|
88
|
+
also `DEFERRED_PATHS`, so a literal secret is rejected for every email provider. Before the merge, a
|
|
89
|
+
layer that changes `email.provider` drops `options` and `module` from the lower layers, so the
|
|
90
|
+
settings of two providers never mix.
|
|
90
91
|
|
|
91
92
|
`ResolvedConfig` narrows eager properties by hand. Adding an eager property requires its `Config`
|
|
92
93
|
member and resolved narrowing. A secret also needs a `SECRET_PATHS` entry for untyped configs.
|
package/src/config/defaults.ts
CHANGED
|
@@ -15,8 +15,12 @@ export const isMcpEnabled = (config: Pick<Config, 'runtime'> | Pick<ResolvedConf
|
|
|
15
15
|
config?.runtime?.mcp !== false;
|
|
16
16
|
|
|
17
17
|
export const defaultConfig = (): Config => ({
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
integrations: {
|
|
19
|
+
figma: {
|
|
20
|
+
projectId: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
|
|
21
|
+
accessToken: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
20
24
|
runtime: {
|
|
21
25
|
mode: 'workspace',
|
|
22
26
|
registryConnection: {
|
|
@@ -24,8 +28,7 @@ export const defaultConfig = (): Config => ({
|
|
|
24
28
|
accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN', { default: '' }),
|
|
25
29
|
},
|
|
26
30
|
registry: {
|
|
27
|
-
|
|
28
|
-
assetStorage: { token: fromEnv('BLOB_READ_WRITE_TOKEN') },
|
|
31
|
+
database: { url: fromEnv('DATABASE_URL') },
|
|
29
32
|
},
|
|
30
33
|
},
|
|
31
34
|
exportsOutputDirectory: fromEnv('HANDOFF_OUTPUT_DIR', { default: 'exported' }),
|
package/src/config/from-env.ts
CHANGED
|
@@ -24,3 +24,36 @@ export const fromEnv = <T = string>(name: string, options?: { default: T }): Env
|
|
|
24
24
|
|
|
25
25
|
export const isEnvReference = (value: unknown): value is EnvReference<unknown> =>
|
|
26
26
|
value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, '$env');
|
|
27
|
+
|
|
28
|
+
const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
/** Replace each reference nested in `options` with its value from `env`. An unset or empty variable becomes `undefined`. */
|
|
32
|
+
export const resolveEnvReferences = (options: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown> => {
|
|
33
|
+
const resolve = (value: unknown): unknown => {
|
|
34
|
+
if (isEnvReference(value)) {
|
|
35
|
+
const resolved = typeof value.$env === 'string' ? env[value.$env]?.trim() : undefined;
|
|
36
|
+
return resolved || undefined;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value)) return value.map(resolve);
|
|
39
|
+
if (isPlainRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolve(entry)]));
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
return resolve(options) as Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The variable names that the references nested in `options` use, for deployment instructions. */
|
|
46
|
+
export const envReferenceNames = (options: Record<string, unknown>): string[] => {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
const collect = (value: unknown): void => {
|
|
49
|
+
if (isEnvReference(value)) {
|
|
50
|
+
if (typeof value.$env === 'string') names.add(value.$env);
|
|
51
|
+
} else if (Array.isArray(value)) {
|
|
52
|
+
value.forEach(collect);
|
|
53
|
+
} else if (isPlainRecord(value)) {
|
|
54
|
+
Object.values(value).forEach(collect);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
collect(options);
|
|
58
|
+
return [...names];
|
|
59
|
+
};
|
package/src/config/helpers.ts
CHANGED
|
@@ -25,12 +25,10 @@ const normalizeAppConfig = (app?: Config['app']): Config['app'] => {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export const normalizeConfig = (config: Config): Config => {
|
|
28
|
-
const {
|
|
28
|
+
const { assetsZipLinks, app, ...rest } = config;
|
|
29
29
|
|
|
30
30
|
return {
|
|
31
31
|
...(rest as Config),
|
|
32
|
-
dev_access_token: devAccessToken !== undefined ? devAccessToken : config.dev_access_token,
|
|
33
|
-
figma_project_id: figmaProjectId !== undefined ? figmaProjectId : config.figma_project_id,
|
|
34
32
|
assets_zip_links: assetsZipLinks ?? config.assets_zip_links,
|
|
35
33
|
app: normalizeAppConfig(app),
|
|
36
34
|
};
|
package/src/config/index.ts
CHANGED
|
@@ -15,9 +15,6 @@ export { defineConfig } from './helpers';
|
|
|
15
15
|
// Runtime config resolution
|
|
16
16
|
export { initRuntimeConfig } from './runtime';
|
|
17
17
|
|
|
18
|
-
// Config validation
|
|
19
|
-
export { validateConfig } from './validator';
|
|
20
|
-
|
|
21
18
|
// Registering catalog item directories under `catalog.include`
|
|
22
19
|
export { addToCatalog, isIncluded } from './catalog-include';
|
|
23
20
|
export type { AddToCatalogResult } from './catalog-include';
|
package/src/config/loader.ts
CHANGED
|
@@ -2,9 +2,10 @@ import esbuild from 'esbuild';
|
|
|
2
2
|
import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import mergeWith from 'lodash/mergeWith';
|
|
5
|
+
import omit from 'lodash/omit';
|
|
5
6
|
import { createRequire } from 'module';
|
|
6
7
|
import path from 'path';
|
|
7
|
-
import { Config, ResolvedConfig } from '../types/config';
|
|
8
|
+
import { Config, HandoffEmailConfig, ResolvedConfig } from '../types/config';
|
|
8
9
|
import { Logger } from '../utils/logger';
|
|
9
10
|
import { resolveWorkingPath } from '../utils/path';
|
|
10
11
|
import { defaultConfig } from './defaults';
|
|
@@ -65,7 +66,7 @@ const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string):
|
|
|
65
66
|
write: false,
|
|
66
67
|
platform: 'node',
|
|
67
68
|
format: 'cjs',
|
|
68
|
-
target: '
|
|
69
|
+
target: 'node22',
|
|
69
70
|
logLevel: 'silent',
|
|
70
71
|
external: ['handoff-app'],
|
|
71
72
|
loader: STYLE_IMPORT_LOADERS,
|
|
@@ -233,6 +234,27 @@ const replaceValues = (destination: unknown, source: unknown) => {
|
|
|
233
234
|
return Array.isArray(source) || isEnvReference(source) || isEnvReference(destination) ? source : undefined;
|
|
234
235
|
};
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A layer that changes `runtime.registry.email.provider` starts from fresh `options` and `module`, so
|
|
239
|
+
* the deep merge never mixes the settings of two providers.
|
|
240
|
+
*/
|
|
241
|
+
const resetEmailOnProviderChange = (layers: Partial<Config>[]): Partial<Config>[] => {
|
|
242
|
+
let provider: string | undefined;
|
|
243
|
+
let start = 0;
|
|
244
|
+
layers.forEach((layer, index) => {
|
|
245
|
+
const next = layer.runtime?.registry?.email?.provider;
|
|
246
|
+
if (next !== undefined && next !== provider) {
|
|
247
|
+
provider = next;
|
|
248
|
+
start = index;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return layers.map((layer, index) => {
|
|
252
|
+
const email = layer.runtime?.registry?.email;
|
|
253
|
+
if (index >= start || !email) return layer;
|
|
254
|
+
return { ...layer, runtime: { ...layer.runtime, registry: { ...layer.runtime!.registry, email: omit(email, ['options', 'module']) as HandoffEmailConfig } } };
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
236
258
|
/**
|
|
237
259
|
* Loads the handoff configuration for the given working directory and returns metadata.
|
|
238
260
|
*/
|
|
@@ -264,7 +286,7 @@ export const initConfigWithMetadata = (configOverride?: Partial<Config>, context
|
|
|
264
286
|
Logger.debug(`Config profile: ${profileLabel}`);
|
|
265
287
|
Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
|
|
266
288
|
|
|
267
|
-
const merged = mergeWith({}, ...layers, replaceValues) as Config;
|
|
289
|
+
const merged = mergeWith({}, ...resetEmailOnProviderChange(layers), replaceValues) as Config;
|
|
268
290
|
const config = resolveConfigEnv(merged, profileSelection?.name, layers[0]);
|
|
269
291
|
|
|
270
292
|
// Guards run on the resolved config so a profile cannot slip a removed setting past them.
|
|
@@ -4,11 +4,32 @@ import { Logger } from '../utils/logger';
|
|
|
4
4
|
import { isEnvReference } from './from-env';
|
|
5
5
|
import { HandoffConfigError } from './errors';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
7
|
+
/**
|
|
8
|
+
* Match a dot path against a pattern. A `*` segment matches exactly one segment, so one pattern
|
|
9
|
+
* covers every element of a list. With `prefix`, the pattern also matches anything nested under it.
|
|
10
|
+
*/
|
|
11
|
+
const pathMatches = (pattern: string, path: string, prefix = false): boolean => {
|
|
12
|
+
const expected = pattern.split('.');
|
|
13
|
+
const actual = path.split('.');
|
|
14
|
+
if (prefix ? expected.length > actual.length : expected.length !== actual.length) return false;
|
|
15
|
+
return expected.every((segment, index) => segment === '*' || segment === actual[index]);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Paths whose value the deployed app reads at request time, so only the variable name is stored. */
|
|
19
|
+
export const DEFERRED_PATHS = [
|
|
20
|
+
'runtime.registry.database.url',
|
|
21
|
+
'runtime.registry.email.options.apiKey',
|
|
22
|
+
'runtime.registry.email.options.password',
|
|
23
|
+
'runtime.ai.connections.*.apiKey',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Path prefixes whose nested references the deployed app reads at request time. Literals below them
|
|
27
|
+
* are baked. `DEFERRED_PATHS` entries below them still reject literals.
|
|
28
|
+
*/
|
|
29
|
+
const DEFERRED_PREFIXES = ['runtime.registry.email.options', 'runtime.registry.assetStorage.options', 'runtime.ai.connections.*.options'];
|
|
30
|
+
const SECRET_PATHS = new Set(['integrations.figma.accessToken', 'runtime.registryConnection.accessToken']);
|
|
31
|
+
const isDeferred = (path: string): boolean => DEFERRED_PATHS.some((deferred) => pathMatches(deferred, path));
|
|
32
|
+
const isUnderDeferredPrefix = (path: string): boolean => DEFERRED_PREFIXES.some((prefix) => pathMatches(`${prefix}.*`, path, true));
|
|
12
33
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
34
|
const ALIASES: Record<string, string> = {
|
|
14
35
|
HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
|
|
@@ -40,20 +61,12 @@ export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Co
|
|
|
40
61
|
throw new HandoffConfigError(`Config "${path}" (profile "${profile ?? 'default'}"): ${message}`);
|
|
41
62
|
};
|
|
42
63
|
const walk = (value: unknown, path: string, seed?: unknown): unknown => {
|
|
43
|
-
const deferred =
|
|
64
|
+
const deferred = isDeferred(path);
|
|
44
65
|
const secret = SECRET_PATHS.has(path);
|
|
45
66
|
if ((secret || deferred) && value !== undefined && !isEnvReference(value)) {
|
|
46
67
|
fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
|
|
47
68
|
}
|
|
48
69
|
if (isEnvReference(value)) {
|
|
49
|
-
// Checked before the name, so the message names the supported pattern instead of the reference shape.
|
|
50
|
-
if (isBaked(path)) {
|
|
51
|
-
fail(
|
|
52
|
-
path,
|
|
53
|
-
'Environment references are not allowed here because the resolved value is baked into the build. ' +
|
|
54
|
-
"Put the variable name in options and read it through the adapter factory's `env` argument."
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
70
|
if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
|
|
58
71
|
fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
|
|
59
72
|
}
|
|
@@ -62,7 +75,7 @@ export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Co
|
|
|
62
75
|
if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
|
|
63
76
|
fail(path, `Environment variable "${name}" has an invalid reference shape.`);
|
|
64
77
|
}
|
|
65
|
-
if (deferred) {
|
|
78
|
+
if (deferred || isUnderDeferredPrefix(path)) {
|
|
66
79
|
if ('default' in value) fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
|
|
67
80
|
return { $env: name };
|
|
68
81
|
}
|
package/src/config/runtime.ts
CHANGED
|
@@ -72,7 +72,7 @@ const evaluateDeclaration = (filePath: string, handoffModulePath: string): any =
|
|
|
72
72
|
write: false,
|
|
73
73
|
platform: 'node',
|
|
74
74
|
format: 'cjs',
|
|
75
|
-
target: '
|
|
75
|
+
target: 'node22',
|
|
76
76
|
logLevel: 'silent',
|
|
77
77
|
jsx: 'automatic',
|
|
78
78
|
external: ['react', 'react-dom', 'handoff-app'],
|