handoff-app 2.0.0-7 → 2.0.0-9

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 (125) hide show
  1. package/README.md +130 -14
  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/catalog/normalize.js +3 -0
  8. package/dist/catalog/previews.js +5 -4
  9. package/dist/catalog/types.d.ts +8 -0
  10. package/dist/cli/auth/device.d.ts +6 -1
  11. package/dist/cli/auth/device.js +5 -2
  12. package/dist/cli/auth/store.d.ts +15 -6
  13. package/dist/cli/auth/store.js +72 -19
  14. package/dist/commands/login/index.d.ts +5 -0
  15. package/dist/commands/login/index.js +18 -6
  16. package/dist/commands/logout/index.d.ts +5 -0
  17. package/dist/commands/logout/index.js +54 -15
  18. package/dist/commands/types.d.ts +2 -0
  19. package/dist/commands/utils.d.ts +21 -7
  20. package/dist/commands/utils.js +11 -10
  21. package/dist/config/catalog-include.d.ts +2 -2
  22. package/dist/config/defaults.d.ts +3 -3
  23. package/dist/config/defaults.js +54 -48
  24. package/dist/config/env.d.ts +9 -0
  25. package/dist/config/env.js +43 -0
  26. package/dist/config/errors.d.ts +4 -0
  27. package/dist/config/errors.js +11 -0
  28. package/dist/config/from-env.d.ts +24 -0
  29. package/dist/config/from-env.js +7 -0
  30. package/dist/config/helpers.d.ts +2 -0
  31. package/dist/config/helpers.js +3 -1
  32. package/dist/config/index.d.ts +2 -1
  33. package/dist/config/index.js +6 -1
  34. package/dist/config/loader.d.ts +29 -11
  35. package/dist/config/loader.js +121 -46
  36. package/dist/config/resolve-env.d.ts +5 -0
  37. package/dist/config/resolve-env.js +102 -0
  38. package/dist/config/runtime.d.ts +3 -3
  39. package/dist/config/runtime.js +8 -10
  40. package/dist/config/validator.d.ts +2 -2
  41. package/dist/config/validator.js +2 -4
  42. package/dist/handlebars.d.ts +10 -2
  43. package/dist/index.d.ts +33 -4
  44. package/dist/index.js +60 -16
  45. package/dist/pipeline/index.d.ts +1 -1
  46. package/dist/pipeline/index.js +1 -1
  47. package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
  48. package/dist/registry/asset-storage/resolve.d.ts +2 -2
  49. package/dist/registry/asset-storage/resolve.js +4 -1
  50. package/dist/registry/checkout/index.js +4 -4
  51. package/dist/registry/connection.d.ts +28 -24
  52. package/dist/registry/connection.js +53 -67
  53. package/dist/registry/db/driver.d.ts +4 -11
  54. package/dist/registry/db/driver.js +4 -16
  55. package/dist/registry/db/index.d.ts +1 -1
  56. package/dist/registry/db/index.js +1 -2
  57. package/dist/registry/publish/index.js +4 -4
  58. package/dist/store/filesystem.d.ts +2 -2
  59. package/dist/transformers/plugins/ssr-render.js +40 -15
  60. package/dist/transformers/preview/component/css.d.ts +1 -0
  61. package/dist/transformers/preview/component/css.js +85 -47
  62. package/dist/transformers/preview/pattern/html.js +3 -1
  63. package/dist/transformers/preview/types.d.ts +13 -0
  64. package/dist/transformers/utils/build.d.ts +19 -0
  65. package/dist/transformers/utils/build.js +83 -1
  66. package/dist/transformers/utils/module.js +2 -1
  67. package/dist/types/config.d.ts +54 -29
  68. package/docs/api.md +31 -0
  69. package/docs/cli.md +2 -2
  70. package/package.json +1 -1
  71. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  72. package/src/app/components/Component/Preview.tsx +380 -189
  73. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  74. package/src/app/pages/account/tokens.tsx +3 -1
  75. package/src/app-builder/build.ts +10 -2
  76. package/src/app-builder/client-config.ts +5 -3
  77. package/src/app-builder/watchers.ts +18 -8
  78. package/src/cache/build-cache.ts +5 -1
  79. package/src/catalog/README.md +3 -0
  80. package/src/catalog/normalize.ts +2 -0
  81. package/src/catalog/previews.ts +6 -3
  82. package/src/catalog/types.ts +8 -0
  83. package/src/cli/auth/device.ts +8 -3
  84. package/src/cli/auth/store.ts +75 -18
  85. package/src/commands/login/index.ts +20 -6
  86. package/src/commands/logout/index.ts +74 -20
  87. package/src/commands/types.ts +2 -0
  88. package/src/commands/utils.ts +14 -5
  89. package/src/config/README.md +61 -1
  90. package/src/config/catalog-include.ts +2 -2
  91. package/src/config/defaults.ts +24 -14
  92. package/src/config/env.ts +41 -0
  93. package/src/config/errors.ts +7 -0
  94. package/src/config/from-env.ts +26 -0
  95. package/src/config/helpers.ts +4 -2
  96. package/src/config/index.ts +4 -1
  97. package/src/config/loader.ts +158 -50
  98. package/src/config/resolve-env.ts +94 -0
  99. package/src/config/runtime.ts +12 -7
  100. package/src/config/validator.ts +4 -6
  101. package/src/handlebars.ts +8 -2
  102. package/src/index.d.ts +2 -2
  103. package/src/index.ts +85 -8
  104. package/src/pipeline/index.ts +1 -1
  105. package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
  106. package/src/registry/asset-storage/resolve.ts +6 -3
  107. package/src/registry/checkout/index.ts +5 -5
  108. package/src/registry/connection.ts +72 -71
  109. package/src/registry/db/driver.ts +6 -17
  110. package/src/registry/db/index.ts +0 -1
  111. package/src/registry/publish/index.ts +5 -5
  112. package/src/store/filesystem.ts +2 -2
  113. package/src/transformers/plugins/ssr-render.ts +53 -16
  114. package/src/transformers/preview/component/css.ts +93 -50
  115. package/src/transformers/preview/pattern/html.ts +3 -1
  116. package/src/transformers/preview/types.ts +14 -0
  117. package/src/transformers/utils/build.ts +80 -0
  118. package/src/transformers/utils/module.ts +4 -2
  119. package/src/types/config.ts +51 -29
  120. package/templates/default/env.tpl +10 -0
  121. package/templates/default/gitignore.tpl +7 -0
  122. package/templates/default/handoff.config.js.tpl +21 -172
  123. package/templates/default/handoff.config.ts.tpl +21 -156
  124. package/templates/default/handoff.config.with-examples.js.tpl +20 -171
  125. package/templates/default/handoff.config.with-examples.ts.tpl +20 -155
@@ -7,10 +7,15 @@ Handles loading, validation, and resolution of handoff configuration.
7
7
  | File | Purpose |
8
8
  |------|---------|
9
9
  | `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
10
- | `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order), warns on conflicts, and merges overrides |
10
+ | `from-env.ts` | JSON-compatible environment markers and public types |
11
+ | `resolve-env.ts` | Resolves merged environment references and guards reference-only properties |
12
+ | `errors.ts` | Shared `HandoffConfigError` |
13
+ | `env.ts` | Reads `.env` at load, then `loadProfileEnv()` applies `.env.<profile>` on top |
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 |
11
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 |
12
16
  | `catalog-include.ts` | `isIncluded()` / `addToCatalog()` — registers a directory under `catalog.include` |
13
17
  | `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
18
+ | `helpers.ts` | `defineConfig()` — typed authoring; maps camelCase keys to the runtime keys |
14
19
  | `index.ts` | Barrel re-exports |
15
20
 
16
21
  ## Usage
@@ -31,3 +36,58 @@ When multiple main config files exist in the project root, Handoff picks the fir
31
36
  2. `handoff.config.js`
32
37
  3. `handoff.config.cjs`
33
38
  4. `handoff.config.json`
39
+
40
+ ## Profiles
41
+
42
+ `--profile <name>`, or `HANDOFF_PROFILE` when the flag is absent, adds one sidecar file. It sits beside the resolved base config: the base file name without its extension, then the profile name, then any config extension. So `handoff.config.ts` pairs with `handoff.config.<name>.*`, and a config named through `-c` keeps its own name. The same four extensions are probed in the same order. A selected profile that does not resolve is a `HandoffConfigError`.
43
+
44
+ `ConfigLoadContext.knownProfiles` widens what resolving means: a listed name loads the base config unchanged instead of failing. The CLI passes the saved registry login names for `login`, `logout`, `publish`, and `checkout`, where a profile selects the registry rather than a config layer. `profileNotFoundHint` is the caller's sentence for a name that matches neither. Everything else keeps the strict rule, so a typo cannot silently run with the base config.
45
+
46
+ ## Environment Files
47
+
48
+ `.env` is read once when `env.ts` loads, before the first `Handoff` is constructed. A selected profile then adds an optional `.env.<profile>` from the same directory. Precedence is the process environment, then the profile env file, then `.env`. The keys present at startup are recorded before `.env` is read and are never overwritten, so an inline `VAR=… handoff-app …` still wins.
49
+
50
+ `loadProfileEnv()` runs twice: in `Handoff.construct()` before `Logger.init()`, so the log level can come from a profile, and in `initConfigWithMetadata()` before the merged environment references resolve. Applying it twice changes nothing.
51
+
52
+ ## Layer Precedence
53
+
54
+ Every loaded file is normalized, then the layers are merged in this order:
55
+
56
+ 1. `defaultConfig()`
57
+ 2. Base config file
58
+ 3. Profile file
59
+ 4. Programmatic override (`HandoffOptions.config`)
60
+
61
+ Plain objects merge recursively. Arrays, scalars, `null`, and functions replace. `undefined` is skipped.
62
+
63
+ ## Environment references
64
+
65
+ Import `fromEnv` from `handoff-app`. `fromEnv('OUTPUT_DIR', { default: 'exported' })` creates
66
+ `{ $env: 'OUTPUT_DIR', default: 'exported' }` without reading the environment. JSON configs use
67
+ that same object. Without a default, an unset variable raises `HandoffConfigError` with the
68
+ property path, variable name, and active profile. Invalid names and rejected literals are never echoed.
69
+
70
+ `EnvValue<T>` accepts a literal or reference. `EnvSecret<T>` accepts only a reference and resolves
71
+ at config load. Secret defaults can only be empty or null, for optional credentials.
72
+ `RuntimeEnvReference<T>` accepts only a reference with no default. Its name survives config loading and
73
+ is read in the deployed process. Database URLs and asset-storage tokens are deferred.
74
+
75
+ The loader resolves references once after all layers merge, before other config guards.
76
+ References replace whole values, including earlier references and their defaults. A profile can
77
+ replace an ordinary reference with a literal. Functions in hooks and pipeline remain functions.
78
+ Defaults retain the existing `HANDOFF_*` seeds: a higher-layer literal beats a seed, and an explicit
79
+ reference beats every lower layer. Only the winning reference reads the environment.
80
+
81
+ Environment values are strings. A numeric or boolean default selects conversion; the built-in
82
+ numeric and boolean properties also retain their default's conversion when a reference omits its
83
+ fallback. Booleans are true only for `true`. Numbers must be finite. A lower-layer fallback never
84
+ becomes the fallback for a replacement reference.
85
+
86
+ `runtime.registry.assetStorage.options` is for non-secret values only. Its resolved contents are
87
+ JSON-encoded into the bundle, so the loader rejects an environment reference anywhere inside it
88
+ (`BAKED_PATHS`). For custom adapter secrets, put a variable name in options and read it through the
89
+ adapter factory's `env` argument at runtime. A per-environment literal belongs in a profile.
90
+
91
+ `ResolvedConfig` narrows eager properties by hand. Adding an eager property requires its `Config`
92
+ member and resolved narrowing. A secret also needs a `SECRET_PATHS` entry for untyped configs.
93
+ Adding a deferred property requires its member and a `DEFERRED_PATHS` entry.
@@ -14,13 +14,13 @@
14
14
 
15
15
  import fs from 'fs-extra';
16
16
  import path from 'path';
17
- import { Config } from '../types/config';
17
+ import { ResolvedConfig } from '../types/config';
18
18
  import { arePathsEqual } from '../utils/path';
19
19
  import { getComponentsForPath } from './runtime';
20
20
 
21
21
  /** Minimal handoff shape needed here; avoids importing the full Handoff class (circular dep). */
22
22
  interface ConfigContext {
23
- config?: Config;
23
+ config?: ResolvedConfig;
24
24
  workingPath: string;
25
25
  }
26
26
 
@@ -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,20 @@
1
1
  import esbuild from 'esbuild';
2
+ import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
2
3
  import fs from 'fs-extra';
3
- import merge from 'lodash/merge';
4
+ import mergeWith from 'lodash/mergeWith';
4
5
  import { createRequire } from 'module';
5
6
  import path from 'path';
6
- import { Config } from '../types/config';
7
+ import { Config, ResolvedConfig } from '../types/config';
7
8
  import { Logger } from '../utils/logger';
8
9
  import { resolveWorkingPath } from '../utils/path';
9
10
  import { defaultConfig } from './defaults';
11
+ import { loadProfileEnv } from './env';
12
+ import { normalizeConfig } from './helpers';
13
+ import { HandoffConfigError } from './errors';
14
+ import { isEnvReference } from './from-env';
15
+ import { resolveConfigEnv } from './resolve-env';
10
16
 
11
- const CONFIG_FILE_PREFERENCE = [
17
+ export const CONFIG_FILE_PREFERENCE = [
12
18
  'handoff.config.ts',
13
19
  'handoff.config.js',
14
20
  'handoff.config.cjs',
@@ -18,17 +24,17 @@ const CONFIG_FILE_PREFERENCE = [
18
24
  /** Extensions the loader knows how to evaluate, derived from the discoverable file names. */
19
25
  const SUPPORTED_CONFIG_EXTENSIONS = Array.from(new Set(CONFIG_FILE_PREFERENCE.map((fileName) => path.extname(fileName))));
20
26
 
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
- }
27
+ /** A profile name becomes part of a file name, so it stays lowercase letters, numbers, and hyphens. */
28
+ const PROFILE_NAME_PATTERN = /^[a-z0-9-]+$/;
29
+
30
+ export { HandoffConfigError } from './errors';
28
31
 
29
32
  type ConfigLoadResult = {
30
- config: Config;
33
+ config: ResolvedConfig;
31
34
  configPath?: string;
35
+ profile?: string;
36
+ /** The profile file merged onto the base config. */
37
+ profileConfigPath?: string;
32
38
  };
33
39
 
34
40
  /** Where to look for the config: the working directory, plus an optional explicit file. */
@@ -37,8 +43,21 @@ export type ConfigLoadContext = {
37
43
  workingPath?: string;
38
44
  /** Explicit config file (the CLI's `-c, --config`), replacing discovery entirely. */
39
45
  configPath?: string;
46
+ /** Profile to merge onto the base config (the CLI's `--profile`), overriding `HANDOFF_PROFILE`. */
47
+ profile?: string;
48
+ /**
49
+ * Profile names that resolve without a `handoff.config.<profile>.*` file, leaving the base config
50
+ * unchanged. Registry commands pass the names of saved logins, because there a profile selects the
51
+ * registry rather than a config layer.
52
+ */
53
+ knownProfiles?: string[];
54
+ /** Sentence appended when a selected profile matches neither a config file nor `knownProfiles`. */
55
+ profileNotFoundHint?: string;
40
56
  };
41
57
 
58
+ /** Which selector picked the profile, so an error can name the one the user set. */
59
+ type ProfileSelection = { name: string; source: '--profile' | 'HANDOFF_PROFILE' };
60
+
42
61
  const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string): any => {
43
62
  const buildResult = esbuild.buildSync({
44
63
  entryPoints: [filePath],
@@ -49,6 +68,7 @@ const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string):
49
68
  target: 'node16',
50
69
  logLevel: 'silent',
51
70
  external: ['handoff-app'],
71
+ loader: STYLE_IMPORT_LOADERS,
52
72
  });
53
73
 
54
74
  const code = buildResult.outputFiles?.[0]?.text;
@@ -91,6 +111,36 @@ const loadConfigFile = (configPath: string): Config => {
91
111
  return (importedConfig.default || importedConfig) as Config;
92
112
  };
93
113
 
114
+ /**
115
+ * Normalize here and not only in `defineConfig`: a `.json` config, or a profile that skips the
116
+ * helper, would otherwise keep its camelCase keys. The rest of the codebase reads the runtime keys,
117
+ * so a later layer could not override an earlier one.
118
+ */
119
+ const loadConfigLayer = (configPath: string): Partial<Config> => normalizeConfig(loadConfigFile(configPath));
120
+
121
+ const pickExisting = (candidates: string[]): { selected?: string; ignored: string[] } => {
122
+ const existing = candidates.filter((filePath) => fs.existsSync(filePath));
123
+
124
+ if (!existing.length) {
125
+ return { selected: undefined, ignored: [] };
126
+ }
127
+
128
+ const [selected, ...ignored] = existing;
129
+ return { selected, ignored };
130
+ };
131
+
132
+ const warnIgnored = (selected: string | undefined, ignored: string[]): void => {
133
+ if (!selected || ignored.length === 0) {
134
+ return;
135
+ }
136
+
137
+ Logger.warn(
138
+ `Multiple config files found. Using "${path.basename(selected)}" and ignoring: ${ignored
139
+ .map((filePath) => `"${path.basename(filePath)}"`)
140
+ .join(', ')}.`
141
+ );
142
+ };
143
+
94
144
  /**
95
145
  * Picks the config file to load. An explicitly requested file replaces discovery - it is resolved
96
146
  * from `workingPath` and must exist in a format the loader can evaluate. Otherwise every known
@@ -113,46 +163,111 @@ const resolveConfigFilePath = (workingPath: string, requestedPath?: string): { s
113
163
  return { selected, ignored: [] };
114
164
  }
115
165
 
116
- const existing = CONFIG_FILE_PREFERENCE
117
- .map((fileName) => path.resolve(workingPath, fileName))
118
- .filter((filePath) => fs.existsSync(filePath));
166
+ return pickExisting(CONFIG_FILE_PREFERENCE.map((fileName) => path.resolve(workingPath, fileName)));
167
+ };
119
168
 
120
- if (!existing.length) {
121
- return { selected: undefined, ignored: [] };
169
+ /** `--profile` wins over `HANDOFF_PROFILE`, and an empty value counts as unset. */
170
+ export const resolveProfileSelection = (requestedProfile?: string): ProfileSelection | undefined => {
171
+ const fromOption = requestedProfile?.trim();
172
+ if (fromOption) {
173
+ return { name: fromOption, source: '--profile' };
122
174
  }
123
175
 
124
- const [selected, ...ignored] = existing;
125
- return { selected, ignored };
176
+ const fromEnvironment = process.env.HANDOFF_PROFILE?.trim();
177
+ if (fromEnvironment) {
178
+ return { name: fromEnvironment, source: 'HANDOFF_PROFILE' };
179
+ }
180
+
181
+ return undefined;
126
182
  };
127
183
 
128
184
  /**
129
- * Loads the handoff configuration for the given working directory and returns metadata.
185
+ * Picks the profile file for a selected profile. It sits beside the base config: the base file name
186
+ * without its extension, then the profile name, then any config extension. So `handoff.config.ts`
187
+ * pairs with `handoff.config.local.*`, and a config named through `-c` keeps its own name. A selected
188
+ * profile has to resolve - silently falling back to the base config would deploy the wrong settings.
189
+ * A name listed in `knownProfiles` resolves without a file, because it names something else that
190
+ * exists, such as a saved registry login.
130
191
  */
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(', ')}.`
192
+ const resolveProfileFilePath = (
193
+ workingPath: string,
194
+ baseConfigPath: string | undefined,
195
+ selection: ProfileSelection,
196
+ context: ConfigLoadContext
197
+ ): string | undefined => {
198
+ if (!PROFILE_NAME_PATTERN.test(selection.name)) {
199
+ throw new HandoffConfigError(
200
+ `Invalid config profile "${selection.name}" (from ${selection.source}). ` +
201
+ `A profile name can contain lowercase letters, numbers, and hyphens.`
140
202
  );
141
203
  }
142
204
 
143
- if (configPath) {
144
- config = loadConfigFile(configPath);
205
+ const baseName = baseConfigPath
206
+ ? path.join(path.dirname(baseConfigPath), path.basename(baseConfigPath, path.extname(baseConfigPath)))
207
+ : path.resolve(workingPath, 'handoff.config');
208
+ const candidates = SUPPORTED_CONFIG_EXTENSIONS.map((extension) => `${baseName}.${selection.name}${extension}`);
209
+ const { selected, ignored } = pickExisting(candidates);
210
+
211
+ if (!selected) {
212
+ if (context.knownProfiles?.includes(selection.name)) {
213
+ return undefined;
214
+ }
215
+ throw new HandoffConfigError(
216
+ `Config profile "${selection.name}" (from ${selection.source}) not found. ` +
217
+ `Create one of: ${candidates.map((candidate) => path.basename(candidate)).join(', ')} in ${path.dirname(candidates[0])}.` +
218
+ (context.profileNotFoundHint ? ` ${context.profileNotFoundHint}` : '')
219
+ );
145
220
  }
146
221
 
147
- // Apply overrides if provided
222
+ warnIgnored(selected, ignored);
223
+ return selected;
224
+ };
225
+
226
+ /**
227
+ * Arrays and environment references are whole values: a later layer replaces them, including
228
+ * any reference default. Everything else keeps lodash semantics: plain
229
+ * objects merge recursively, scalars, `null` and functions replace, and `undefined` is skipped.
230
+ */
231
+ const replaceValues = (destination: unknown, source: unknown) => {
232
+ if (source === undefined) return undefined;
233
+ return Array.isArray(source) || isEnvReference(source) || isEnvReference(destination) ? source : undefined;
234
+ };
235
+
236
+ /**
237
+ * Loads the handoff configuration for the given working directory and returns metadata.
238
+ */
239
+ export const initConfigWithMetadata = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ConfigLoadResult => {
240
+ const workingPath = context?.workingPath ?? resolveWorkingPath();
241
+ const { selected: configPath, ignored } = resolveConfigFilePath(workingPath, context?.configPath);
242
+ warnIgnored(configPath, ignored);
243
+
244
+ const profileSelection = resolveProfileSelection(context?.profile);
245
+ const profileConfigPath = profileSelection ? resolveProfileFilePath(workingPath, configPath, profileSelection, context ?? {}) : undefined;
246
+
247
+ // Profile environment values must be present before the merged references resolve.
248
+ loadProfileEnv(profileSelection?.name);
249
+
250
+ const layers: Partial<Config>[] = [defaultConfig()];
251
+ if (configPath) {
252
+ layers.push(loadConfigLayer(configPath));
253
+ }
254
+ if (profileConfigPath) {
255
+ layers.push(loadConfigLayer(profileConfigPath));
256
+ }
148
257
  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
- });
258
+ layers.push(normalizeConfig(configOverride as Config));
154
259
  }
155
260
 
261
+ const profileLabel = profileSelection
262
+ ? `"${profileSelection.name}" (from ${profileSelection.source})${profileConfigPath ? '' : ' - no config file, the base config is used unchanged'}`
263
+ : 'none selected';
264
+ Logger.debug(`Config profile: ${profileLabel}`);
265
+ Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
266
+
267
+ const merged = mergeWith({}, ...layers, replaceValues) as Config;
268
+ const config = resolveConfigEnv(merged, profileSelection?.name, layers[0]);
269
+
270
+ // Guards run on the resolved config so a profile cannot slip a removed setting past them.
156
271
  const entries = config.entries as Record<string, unknown> | undefined;
157
272
  if (entries && ('components' in entries || 'patterns' in entries)) {
158
273
  throw new HandoffConfigError(
@@ -165,29 +280,22 @@ export const initConfigWithMetadata = (configOverride?: Partial<Config>, context
165
280
  'Item declarations support ts, js, and cjs. Convert JSON declarations to defineCatalogItem. See UPGRADE.md#catalog-items.'
166
281
  );
167
282
  }
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 };
283
+
284
+ return { config, configPath, profile: profileSelection?.name, profileConfigPath };
178
285
  };
179
286
 
180
287
  /**
181
288
  * Loads the handoff configuration for the given working directory.
182
289
  *
183
290
  * 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.
291
+ * unless `context.configPath` names one explicitly. A selected profile adds a `handoff.config.<profile>.*` sidecar,
292
+ * which only `context.knownProfiles` makes optional.
293
+ * Layers resolve as defaults, base config, profile, then the given overrides, merging plain objects recursively.
186
294
  *
187
295
  * @param configOverride - Optional partial config to override file-loaded values.
188
- * @param context - Optional working directory and explicit config file to load.
296
+ * @param context - Optional working directory, explicit config file, and profile to load.
189
297
  * @returns The fully resolved Config object.
190
298
  */
191
- export const initConfig = (configOverride?: Partial<Config>, context?: ConfigLoadContext): Config => {
299
+ export const initConfig = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ResolvedConfig => {
192
300
  return initConfigWithMetadata(configOverride, context).config;
193
301
  };