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.
- package/README.md +130 -14
- 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/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- 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 +121 -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/runtime.js +8 -10
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/handlebars.d.ts +10 -2
- 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/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/css.d.ts +1 -0
- package/dist/transformers/preview/component/css.js +85 -47
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +19 -0
- package/dist/transformers/utils/build.js +83 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +54 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +380 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- 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/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- 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 +158 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +12 -7
- package/src/config/validator.ts +4 -6
- package/src/handlebars.ts +8 -2
- 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/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/css.ts +93 -50
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +80 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +51 -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/README.md
CHANGED
|
@@ -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
|
-
| `
|
|
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 {
|
|
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?:
|
|
23
|
+
config?: ResolvedConfig;
|
|
24
24
|
workingPath: string;
|
|
25
25
|
}
|
|
26
26
|
|
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,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
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
.filter((filePath) => fs.existsSync(filePath));
|
|
166
|
+
return pickExisting(CONFIG_FILE_PREFERENCE.map((fileName) => path.resolve(workingPath, fileName)));
|
|
167
|
+
};
|
|
119
168
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
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.
|
|
185
|
-
*
|
|
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
|
|
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):
|
|
299
|
+
export const initConfig = (configOverride?: Partial<Config>, context?: ConfigLoadContext): ResolvedConfig => {
|
|
192
300
|
return initConfigWithMetadata(configOverride, context).config;
|
|
193
301
|
};
|