handoff-app 2.0.0-7 → 2.0.0-8
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 +111 -11
- package/UPGRADE.md +20 -0
- package/dist/app-builder/build.js +10 -2
- package/dist/app-builder/client-config.js +3 -1
- package/dist/app-builder/watchers.js +17 -7
- package/dist/cache/build-cache.js +5 -1
- package/dist/cli/auth/device.d.ts +6 -1
- package/dist/cli/auth/device.js +5 -2
- package/dist/cli/auth/store.d.ts +15 -6
- package/dist/cli/auth/store.js +72 -19
- package/dist/commands/login/index.d.ts +5 -0
- package/dist/commands/login/index.js +18 -6
- package/dist/commands/logout/index.d.ts +5 -0
- package/dist/commands/logout/index.js +54 -15
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/utils.d.ts +21 -7
- package/dist/commands/utils.js +11 -10
- package/dist/config/catalog-include.d.ts +2 -2
- package/dist/config/defaults.d.ts +3 -3
- package/dist/config/defaults.js +54 -48
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +43 -0
- package/dist/config/errors.d.ts +4 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/from-env.d.ts +24 -0
- package/dist/config/from-env.js +7 -0
- package/dist/config/helpers.d.ts +2 -0
- package/dist/config/helpers.js +3 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +6 -1
- package/dist/config/loader.d.ts +29 -11
- package/dist/config/loader.js +119 -46
- package/dist/config/resolve-env.d.ts +5 -0
- package/dist/config/resolve-env.js +102 -0
- package/dist/config/runtime.d.ts +3 -3
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +60 -16
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
- package/dist/registry/asset-storage/resolve.d.ts +2 -2
- package/dist/registry/asset-storage/resolve.js +4 -1
- package/dist/registry/checkout/index.js +4 -4
- package/dist/registry/connection.d.ts +28 -24
- package/dist/registry/connection.js +53 -67
- package/dist/registry/db/driver.d.ts +4 -11
- package/dist/registry/db/driver.js +4 -16
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +1 -2
- package/dist/registry/publish/index.js +4 -4
- package/dist/store/filesystem.d.ts +2 -2
- package/dist/types/config.d.ts +48 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/pages/account/tokens.tsx +3 -1
- package/src/app-builder/build.ts +10 -2
- package/src/app-builder/client-config.ts +5 -3
- package/src/app-builder/watchers.ts +18 -8
- package/src/cache/build-cache.ts +5 -1
- package/src/cli/auth/device.ts +8 -3
- package/src/cli/auth/store.ts +75 -18
- package/src/commands/login/index.ts +20 -6
- package/src/commands/logout/index.ts +74 -20
- package/src/commands/types.ts +2 -0
- package/src/commands/utils.ts +14 -5
- package/src/config/README.md +61 -1
- package/src/config/catalog-include.ts +2 -2
- package/src/config/defaults.ts +24 -14
- package/src/config/env.ts +41 -0
- package/src/config/errors.ts +7 -0
- package/src/config/from-env.ts +26 -0
- package/src/config/helpers.ts +4 -2
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +156 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +3 -3
- package/src/config/validator.ts +4 -6
- package/src/index.d.ts +2 -2
- package/src/index.ts +85 -8
- package/src/pipeline/index.ts +1 -1
- package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
- package/src/registry/asset-storage/resolve.ts +6 -3
- package/src/registry/checkout/index.ts +5 -5
- package/src/registry/connection.ts +72 -71
- package/src/registry/db/driver.ts +6 -17
- package/src/registry/db/index.ts +0 -1
- package/src/registry/publish/index.ts +5 -5
- package/src/store/filesystem.ts +2 -2
- package/src/types/config.ts +45 -29
- package/templates/default/env.tpl +10 -0
- package/templates/default/gitignore.tpl +7 -0
- package/templates/default/handoff.config.js.tpl +21 -172
- package/templates/default/handoff.config.ts.tpl +21 -156
- package/templates/default/handoff.config.with-examples.js.tpl +20 -171
- package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
package/src/config/defaults.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { fromEnv } from './from-env';
|
|
1
2
|
import { resolveRegistryConnection } from '../registry/connection';
|
|
2
|
-
import { ClientConfig, Config } from '../types/config';
|
|
3
|
+
import { ClientConfig, Config, ResolvedConfig } from '../types/config';
|
|
3
4
|
|
|
4
5
|
export interface ImageStyle {
|
|
5
6
|
name: string;
|
|
@@ -10,25 +11,34 @@ export interface ImageStyle {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/** Whether the MCP endpoint is served. On unless `runtime.mcp` is explicitly `false`. */
|
|
13
|
-
export const isMcpEnabled = (config: Config | null | undefined): boolean =>
|
|
14
|
+
export const isMcpEnabled = (config: Pick<Config, 'runtime'> | Pick<ResolvedConfig, 'runtime'> | null | undefined): boolean =>
|
|
15
|
+
config?.runtime?.mcp !== false;
|
|
14
16
|
|
|
15
17
|
export const defaultConfig = (): Config => ({
|
|
16
|
-
dev_access_token:
|
|
17
|
-
figma_project_id:
|
|
18
|
+
dev_access_token: fromEnv('HANDOFF_DEV_ACCESS_TOKEN', { default: null }),
|
|
19
|
+
figma_project_id: fromEnv('HANDOFF_FIGMA_PROJECT_ID', { default: null }),
|
|
18
20
|
runtime: {
|
|
19
21
|
mode: 'workspace',
|
|
22
|
+
registryConnection: {
|
|
23
|
+
url: fromEnv('HANDOFF_REGISTRY_URL', { default: '' }),
|
|
24
|
+
accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN', { default: '' }),
|
|
25
|
+
},
|
|
26
|
+
registry: {
|
|
27
|
+
databaseUrl: fromEnv('DATABASE_URL'),
|
|
28
|
+
assetStorage: { token: fromEnv('BLOB_READ_WRITE_TOKEN') },
|
|
29
|
+
},
|
|
20
30
|
},
|
|
21
|
-
exportsOutputDirectory:
|
|
22
|
-
sitesOutputDirectory:
|
|
23
|
-
useVariables:
|
|
31
|
+
exportsOutputDirectory: fromEnv('HANDOFF_OUTPUT_DIR', { default: 'exported' }),
|
|
32
|
+
sitesOutputDirectory: fromEnv('HANDOFF_SITES_DIR', { default: 'out' }),
|
|
33
|
+
useVariables: fromEnv('HANDOFF_USE_VARIABLES', { default: false }),
|
|
24
34
|
reactDocgen: {
|
|
25
35
|
maxDepth: 7,
|
|
26
36
|
excludeDirectories: ['dist', 'build', '.next'],
|
|
27
37
|
},
|
|
28
38
|
app: {
|
|
29
39
|
theme: 'default',
|
|
30
|
-
title: '
|
|
31
|
-
client: '
|
|
40
|
+
title: 'Handoff Design System',
|
|
41
|
+
client: 'Handoff',
|
|
32
42
|
google_tag_manager: null,
|
|
33
43
|
attribution: true,
|
|
34
44
|
type_copy: 'Almost before we knew it, we had left the ground.',
|
|
@@ -54,9 +64,9 @@ export const defaultConfig = (): Config => ({
|
|
|
54
64
|
desktop: { size: 1100, name: 'Large' },
|
|
55
65
|
},
|
|
56
66
|
ports: {
|
|
57
|
-
app:
|
|
58
|
-
websocket:
|
|
59
|
-
}
|
|
67
|
+
app: fromEnv('HANDOFF_APP_PORT', { default: 3000 }),
|
|
68
|
+
websocket: fromEnv('HANDOFF_WEBSOCKET_PORT', { default: 3001 }),
|
|
69
|
+
},
|
|
60
70
|
},
|
|
61
71
|
});
|
|
62
72
|
|
|
@@ -66,7 +76,7 @@ export const defaultConfig = (): Config => ({
|
|
|
66
76
|
* @param config - The full handoff Config object.
|
|
67
77
|
* @returns The client configuration object.
|
|
68
78
|
*/
|
|
69
|
-
export const getClientConfig = (config:
|
|
79
|
+
export const getClientConfig = (config: ResolvedConfig): ClientConfig => {
|
|
70
80
|
const {
|
|
71
81
|
app,
|
|
72
82
|
exportsOutputDirectory,
|
|
@@ -74,7 +84,7 @@ export const getClientConfig = (config: Config): ClientConfig => {
|
|
|
74
84
|
assets_zip_links = { icons: null, logos: null },
|
|
75
85
|
useVariables,
|
|
76
86
|
runtime,
|
|
77
|
-
} =
|
|
87
|
+
} = config;
|
|
78
88
|
|
|
79
89
|
const mode = runtime?.mode ?? 'workspace';
|
|
80
90
|
// Connected-workspace affordance: only a workspace with a resolvable registry URL is "connected".
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Environment variables that were already set when the process started. They outrank every `.env`
|
|
7
|
+
* file, so an inline `DATABASE_URL=… handoff-app db:migrate` wins over what a file says. Recorded
|
|
8
|
+
* before the base `.env` is read, which is why this module is imported first.
|
|
9
|
+
*/
|
|
10
|
+
const processEnvKeys = new Set(Object.keys(process.env));
|
|
11
|
+
|
|
12
|
+
// The base `.env`, read from the directory the command runs in, at module load so `process.env` is
|
|
13
|
+
// populated before the first Handoff is constructed.
|
|
14
|
+
dotenv.config();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply `.env.<profile>` on top of the base `.env` for the selected profile. The file is optional:
|
|
18
|
+
* a profile needs a config file, not an env file.
|
|
19
|
+
*
|
|
20
|
+
* Precedence is the process environment, then the profile env file, then `.env`. Dotenv's own
|
|
21
|
+
* `override` option cannot express that - it would replace the values the shell supplied too - so
|
|
22
|
+
* the keys already present at startup are skipped here.
|
|
23
|
+
*/
|
|
24
|
+
export const loadProfileEnv = (profile?: string, directory: string = process.cwd()): void => {
|
|
25
|
+
if (!profile) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const envPath = path.resolve(directory, `.env.${profile}`);
|
|
30
|
+
if (!fs.existsSync(envPath)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsed = dotenv.parse(fs.readFileSync(envPath));
|
|
35
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
36
|
+
if (processEnvKeys.has(key)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
process.env[key] = value;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A JSON-compatible environment reference. Creating one never reads the environment. */
|
|
2
|
+
export type EnvReference<T> = { $env: string; default?: T };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Use for values that can be literals or environment references, such as output directories.
|
|
6
|
+
* References resolve at config load; ResolvedConfig holds the value.
|
|
7
|
+
*/
|
|
8
|
+
export type EnvValue<T> = T | EnvReference<T>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Use for credentials the CLI needs, such as the Figma token. Only references are allowed.
|
|
12
|
+
* References resolve at config load; ResolvedConfig holds the secret value.
|
|
13
|
+
*/
|
|
14
|
+
export type EnvSecret<T> = EnvReference<T>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Use for values the deployed app reads, such as its database URL. Only references are allowed.
|
|
18
|
+
* ResolvedConfig keeps the reference; the build stores its name, and the deployed app reads the value at runtime.
|
|
19
|
+
*/
|
|
20
|
+
export type RuntimeEnvReference<T> = EnvReference<T>;
|
|
21
|
+
|
|
22
|
+
export const fromEnv = <T = string>(name: string, options?: { default: T }): EnvReference<T> =>
|
|
23
|
+
options === undefined ? { $env: name } : { $env: name, default: options.default };
|
|
24
|
+
|
|
25
|
+
export const isEnvReference = (value: unknown): value is EnvReference<unknown> =>
|
|
26
|
+
value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, '$env');
|
package/src/config/helpers.ts
CHANGED
|
@@ -29,8 +29,8 @@ export const normalizeConfig = (config: Config): Config => {
|
|
|
29
29
|
|
|
30
30
|
return {
|
|
31
31
|
...(rest as Config),
|
|
32
|
-
dev_access_token: devAccessToken
|
|
33
|
-
figma_project_id: figmaProjectId
|
|
32
|
+
dev_access_token: devAccessToken !== undefined ? devAccessToken : config.dev_access_token,
|
|
33
|
+
figma_project_id: figmaProjectId !== undefined ? figmaProjectId : config.figma_project_id,
|
|
34
34
|
assets_zip_links: assetsZipLinks ?? config.assets_zip_links,
|
|
35
35
|
app: normalizeAppConfig(app),
|
|
36
36
|
};
|
|
@@ -39,5 +39,7 @@ export const normalizeConfig = (config: Config): Config => {
|
|
|
39
39
|
/**
|
|
40
40
|
* Optional helper for strongly-typed config authoring.
|
|
41
41
|
* It normalizes modern camelCase keys to legacy runtime keys.
|
|
42
|
+
*
|
|
43
|
+
* Every part of `Config` is optional, so this types a profile as well as a base config.
|
|
42
44
|
*/
|
|
43
45
|
export const defineConfig = (config: Config): Config => normalizeConfig(config);
|
package/src/config/index.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
export { defaultConfig, getClientConfig, isMcpEnabled } from './defaults';
|
|
3
3
|
export type { ImageStyle } from './defaults';
|
|
4
4
|
|
|
5
|
+
// Environment files
|
|
6
|
+
export { loadProfileEnv } from './env';
|
|
7
|
+
|
|
5
8
|
// Config file loading
|
|
6
|
-
export { HandoffConfigError, initConfig, initConfigWithMetadata } from './loader';
|
|
9
|
+
export { CONFIG_FILE_PREFERENCE, HandoffConfigError, initConfig, initConfigWithMetadata, resolveProfileSelection } from './loader';
|
|
7
10
|
export type { ConfigLoadContext } from './loader';
|
|
8
11
|
|
|
9
12
|
// Config helpers
|
package/src/config/loader.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import esbuild from 'esbuild';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
|
-
import
|
|
3
|
+
import mergeWith from 'lodash/mergeWith';
|
|
4
4
|
import { createRequire } from 'module';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import { Config } from '../types/config';
|
|
6
|
+
import { Config, ResolvedConfig } from '../types/config';
|
|
7
7
|
import { Logger } from '../utils/logger';
|
|
8
8
|
import { resolveWorkingPath } from '../utils/path';
|
|
9
9
|
import { defaultConfig } from './defaults';
|
|
10
|
+
import { loadProfileEnv } from './env';
|
|
11
|
+
import { normalizeConfig } from './helpers';
|
|
12
|
+
import { HandoffConfigError } from './errors';
|
|
13
|
+
import { isEnvReference } from './from-env';
|
|
14
|
+
import { resolveConfigEnv } from './resolve-env';
|
|
10
15
|
|
|
11
|
-
const CONFIG_FILE_PREFERENCE = [
|
|
16
|
+
export const CONFIG_FILE_PREFERENCE = [
|
|
12
17
|
'handoff.config.ts',
|
|
13
18
|
'handoff.config.js',
|
|
14
19
|
'handoff.config.cjs',
|
|
@@ -18,17 +23,17 @@ const CONFIG_FILE_PREFERENCE = [
|
|
|
18
23
|
/** Extensions the loader knows how to evaluate, derived from the discoverable file names. */
|
|
19
24
|
const SUPPORTED_CONFIG_EXTENSIONS = Array.from(new Set(CONFIG_FILE_PREFERENCE.map((fileName) => path.extname(fileName))));
|
|
20
25
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
this.name = 'HandoffConfigError';
|
|
26
|
-
}
|
|
27
|
-
}
|
|
26
|
+
/** A profile name becomes part of a file name, so it stays lowercase letters, numbers, and hyphens. */
|
|
27
|
+
const PROFILE_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
28
|
+
|
|
29
|
+
export { HandoffConfigError } from './errors';
|
|
28
30
|
|
|
29
31
|
type ConfigLoadResult = {
|
|
30
|
-
config:
|
|
32
|
+
config: ResolvedConfig;
|
|
31
33
|
configPath?: string;
|
|
34
|
+
profile?: string;
|
|
35
|
+
/** The profile file merged onto the base config. */
|
|
36
|
+
profileConfigPath?: string;
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
/** Where to look for the config: the working directory, plus an optional explicit file. */
|
|
@@ -37,8 +42,21 @@ export type ConfigLoadContext = {
|
|
|
37
42
|
workingPath?: string;
|
|
38
43
|
/** Explicit config file (the CLI's `-c, --config`), replacing discovery entirely. */
|
|
39
44
|
configPath?: string;
|
|
45
|
+
/** Profile to merge onto the base config (the CLI's `--profile`), overriding `HANDOFF_PROFILE`. */
|
|
46
|
+
profile?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Profile names that resolve without a `handoff.config.<profile>.*` file, leaving the base config
|
|
49
|
+
* unchanged. Registry commands pass the names of saved logins, because there a profile selects the
|
|
50
|
+
* registry rather than a config layer.
|
|
51
|
+
*/
|
|
52
|
+
knownProfiles?: string[];
|
|
53
|
+
/** Sentence appended when a selected profile matches neither a config file nor `knownProfiles`. */
|
|
54
|
+
profileNotFoundHint?: string;
|
|
40
55
|
};
|
|
41
56
|
|
|
57
|
+
/** Which selector picked the profile, so an error can name the one the user set. */
|
|
58
|
+
type ProfileSelection = { name: string; source: '--profile' | 'HANDOFF_PROFILE' };
|
|
59
|
+
|
|
42
60
|
const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string): any => {
|
|
43
61
|
const buildResult = esbuild.buildSync({
|
|
44
62
|
entryPoints: [filePath],
|
|
@@ -91,6 +109,36 @@ const loadConfigFile = (configPath: string): Config => {
|
|
|
91
109
|
return (importedConfig.default || importedConfig) as Config;
|
|
92
110
|
};
|
|
93
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Normalize here and not only in `defineConfig`: a `.json` config, or a profile that skips the
|
|
114
|
+
* helper, would otherwise keep its camelCase keys. The rest of the codebase reads the runtime keys,
|
|
115
|
+
* so a later layer could not override an earlier one.
|
|
116
|
+
*/
|
|
117
|
+
const loadConfigLayer = (configPath: string): Partial<Config> => normalizeConfig(loadConfigFile(configPath));
|
|
118
|
+
|
|
119
|
+
const pickExisting = (candidates: string[]): { selected?: string; ignored: string[] } => {
|
|
120
|
+
const existing = candidates.filter((filePath) => fs.existsSync(filePath));
|
|
121
|
+
|
|
122
|
+
if (!existing.length) {
|
|
123
|
+
return { selected: undefined, ignored: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [selected, ...ignored] = existing;
|
|
127
|
+
return { selected, ignored };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const warnIgnored = (selected: string | undefined, ignored: string[]): void => {
|
|
131
|
+
if (!selected || ignored.length === 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Logger.warn(
|
|
136
|
+
`Multiple config files found. Using "${path.basename(selected)}" and ignoring: ${ignored
|
|
137
|
+
.map((filePath) => `"${path.basename(filePath)}"`)
|
|
138
|
+
.join(', ')}.`
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
|
|
94
142
|
/**
|
|
95
143
|
* Picks the config file to load. An explicitly requested file replaces discovery - it is resolved
|
|
96
144
|
* from `workingPath` and must exist in a format the loader can evaluate. Otherwise every known
|
|
@@ -113,46 +161,111 @@ const resolveConfigFilePath = (workingPath: string, requestedPath?: string): { s
|
|
|
113
161
|
return { selected, ignored: [] };
|
|
114
162
|
}
|
|
115
163
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.filter((filePath) => fs.existsSync(filePath));
|
|
164
|
+
return pickExisting(CONFIG_FILE_PREFERENCE.map((fileName) => path.resolve(workingPath, fileName)));
|
|
165
|
+
};
|
|
119
166
|
|
|
120
|
-
|
|
121
|
-
|
|
167
|
+
/** `--profile` wins over `HANDOFF_PROFILE`, and an empty value counts as unset. */
|
|
168
|
+
export const resolveProfileSelection = (requestedProfile?: string): ProfileSelection | undefined => {
|
|
169
|
+
const fromOption = requestedProfile?.trim();
|
|
170
|
+
if (fromOption) {
|
|
171
|
+
return { name: fromOption, source: '--profile' };
|
|
122
172
|
}
|
|
123
173
|
|
|
124
|
-
const
|
|
125
|
-
|
|
174
|
+
const fromEnvironment = process.env.HANDOFF_PROFILE?.trim();
|
|
175
|
+
if (fromEnvironment) {
|
|
176
|
+
return { name: fromEnvironment, source: 'HANDOFF_PROFILE' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return undefined;
|
|
126
180
|
};
|
|
127
181
|
|
|
128
182
|
/**
|
|
129
|
-
*
|
|
183
|
+
* Picks the profile file for a selected profile. It sits beside the base config: the base file name
|
|
184
|
+
* without its extension, then the profile name, then any config extension. So `handoff.config.ts`
|
|
185
|
+
* pairs with `handoff.config.local.*`, and a config named through `-c` keeps its own name. A selected
|
|
186
|
+
* profile has to resolve - silently falling back to the base config would deploy the wrong settings.
|
|
187
|
+
* A name listed in `knownProfiles` resolves without a file, because it names something else that
|
|
188
|
+
* exists, such as a saved registry login.
|
|
130
189
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
190
|
+
const resolveProfileFilePath = (
|
|
191
|
+
workingPath: string,
|
|
192
|
+
baseConfigPath: string | undefined,
|
|
193
|
+
selection: ProfileSelection,
|
|
194
|
+
context: ConfigLoadContext
|
|
195
|
+
): string | undefined => {
|
|
196
|
+
if (!PROFILE_NAME_PATTERN.test(selection.name)) {
|
|
197
|
+
throw new HandoffConfigError(
|
|
198
|
+
`Invalid config profile "${selection.name}" (from ${selection.source}). ` +
|
|
199
|
+
`A profile name can contain lowercase letters, numbers, and hyphens.`
|
|
140
200
|
);
|
|
141
201
|
}
|
|
142
202
|
|
|
143
|
-
|
|
144
|
-
|
|
203
|
+
const baseName = baseConfigPath
|
|
204
|
+
? path.join(path.dirname(baseConfigPath), path.basename(baseConfigPath, path.extname(baseConfigPath)))
|
|
205
|
+
: path.resolve(workingPath, 'handoff.config');
|
|
206
|
+
const candidates = SUPPORTED_CONFIG_EXTENSIONS.map((extension) => `${baseName}.${selection.name}${extension}`);
|
|
207
|
+
const { selected, ignored } = pickExisting(candidates);
|
|
208
|
+
|
|
209
|
+
if (!selected) {
|
|
210
|
+
if (context.knownProfiles?.includes(selection.name)) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
throw new HandoffConfigError(
|
|
214
|
+
`Config profile "${selection.name}" (from ${selection.source}) not found. ` +
|
|
215
|
+
`Create one of: ${candidates.map((candidate) => path.basename(candidate)).join(', ')} in ${path.dirname(candidates[0])}.` +
|
|
216
|
+
(context.profileNotFoundHint ? ` ${context.profileNotFoundHint}` : '')
|
|
217
|
+
);
|
|
145
218
|
}
|
|
146
219
|
|
|
147
|
-
|
|
220
|
+
warnIgnored(selected, ignored);
|
|
221
|
+
return selected;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Arrays and environment references are whole values: a later layer replaces them, including
|
|
226
|
+
* any reference default. Everything else keeps lodash semantics: plain
|
|
227
|
+
* objects merge recursively, scalars, `null` and functions replace, and `undefined` is skipped.
|
|
228
|
+
*/
|
|
229
|
+
const replaceValues = (destination: unknown, source: unknown) => {
|
|
230
|
+
if (source === undefined) return undefined;
|
|
231
|
+
return Array.isArray(source) || isEnvReference(source) || isEnvReference(destination) ? source : undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Loads the handoff configuration for the given working directory and returns metadata.
|
|
236
|
+
*/
|
|
237
|
+
export const initConfigWithMetadata = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ConfigLoadResult => {
|
|
238
|
+
const workingPath = context?.workingPath ?? resolveWorkingPath();
|
|
239
|
+
const { selected: configPath, ignored } = resolveConfigFilePath(workingPath, context?.configPath);
|
|
240
|
+
warnIgnored(configPath, ignored);
|
|
241
|
+
|
|
242
|
+
const profileSelection = resolveProfileSelection(context?.profile);
|
|
243
|
+
const profileConfigPath = profileSelection ? resolveProfileFilePath(workingPath, configPath, profileSelection, context ?? {}) : undefined;
|
|
244
|
+
|
|
245
|
+
// Profile environment values must be present before the merged references resolve.
|
|
246
|
+
loadProfileEnv(profileSelection?.name);
|
|
247
|
+
|
|
248
|
+
const layers: Partial<Config>[] = [defaultConfig()];
|
|
249
|
+
if (configPath) {
|
|
250
|
+
layers.push(loadConfigLayer(configPath));
|
|
251
|
+
}
|
|
252
|
+
if (profileConfigPath) {
|
|
253
|
+
layers.push(loadConfigLayer(profileConfigPath));
|
|
254
|
+
}
|
|
148
255
|
if (configOverride) {
|
|
149
|
-
|
|
150
|
-
if (value !== undefined) {
|
|
151
|
-
(config as Record<string, unknown>)[key as string] = value;
|
|
152
|
-
}
|
|
153
|
-
});
|
|
256
|
+
layers.push(normalizeConfig(configOverride as Config));
|
|
154
257
|
}
|
|
155
258
|
|
|
259
|
+
const profileLabel = profileSelection
|
|
260
|
+
? `"${profileSelection.name}" (from ${profileSelection.source})${profileConfigPath ? '' : ' - no config file, the base config is used unchanged'}`
|
|
261
|
+
: 'none selected';
|
|
262
|
+
Logger.debug(`Config profile: ${profileLabel}`);
|
|
263
|
+
Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
|
|
264
|
+
|
|
265
|
+
const merged = mergeWith({}, ...layers, replaceValues) as Config;
|
|
266
|
+
const config = resolveConfigEnv(merged, profileSelection?.name, layers[0]);
|
|
267
|
+
|
|
268
|
+
// Guards run on the resolved config so a profile cannot slip a removed setting past them.
|
|
156
269
|
const entries = config.entries as Record<string, unknown> | undefined;
|
|
157
270
|
if (entries && ('components' in entries || 'patterns' in entries)) {
|
|
158
271
|
throw new HandoffConfigError(
|
|
@@ -165,29 +278,22 @@ export const initConfigWithMetadata = (configOverride?: Partial<Config>, context
|
|
|
165
278
|
'Item declarations support ts, js, and cjs. Convert JSON declarations to defineCatalogItem. See UPGRADE.md#catalog-items.'
|
|
166
279
|
);
|
|
167
280
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// block which is deep-merged so a partial user `runtime` (e.g. only `mode`) does not
|
|
171
|
-
// wipe the defaults supplied for the rest of the block.
|
|
172
|
-
const resolvedConfig = {
|
|
173
|
-
...defaults,
|
|
174
|
-
...config,
|
|
175
|
-
runtime: merge({}, defaults.runtime, config.runtime),
|
|
176
|
-
} as Config;
|
|
177
|
-
return { config: resolvedConfig, configPath };
|
|
281
|
+
|
|
282
|
+
return { config, configPath, profile: profileSelection?.name, profileConfigPath };
|
|
178
283
|
};
|
|
179
284
|
|
|
180
285
|
/**
|
|
181
286
|
* Loads the handoff configuration for the given working directory.
|
|
182
287
|
*
|
|
183
288
|
* Searches for config files in order: handoff.config.ts, handoff.config.js, handoff.config.cjs, handoff.config.json,
|
|
184
|
-
* unless `context.configPath` names one explicitly.
|
|
185
|
-
*
|
|
289
|
+
* unless `context.configPath` names one explicitly. A selected profile adds a `handoff.config.<profile>.*` sidecar,
|
|
290
|
+
* which only `context.knownProfiles` makes optional.
|
|
291
|
+
* Layers resolve as defaults, base config, profile, then the given overrides, merging plain objects recursively.
|
|
186
292
|
*
|
|
187
293
|
* @param configOverride - Optional partial config to override file-loaded values.
|
|
188
|
-
* @param context - Optional working directory
|
|
294
|
+
* @param context - Optional working directory, explicit config file, and profile to load.
|
|
189
295
|
* @returns The fully resolved Config object.
|
|
190
296
|
*/
|
|
191
|
-
export const initConfig = (configOverride?: Partial<Config>, context?: ConfigLoadContext):
|
|
297
|
+
export const initConfig = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ResolvedConfig => {
|
|
192
298
|
return initConfigWithMetadata(configOverride, context).config;
|
|
193
299
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import isPlainObject from 'lodash/isPlainObject';
|
|
2
|
+
import type { Config, ResolvedConfig } from '../types/config';
|
|
3
|
+
import { Logger } from '../utils/logger';
|
|
4
|
+
import { isEnvReference } from './from-env';
|
|
5
|
+
import { HandoffConfigError } from './errors';
|
|
6
|
+
|
|
7
|
+
export const DEFERRED_PATHS = new Set(['runtime.registry.databaseUrl', 'runtime.registry.assetStorage.token']);
|
|
8
|
+
const SECRET_PATHS = new Set(['dev_access_token', 'devAccessToken', 'runtime.registryConnection.accessToken']);
|
|
9
|
+
/** Path prefixes whose resolved contents are JSON-baked into the bundle, so a reference would bake its value. */
|
|
10
|
+
const BAKED_PATHS = ['runtime.registry.assetStorage.options'];
|
|
11
|
+
const isBaked = (path: string): boolean => BAKED_PATHS.some((baked) => path === baked || path.startsWith(`${baked}.`));
|
|
12
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
const ALIASES: Record<string, string> = {
|
|
14
|
+
HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
|
|
15
|
+
HANDOFF_REGISTRY_ACCESS_TOKEN: 'HANDOFF_CLOUD_TOKEN',
|
|
16
|
+
};
|
|
17
|
+
const warnedAliases = new Set<string>();
|
|
18
|
+
const referenceNames = new WeakMap<ResolvedConfig, Map<string, string>>();
|
|
19
|
+
|
|
20
|
+
export const configEnvName = (config: ResolvedConfig | null | undefined, path: string): string | undefined =>
|
|
21
|
+
referenceNames.get(config)?.get(path);
|
|
22
|
+
|
|
23
|
+
const readEnv = (name: string): string | undefined => {
|
|
24
|
+
const value = process.env[name];
|
|
25
|
+
const alias = ALIASES[name];
|
|
26
|
+
if (!alias || value?.trim()) return value;
|
|
27
|
+
const inherited = process.env[alias];
|
|
28
|
+
if (!inherited?.trim()) return value;
|
|
29
|
+
if (!warnedAliases.has(alias)) {
|
|
30
|
+
warnedAliases.add(alias);
|
|
31
|
+
Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
|
|
32
|
+
}
|
|
33
|
+
return inherited;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Resolve only the merged winner. Functions and non-plain objects retain their identity. */
|
|
37
|
+
export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Config): ResolvedConfig => {
|
|
38
|
+
const names = new Map<string, string>();
|
|
39
|
+
const fail = (path: string, message: string): never => {
|
|
40
|
+
throw new HandoffConfigError(`Config "${path}" (profile "${profile ?? 'default'}"): ${message}`);
|
|
41
|
+
};
|
|
42
|
+
const walk = (value: unknown, path: string, seed?: unknown): unknown => {
|
|
43
|
+
const deferred = DEFERRED_PATHS.has(path);
|
|
44
|
+
const secret = SECRET_PATHS.has(path);
|
|
45
|
+
if ((secret || deferred) && value !== undefined && !isEnvReference(value)) {
|
|
46
|
+
fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
|
|
47
|
+
}
|
|
48
|
+
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
|
+
if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
|
|
58
|
+
fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
|
|
59
|
+
}
|
|
60
|
+
const name = value.$env;
|
|
61
|
+
names.set(path, name);
|
|
62
|
+
if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
|
|
63
|
+
fail(path, `Environment variable "${name}" has an invalid reference shape.`);
|
|
64
|
+
}
|
|
65
|
+
if (deferred) {
|
|
66
|
+
if ('default' in value) fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
|
|
67
|
+
return { $env: name };
|
|
68
|
+
}
|
|
69
|
+
// Secret defaults can mark an optional credential, but cannot embed a credential value.
|
|
70
|
+
if (secret && value.default !== undefined && value.default !== null && value.default !== '') {
|
|
71
|
+
fail(path, `Environment variable "${name}" cannot have a literal secret default.`);
|
|
72
|
+
}
|
|
73
|
+
const raw = readEnv(name);
|
|
74
|
+
if (raw === undefined) {
|
|
75
|
+
if (Object.prototype.hasOwnProperty.call(value, 'default')) return secret ? value.default : walk(value.default, path, seed);
|
|
76
|
+
fail(path, `Environment variable "${name}" is not set and has no default.`);
|
|
77
|
+
}
|
|
78
|
+
const typeHint = value.default ?? (isEnvReference(seed) ? seed.default : seed);
|
|
79
|
+
if (typeof typeHint === 'boolean') return raw === 'true';
|
|
80
|
+
if (typeof typeHint === 'number') {
|
|
81
|
+
const number = Number(raw);
|
|
82
|
+
if (!Number.isFinite(number)) fail(path, `Environment variable "${name}" must contain a finite number.`);
|
|
83
|
+
return number;
|
|
84
|
+
}
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) return value.map((entry, index) => walk(entry, `${path}.${index}`, seed?.[index]));
|
|
88
|
+
if (!isPlainObject(value)) return value;
|
|
89
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry, path ? `${path}.${key}` : key, seed?.[key])]));
|
|
90
|
+
};
|
|
91
|
+
const resolved = walk(config, '', defaults) as ResolvedConfig;
|
|
92
|
+
referenceNames.set(resolved, names);
|
|
93
|
+
return resolved;
|
|
94
|
+
};
|
package/src/config/runtime.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { HOME_PAGE_ID, HOME_PAGE_PATH } from '../registry/content-kinds';
|
|
|
14
14
|
import { ComponentListObject } from '../transformers/preview/types';
|
|
15
15
|
import { createCsfStoryPreviews } from '../transformers/utils/csf';
|
|
16
16
|
import { buildAndEvaluateModuleSync } from '../transformers/utils/module';
|
|
17
|
-
import {
|
|
17
|
+
import { ResolvedConfig, ConfigFileEntry, RuntimeConfig } from '../types/config';
|
|
18
18
|
import { Logger } from '../utils/logger';
|
|
19
19
|
import { parseMarkdown } from '../utils/markdown';
|
|
20
20
|
import { collectPageSlugSegments } from '../utils/pages';
|
|
@@ -26,7 +26,7 @@ import { normalizePageDeclaration } from './normalizers/page';
|
|
|
26
26
|
* Avoids importing the full Handoff class to prevent circular deps.
|
|
27
27
|
*/
|
|
28
28
|
interface HandoffContext {
|
|
29
|
-
config:
|
|
29
|
+
config: ResolvedConfig;
|
|
30
30
|
workingPath: string;
|
|
31
31
|
modulePath: string;
|
|
32
32
|
}
|
|
@@ -129,7 +129,7 @@ const discoverCsfPreviews = (component: ComponentListObject, handoff: HandoffCon
|
|
|
129
129
|
/**
|
|
130
130
|
* Expands catalog.include into item directories and removes duplicate paths.
|
|
131
131
|
*/
|
|
132
|
-
export const getCatalogItemDirectories = (config:
|
|
132
|
+
export const getCatalogItemDirectories = (config: ResolvedConfig, workingPath: string): string[] => {
|
|
133
133
|
const registered = [...(config.catalog?.include ?? [])];
|
|
134
134
|
|
|
135
135
|
const seen = new Set<string>();
|
package/src/config/validator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ResolvedConfig } from '../types/config';
|
|
2
2
|
import { Logger } from '../utils/logger';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -8,15 +8,13 @@ import { Logger } from '../utils/logger';
|
|
|
8
8
|
* @returns The validated config (same reference).
|
|
9
9
|
* @throws {Error} If required Figma credentials are missing.
|
|
10
10
|
*/
|
|
11
|
-
export const validateConfig = (config:
|
|
11
|
+
export const validateConfig = (config: ResolvedConfig): ResolvedConfig => {
|
|
12
12
|
// TODO: Check to see if the exported folder exists before we run start
|
|
13
|
-
if (!config.figma_project_id
|
|
14
|
-
// check to see if we can get this from the env
|
|
13
|
+
if (!config.figma_project_id) {
|
|
15
14
|
Logger.error('Figma Project ID missing. Please set HANDOFF_FIGMA_PROJECT_ID or run "handoff-app fetch".');
|
|
16
15
|
throw new Error('Cannot initialize configuration');
|
|
17
16
|
}
|
|
18
|
-
if (!config.dev_access_token
|
|
19
|
-
// check to see if we can get this from the env
|
|
17
|
+
if (!config.dev_access_token) {
|
|
20
18
|
Logger.error('Figma Access Token missing. Please set HANDOFF_DEV_ACCESS_TOKEN or run "handoff-app fetch".');
|
|
21
19
|
throw new Error('Cannot initialize configuration');
|
|
22
20
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
|
|
2
2
|
import 'dotenv/config';
|
|
3
|
-
import {
|
|
3
|
+
import { ResolvedConfig } from './types/config';
|
|
4
4
|
|
|
5
5
|
declare module 'handoff-app';
|
|
6
6
|
|
|
7
7
|
declare class Handoff {
|
|
8
|
-
config:
|
|
8
|
+
config: ResolvedConfig;
|
|
9
9
|
modulePath: string;
|
|
10
10
|
workingPath: string;
|
|
11
11
|
force: boolean;
|