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
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;
@@ -1,22 +1,14 @@
1
1
  /**
2
- * Connected-workspace registry connection resolution.
3
- *
4
- * A connected workspace is `runtime.mode: 'workspace'` plus a `runtime.registryConnection` block
5
- * pointing at a remote registry — it is not a third runtime mode. The connection is resolved from
6
- * config + environment: the registry URL may be given inline or via an env-var *name*, and the
7
- * access token is always read from an env-var name (never persisted as a value). This is the single
8
- * source the publish (and later checkout) client reads its target + credentials from.
9
- *
10
- * The older `HANDOFF_CLOUD_*` names still work as deprecated fallbacks; see
11
- * {@link DEPRECATED_ENV_ALIASES}.
2
+ * Registry connection values resolve at config load. Saved device login credentials can fill
3
+ * missing values for CLI operations; neither source is persisted in the app's client config.
12
4
  */
13
5
 
14
- import type { Config } from '../types/config';
15
- import { cliAuthMatchesRegistry, cliAuthTokenIsValid, readCliAuth } from '../cli/auth/store';
6
+ import { configEnvName } from '../config/resolve-env';
7
+ import type { ResolvedConfig } from '../types/config';
8
+ import { type CliAuth, cliAuthMatchesRegistry, cliAuthTokenIsValid, DEFAULT_LOGIN_PROFILE, readCliAuth } from '../cli/auth/store';
16
9
  // Imported straight from the module, not the `auth` barrel, to keep the database schema off the CLI
17
10
  // startup path; this file is resolved for every command.
18
11
  import { resolveRegistrySyncSecret } from './auth/sync-secret';
19
- import { Logger } from '../utils/logger';
20
12
 
21
13
  /** Default env-var name holding the remote registry base URL. */
22
14
  export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
@@ -24,38 +16,11 @@ export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
24
16
  /** Default env-var name holding the connected-workspace access token. */
25
17
  export const DEFAULT_REGISTRY_ACCESS_TOKEN_ENV = 'HANDOFF_REGISTRY_ACCESS_TOKEN';
26
18
 
27
- /**
28
- * Old env-var names we still read, keyed by the name that replaced them. Keeps workspaces set up
29
- * from the older docs working instead of silently reporting no registry.
30
- */
31
- const DEPRECATED_ENV_ALIASES: Readonly<Record<string, string>> = {
32
- [DEFAULT_REGISTRY_URL_ENV]: 'HANDOFF_CLOUD_URL',
33
- [DEFAULT_REGISTRY_ACCESS_TOKEN_ENV]: 'HANDOFF_CLOUD_TOKEN',
34
- };
35
-
36
- /** Aliases we've already warned about, so each one warns once per run and not once per lookup. */
37
- const warnedAliases = new Set<string>();
38
-
39
- /**
40
- * Value of the named env var, falling back to its deprecated alias. Only the default names have
41
- * aliases: a custom `urlEnv`/`accessTokenEnv` is a deliberate choice, so a stray `HANDOFF_CLOUD_*`
42
- * must not stand in for it. The warning goes through {@link Logger.warn}, so
43
- * `HANDOFF_LOG_LEVEL=error` silences it in CI.
44
- */
45
- const readConnectionEnv = (name: string): string => {
46
- const configured = process.env[name]?.trim();
47
- if (configured) return configured;
48
-
49
- const alias = DEPRECATED_ENV_ALIASES[name];
50
- const inherited = alias ? process.env[alias]?.trim() : '';
51
- if (!inherited) return '';
52
-
53
- if (!warnedAliases.has(alias)) {
54
- warnedAliases.add(alias);
55
- Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
56
- }
57
- return inherited;
58
- };
19
+ /** A saved login and the profile it is stored under, which is not always the selected profile. */
20
+ interface SavedLogin {
21
+ profile: string;
22
+ url: string;
23
+ }
59
24
 
60
25
  /** Fully resolved connected-workspace registry connection. */
61
26
  export interface ResolvedRegistryConnection {
@@ -67,55 +32,91 @@ export interface ResolvedRegistryConnection {
67
32
  urlEnv: string;
68
33
  /** Name of the env var the access token is read from. */
69
34
  accessTokenEnv: string;
35
+ /** Profile the saved login was looked up under. */
36
+ loginProfile: string;
37
+ /** Set when a saved login was skipped because it was issued for another registry. */
38
+ unmatchedLogin?: SavedLogin;
70
39
  }
71
40
 
72
- /**
73
- * Resolve the connected-workspace registry connection from config + environment. Never throws —
74
- * Callers inspect the resolved URL and access token to surface specific configuration errors.
75
- *
76
- * `urlEnv` and `accessTokenEnv` always report the canonical name, even when an alias supplied the
77
- * value, so errors built from them point at the variable to adopt.
78
- */
79
- export const resolveRegistryConnection = (config: Config | null | undefined): ResolvedRegistryConnection => {
41
+ /** Read values resolved at config load, retaining variable names for CLI diagnostics. */
42
+ export const resolveRegistryConnection = (config: ResolvedConfig | null | undefined): ResolvedRegistryConnection => {
80
43
  const connection = config?.runtime?.registryConnection;
81
- const urlEnv = connection?.urlEnv?.trim() || DEFAULT_REGISTRY_URL_ENV;
82
- const accessTokenEnv = connection?.accessTokenEnv?.trim() || DEFAULT_REGISTRY_ACCESS_TOKEN_ENV;
83
-
84
- const inlineUrl = connection?.url?.trim();
85
- const url = inlineUrl || readConnectionEnv(urlEnv);
86
- const accessToken = readConnectionEnv(accessTokenEnv);
44
+ const urlEnv = configEnvName(config, 'runtime.registryConnection.url') || DEFAULT_REGISTRY_URL_ENV;
45
+ const accessTokenEnv = configEnvName(config, 'runtime.registryConnection.accessToken') || DEFAULT_REGISTRY_ACCESS_TOKEN_ENV;
46
+ const url = connection?.url?.trim() || '';
47
+ const accessToken = connection?.accessToken?.trim() || '';
87
48
 
88
49
  return {
89
50
  url,
90
51
  accessToken,
91
52
  urlEnv,
92
53
  accessTokenEnv,
54
+ loginProfile: DEFAULT_LOGIN_PROFILE,
93
55
  };
94
56
  };
95
57
 
58
+ /**
59
+ * The valid login to use for a profile. A profile without its own login falls back to the default
60
+ * login, so a workspace that names profiles for other reasons keeps working after one plain
61
+ * `handoff-app login`. The fallback is safe because a token is only ever used for the registry URL
62
+ * it was issued for.
63
+ */
64
+ const readProfileLogin = async (workingPath: string, profile: string): Promise<{ auth: CliAuth; profile: string } | null> => {
65
+ const own = await readCliAuth(workingPath, profile);
66
+ if (cliAuthTokenIsValid(own)) return { auth: own!, profile };
67
+
68
+ const fallback = profile === DEFAULT_LOGIN_PROFILE ? null : await readCliAuth(workingPath);
69
+ return cliAuthTokenIsValid(fallback) ? { auth: fallback!, profile: DEFAULT_LOGIN_PROFILE } : null;
70
+ };
71
+
96
72
  /**
97
73
  * Resolve the same connection for an authenticated CLI operation, letting a saved device login fill
98
74
  * in what the environment does not provide. A valid login supplies both the registry URL and the
99
- * access token, so `handoff-app login` alone is enough to publish and checkout. Environment values
100
- * keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config) wins over the
101
- * saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved token only
102
- * applies to the URL it was issued for.
75
+ * access token, so `handoff-app login` alone is enough to publish and checkout. The login is the one
76
+ * saved under the selected profile, which is how one workspace addresses several registries.
77
+ * Environment values keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config)
78
+ * wins over the saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved
79
+ * token only applies to the URL it was issued for.
103
80
  *
104
81
  * A configured `HANDOFF_SYNC_SECRET` is the last resort. Being deployment-wide rather than personal,
105
82
  * it fills in only where there is no explicit token and no login (CI, in practice), instead of quietly
106
83
  * speaking for a developer who is signed in.
107
84
  */
108
85
  export const resolveAuthenticatedRegistryConnection = async (
109
- config: Config | null | undefined,
110
- workingPath = process.cwd()
86
+ config: ResolvedConfig | null | undefined,
87
+ workingPath = process.cwd(),
88
+ profile?: string
111
89
  ): Promise<ResolvedRegistryConnection> => {
112
90
  const connection = resolveRegistryConnection(config);
91
+ const loginProfile = profile?.trim() || DEFAULT_LOGIN_PROFILE;
113
92
 
114
- const auth = await readCliAuth(workingPath);
115
- const login = cliAuthTokenIsValid(auth) ? auth! : null;
93
+ const login = await readProfileLogin(workingPath, loginProfile);
94
+ const url = connection.url || login?.auth.remoteUrl || '';
95
+ const matches = Boolean(login) && cliAuthMatchesRegistry(login!.auth, url);
96
+ const accessToken = connection.accessToken || (matches ? login!.auth.accessToken : '') || resolveRegistrySyncSecret();
116
97
 
117
- const url = connection.url || login?.remoteUrl || '';
118
- const savedToken = login && cliAuthMatchesRegistry(login, url) ? login.accessToken : '';
119
- const accessToken = connection.accessToken || savedToken || resolveRegistrySyncSecret();
120
- return { ...connection, url, accessToken };
98
+ return {
99
+ ...connection,
100
+ url,
101
+ accessToken,
102
+ loginProfile,
103
+ unmatchedLogin: login && !matches ? { profile: login.profile, url: login.auth.remoteUrl } : undefined,
104
+ };
121
105
  };
106
+
107
+ /** The `handoff-app login` call that would authorize this connection, naming the selected profile. */
108
+ export const loginCommandHint = (connection: ResolvedRegistryConnection): string =>
109
+ [
110
+ 'handoff-app login',
111
+ connection.loginProfile === DEFAULT_LOGIN_PROFILE ? '' : ` --profile ${connection.loginProfile}`,
112
+ ` --url ${connection.url || '<registry-url>'}`,
113
+ ].join('');
114
+
115
+ /**
116
+ * Names a saved login that was skipped because it belongs to another registry. Without it, a run
117
+ * that resolves its URL from the environment reports a missing token while a login is in place.
118
+ */
119
+ export const unusedLoginNote = (connection: ResolvedRegistryConnection): string =>
120
+ connection.unmatchedLogin
121
+ ? ` The login saved for profile "${connection.unmatchedLogin.profile}" is for ${connection.unmatchedLogin.url}, not ${connection.url}.`
122
+ : '';
@@ -10,7 +10,7 @@
10
10
  * the value is never stored in config.
11
11
  */
12
12
 
13
- import type { Config } from '../../types/config';
13
+ import type { ResolvedConfig } from '../../types/config';
14
14
 
15
15
  /** Built-in registry database connection driver. Both ship with the package and target Postgres. */
16
16
  export type RegistryDatabaseDriver = 'pg' | 'neon';
@@ -35,24 +35,13 @@ export interface ResolvedRegistryDatabase {
35
35
  }
36
36
 
37
37
  /** Resolve the env-var name holding the database URL (defaults to `DATABASE_URL`). */
38
- export const resolveDatabaseUrlEnv = (config: Config | null | undefined): string => {
39
- const configured = config?.runtime?.registry?.databaseUrlEnv?.trim();
38
+ export const resolveDatabaseUrlEnv = (config: ResolvedConfig | null | undefined): string => {
39
+ const configured = config?.runtime?.registry?.databaseUrl?.$env?.trim();
40
40
  return configured || DEFAULT_DATABASE_URL_ENV;
41
41
  };
42
42
 
43
- /**
44
- * Resolve the legacy fixed-token env-var name for configuration compatibility.
45
- *
46
- * @deprecated The returned env var is intentionally ignored by registry authorization. Use a
47
- * user-issued access token or `handoff-app login`.
48
- */
49
- export const resolveApiTokenEnv = (config: Config | null | undefined): string => {
50
- const configured = config?.runtime?.registry?.apiTokenEnv?.trim();
51
- return configured || DEFAULT_REGISTRY_API_TOKEN_ENV;
52
- };
53
-
54
43
  /** Resolve the configured registry database driver (defaults to `pg`). */
55
- export const resolveRegistryDriver = (config: Config | null | undefined): RegistryDatabaseDriver => {
44
+ export const resolveRegistryDriver = (config: ResolvedConfig | null | undefined): RegistryDatabaseDriver => {
56
45
  return config?.runtime?.registry?.database?.driver ?? DEFAULT_REGISTRY_DRIVER;
57
46
  };
58
47
 
@@ -61,7 +50,7 @@ export const resolveRegistryDriver = (config: Config | null | undefined): Regist
61
50
  * when the configured env var is unset so the CLI can surface a clear "missing database URL"
62
51
  * message instead of failing deep inside the driver.
63
52
  */
64
- export const resolveRegistryDatabase = (config: Config | null | undefined): ResolvedRegistryDatabase => {
53
+ export const resolveRegistryDatabase = (config: ResolvedConfig | null | undefined): ResolvedRegistryDatabase => {
65
54
  const driver = resolveRegistryDriver(config);
66
55
  const databaseUrlEnv = resolveDatabaseUrlEnv(config);
67
56
  const connectionString = process.env[databaseUrlEnv]?.trim();
@@ -69,7 +58,7 @@ export const resolveRegistryDatabase = (config: Config | null | undefined): Reso
69
58
  if (!connectionString) {
70
59
  throw new Error(
71
60
  `Registry database URL is not configured. Set the "${databaseUrlEnv}" environment variable to a ` +
72
- `PostgreSQL connection string (or change "runtime.registry.databaseUrlEnv" to point at the env var you use).`
61
+ `PostgreSQL connection string (or change "runtime.registry.databaseUrl" to point at the env var you use).`
73
62
  );
74
63
  }
75
64
 
@@ -29,7 +29,6 @@ export {
29
29
  DEFAULT_DATABASE_URL_ENV,
30
30
  DEFAULT_REGISTRY_DRIVER,
31
31
  DEFAULT_REGISTRY_API_TOKEN_ENV,
32
- resolveApiTokenEnv,
33
32
  resolveDatabaseUrlEnv,
34
33
  resolveRegistryDriver,
35
34
  resolveRegistryDatabase,
@@ -17,7 +17,7 @@ import processComponents from '../../transformers/preview/component/builder';
17
17
  import { processPatterns } from '../../transformers/preview/pattern/builder';
18
18
  import { Logger } from '../../utils/logger';
19
19
  import { createRegistryClient, RegistryClientError, type RegistryClient } from '../client';
20
- import { resolveAuthenticatedRegistryConnection } from '../connection';
20
+ import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
21
21
  import type { EntitySummary, TransferEntityKind } from '../transfer';
22
22
  import { quoteIds, selectIds, splitCatalogIds } from '../selection';
23
23
  import { describePublishError, describeUploadFailure } from './errors';
@@ -74,17 +74,17 @@ const assertUniqueCatalogIds = (handoff: Handoff): void => {
74
74
  export const resolveConnectionOrThrow = async (handoff: Handoff) => {
75
75
  assertPublishableWorkspace(handoff);
76
76
 
77
- const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
77
+ const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
78
78
  if (!connection.url) {
79
79
  throw new PublishError(
80
- `No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
80
+ `No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
81
81
  `or set the "${connection.urlEnv}" environment variable to the base URL of the registry to publish to.`
82
82
  );
83
83
  }
84
84
  if (!connection.accessToken) {
85
85
  throw new PublishError(
86
- `No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
87
- `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
86
+ `No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
87
+ `"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
88
88
  );
89
89
  }
90
90
  return connection;
@@ -14,7 +14,7 @@ import path from 'path';
14
14
  import { resolveTokenTransformers, tokenArtifactPathsForSet } from '../pipeline/token-transformers';
15
15
  import { deriveTokenSets, isComponentSet, setNameForId } from '../registry/tokens/sets';
16
16
  import type { ComponentListObject, PageListObject, PatternListObject } from '../transformers/preview/types';
17
- import type { Config, RuntimeConfig } from '../types/config';
17
+ import type { ResolvedConfig, RuntimeConfig } from '../types/config';
18
18
  import { getRelatedSourceFilesForRecord, sourceContentTypeForPath } from './source-files';
19
19
  import type {
20
20
  ComponentStore,
@@ -34,7 +34,7 @@ import type {
34
34
  */
35
35
  export interface FilesystemStoreContext {
36
36
  runtimeConfig?: RuntimeConfig | null;
37
- config?: Config | null;
37
+ config?: ResolvedConfig | null;
38
38
  /** Workspace root (`Handoff.workingPath`), holding `public/` and `fonts/`. */
39
39
  workingPath?: string;
40
40
  /** Absolute path to the generated `tokens.json` (`Handoff.getTokensFilePath()`). */