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
@@ -1,11 +1,12 @@
1
- import { Config } from '../types/config';
2
- /** Raised when an explicitly requested config file cannot be used. */
3
- export declare class HandoffConfigError extends Error {
4
- constructor(message: string);
5
- }
1
+ import { Config, ResolvedConfig } from '../types/config';
2
+ export declare const CONFIG_FILE_PREFERENCE: readonly ["handoff.config.ts", "handoff.config.js", "handoff.config.cjs", "handoff.config.json"];
3
+ export { HandoffConfigError } from './errors';
6
4
  type ConfigLoadResult = {
7
- config: Config;
5
+ config: ResolvedConfig;
8
6
  configPath?: string;
7
+ profile?: string;
8
+ /** The profile file merged onto the base config. */
9
+ profileConfigPath?: string;
9
10
  };
10
11
  /** Where to look for the config: the working directory, plus an optional explicit file. */
11
12
  export type ConfigLoadContext = {
@@ -13,7 +14,24 @@ export type ConfigLoadContext = {
13
14
  workingPath?: string;
14
15
  /** Explicit config file (the CLI's `-c, --config`), replacing discovery entirely. */
15
16
  configPath?: string;
17
+ /** Profile to merge onto the base config (the CLI's `--profile`), overriding `HANDOFF_PROFILE`. */
18
+ profile?: string;
19
+ /**
20
+ * Profile names that resolve without a `handoff.config.<profile>.*` file, leaving the base config
21
+ * unchanged. Registry commands pass the names of saved logins, because there a profile selects the
22
+ * registry rather than a config layer.
23
+ */
24
+ knownProfiles?: string[];
25
+ /** Sentence appended when a selected profile matches neither a config file nor `knownProfiles`. */
26
+ profileNotFoundHint?: string;
16
27
  };
28
+ /** Which selector picked the profile, so an error can name the one the user set. */
29
+ type ProfileSelection = {
30
+ name: string;
31
+ source: '--profile' | 'HANDOFF_PROFILE';
32
+ };
33
+ /** `--profile` wins over `HANDOFF_PROFILE`, and an empty value counts as unset. */
34
+ export declare const resolveProfileSelection: (requestedProfile?: string) => ProfileSelection | undefined;
17
35
  /**
18
36
  * Loads the handoff configuration for the given working directory and returns metadata.
19
37
  */
@@ -22,12 +40,12 @@ export declare const initConfigWithMetadata: (configOverride?: Partial<Config>,
22
40
  * Loads the handoff configuration for the given working directory.
23
41
  *
24
42
  * Searches for config files in order: handoff.config.ts, handoff.config.js, handoff.config.cjs, handoff.config.json,
25
- * unless `context.configPath` names one explicitly. Merges file config with any provided overrides,
26
- * then applies defaults for missing values.
43
+ * unless `context.configPath` names one explicitly. A selected profile adds a `handoff.config.<profile>.*` sidecar,
44
+ * which only `context.knownProfiles` makes optional.
45
+ * Layers resolve as defaults, base config, profile, then the given overrides, merging plain objects recursively.
27
46
  *
28
47
  * @param configOverride - Optional partial config to override file-loaded values.
29
- * @param context - Optional working directory and explicit config file to load.
48
+ * @param context - Optional working directory, explicit config file, and profile to load.
30
49
  * @returns The fully resolved Config object.
31
50
  */
32
- export declare const initConfig: (configOverride?: Partial<Config>, context?: ConfigLoadContext) => Config;
33
- export {};
51
+ export declare const initConfig: (configOverride?: Partial<Config>, context?: ConfigLoadContext) => ResolvedConfig;
@@ -3,31 +3,33 @@ 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
+ const build_1 = require("../transformers/utils/build");
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
- const merge_1 = __importDefault(require("lodash/merge"));
10
+ const mergeWith_1 = __importDefault(require("lodash/mergeWith"));
10
11
  const module_1 = require("module");
11
12
  const path_1 = __importDefault(require("path"));
12
13
  const logger_1 = require("../utils/logger");
13
14
  const path_2 = require("../utils/path");
14
15
  const defaults_1 = require("./defaults");
15
- const CONFIG_FILE_PREFERENCE = [
16
+ const env_1 = require("./env");
17
+ const helpers_1 = require("./helpers");
18
+ const errors_1 = require("./errors");
19
+ const from_env_1 = require("./from-env");
20
+ const resolve_env_1 = require("./resolve-env");
21
+ exports.CONFIG_FILE_PREFERENCE = [
16
22
  'handoff.config.ts',
17
23
  'handoff.config.js',
18
24
  'handoff.config.cjs',
19
25
  'handoff.config.json',
20
26
  ];
21
27
  /** 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;
28
+ const SUPPORTED_CONFIG_EXTENSIONS = Array.from(new Set(exports.CONFIG_FILE_PREFERENCE.map((fileName) => path_1.default.extname(fileName))));
29
+ /** A profile name becomes part of a file name, so it stays lowercase letters, numbers, and hyphens. */
30
+ const PROFILE_NAME_PATTERN = /^[a-z0-9-]+$/;
31
+ var errors_2 = require("./errors");
32
+ Object.defineProperty(exports, "HandoffConfigError", { enumerable: true, get: function () { return errors_2.HandoffConfigError; } });
31
33
  const evaluateTypeScriptConfig = (filePath, handoffModulePath) => {
32
34
  var _a, _b;
33
35
  const buildResult = esbuild_1.default.buildSync({
@@ -39,6 +41,7 @@ const evaluateTypeScriptConfig = (filePath, handoffModulePath) => {
39
41
  target: 'node16',
40
42
  logLevel: 'silent',
41
43
  external: ['handoff-app'],
44
+ loader: build_1.STYLE_IMPORT_LOADERS,
42
45
  });
43
46
  const code = (_b = (_a = buildResult.outputFiles) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text;
44
47
  if (!code) {
@@ -75,6 +78,28 @@ const loadConfigFile = (configPath) => {
75
78
  const importedConfig = require(configPath);
76
79
  return (importedConfig.default || importedConfig);
77
80
  };
81
+ /**
82
+ * Normalize here and not only in `defineConfig`: a `.json` config, or a profile that skips the
83
+ * helper, would otherwise keep its camelCase keys. The rest of the codebase reads the runtime keys,
84
+ * so a later layer could not override an earlier one.
85
+ */
86
+ const loadConfigLayer = (configPath) => (0, helpers_1.normalizeConfig)(loadConfigFile(configPath));
87
+ const pickExisting = (candidates) => {
88
+ const existing = candidates.filter((filePath) => fs_extra_1.default.existsSync(filePath));
89
+ if (!existing.length) {
90
+ return { selected: undefined, ignored: [] };
91
+ }
92
+ const [selected, ...ignored] = existing;
93
+ return { selected, ignored };
94
+ };
95
+ const warnIgnored = (selected, ignored) => {
96
+ if (!selected || ignored.length === 0) {
97
+ return;
98
+ }
99
+ logger_1.Logger.warn(`Multiple config files found. Using "${path_1.default.basename(selected)}" and ignoring: ${ignored
100
+ .map((filePath) => `"${path_1.default.basename(filePath)}"`)
101
+ .join(', ')}.`);
102
+ };
78
103
  /**
79
104
  * Picks the config file to load. An explicitly requested file replaces discovery - it is resolved
80
105
  * from `workingPath` and must exist in a format the loader can evaluate. Otherwise every known
@@ -84,70 +109,120 @@ const resolveConfigFilePath = (workingPath, requestedPath) => {
84
109
  if (requestedPath) {
85
110
  const selected = path_1.default.resolve(workingPath, requestedPath);
86
111
  if (!fs_extra_1.default.existsSync(selected)) {
87
- throw new HandoffConfigError(`Config file not found: "${requestedPath}" (resolved to ${selected})`);
112
+ throw new errors_1.HandoffConfigError(`Config file not found: "${requestedPath}" (resolved to ${selected})`);
88
113
  }
89
114
  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(', ')}`);
115
+ throw new errors_1.HandoffConfigError(`Unsupported config file "${path_1.default.basename(selected)}". Supported extensions: ${SUPPORTED_CONFIG_EXTENSIONS.join(', ')}`);
91
116
  }
92
117
  return { selected, ignored: [] };
93
118
  }
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: [] };
119
+ return pickExisting(exports.CONFIG_FILE_PREFERENCE.map((fileName) => path_1.default.resolve(workingPath, fileName)));
120
+ };
121
+ /** `--profile` wins over `HANDOFF_PROFILE`, and an empty value counts as unset. */
122
+ const resolveProfileSelection = (requestedProfile) => {
123
+ var _a;
124
+ const fromOption = requestedProfile === null || requestedProfile === void 0 ? void 0 : requestedProfile.trim();
125
+ if (fromOption) {
126
+ return { name: fromOption, source: '--profile' };
99
127
  }
100
- const [selected, ...ignored] = existing;
101
- return { selected, ignored };
128
+ const fromEnvironment = (_a = process.env.HANDOFF_PROFILE) === null || _a === void 0 ? void 0 : _a.trim();
129
+ if (fromEnvironment) {
130
+ return { name: fromEnvironment, source: 'HANDOFF_PROFILE' };
131
+ }
132
+ return undefined;
133
+ };
134
+ exports.resolveProfileSelection = resolveProfileSelection;
135
+ /**
136
+ * Picks the profile file for a selected profile. It sits beside the base config: the base file name
137
+ * without its extension, then the profile name, then any config extension. So `handoff.config.ts`
138
+ * pairs with `handoff.config.local.*`, and a config named through `-c` keeps its own name. A selected
139
+ * profile has to resolve - silently falling back to the base config would deploy the wrong settings.
140
+ * A name listed in `knownProfiles` resolves without a file, because it names something else that
141
+ * exists, such as a saved registry login.
142
+ */
143
+ const resolveProfileFilePath = (workingPath, baseConfigPath, selection, context) => {
144
+ var _a;
145
+ if (!PROFILE_NAME_PATTERN.test(selection.name)) {
146
+ throw new errors_1.HandoffConfigError(`Invalid config profile "${selection.name}" (from ${selection.source}). ` +
147
+ `A profile name can contain lowercase letters, numbers, and hyphens.`);
148
+ }
149
+ const baseName = baseConfigPath
150
+ ? path_1.default.join(path_1.default.dirname(baseConfigPath), path_1.default.basename(baseConfigPath, path_1.default.extname(baseConfigPath)))
151
+ : path_1.default.resolve(workingPath, 'handoff.config');
152
+ const candidates = SUPPORTED_CONFIG_EXTENSIONS.map((extension) => `${baseName}.${selection.name}${extension}`);
153
+ const { selected, ignored } = pickExisting(candidates);
154
+ if (!selected) {
155
+ if ((_a = context.knownProfiles) === null || _a === void 0 ? void 0 : _a.includes(selection.name)) {
156
+ return undefined;
157
+ }
158
+ throw new errors_1.HandoffConfigError(`Config profile "${selection.name}" (from ${selection.source}) not found. ` +
159
+ `Create one of: ${candidates.map((candidate) => path_1.default.basename(candidate)).join(', ')} in ${path_1.default.dirname(candidates[0])}.` +
160
+ (context.profileNotFoundHint ? ` ${context.profileNotFoundHint}` : ''));
161
+ }
162
+ warnIgnored(selected, ignored);
163
+ return selected;
164
+ };
165
+ /**
166
+ * Arrays and environment references are whole values: a later layer replaces them, including
167
+ * any reference default. Everything else keeps lodash semantics: plain
168
+ * objects merge recursively, scalars, `null` and functions replace, and `undefined` is skipped.
169
+ */
170
+ const replaceValues = (destination, source) => {
171
+ if (source === undefined)
172
+ return undefined;
173
+ return Array.isArray(source) || (0, from_env_1.isEnvReference)(source) || (0, from_env_1.isEnvReference)(destination) ? source : undefined;
102
174
  };
103
175
  /**
104
176
  * Loads the handoff configuration for the given working directory and returns metadata.
105
177
  */
106
178
  const initConfigWithMetadata = (configOverride, context) => {
107
179
  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
- }
180
+ const workingPath = (_a = context === null || context === void 0 ? void 0 : context.workingPath) !== null && _a !== void 0 ? _a : (0, path_2.resolveWorkingPath)();
181
+ const { selected: configPath, ignored } = resolveConfigFilePath(workingPath, context === null || context === void 0 ? void 0 : context.configPath);
182
+ warnIgnored(configPath, ignored);
183
+ const profileSelection = (0, exports.resolveProfileSelection)(context === null || context === void 0 ? void 0 : context.profile);
184
+ const profileConfigPath = profileSelection ? resolveProfileFilePath(workingPath, configPath, profileSelection, context !== null && context !== void 0 ? context : {}) : undefined;
185
+ // Profile environment values must be present before the merged references resolve.
186
+ (0, env_1.loadProfileEnv)(profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name);
187
+ const layers = [(0, defaults_1.defaultConfig)()];
115
188
  if (configPath) {
116
- config = loadConfigFile(configPath);
189
+ layers.push(loadConfigLayer(configPath));
190
+ }
191
+ if (profileConfigPath) {
192
+ layers.push(loadConfigLayer(profileConfigPath));
117
193
  }
118
- // Apply overrides if provided
119
194
  if (configOverride) {
120
- Object.entries(configOverride).forEach(([key, value]) => {
121
- if (value !== undefined) {
122
- config[key] = value;
123
- }
124
- });
195
+ layers.push((0, helpers_1.normalizeConfig)(configOverride));
125
196
  }
197
+ const profileLabel = profileSelection
198
+ ? `"${profileSelection.name}" (from ${profileSelection.source})${profileConfigPath ? '' : ' - no config file, the base config is used unchanged'}`
199
+ : 'none selected';
200
+ logger_1.Logger.debug(`Config profile: ${profileLabel}`);
201
+ logger_1.Logger.debug(`Config files loaded: ${[configPath, profileConfigPath].filter(Boolean).join(', ') || 'none, using defaults'}`);
202
+ const merged = (0, mergeWith_1.default)({}, ...layers, replaceValues);
203
+ const config = (0, resolve_env_1.resolveConfigEnv)(merged, profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name, layers[0]);
204
+ // Guards run on the resolved config so a profile cannot slip a removed setting past them.
126
205
  const entries = config.entries;
127
206
  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.');
207
+ throw new errors_1.HandoffConfigError('entries.components and entries.patterns were removed. Use catalog.include. See UPGRADE.md#catalog-items.');
129
208
  }
130
209
  const format = (_c = (_b = config.runtime) === null || _b === void 0 ? void 0 : _b.workspace) === null || _c === void 0 ? void 0 : _c.declarationFormat;
131
210
  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.');
211
+ throw new errors_1.HandoffConfigError('Item declarations support ts, js, and cjs. Convert JSON declarations to defineCatalogItem. See UPGRADE.md#catalog-items.');
133
212
  }
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 };
213
+ return { config, configPath, profile: profileSelection === null || profileSelection === void 0 ? void 0 : profileSelection.name, profileConfigPath };
140
214
  };
141
215
  exports.initConfigWithMetadata = initConfigWithMetadata;
142
216
  /**
143
217
  * Loads the handoff configuration for the given working directory.
144
218
  *
145
219
  * 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.
220
+ * unless `context.configPath` names one explicitly. A selected profile adds a `handoff.config.<profile>.*` sidecar,
221
+ * which only `context.knownProfiles` makes optional.
222
+ * Layers resolve as defaults, base config, profile, then the given overrides, merging plain objects recursively.
148
223
  *
149
224
  * @param configOverride - Optional partial config to override file-loaded values.
150
- * @param context - Optional working directory and explicit config file to load.
225
+ * @param context - Optional working directory, explicit config file, and profile to load.
151
226
  * @returns The fully resolved Config object.
152
227
  */
153
228
  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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.toLowerCaseKeysAndValues = exports.getComponentsForPath = exports.isComponentDirectory = exports.initRuntimeConfig = exports.getCatalogItemDirectories = exports.resolveComponentDeclaration = void 0;
7
7
  const esbuild_1 = __importDefault(require("esbuild"));
8
+ const build_1 = require("../transformers/utils/build");
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const module_1 = require("module");
10
11
  const path_1 = __importDefault(require("path"));
@@ -17,7 +18,8 @@ const markdown_1 = require("../utils/markdown");
17
18
  const pages_1 = require("../utils/pages");
18
19
  const path_2 = require("../utils/path");
19
20
  const page_1 = require("./normalizers/page");
20
- const DECLARATION_EXTENSIONS = ['ts', 'js', 'cjs'];
21
+ /** `.tsx`/`.jsx` are accepted so a preview can write its `render` as JSX. */
22
+ const DECLARATION_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'cjs'];
21
23
  const getDeclarationFiles = (componentBaseName) => DECLARATION_EXTENSIONS.map((ext) => `${componentBaseName}.handoff.${ext}`);
22
24
  const findPreferredDeclaration = (componentDir, componentBaseName) => {
23
25
  const exactCandidates = getDeclarationFiles(componentBaseName);
@@ -25,7 +27,7 @@ const findPreferredDeclaration = (componentDir, componentBaseName) => {
25
27
  if (exactMatch)
26
28
  return exactMatch;
27
29
  const allFiles = fs_extra_1.default.existsSync(componentDir) ? fs_extra_1.default.readdirSync(componentDir) : [];
28
- const declarationFiles = allFiles.filter((file) => /\.handoff\.(ts|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
30
+ const declarationFiles = allFiles.filter((file) => /\.handoff\.(tsx|ts|jsx|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
29
31
  if (!declarationFiles.length)
30
32
  return undefined;
31
33
  for (const ext of DECLARATION_EXTENSIONS) {
@@ -52,6 +54,7 @@ const evaluateDeclaration = (filePath, handoffModulePath) => {
52
54
  logLevel: 'silent',
53
55
  jsx: 'automatic',
54
56
  external: ['react', 'react-dom', 'handoff-app'],
57
+ loader: build_1.STYLE_IMPORT_LOADERS,
55
58
  });
56
59
  const code = (_b = (_a = buildResult.outputFiles) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text;
57
60
  if (!code) {
@@ -178,7 +181,7 @@ const initRuntimeConfig = (handoff) => {
178
181
  const declaration = (0, exports.resolveComponentDeclaration)(itemPath, itemBaseName);
179
182
  if (!declaration) {
180
183
  const declarationFiles = getDeclarationFiles(itemBaseName);
181
- logger_1.Logger.warn(`Missing catalog declaration (use .handoff.ts, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path_1.default.resolve(itemPath, declarationFiles.join(' or '))}`);
184
+ logger_1.Logger.warn(`Missing catalog declaration (use .handoff.tsx, .handoff.ts, .handoff.jsx, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path_1.default.resolve(itemPath, declarationFiles.join(' or '))}`);
182
185
  continue;
183
186
  }
184
187
  const declarationPath = path_1.default.resolve(itemPath, declaration.fileName);
@@ -361,8 +364,8 @@ const injectPatternPreviews = (result) => {
361
364
  }
362
365
  // Case 3: args present (with or without preview base) -> create synthetic preview
363
366
  let resolvedValues = {};
367
+ const basePreview = ref.preview ? (_b = component.previews) === null || _b === void 0 ? void 0 : _b[ref.preview] : undefined;
364
368
  if (ref.preview) {
365
- const basePreview = (_b = component.previews) === null || _b === void 0 ? void 0 : _b[ref.preview];
366
369
  if (basePreview) {
367
370
  resolvedValues = Object.assign({}, basePreview.values);
368
371
  }
@@ -374,12 +377,7 @@ const injectPatternPreviews = (result) => {
374
377
  resolvedValues = Object.assign(Object.assign({}, resolvedValues), ref.args);
375
378
  const syntheticKey = `__pattern_${patternId}_${i}`;
376
379
  component.internalPatternPreviews = component.internalPatternPreviews || {};
377
- component.internalPatternPreviews[syntheticKey] = {
378
- title: syntheticKey,
379
- values: resolvedValues,
380
- url: '',
381
- sourcePreview: ref.preview,
382
- };
380
+ component.internalPatternPreviews[syntheticKey] = Object.assign({ title: syntheticKey, values: resolvedValues, url: '', sourcePreview: ref.preview }, ((basePreview === null || basePreview === void 0 ? void 0 : basePreview.render) ? { render: basePreview.render } : {}));
383
381
  ref.resolvedPreview = syntheticKey;
384
382
  if (ref.resolved === undefined) {
385
383
  ref.resolved = true;
@@ -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
  }
@@ -1,5 +1,13 @@
1
- import type { CatalogItem, CatalogItemMeta } from './catalog/types';
2
- export type { CatalogItem, CatalogItemMeta, CatalogPreview, CompositionRef, Preview, SourceDescriptor } from './catalog/types';
1
+ import type { CatalogItem, CatalogItemMeta, CatalogPreview as RendererPreview } from './catalog/types';
2
+ export type { CatalogItem, CatalogItemMeta, CompositionRef, SourceDescriptor } from './catalog/types';
3
+ /** A template renderer never calls a preview `render`, so declaring one is an error, not a no-op. */
4
+ export type CatalogPreview<TArgs = Record<string, unknown>> = Omit<RendererPreview<TArgs>, 'render'> & {
5
+ render?: never;
6
+ };
7
+ /** Preview type for a Handlebars catalog item, with its argument type applied. */
8
+ export type Preview<TItem> = TItem extends {
9
+ readonly __args?: infer TArgs;
10
+ } ? CatalogPreview<Partial<NonNullable<TArgs>>> : never;
3
11
  /**
4
12
  * Declares a Handlebars catalog item. `implementation` is the path to its `.hbs` template. The
5
13
  * renderer comes from this module, so the build fails an item whose file does not match it.
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';