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.
Files changed (98) hide show
  1. package/README.md +111 -11
  2. package/UPGRADE.md +20 -0
  3. package/dist/app-builder/build.js +10 -2
  4. package/dist/app-builder/client-config.js +3 -1
  5. package/dist/app-builder/watchers.js +17 -7
  6. package/dist/cache/build-cache.js +5 -1
  7. package/dist/cli/auth/device.d.ts +6 -1
  8. package/dist/cli/auth/device.js +5 -2
  9. package/dist/cli/auth/store.d.ts +15 -6
  10. package/dist/cli/auth/store.js +72 -19
  11. package/dist/commands/login/index.d.ts +5 -0
  12. package/dist/commands/login/index.js +18 -6
  13. package/dist/commands/logout/index.d.ts +5 -0
  14. package/dist/commands/logout/index.js +54 -15
  15. package/dist/commands/types.d.ts +2 -0
  16. package/dist/commands/utils.d.ts +21 -7
  17. package/dist/commands/utils.js +11 -10
  18. package/dist/config/catalog-include.d.ts +2 -2
  19. package/dist/config/defaults.d.ts +3 -3
  20. package/dist/config/defaults.js +54 -48
  21. package/dist/config/env.d.ts +9 -0
  22. package/dist/config/env.js +43 -0
  23. package/dist/config/errors.d.ts +4 -0
  24. package/dist/config/errors.js +11 -0
  25. package/dist/config/from-env.d.ts +24 -0
  26. package/dist/config/from-env.js +7 -0
  27. package/dist/config/helpers.d.ts +2 -0
  28. package/dist/config/helpers.js +3 -1
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +6 -1
  31. package/dist/config/loader.d.ts +29 -11
  32. package/dist/config/loader.js +119 -46
  33. package/dist/config/resolve-env.d.ts +5 -0
  34. package/dist/config/resolve-env.js +102 -0
  35. package/dist/config/runtime.d.ts +3 -3
  36. package/dist/config/validator.d.ts +2 -2
  37. package/dist/config/validator.js +2 -4
  38. package/dist/index.d.ts +33 -4
  39. package/dist/index.js +60 -16
  40. package/dist/pipeline/index.d.ts +1 -1
  41. package/dist/pipeline/index.js +1 -1
  42. package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
  43. package/dist/registry/asset-storage/resolve.d.ts +2 -2
  44. package/dist/registry/asset-storage/resolve.js +4 -1
  45. package/dist/registry/checkout/index.js +4 -4
  46. package/dist/registry/connection.d.ts +28 -24
  47. package/dist/registry/connection.js +53 -67
  48. package/dist/registry/db/driver.d.ts +4 -11
  49. package/dist/registry/db/driver.js +4 -16
  50. package/dist/registry/db/index.d.ts +1 -1
  51. package/dist/registry/db/index.js +1 -2
  52. package/dist/registry/publish/index.js +4 -4
  53. package/dist/store/filesystem.d.ts +2 -2
  54. package/dist/types/config.d.ts +48 -29
  55. package/docs/api.md +31 -0
  56. package/docs/cli.md +2 -2
  57. package/package.json +1 -1
  58. package/src/app/pages/account/tokens.tsx +3 -1
  59. package/src/app-builder/build.ts +10 -2
  60. package/src/app-builder/client-config.ts +5 -3
  61. package/src/app-builder/watchers.ts +18 -8
  62. package/src/cache/build-cache.ts +5 -1
  63. package/src/cli/auth/device.ts +8 -3
  64. package/src/cli/auth/store.ts +75 -18
  65. package/src/commands/login/index.ts +20 -6
  66. package/src/commands/logout/index.ts +74 -20
  67. package/src/commands/types.ts +2 -0
  68. package/src/commands/utils.ts +14 -5
  69. package/src/config/README.md +61 -1
  70. package/src/config/catalog-include.ts +2 -2
  71. package/src/config/defaults.ts +24 -14
  72. package/src/config/env.ts +41 -0
  73. package/src/config/errors.ts +7 -0
  74. package/src/config/from-env.ts +26 -0
  75. package/src/config/helpers.ts +4 -2
  76. package/src/config/index.ts +4 -1
  77. package/src/config/loader.ts +156 -50
  78. package/src/config/resolve-env.ts +94 -0
  79. package/src/config/runtime.ts +3 -3
  80. package/src/config/validator.ts +4 -6
  81. package/src/index.d.ts +2 -2
  82. package/src/index.ts +85 -8
  83. package/src/pipeline/index.ts +1 -1
  84. package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
  85. package/src/registry/asset-storage/resolve.ts +6 -3
  86. package/src/registry/checkout/index.ts +5 -5
  87. package/src/registry/connection.ts +72 -71
  88. package/src/registry/db/driver.ts +6 -17
  89. package/src/registry/db/index.ts +0 -1
  90. package/src/registry/publish/index.ts +5 -5
  91. package/src/store/filesystem.ts +2 -2
  92. package/src/types/config.ts +45 -29
  93. package/templates/default/env.tpl +10 -0
  94. package/templates/default/gitignore.tpl +7 -0
  95. package/templates/default/handoff.config.js.tpl +21 -172
  96. package/templates/default/handoff.config.ts.tpl +21 -156
  97. package/templates/default/handoff.config.with-examples.js.tpl +20 -171
  98. package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
@@ -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 => config?.runtime?.mcp !== false;
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: process.env.HANDOFF_DEV_ACCESS_TOKEN ?? null,
17
- figma_project_id: process.env.HANDOFF_FIGMA_PROJECT_ID ?? null,
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: process.env.HANDOFF_OUTPUT_DIR ?? 'exported',
22
- sitesOutputDirectory: process.env.HANDOFF_SITES_DIR ?? 'out',
23
- useVariables: process.env.HANDOFF_USE_VARIABLES === 'true',
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: 'Convertiv Design System',
31
- client: 'Convertiv',
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: Number(process.env.HANDOFF_APP_PORT) || 3000,
58
- websocket: Number(process.env.HANDOFF_WEBSOCKET_PORT) || 3001
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: Config): ClientConfig => {
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
- } = { ...defaultConfig(), ...config };
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,7 @@
1
+ /** Raised when configuration cannot be loaded or resolved. */
2
+ export class HandoffConfigError extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = 'HandoffConfigError';
6
+ }
7
+ }
@@ -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');
@@ -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 ?? config.dev_access_token,
33
- figma_project_id: figmaProjectId ?? config.figma_project_id,
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);
@@ -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
@@ -1,14 +1,19 @@
1
1
  import esbuild from 'esbuild';
2
2
  import fs from 'fs-extra';
3
- import merge from 'lodash/merge';
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
- /** Raised when an explicitly requested config file cannot be used. */
22
- export class HandoffConfigError extends Error {
23
- constructor(message: string) {
24
- super(message);
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: 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
- const existing = CONFIG_FILE_PREFERENCE
117
- .map((fileName) => path.resolve(workingPath, fileName))
118
- .filter((filePath) => fs.existsSync(filePath));
164
+ return pickExisting(CONFIG_FILE_PREFERENCE.map((fileName) => path.resolve(workingPath, fileName)));
165
+ };
119
166
 
120
- if (!existing.length) {
121
- return { selected: undefined, ignored: [] };
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 [selected, ...ignored] = existing;
125
- return { selected, ignored };
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
- * Loads the handoff configuration for the given working directory and returns metadata.
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
- export const initConfigWithMetadata = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ConfigLoadResult => {
132
- let config: Partial<Config> = {};
133
- const { selected: configPath, ignored } = resolveConfigFilePath(context?.workingPath ?? resolveWorkingPath(), context?.configPath);
134
-
135
- if (ignored.length > 0 && configPath) {
136
- Logger.warn(
137
- `Multiple config files found. Using "${path.basename(configPath)}" and ignoring: ${ignored
138
- .map((filePath) => `"${path.basename(filePath)}"`)
139
- .join(', ')}.`
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
- if (configPath) {
144
- config = loadConfigFile(configPath);
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
- // Apply overrides if provided
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
- (Object.entries(configOverride) as [keyof Config, Config[keyof Config]][]).forEach(([key, value]) => {
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
- const defaults = defaultConfig();
169
- // Top-level merge stays shallow to preserve 1.x semantics, except for the `runtime`
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. Merges file config with any provided overrides,
185
- * then applies defaults for missing values.
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 and explicit config file to load.
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): Config => {
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
+ };
@@ -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 { Config, ConfigFileEntry, RuntimeConfig } from '../types/config';
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: 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: Config, workingPath: string): string[] => {
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>();
@@ -1,4 +1,4 @@
1
- import { Config } from '../types/config';
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: Config): 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 && !process.env.HANDOFF_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 && !process.env.HANDOFF_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 { Config } from './types/config';
3
+ import { ResolvedConfig } from './types/config';
4
4
 
5
5
  declare module 'handoff-app';
6
6
 
7
7
  declare class Handoff {
8
- config: Config;
8
+ config: ResolvedConfig;
9
9
  modulePath: string;
10
10
  workingPath: string;
11
11
  force: boolean;