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
@@ -3,31 +3,32 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.initConfig = exports.initConfigWithMetadata = exports.HandoffConfigError = void 0;
6
+ exports.initConfig = exports.initConfigWithMetadata = exports.resolveProfileSelection = exports.HandoffConfigError = exports.CONFIG_FILE_PREFERENCE = void 0;
7
7
  const esbuild_1 = __importDefault(require("esbuild"));
8
8
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
- const merge_1 = __importDefault(require("lodash/merge"));
9
+ const mergeWith_1 = __importDefault(require("lodash/mergeWith"));
10
10
  const module_1 = require("module");
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const logger_1 = require("../utils/logger");
13
13
  const path_2 = require("../utils/path");
14
14
  const defaults_1 = require("./defaults");
15
- const CONFIG_FILE_PREFERENCE = [
15
+ const env_1 = require("./env");
16
+ const helpers_1 = require("./helpers");
17
+ const errors_1 = require("./errors");
18
+ const from_env_1 = require("./from-env");
19
+ const resolve_env_1 = require("./resolve-env");
20
+ exports.CONFIG_FILE_PREFERENCE = [
16
21
  'handoff.config.ts',
17
22
  'handoff.config.js',
18
23
  'handoff.config.cjs',
19
24
  'handoff.config.json',
20
25
  ];
21
26
  /** Extensions the loader knows how to evaluate, derived from the discoverable file names. */
22
- const SUPPORTED_CONFIG_EXTENSIONS = Array.from(new Set(CONFIG_FILE_PREFERENCE.map((fileName) => path_1.default.extname(fileName))));
23
- /** Raised when an explicitly requested config file cannot be used. */
24
- class HandoffConfigError extends Error {
25
- constructor(message) {
26
- super(message);
27
- this.name = 'HandoffConfigError';
28
- }
29
- }
30
- exports.HandoffConfigError = HandoffConfigError;
27
+ const SUPPORTED_CONFIG_EXTENSIONS = Array.from(new Set(exports.CONFIG_FILE_PREFERENCE.map((fileName) => path_1.default.extname(fileName))));
28
+ /** A profile name becomes part of a file name, so it stays lowercase letters, numbers, and hyphens. */
29
+ const PROFILE_NAME_PATTERN = /^[a-z0-9-]+$/;
30
+ var errors_2 = require("./errors");
31
+ Object.defineProperty(exports, "HandoffConfigError", { enumerable: true, get: function () { return errors_2.HandoffConfigError; } });
31
32
  const evaluateTypeScriptConfig = (filePath, handoffModulePath) => {
32
33
  var _a, _b;
33
34
  const buildResult = esbuild_1.default.buildSync({
@@ -75,6 +76,28 @@ const loadConfigFile = (configPath) => {
75
76
  const importedConfig = require(configPath);
76
77
  return (importedConfig.default || importedConfig);
77
78
  };
79
+ /**
80
+ * Normalize here and not only in `defineConfig`: a `.json` config, or a profile that skips the
81
+ * helper, would otherwise keep its camelCase keys. The rest of the codebase reads the runtime keys,
82
+ * so a later layer could not override an earlier one.
83
+ */
84
+ const loadConfigLayer = (configPath) => (0, helpers_1.normalizeConfig)(loadConfigFile(configPath));
85
+ const pickExisting = (candidates) => {
86
+ const existing = candidates.filter((filePath) => fs_extra_1.default.existsSync(filePath));
87
+ if (!existing.length) {
88
+ return { selected: undefined, ignored: [] };
89
+ }
90
+ const [selected, ...ignored] = existing;
91
+ return { selected, ignored };
92
+ };
93
+ const warnIgnored = (selected, ignored) => {
94
+ if (!selected || ignored.length === 0) {
95
+ return;
96
+ }
97
+ logger_1.Logger.warn(`Multiple config files found. Using "${path_1.default.basename(selected)}" and ignoring: ${ignored
98
+ .map((filePath) => `"${path_1.default.basename(filePath)}"`)
99
+ .join(', ')}.`);
100
+ };
78
101
  /**
79
102
  * Picks the config file to load. An explicitly requested file replaces discovery - it is resolved
80
103
  * from `workingPath` and must exist in a format the loader can evaluate. Otherwise every known
@@ -84,70 +107,120 @@ const resolveConfigFilePath = (workingPath, requestedPath) => {
84
107
  if (requestedPath) {
85
108
  const selected = path_1.default.resolve(workingPath, requestedPath);
86
109
  if (!fs_extra_1.default.existsSync(selected)) {
87
- throw new HandoffConfigError(`Config file not found: "${requestedPath}" (resolved to ${selected})`);
110
+ throw new errors_1.HandoffConfigError(`Config file not found: "${requestedPath}" (resolved to ${selected})`);
88
111
  }
89
112
  if (!SUPPORTED_CONFIG_EXTENSIONS.includes(path_1.default.extname(selected))) {
90
- throw new HandoffConfigError(`Unsupported config file "${path_1.default.basename(selected)}". Supported extensions: ${SUPPORTED_CONFIG_EXTENSIONS.join(', ')}`);
113
+ throw new errors_1.HandoffConfigError(`Unsupported config file "${path_1.default.basename(selected)}". Supported extensions: ${SUPPORTED_CONFIG_EXTENSIONS.join(', ')}`);
91
114
  }
92
115
  return { selected, ignored: [] };
93
116
  }
94
- const existing = CONFIG_FILE_PREFERENCE
95
- .map((fileName) => path_1.default.resolve(workingPath, fileName))
96
- .filter((filePath) => fs_extra_1.default.existsSync(filePath));
97
- if (!existing.length) {
98
- return { selected: undefined, ignored: [] };
117
+ return pickExisting(exports.CONFIG_FILE_PREFERENCE.map((fileName) => path_1.default.resolve(workingPath, fileName)));
118
+ };
119
+ /** `--profile` wins over `HANDOFF_PROFILE`, and an empty value counts as unset. */
120
+ const resolveProfileSelection = (requestedProfile) => {
121
+ var _a;
122
+ const fromOption = requestedProfile === null || requestedProfile === void 0 ? void 0 : requestedProfile.trim();
123
+ if (fromOption) {
124
+ return { name: fromOption, source: '--profile' };
99
125
  }
100
- const [selected, ...ignored] = existing;
101
- return { selected, ignored };
126
+ const fromEnvironment = (_a = process.env.HANDOFF_PROFILE) === null || _a === void 0 ? void 0 : _a.trim();
127
+ if (fromEnvironment) {
128
+ return { name: fromEnvironment, source: 'HANDOFF_PROFILE' };
129
+ }
130
+ return undefined;
131
+ };
132
+ exports.resolveProfileSelection = resolveProfileSelection;
133
+ /**
134
+ * Picks the profile file for a selected profile. It sits beside the base config: the base file name
135
+ * without its extension, then the profile name, then any config extension. So `handoff.config.ts`
136
+ * pairs with `handoff.config.local.*`, and a config named through `-c` keeps its own name. A selected
137
+ * profile has to resolve - silently falling back to the base config would deploy the wrong settings.
138
+ * A name listed in `knownProfiles` resolves without a file, because it names something else that
139
+ * exists, such as a saved registry login.
140
+ */
141
+ const resolveProfileFilePath = (workingPath, baseConfigPath, selection, context) => {
142
+ var _a;
143
+ if (!PROFILE_NAME_PATTERN.test(selection.name)) {
144
+ throw new errors_1.HandoffConfigError(`Invalid config profile "${selection.name}" (from ${selection.source}). ` +
145
+ `A profile name can contain lowercase letters, numbers, and hyphens.`);
146
+ }
147
+ const baseName = baseConfigPath
148
+ ? path_1.default.join(path_1.default.dirname(baseConfigPath), path_1.default.basename(baseConfigPath, path_1.default.extname(baseConfigPath)))
149
+ : path_1.default.resolve(workingPath, 'handoff.config');
150
+ const candidates = SUPPORTED_CONFIG_EXTENSIONS.map((extension) => `${baseName}.${selection.name}${extension}`);
151
+ const { selected, ignored } = pickExisting(candidates);
152
+ if (!selected) {
153
+ if ((_a = context.knownProfiles) === null || _a === void 0 ? void 0 : _a.includes(selection.name)) {
154
+ return undefined;
155
+ }
156
+ throw new errors_1.HandoffConfigError(`Config profile "${selection.name}" (from ${selection.source}) not found. ` +
157
+ `Create one of: ${candidates.map((candidate) => path_1.default.basename(candidate)).join(', ')} in ${path_1.default.dirname(candidates[0])}.` +
158
+ (context.profileNotFoundHint ? ` ${context.profileNotFoundHint}` : ''));
159
+ }
160
+ warnIgnored(selected, ignored);
161
+ return selected;
162
+ };
163
+ /**
164
+ * Arrays and environment references are whole values: a later layer replaces them, including
165
+ * any reference default. Everything else keeps lodash semantics: plain
166
+ * objects merge recursively, scalars, `null` and functions replace, and `undefined` is skipped.
167
+ */
168
+ const replaceValues = (destination, source) => {
169
+ if (source === undefined)
170
+ return undefined;
171
+ return Array.isArray(source) || (0, from_env_1.isEnvReference)(source) || (0, from_env_1.isEnvReference)(destination) ? source : undefined;
102
172
  };
103
173
  /**
104
174
  * Loads the handoff configuration for the given working directory and returns metadata.
105
175
  */
106
176
  const initConfigWithMetadata = (configOverride, context) => {
107
177
  var _a, _b, _c;
108
- let config = {};
109
- const { selected: configPath, ignored } = resolveConfigFilePath((_a = context === null || context === void 0 ? void 0 : context.workingPath) !== null && _a !== void 0 ? _a : (0, path_2.resolveWorkingPath)(), context === null || context === void 0 ? void 0 : context.configPath);
110
- if (ignored.length > 0 && configPath) {
111
- logger_1.Logger.warn(`Multiple config files found. Using "${path_1.default.basename(configPath)}" and ignoring: ${ignored
112
- .map((filePath) => `"${path_1.default.basename(filePath)}"`)
113
- .join(', ')}.`);
114
- }
178
+ const workingPath = (_a = context === null || context === void 0 ? void 0 : context.workingPath) !== null && _a !== void 0 ? _a : (0, path_2.resolveWorkingPath)();
179
+ const { selected: configPath, ignored } = resolveConfigFilePath(workingPath, context === null || context === void 0 ? void 0 : context.configPath);
180
+ warnIgnored(configPath, ignored);
181
+ const profileSelection = (0, exports.resolveProfileSelection)(context === null || context === void 0 ? void 0 : context.profile);
182
+ const profileConfigPath = profileSelection ? resolveProfileFilePath(workingPath, configPath, profileSelection, context !== null && context !== void 0 ? context : {}) : undefined;
183
+ // Profile environment values must be present before the merged references resolve.
184
+ (0, env_1.loadProfileEnv)(profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name);
185
+ const layers = [(0, defaults_1.defaultConfig)()];
115
186
  if (configPath) {
116
- config = loadConfigFile(configPath);
187
+ layers.push(loadConfigLayer(configPath));
188
+ }
189
+ if (profileConfigPath) {
190
+ layers.push(loadConfigLayer(profileConfigPath));
117
191
  }
118
- // Apply overrides if provided
119
192
  if (configOverride) {
120
- Object.entries(configOverride).forEach(([key, value]) => {
121
- if (value !== undefined) {
122
- config[key] = value;
123
- }
124
- });
193
+ layers.push((0, helpers_1.normalizeConfig)(configOverride));
125
194
  }
195
+ const profileLabel = profileSelection
196
+ ? `"${profileSelection.name}" (from ${profileSelection.source})${profileConfigPath ? '' : ' - no config file, the base config is used unchanged'}`
197
+ : 'none selected';
198
+ logger_1.Logger.debug(`Config profile: ${profileLabel}`);
199
+ logger_1.Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
200
+ const merged = (0, mergeWith_1.default)({}, ...layers, replaceValues);
201
+ const config = (0, resolve_env_1.resolveConfigEnv)(merged, profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name, layers[0]);
202
+ // Guards run on the resolved config so a profile cannot slip a removed setting past them.
126
203
  const entries = config.entries;
127
204
  if (entries && ('components' in entries || 'patterns' in entries)) {
128
- throw new HandoffConfigError('entries.components and entries.patterns were removed. Use catalog.include. See UPGRADE.md#catalog-items.');
205
+ throw new errors_1.HandoffConfigError('entries.components and entries.patterns were removed. Use catalog.include. See UPGRADE.md#catalog-items.');
129
206
  }
130
207
  const format = (_c = (_b = config.runtime) === null || _b === void 0 ? void 0 : _b.workspace) === null || _c === void 0 ? void 0 : _c.declarationFormat;
131
208
  if (format !== undefined && !['ts', 'js', 'cjs'].includes(format)) {
132
- throw new HandoffConfigError('Item declarations support ts, js, and cjs. Convert JSON declarations to defineCatalogItem. See UPGRADE.md#catalog-items.');
209
+ throw new errors_1.HandoffConfigError('Item declarations support ts, js, and cjs. Convert JSON declarations to defineCatalogItem. See UPGRADE.md#catalog-items.');
133
210
  }
134
- const defaults = (0, defaults_1.defaultConfig)();
135
- // Top-level merge stays shallow to preserve 1.x semantics, except for the `runtime`
136
- // block which is deep-merged so a partial user `runtime` (e.g. only `mode`) does not
137
- // wipe the defaults supplied for the rest of the block.
138
- const resolvedConfig = Object.assign(Object.assign(Object.assign({}, defaults), config), { runtime: (0, merge_1.default)({}, defaults.runtime, config.runtime) });
139
- return { config: resolvedConfig, configPath };
211
+ return { config, configPath, profile: profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name, profileConfigPath };
140
212
  };
141
213
  exports.initConfigWithMetadata = initConfigWithMetadata;
142
214
  /**
143
215
  * Loads the handoff configuration for the given working directory.
144
216
  *
145
217
  * Searches for config files in order: handoff.config.ts, handoff.config.js, handoff.config.cjs, handoff.config.json,
146
- * unless `context.configPath` names one explicitly. Merges file config with any provided overrides,
147
- * then applies defaults for missing values.
218
+ * unless `context.configPath` names one explicitly. A selected profile adds a `handoff.config.<profile>.*` sidecar,
219
+ * which only `context.knownProfiles` makes optional.
220
+ * Layers resolve as defaults, base config, profile, then the given overrides, merging plain objects recursively.
148
221
  *
149
222
  * @param configOverride - Optional partial config to override file-loaded values.
150
- * @param context - Optional working directory and explicit config file to load.
223
+ * @param context - Optional working directory, explicit config file, and profile to load.
151
224
  * @returns The fully resolved Config object.
152
225
  */
153
226
  const initConfig = (configOverride, context) => {
@@ -0,0 +1,5 @@
1
+ import type { Config, ResolvedConfig } from '../types/config';
2
+ export declare const DEFERRED_PATHS: Set<string>;
3
+ export declare const configEnvName: (config: ResolvedConfig | null | undefined, path: string) => string | undefined;
4
+ /** Resolve only the merged winner. Functions and non-plain objects retain their identity. */
5
+ export declare const resolveConfigEnv: (config: Config, profile?: string, defaults?: Config) => ResolvedConfig;
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveConfigEnv = exports.configEnvName = exports.DEFERRED_PATHS = void 0;
7
+ const isPlainObject_1 = __importDefault(require("lodash/isPlainObject"));
8
+ const logger_1 = require("../utils/logger");
9
+ const from_env_1 = require("./from-env");
10
+ const errors_1 = require("./errors");
11
+ exports.DEFERRED_PATHS = new Set(['runtime.registry.databaseUrl', 'runtime.registry.assetStorage.token']);
12
+ const SECRET_PATHS = new Set(['dev_access_token', 'devAccessToken', 'runtime.registryConnection.accessToken']);
13
+ /** Path prefixes whose resolved contents are JSON-baked into the bundle, so a reference would bake its value. */
14
+ const BAKED_PATHS = ['runtime.registry.assetStorage.options'];
15
+ const isBaked = (path) => BAKED_PATHS.some((baked) => path === baked || path.startsWith(`${baked}.`));
16
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
+ const ALIASES = {
18
+ HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
19
+ HANDOFF_REGISTRY_ACCESS_TOKEN: 'HANDOFF_CLOUD_TOKEN',
20
+ };
21
+ const warnedAliases = new Set();
22
+ const referenceNames = new WeakMap();
23
+ const configEnvName = (config, path) => { var _a; return (_a = referenceNames.get(config)) === null || _a === void 0 ? void 0 : _a.get(path); };
24
+ exports.configEnvName = configEnvName;
25
+ const readEnv = (name) => {
26
+ const value = process.env[name];
27
+ const alias = ALIASES[name];
28
+ if (!alias || (value === null || value === void 0 ? void 0 : value.trim()))
29
+ return value;
30
+ const inherited = process.env[alias];
31
+ if (!(inherited === null || inherited === void 0 ? void 0 : inherited.trim()))
32
+ return value;
33
+ if (!warnedAliases.has(alias)) {
34
+ warnedAliases.add(alias);
35
+ logger_1.Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
36
+ }
37
+ return inherited;
38
+ };
39
+ /** Resolve only the merged winner. Functions and non-plain objects retain their identity. */
40
+ const resolveConfigEnv = (config, profile, defaults) => {
41
+ const names = new Map();
42
+ const fail = (path, message) => {
43
+ throw new errors_1.HandoffConfigError(`Config "${path}" (profile "${profile !== null && profile !== void 0 ? profile : 'default'}"): ${message}`);
44
+ };
45
+ const walk = (value, path, seed) => {
46
+ var _a;
47
+ const deferred = exports.DEFERRED_PATHS.has(path);
48
+ const secret = SECRET_PATHS.has(path);
49
+ if ((secret || deferred) && value !== undefined && !(0, from_env_1.isEnvReference)(value)) {
50
+ fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
51
+ }
52
+ if ((0, from_env_1.isEnvReference)(value)) {
53
+ // Checked before the name, so the message names the supported pattern instead of the reference shape.
54
+ if (isBaked(path)) {
55
+ fail(path, 'Environment references are not allowed here because the resolved value is baked into the build. ' +
56
+ "Put the variable name in options and read it through the adapter factory's `env` argument.");
57
+ }
58
+ if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
59
+ fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
60
+ }
61
+ const name = value.$env;
62
+ names.set(path, name);
63
+ if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
64
+ fail(path, `Environment variable "${name}" has an invalid reference shape.`);
65
+ }
66
+ if (deferred) {
67
+ if ('default' in value)
68
+ fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
69
+ return { $env: name };
70
+ }
71
+ // Secret defaults can mark an optional credential, but cannot embed a credential value.
72
+ if (secret && value.default !== undefined && value.default !== null && value.default !== '') {
73
+ fail(path, `Environment variable "${name}" cannot have a literal secret default.`);
74
+ }
75
+ const raw = readEnv(name);
76
+ if (raw === undefined) {
77
+ if (Object.prototype.hasOwnProperty.call(value, 'default'))
78
+ return secret ? value.default : walk(value.default, path, seed);
79
+ fail(path, `Environment variable "${name}" is not set and has no default.`);
80
+ }
81
+ const typeHint = (_a = value.default) !== null && _a !== void 0 ? _a : ((0, from_env_1.isEnvReference)(seed) ? seed.default : seed);
82
+ if (typeof typeHint === 'boolean')
83
+ return raw === 'true';
84
+ if (typeof typeHint === 'number') {
85
+ const number = Number(raw);
86
+ if (!Number.isFinite(number))
87
+ fail(path, `Environment variable "${name}" must contain a finite number.`);
88
+ return number;
89
+ }
90
+ return raw;
91
+ }
92
+ if (Array.isArray(value))
93
+ return value.map((entry, index) => walk(entry, `${path}.${index}`, seed === null || seed === void 0 ? void 0 : seed[index]));
94
+ if (!(0, isPlainObject_1.default)(value))
95
+ return value;
96
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry, path ? `${path}.${key}` : key, seed === null || seed === void 0 ? void 0 : seed[key])]));
97
+ };
98
+ const resolved = walk(config, '', defaults);
99
+ referenceNames.set(resolved, names);
100
+ return resolved;
101
+ };
102
+ exports.resolveConfigEnv = resolveConfigEnv;
@@ -1,10 +1,10 @@
1
- import { Config, ConfigFileEntry, RuntimeConfig } from '../types/config';
1
+ import { ResolvedConfig, ConfigFileEntry, RuntimeConfig } from '../types/config';
2
2
  /**
3
3
  * Handoff instance shape needed by initRuntimeConfig.
4
4
  * Avoids importing the full Handoff class to prevent circular deps.
5
5
  */
6
6
  interface HandoffContext {
7
- config: Config;
7
+ config: ResolvedConfig;
8
8
  workingPath: string;
9
9
  modulePath: string;
10
10
  }
@@ -15,7 +15,7 @@ export declare const resolveComponentDeclaration: (componentDir: string, compone
15
15
  /**
16
16
  * Expands catalog.include into item directories and removes duplicate paths.
17
17
  */
18
- export declare const getCatalogItemDirectories: (config: Config, workingPath: string) => string[];
18
+ export declare const getCatalogItemDirectories: (config: ResolvedConfig, workingPath: string) => string[];
19
19
  /**
20
20
  * Initializes the runtime configuration by resolving component entries,
21
21
  * SCSS/JS paths, and transformer options from the handoff config.
@@ -1,4 +1,4 @@
1
- import { Config } from '../types/config';
1
+ import { ResolvedConfig } from '../types/config';
2
2
  /**
3
3
  * Validates that the configuration has the required Figma credentials.
4
4
  *
@@ -6,4 +6,4 @@ import { Config } from '../types/config';
6
6
  * @returns The validated config (same reference).
7
7
  * @throws {Error} If required Figma credentials are missing.
8
8
  */
9
- export declare const validateConfig: (config: Config) => Config;
9
+ export declare const validateConfig: (config: ResolvedConfig) => ResolvedConfig;
@@ -11,13 +11,11 @@ const logger_1 = require("../utils/logger");
11
11
  */
12
12
  const validateConfig = (config) => {
13
13
  // TODO: Check to see if the exported folder exists before we run start
14
- if (!config.figma_project_id && !process.env.HANDOFF_FIGMA_PROJECT_ID) {
15
- // check to see if we can get this from the env
14
+ if (!config.figma_project_id) {
16
15
  logger_1.Logger.error('Figma Project ID missing. Please set HANDOFF_FIGMA_PROJECT_ID or run "handoff-app fetch".');
17
16
  throw new Error('Cannot initialize configuration');
18
17
  }
19
- if (!config.dev_access_token && !process.env.HANDOFF_DEV_ACCESS_TOKEN) {
20
- // check to see if we can get this from the env
18
+ if (!config.dev_access_token) {
21
19
  logger_1.Logger.error('Figma Access Token missing. Please set HANDOFF_DEV_ACCESS_TOKEN or run "handoff-app fetch".');
22
20
  throw new Error('Cannot initialize configuration');
23
21
  }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- import 'dotenv/config';
1
+ import './config/env';
2
2
  import { Types as CoreTypes, Handoff as HandoffRunner } from 'handoff-core';
3
3
  import { type BuildPackage, type BuildTarget } from './app-builder';
4
4
  import { type RegistryEntityKind } from './registry/content-kinds';
5
5
  import type { TransferEntityKind } from './registry/transfer';
6
6
  import { type HandoffStore } from './store';
7
- import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
7
+ import { Config, ResolvedConfig, ConfigFileEntry, RuntimeConfig } from './types/config';
8
8
  /** How a {@link Handoff} instance is set up. */
9
9
  export interface HandoffOptions {
10
10
  debug?: boolean;
@@ -21,6 +21,19 @@ export interface HandoffOptions {
21
21
  config?: Partial<Config>;
22
22
  /** Explicit config file (the CLI's `-c, --config`), resolved from the working path. */
23
23
  configPath?: string;
24
+ /**
25
+ * Config profile to merge onto the base config (the CLI's `--profile`). It overrides
26
+ * `HANDOFF_PROFILE`, and a selected profile has to resolve to a `handoff.config.<profile>.*` file
27
+ * unless {@link profileWithoutConfig} widens that.
28
+ */
29
+ profile?: string;
30
+ /**
31
+ * What a selected profile may resolve to when it has no config file. `'saved-login'` accepts a name
32
+ * a saved registry login uses, so publish and checkout can address a registry that no config file
33
+ * describes. `'any'` accepts any valid name, for the `login` command that creates the login. Unset,
34
+ * a profile must name a config file, so a typo cannot silently run with the base config.
35
+ */
36
+ profileWithoutConfig?: 'saved-login' | 'any';
24
37
  }
25
38
  /**
26
39
  * A content kind to act on, optionally narrowed to specific ids. The plain form is the common case
@@ -31,7 +44,7 @@ export type ContentTarget = RegistryEntityKind | {
31
44
  ids?: string[];
32
45
  };
33
46
  declare class Handoff {
34
- config: Config | null;
47
+ config: ResolvedConfig | null;
35
48
  debug: boolean;
36
49
  force: boolean;
37
50
  dryRun: boolean;
@@ -56,10 +69,18 @@ declare class Handoff {
56
69
  private _configFilePaths;
57
70
  private _configFileIndex;
58
71
  private _mainConfigFilePath?;
72
+ private _profileConfigFilePath?;
73
+ private _profile?;
59
74
  private _documentationObjectCache?;
60
75
  private _handoffRunner?;
61
76
  constructor(options?: HandoffOptions);
62
77
  private construct;
78
+ /**
79
+ * What a selected profile may resolve to besides a config file, and how to explain a name that
80
+ * resolves to nothing. A command that does not accept a login still names one it found, so the
81
+ * difference between the two kinds of profile is visible rather than puzzling.
82
+ */
83
+ private profilesWithoutConfig;
63
84
  init(configOverride?: Partial<Config>): Handoff;
64
85
  reload(): Handoff;
65
86
  preRunner(validate?: boolean): Handoff;
@@ -215,6 +236,12 @@ declare class Handoff {
215
236
  * Gets the selected main config file path if one exists.
216
237
  */
217
238
  getMainConfigFilePath(): string | undefined;
239
+ getProfile(): string | undefined;
240
+ /**
241
+ * True for the base config file and for the profile file merged onto it. Both carry project-wide
242
+ * settings, so a change to either one invalidates every entity rather than a single declaration.
243
+ */
244
+ isMainConfigFile(filePath: string): boolean;
218
245
  getConfigFileEntry(configPath: string): ConfigFileEntry | undefined;
219
246
  /**
220
247
  * Clears all cached data
@@ -228,8 +255,10 @@ declare class Handoff {
228
255
  */
229
256
  private readJsonFile;
230
257
  }
231
- export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
258
+ export type { Config, ResolvedConfig, RegisterHandlebarsHelpersContext } from './types/config';
232
259
  export { defineConfig } from './config';
260
+ export { fromEnv } from './config/from-env';
261
+ export type { EnvValue, EnvSecret, RuntimeEnvReference } from './config/from-env';
233
262
  export type { CatalogItem, CatalogItemEntries, CatalogItemInput, CatalogItemMeta, CatalogPreview, CompositionRef, Preview, SourceDescriptor, SourceFormat, RendererKind, } from './catalog';
234
263
  export { defineAssetStorage } from './registry/asset-storage/define';
235
264
  export type { AssetStorage, AssetStorageContext, AssetStorageFactory, AssetStorageInput, AssetStorageReadResult, } from './registry/asset-storage/types';
package/dist/index.js CHANGED
@@ -35,18 +35,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  return (mod && mod.__esModule) ? mod : { "default": mod };
36
36
  };
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.CoreTypes = exports.CoreTransformerUtils = exports.CoreTransformers = exports.defineAssetStorage = exports.defineConfig = void 0;
39
- require("dotenv/config");
38
+ exports.CoreTypes = exports.CoreTransformerUtils = exports.CoreTransformers = exports.defineAssetStorage = exports.fromEnv = exports.defineConfig = void 0;
39
+ require("./config/env");
40
40
  const fs_extra_1 = __importDefault(require("fs-extra"));
41
41
  const handoff_core_1 = require("handoff-core");
42
42
  const path_1 = __importDefault(require("path"));
43
43
  const app_builder_1 = __importStar(require("./app-builder"));
44
44
  const eject_1 = require("./cli/eject");
45
45
  const make_1 = require("./cli/make");
46
+ const store_1 = require("./cli/auth/store");
46
47
  const config_1 = require("./config");
47
48
  const pipeline_1 = __importStar(require("./pipeline"));
48
49
  const content_kinds_1 = require("./registry/content-kinds");
49
- const store_1 = require("./store");
50
+ const store_2 = require("./store");
50
51
  const builder_1 = __importStar(require("./transformers/preview/component/builder"));
51
52
  const logger_1 = require("./utils/logger");
52
53
  const path_2 = require("./utils/path");
@@ -114,7 +115,7 @@ class Handoff {
114
115
  */
115
116
  get store() {
116
117
  if (!this._store) {
117
- this._store = (0, store_1.createFilesystemStore)(this);
118
+ this._store = (0, store_2.createFilesystemStore)(this);
118
119
  }
119
120
  return this._store;
120
121
  }
@@ -134,24 +135,52 @@ class Handoff {
134
135
  this.construct(options);
135
136
  }
136
137
  construct(options) {
137
- var _a, _b, _c, _d;
138
- this.config = null;
139
- this.debug = (_a = options.debug) !== null && _a !== void 0 ? _a : false;
140
- this.force = (_b = options.force) !== null && _b !== void 0 ? _b : false;
141
- this.dryRun = (_c = options.dryRun) !== null && _c !== void 0 ? _c : false;
142
- this.skipBuild = (_d = options.skipBuild) !== null && _d !== void 0 ? _d : false;
138
+ var _a, _b, _c, _d, _e;
139
+ // Ahead of `Logger.init` so a profile env file can set `HANDOFF_LOG_LEVEL` and `HANDOFF_LOG_SCOPES`.
140
+ (0, config_1.loadProfileEnv)((_a = (0, config_1.resolveProfileSelection)(options.profile)) === null || _a === void 0 ? void 0 : _a.name);
141
+ this.debug = (_b = options.debug) !== null && _b !== void 0 ? _b : false;
142
+ this.force = (_c = options.force) !== null && _c !== void 0 ? _c : false;
143
+ this.dryRun = (_d = options.dryRun) !== null && _d !== void 0 ? _d : false;
144
+ this.skipBuild = (_e = options.skipBuild) !== null && _e !== void 0 ? _e : false;
143
145
  logger_1.Logger.init({ debug: this.debug });
144
146
  this.init(options.config);
145
147
  }
148
+ /**
149
+ * What a selected profile may resolve to besides a config file, and how to explain a name that
150
+ * resolves to nothing. A command that does not accept a login still names one it found, so the
151
+ * difference between the two kinds of profile is visible rather than puzzling.
152
+ */
153
+ profilesWithoutConfig() {
154
+ var _a;
155
+ const selected = (_a = (0, config_1.resolveProfileSelection)(this._options.profile)) === null || _a === void 0 ? void 0 : _a.name;
156
+ if (!selected) {
157
+ return {};
158
+ }
159
+ const accepts = this._options.profileWithoutConfig;
160
+ const names = (0, store_1.loginProfileNames)(this.workingPath);
161
+ if (!accepts) {
162
+ return names.includes(selected)
163
+ ? {
164
+ profileNotFoundHint: 'A saved registry login uses that name, but only login, logout, publish, and checkout accept a profile without a config file.',
165
+ }
166
+ : {};
167
+ }
168
+ return {
169
+ knownProfiles: accepts === 'any' ? [...names, selected] : names,
170
+ profileNotFoundHint: 'No saved registry login uses that name either. Run `handoff-app login --profile <name> --url <registry-url>` to create one.',
171
+ };
172
+ }
146
173
  init(configOverride) {
147
174
  var _a, _b, _c;
148
- const configResult = (0, config_1.initConfigWithMetadata)(configOverride !== null && configOverride !== void 0 ? configOverride : {}, {
149
- workingPath: this.workingPath,
150
- configPath: this._options.configPath,
151
- });
175
+ // Resolved before anything is assigned: a reload that throws - a deleted profile file, a
176
+ // broken config - then leaves the last good state in place for the watchers holding this
177
+ // instance.
178
+ const configResult = (0, config_1.initConfigWithMetadata)(configOverride !== null && configOverride !== void 0 ? configOverride : {}, Object.assign({ workingPath: this.workingPath, configPath: this._options.configPath, profile: this._options.profile }, this.profilesWithoutConfig()));
152
179
  const config = configResult.config;
153
180
  this.config = config;
154
181
  this._mainConfigFilePath = configResult.configPath;
182
+ this._profileConfigFilePath = configResult.profileConfigPath;
183
+ this._profile = configResult.profile;
155
184
  this.exportsDirectory = (_a = config.exportsOutputDirectory) !== null && _a !== void 0 ? _a : this.exportsDirectory;
156
185
  this.sitesDirectory = (_b = config.sitesOutputDirectory) !== null && _b !== void 0 ? _b : this.exportsDirectory;
157
186
  [this.runtimeConfig, this._configFilePaths, this._configFileIndex] = (0, config_1.initRuntimeConfig)(this);
@@ -569,8 +598,8 @@ class Handoff {
569
598
  * @returns {string[]} Array of absolute paths to config files
570
599
  */
571
600
  getConfigFilePaths() {
572
- const combined = this._mainConfigFilePath ? [this._mainConfigFilePath, ...this._configFilePaths] : this._configFilePaths;
573
- return Array.from(new Set(combined));
601
+ const mainPaths = [this._mainConfigFilePath, this._profileConfigFilePath].filter(Boolean);
602
+ return Array.from(new Set([...mainPaths, ...this._configFilePaths]));
574
603
  }
575
604
  /**
576
605
  * Gets the selected main config file path if one exists.
@@ -578,6 +607,19 @@ class Handoff {
578
607
  getMainConfigFilePath() {
579
608
  return this._mainConfigFilePath;
580
609
  }
610
+ getProfile() {
611
+ return this._profile;
612
+ }
613
+ /**
614
+ * True for the base config file and for the profile file merged onto it. Both carry project-wide
615
+ * settings, so a change to either one invalidates every entity rather than a single declaration.
616
+ */
617
+ isMainConfigFile(filePath) {
618
+ const normalized = (0, path_2.normalizePathForCompare)(filePath);
619
+ return [this._mainConfigFilePath, this._profileConfigFilePath]
620
+ .filter(Boolean)
621
+ .some((mainPath) => (0, path_2.normalizePathForCompare)(mainPath) === normalized);
622
+ }
581
623
  getConfigFileEntry(configPath) {
582
624
  return this._configFileIndex.get((0, path_2.normalizePathForCompare)(configPath));
583
625
  }
@@ -606,6 +648,8 @@ class Handoff {
606
648
  }
607
649
  var config_2 = require("./config");
608
650
  Object.defineProperty(exports, "defineConfig", { enumerable: true, get: function () { return config_2.defineConfig; } });
651
+ var from_env_1 = require("./config/from-env");
652
+ Object.defineProperty(exports, "fromEnv", { enumerable: true, get: function () { return from_env_1.fromEnv; } });
609
653
  var define_1 = require("./registry/asset-storage/define");
610
654
  Object.defineProperty(exports, "defineAssetStorage", { enumerable: true, get: function () { return define_1.defineAssetStorage; } });
611
655
  // Export transformers and types from handoff-core
@@ -1,4 +1,4 @@
1
- import 'dotenv/config';
1
+ import '../config/env';
2
2
  import Handoff from '..';
3
3
  export { readPrevJSONFile, zip, zipAssets } from './archive';
4
4
  export { buildComponents } from './components';
@@ -13,7 +13,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.buildPatterns = exports.buildComponents = exports.zipAssets = exports.zip = exports.readPrevJSONFile = void 0;
16
- require("dotenv/config");
16
+ require("../config/env");
17
17
  const app_builder_1 = __importDefault(require("../app-builder"));
18
18
  const logger_1 = require("../utils/logger");
19
19
  const figma_1 = require("./figma");
@@ -52,7 +52,7 @@ const createVercelBlobStorage = (params) => {
52
52
  const token = (_a = process.env[params.tokenEnv]) === null || _a === void 0 ? void 0 : _a.trim();
53
53
  if (!token) {
54
54
  throw new Error(`Vercel Blob storage is configured but the token env var "${params.tokenEnv}" is not set. ` +
55
- 'Set it to a Blob read/write token, or change runtime.registry.assetStorage.tokenEnv.');
55
+ 'Set it to a Blob read/write token, or change runtime.registry.assetStorage.token.');
56
56
  }
57
57
  return token;
58
58
  };