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
@@ -0,0 +1,94 @@
1
+ import isPlainObject from 'lodash/isPlainObject';
2
+ import type { Config, ResolvedConfig } from '../types/config';
3
+ import { Logger } from '../utils/logger';
4
+ import { isEnvReference } from './from-env';
5
+ import { HandoffConfigError } from './errors';
6
+
7
+ export const DEFERRED_PATHS = new Set(['runtime.registry.databaseUrl', 'runtime.registry.assetStorage.token']);
8
+ const SECRET_PATHS = new Set(['dev_access_token', 'devAccessToken', 'runtime.registryConnection.accessToken']);
9
+ /** Path prefixes whose resolved contents are JSON-baked into the bundle, so a reference would bake its value. */
10
+ const BAKED_PATHS = ['runtime.registry.assetStorage.options'];
11
+ const isBaked = (path: string): boolean => BAKED_PATHS.some((baked) => path === baked || path.startsWith(`${baked}.`));
12
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ const ALIASES: Record<string, string> = {
14
+ HANDOFF_REGISTRY_URL: 'HANDOFF_CLOUD_URL',
15
+ HANDOFF_REGISTRY_ACCESS_TOKEN: 'HANDOFF_CLOUD_TOKEN',
16
+ };
17
+ const warnedAliases = new Set<string>();
18
+ const referenceNames = new WeakMap<ResolvedConfig, Map<string, string>>();
19
+
20
+ export const configEnvName = (config: ResolvedConfig | null | undefined, path: string): string | undefined =>
21
+ referenceNames.get(config)?.get(path);
22
+
23
+ const readEnv = (name: string): string | undefined => {
24
+ const value = process.env[name];
25
+ const alias = ALIASES[name];
26
+ if (!alias || value?.trim()) return value;
27
+ const inherited = process.env[alias];
28
+ if (!inherited?.trim()) return value;
29
+ if (!warnedAliases.has(alias)) {
30
+ warnedAliases.add(alias);
31
+ Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
32
+ }
33
+ return inherited;
34
+ };
35
+
36
+ /** Resolve only the merged winner. Functions and non-plain objects retain their identity. */
37
+ export const resolveConfigEnv = (config: Config, profile?: string, defaults?: Config): ResolvedConfig => {
38
+ const names = new Map<string, string>();
39
+ const fail = (path: string, message: string): never => {
40
+ throw new HandoffConfigError(`Config "${path}" (profile "${profile ?? 'default'}"): ${message}`);
41
+ };
42
+ const walk = (value: unknown, path: string, seed?: unknown): unknown => {
43
+ const deferred = DEFERRED_PATHS.has(path);
44
+ const secret = SECRET_PATHS.has(path);
45
+ if ((secret || deferred) && value !== undefined && !isEnvReference(value)) {
46
+ fail(path, 'Expected an environment reference, such as { $env: "VARIABLE_NAME" }. Literal values are not allowed.');
47
+ }
48
+ if (isEnvReference(value)) {
49
+ // Checked before the name, so the message names the supported pattern instead of the reference shape.
50
+ if (isBaked(path)) {
51
+ fail(
52
+ path,
53
+ 'Environment references are not allowed here because the resolved value is baked into the build. ' +
54
+ "Put the variable name in options and read it through the adapter factory's `env` argument."
55
+ );
56
+ }
57
+ if (typeof value.$env !== 'string' || !ENV_NAME.test(value.$env)) {
58
+ fail(path, 'Invalid environment variable name. Use letters, digits, and underscores, starting with a letter or underscore.');
59
+ }
60
+ const name = value.$env;
61
+ names.set(path, name);
62
+ if (Object.keys(value).some((key) => key !== '$env' && key !== 'default')) {
63
+ fail(path, `Environment variable "${name}" has an invalid reference shape.`);
64
+ }
65
+ if (deferred) {
66
+ if ('default' in value) fail(path, `Environment variable "${name}" cannot have a default because it is read by the deployed app.`);
67
+ return { $env: name };
68
+ }
69
+ // Secret defaults can mark an optional credential, but cannot embed a credential value.
70
+ if (secret && value.default !== undefined && value.default !== null && value.default !== '') {
71
+ fail(path, `Environment variable "${name}" cannot have a literal secret default.`);
72
+ }
73
+ const raw = readEnv(name);
74
+ if (raw === undefined) {
75
+ if (Object.prototype.hasOwnProperty.call(value, 'default')) return secret ? value.default : walk(value.default, path, seed);
76
+ fail(path, `Environment variable "${name}" is not set and has no default.`);
77
+ }
78
+ const typeHint = value.default ?? (isEnvReference(seed) ? seed.default : seed);
79
+ if (typeof typeHint === 'boolean') return raw === 'true';
80
+ if (typeof typeHint === 'number') {
81
+ const number = Number(raw);
82
+ if (!Number.isFinite(number)) fail(path, `Environment variable "${name}" must contain a finite number.`);
83
+ return number;
84
+ }
85
+ return raw;
86
+ }
87
+ if (Array.isArray(value)) return value.map((entry, index) => walk(entry, `${path}.${index}`, seed?.[index]));
88
+ if (!isPlainObject(value)) return value;
89
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry, path ? `${path}.${key}` : key, seed?.[key])]));
90
+ };
91
+ const resolved = walk(config, '', defaults) as ResolvedConfig;
92
+ referenceNames.set(resolved, names);
93
+ return resolved;
94
+ };
@@ -1,4 +1,5 @@
1
1
  import esbuild from 'esbuild';
2
+ import { STYLE_IMPORT_LOADERS } from '../transformers/utils/build';
2
3
  import fs from 'fs-extra';
3
4
  import { createRequire } from 'module';
4
5
  import path from 'path';
@@ -14,7 +15,7 @@ import { HOME_PAGE_ID, HOME_PAGE_PATH } from '../registry/content-kinds';
14
15
  import { ComponentListObject } from '../transformers/preview/types';
15
16
  import { createCsfStoryPreviews } from '../transformers/utils/csf';
16
17
  import { buildAndEvaluateModuleSync } from '../transformers/utils/module';
17
- import { Config, ConfigFileEntry, RuntimeConfig } from '../types/config';
18
+ import { ResolvedConfig, ConfigFileEntry, RuntimeConfig } from '../types/config';
18
19
  import { Logger } from '../utils/logger';
19
20
  import { parseMarkdown } from '../utils/markdown';
20
21
  import { collectPageSlugSegments } from '../utils/pages';
@@ -26,7 +27,7 @@ import { normalizePageDeclaration } from './normalizers/page';
26
27
  * Avoids importing the full Handoff class to prevent circular deps.
27
28
  */
28
29
  interface HandoffContext {
29
- config: Config;
30
+ config: ResolvedConfig;
30
31
  workingPath: string;
31
32
  modulePath: string;
32
33
  }
@@ -35,7 +36,8 @@ type DeclarationResolution = {
35
36
  fileName: string;
36
37
  };
37
38
 
38
- const DECLARATION_EXTENSIONS = ['ts', 'js', 'cjs'] as const;
39
+ /** `.tsx`/`.jsx` are accepted so a preview can write its `render` as JSX. */
40
+ const DECLARATION_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js', 'cjs'] as const;
39
41
 
40
42
  const getDeclarationFiles = (componentBaseName: string): string[] =>
41
43
  DECLARATION_EXTENSIONS.map((ext) => `${componentBaseName}.handoff.${ext}`);
@@ -46,7 +48,7 @@ const findPreferredDeclaration = (componentDir: string, componentBaseName: strin
46
48
  if (exactMatch) return exactMatch;
47
49
 
48
50
  const allFiles = fs.existsSync(componentDir) ? fs.readdirSync(componentDir) : [];
49
- const declarationFiles = allFiles.filter((file) => /\.handoff\.(ts|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
51
+ const declarationFiles = allFiles.filter((file) => /\.handoff\.(tsx|ts|jsx|js|cjs)$/.test(file)).sort((a, b) => a.localeCompare(b));
50
52
 
51
53
  if (!declarationFiles.length) return undefined;
52
54
 
@@ -74,6 +76,7 @@ const evaluateDeclaration = (filePath: string, handoffModulePath: string): any =
74
76
  logLevel: 'silent',
75
77
  jsx: 'automatic',
76
78
  external: ['react', 'react-dom', 'handoff-app'],
79
+ loader: STYLE_IMPORT_LOADERS,
77
80
  });
78
81
 
79
82
  const code = buildResult.outputFiles?.[0]?.text;
@@ -129,7 +132,7 @@ const discoverCsfPreviews = (component: ComponentListObject, handoff: HandoffCon
129
132
  /**
130
133
  * Expands catalog.include into item directories and removes duplicate paths.
131
134
  */
132
- export const getCatalogItemDirectories = (config: Config, workingPath: string): string[] => {
135
+ export const getCatalogItemDirectories = (config: ResolvedConfig, workingPath: string): string[] => {
133
136
  const registered = [...(config.catalog?.include ?? [])];
134
137
 
135
138
  const seen = new Set<string>();
@@ -219,7 +222,7 @@ export const initRuntimeConfig = (
219
222
  if (!declaration) {
220
223
  const declarationFiles = getDeclarationFiles(itemBaseName);
221
224
  Logger.warn(
222
- `Missing catalog declaration (use .handoff.ts, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
225
+ `Missing catalog declaration (use .handoff.tsx, .handoff.ts, .handoff.jsx, .handoff.js, or .handoff.cjs; see UPGRADE.md#catalog-items): ${path.resolve(itemPath, declarationFiles.join(' or '))}`
223
226
  );
224
227
  continue;
225
228
  }
@@ -433,9 +436,9 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
433
436
 
434
437
  // Case 3: args present (with or without preview base) -> create synthetic preview
435
438
  let resolvedValues: Record<string, any> = {};
439
+ const basePreview = ref.preview ? component.previews?.[ref.preview] : undefined;
436
440
 
437
441
  if (ref.preview) {
438
- const basePreview = component.previews?.[ref.preview];
439
442
  if (basePreview) {
440
443
  resolvedValues = { ...basePreview.values };
441
444
  } else {
@@ -454,6 +457,8 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
454
457
  values: resolvedValues,
455
458
  url: '',
456
459
  sourcePreview: ref.preview,
460
+ // The ref overrides arguments, not how the preview renders, so a base `render` still applies.
461
+ ...(basePreview?.render ? { render: basePreview.render } : {}),
457
462
  };
458
463
 
459
464
  ref.resolvedPreview = syntheticKey;
@@ -1,4 +1,4 @@
1
- import { Config } from '../types/config';
1
+ import { ResolvedConfig } from '../types/config';
2
2
  import { Logger } from '../utils/logger';
3
3
 
4
4
  /**
@@ -8,15 +8,13 @@ import { Logger } from '../utils/logger';
8
8
  * @returns The validated config (same reference).
9
9
  * @throws {Error} If required Figma credentials are missing.
10
10
  */
11
- export const validateConfig = (config: Config): Config => {
11
+ export const validateConfig = (config: ResolvedConfig): ResolvedConfig => {
12
12
  // TODO: Check to see if the exported folder exists before we run start
13
- if (!config.figma_project_id && !process.env.HANDOFF_FIGMA_PROJECT_ID) {
14
- // check to see if we can get this from the env
13
+ if (!config.figma_project_id) {
15
14
  Logger.error('Figma Project ID missing. Please set HANDOFF_FIGMA_PROJECT_ID or run "handoff-app fetch".');
16
15
  throw new Error('Cannot initialize configuration');
17
16
  }
18
- if (!config.dev_access_token && !process.env.HANDOFF_DEV_ACCESS_TOKEN) {
19
- // check to see if we can get this from the env
17
+ if (!config.dev_access_token) {
20
18
  Logger.error('Figma Access Token missing. Please set HANDOFF_DEV_ACCESS_TOKEN or run "handoff-app fetch".');
21
19
  throw new Error('Cannot initialize configuration');
22
20
  }
package/src/handlebars.ts CHANGED
@@ -5,9 +5,15 @@
5
5
  * an argument type to check previews: `defineCatalogItem<BadgeArgs>`.
6
6
  */
7
7
  import { createCatalogItem } from './catalog/define';
8
- import type { CatalogItem, CatalogItemMeta } from './catalog/types';
8
+ import type { CatalogItem, CatalogItemMeta, CatalogPreview as RendererPreview } from './catalog/types';
9
9
 
10
- export type { CatalogItem, CatalogItemMeta, CatalogPreview, CompositionRef, Preview, SourceDescriptor } from './catalog/types';
10
+ export type { CatalogItem, CatalogItemMeta, CompositionRef, SourceDescriptor } from './catalog/types';
11
+
12
+ /** A template renderer never calls a preview `render`, so declaring one is an error, not a no-op. */
13
+ export type CatalogPreview<TArgs = Record<string, unknown>> = Omit<RendererPreview<TArgs>, 'render'> & { render?: never };
14
+
15
+ /** Preview type for a Handlebars catalog item, with its argument type applied. */
16
+ export type Preview<TItem> = TItem extends { readonly __args?: infer TArgs } ? CatalogPreview<Partial<NonNullable<TArgs>>> : never;
11
17
 
12
18
  /**
13
19
  * Declares a Handlebars catalog item. `implementation` is the path to its `.hbs` template. The
package/src/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
 
2
2
  import 'dotenv/config';
3
- import { Config } from './types/config';
3
+ import { ResolvedConfig } from './types/config';
4
4
 
5
5
  declare module 'handoff-app';
6
6
 
7
7
  declare class Handoff {
8
- config: Config;
8
+ config: ResolvedConfig;
9
9
  modulePath: string;
10
10
  workingPath: string;
11
11
  force: boolean;
package/src/index.ts CHANGED
@@ -1,17 +1,25 @@
1
- import 'dotenv/config';
1
+ import './config/env';
2
2
  import fs from 'fs-extra';
3
3
  import { Types as CoreTypes, Handoff as HandoffRunner, Providers } from 'handoff-core';
4
4
  import path from 'path';
5
5
  import buildApp, { devApp, watchApp, type BuildPackage, type BuildTarget } from './app-builder';
6
6
  import { ejectConfig, ejectPages, ejectTheme } from './cli/eject';
7
7
  import { makeComponent, makePage } from './cli/make';
8
- import { initConfigWithMetadata, initRuntimeConfig, validateConfig } from './config';
8
+ import { loginProfileNames } from './cli/auth/store';
9
+ import {
10
+ type ConfigLoadContext,
11
+ initConfigWithMetadata,
12
+ initRuntimeConfig,
13
+ loadProfileEnv,
14
+ resolveProfileSelection,
15
+ validateConfig,
16
+ } from './config';
9
17
  import pipeline, { buildComponents, buildPatterns } from './pipeline';
10
18
  import { ALL_KIND_ORDER, ENTITY_WIRE_KIND, isRegistryEntityKind, REGISTRY_ENTITY_KINDS, type RegistryEntityKind } from './registry/content-kinds';
11
19
  import type { TransferEntityKind } from './registry/transfer';
12
20
  import { createFilesystemStore, type HandoffStore } from './store';
13
21
  import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
14
- import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
22
+ import { Config, ResolvedConfig, ConfigFileEntry, RuntimeConfig } from './types/config';
15
23
  import { Logger } from './utils/logger';
16
24
  import { normalizePathForCompare, resolveWorkingPath } from './utils/path';
17
25
  import { generateFilesystemSafeId } from './utils/path';
@@ -32,6 +40,19 @@ export interface HandoffOptions {
32
40
  config?: Partial<Config>;
33
41
  /** Explicit config file (the CLI's `-c, --config`), resolved from the working path. */
34
42
  configPath?: string;
43
+ /**
44
+ * Config profile to merge onto the base config (the CLI's `--profile`). It overrides
45
+ * `HANDOFF_PROFILE`, and a selected profile has to resolve to a `handoff.config.<profile>.*` file
46
+ * unless {@link profileWithoutConfig} widens that.
47
+ */
48
+ profile?: string;
49
+ /**
50
+ * What a selected profile may resolve to when it has no config file. `'saved-login'` accepts a name
51
+ * a saved registry login uses, so publish and checkout can address a registry that no config file
52
+ * describes. `'any'` accepts any valid name, for the `login` command that creates the login. Unset,
53
+ * a profile must name a config file, so a typo cannot silently run with the base config.
54
+ */
55
+ profileWithoutConfig?: 'saved-login' | 'any';
35
56
  }
36
57
 
37
58
  /**
@@ -109,7 +130,7 @@ const runKinds = async (
109
130
  };
110
131
 
111
132
  class Handoff {
112
- config: Config | null;
133
+ config: ResolvedConfig | null;
113
134
  debug: boolean = false;
114
135
  force: boolean = false;
115
136
  dryRun: boolean = false;
@@ -142,6 +163,8 @@ class Handoff {
142
163
  private _configFilePaths: string[] = [];
143
164
  private _configFileIndex: Map<string, ConfigFileEntry> = new Map();
144
165
  private _mainConfigFilePath?: string;
166
+ private _profileConfigFilePath?: string;
167
+ private _profile?: string;
145
168
  private _documentationObjectCache?: CoreTypes.IDocumentationObject;
146
169
  private _handoffRunner?: ReturnType<typeof HandoffRunner> | null;
147
170
 
@@ -151,7 +174,8 @@ class Handoff {
151
174
  }
152
175
 
153
176
  private construct(options: HandoffOptions) {
154
- this.config = null;
177
+ // Ahead of `Logger.init` so a profile env file can set `HANDOFF_LOG_LEVEL` and `HANDOFF_LOG_SCOPES`.
178
+ loadProfileEnv(resolveProfileSelection(options.profile)?.name);
155
179
  this.debug = options.debug ?? false;
156
180
  this.force = options.force ?? false;
157
181
  this.dryRun = options.dryRun ?? false;
@@ -160,14 +184,50 @@ class Handoff {
160
184
  this.init(options.config);
161
185
  }
162
186
 
187
+ /**
188
+ * What a selected profile may resolve to besides a config file, and how to explain a name that
189
+ * resolves to nothing. A command that does not accept a login still names one it found, so the
190
+ * difference between the two kinds of profile is visible rather than puzzling.
191
+ */
192
+ private profilesWithoutConfig(): Pick<ConfigLoadContext, 'knownProfiles' | 'profileNotFoundHint'> {
193
+ const selected = resolveProfileSelection(this._options.profile)?.name;
194
+ if (!selected) {
195
+ return {};
196
+ }
197
+
198
+ const accepts = this._options.profileWithoutConfig;
199
+ const names = loginProfileNames(this.workingPath);
200
+ if (!accepts) {
201
+ return names.includes(selected)
202
+ ? {
203
+ profileNotFoundHint:
204
+ 'A saved registry login uses that name, but only login, logout, publish, and checkout accept a profile without a config file.',
205
+ }
206
+ : {};
207
+ }
208
+
209
+ return {
210
+ knownProfiles: accepts === 'any' ? [...names, selected] : names,
211
+ profileNotFoundHint:
212
+ 'No saved registry login uses that name either. Run `handoff-app login --profile <name> --url <registry-url>` to create one.',
213
+ };
214
+ }
215
+
163
216
  init(configOverride?: Partial<Config>): Handoff {
217
+ // Resolved before anything is assigned: a reload that throws - a deleted profile file, a
218
+ // broken config - then leaves the last good state in place for the watchers holding this
219
+ // instance.
164
220
  const configResult = initConfigWithMetadata(configOverride ?? {}, {
165
221
  workingPath: this.workingPath,
166
222
  configPath: this._options.configPath,
223
+ profile: this._options.profile,
224
+ ...this.profilesWithoutConfig(),
167
225
  });
168
226
  const config = configResult.config;
169
227
  this.config = config;
170
228
  this._mainConfigFilePath = configResult.configPath;
229
+ this._profileConfigFilePath = configResult.profileConfigPath;
230
+ this._profile = configResult.profile;
171
231
  this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
172
232
  this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
173
233
  [this.runtimeConfig, this._configFilePaths, this._configFileIndex] = initRuntimeConfig(this);
@@ -571,8 +631,8 @@ class Handoff {
571
631
  * @returns {string[]} Array of absolute paths to config files
572
632
  */
573
633
  getConfigFilePaths(): string[] {
574
- const combined = this._mainConfigFilePath ? [this._mainConfigFilePath, ...this._configFilePaths] : this._configFilePaths;
575
- return Array.from(new Set(combined));
634
+ const mainPaths = [this._mainConfigFilePath, this._profileConfigFilePath].filter(Boolean) as string[];
635
+ return Array.from(new Set([...mainPaths, ...this._configFilePaths]));
576
636
  }
577
637
 
578
638
  /**
@@ -582,6 +642,21 @@ class Handoff {
582
642
  return this._mainConfigFilePath;
583
643
  }
584
644
 
645
+ getProfile(): string | undefined {
646
+ return this._profile;
647
+ }
648
+
649
+ /**
650
+ * True for the base config file and for the profile file merged onto it. Both carry project-wide
651
+ * settings, so a change to either one invalidates every entity rather than a single declaration.
652
+ */
653
+ isMainConfigFile(filePath: string): boolean {
654
+ const normalized = normalizePathForCompare(filePath);
655
+ return [this._mainConfigFilePath, this._profileConfigFilePath]
656
+ .filter(Boolean)
657
+ .some((mainPath) => normalizePathForCompare(mainPath as string) === normalized);
658
+ }
659
+
585
660
  getConfigFileEntry(configPath: string): ConfigFileEntry | undefined {
586
661
  return this._configFileIndex.get(normalizePathForCompare(configPath));
587
662
  }
@@ -608,8 +683,10 @@ class Handoff {
608
683
  }
609
684
  }
610
685
 
611
- export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
686
+ export type { Config, ResolvedConfig, RegisterHandlebarsHelpersContext } from './types/config';
612
687
  export { defineConfig } from './config';
688
+ export { fromEnv } from './config/from-env';
689
+ export type { EnvValue, EnvSecret, RuntimeEnvReference } from './config/from-env';
613
690
  export type {
614
691
  CatalogItem,
615
692
  CatalogItemEntries,
@@ -1,4 +1,4 @@
1
- import 'dotenv/config';
1
+ import '../config/env';
2
2
  import Handoff from '..';
3
3
  import buildApp from '../app-builder';
4
4
  import { Logger } from '../utils/logger';
@@ -21,7 +21,7 @@ export const createVercelBlobStorage = (params: { tokenEnv: string; options: Rec
21
21
  if (!token) {
22
22
  throw new Error(
23
23
  `Vercel Blob storage is configured but the token env var "${params.tokenEnv}" is not set. ` +
24
- 'Set it to a Blob read/write token, or change runtime.registry.assetStorage.tokenEnv.'
24
+ 'Set it to a Blob read/write token, or change runtime.registry.assetStorage.token.'
25
25
  );
26
26
  }
27
27
  return token;
@@ -7,7 +7,7 @@
7
7
  * env-var *names* are ever persisted; secret *values* are read from `process.env` at request time.
8
8
  */
9
9
 
10
- import type { Config } from '../../types/config';
10
+ import type { ResolvedConfig } from '../../types/config';
11
11
  import type { AssetStorageProvider } from '../db/schema';
12
12
 
13
13
  /** Which adapter implementation is active. */
@@ -68,5 +68,8 @@ export const resolveAssetStorageSettings = (settings: AssetStorageSettings | nul
68
68
  };
69
69
 
70
70
  /** Resolve the active asset storage settings from a loaded config (CLI/build side). */
71
- export const resolveAssetStorageFromConfig = (config: Config | null | undefined): ResolvedAssetStorage =>
72
- resolveAssetStorageSettings(config?.runtime?.registry?.assetStorage);
71
+ export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage =>
72
+ resolveAssetStorageSettings({
73
+ ...config?.runtime?.registry?.assetStorage,
74
+ tokenEnv: config?.runtime?.registry?.assetStorage?.token?.$env,
75
+ });
@@ -36,7 +36,7 @@ import Handoff from '../../index';
36
36
  import type { DeclarationFormat } from '../../types/config';
37
37
  import { Logger } from '../../utils/logger';
38
38
  import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
39
- import { resolveAuthenticatedRegistryConnection } from '../connection';
39
+ import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
40
40
  import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
41
41
  import { quoteIds, selectIds, splitCatalogIds } from '../selection';
42
42
  import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
@@ -76,17 +76,17 @@ export const resolveConnectionOrThrow = async (handoff: Handoff) => {
76
76
  );
77
77
  }
78
78
 
79
- const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
79
+ const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
80
80
  if (!connection.url) {
81
81
  throw new CheckoutError(
82
- `No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
82
+ `No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
83
83
  `or set the "${connection.urlEnv}" environment variable to the base URL of the registry to checkout from.`
84
84
  );
85
85
  }
86
86
  if (!connection.accessToken) {
87
87
  throw new CheckoutError(
88
- `No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
89
- `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
88
+ `No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
89
+ `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
90
90
  );
91
91
  }
92
92
  return connection;