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
@@ -120,7 +120,9 @@ function RegistryTokensPage() {
120
120
  <Card>
121
121
  <CardHeader>
122
122
  <CardTitle>Create access token</CardTitle>
123
- <CardDescription>Use tokens for CI or integrations that cannot complete the browser device login.</CardDescription>
123
+ <CardDescription>
124
+ Connect a workspace, a CI job, or an integration. This is the same credential the CLI login issues.
125
+ </CardDescription>
124
126
  </CardHeader>
125
127
  <form onSubmit={create}>
126
128
  <CardContent className="space-y-4">
@@ -443,13 +443,21 @@ After migrations and deployment, open \`/install\` immediately and create the in
443
443
  The installer verifies the deployment but never changes the schema. An exposed, uninstalled registry
444
444
  can be claimed by its first visitor, so do not leave this step unattended.
445
445
 
446
- Once installation completes, sign in and authorize a workspace:
446
+ Once installation completes, sign in and authorize a workspace. Either approve the CLI in the
447
+ browser:
447
448
 
448
449
  \`\`\`bash
449
450
  handoff-app login --url https://registry.example.com
450
451
  \`\`\`
451
452
 
452
- The browser approval issues a revocable, user-owned credential. The former
453
+ or create a token under Account > Access tokens and set it in the workspace environment:
454
+
455
+ \`\`\`bash
456
+ HANDOFF_REGISTRY_URL=https://registry.example.com
457
+ HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_…
458
+ \`\`\`
459
+
460
+ Both produce the same revocable, user-owned credential. The former
453
461
  \`HANDOFF_REGISTRY_API_TOKEN\` fixed server secret is no longer accepted.
454
462
 
455
463
  ## Run it (self-hosting)
@@ -5,7 +5,7 @@ import { getClientConfig, isMcpEnabled } from '../config';
5
5
  import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
6
6
  import { resolveAuthenticatedRegistryConnection } from '../registry/connection';
7
7
  import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
8
- import type { Config, RuntimeMode } from '../types/config';
8
+ import type { ResolvedConfig, RuntimeMode } from '../types/config';
9
9
  import { getAppPath } from './paths';
10
10
 
11
11
  /**
@@ -53,7 +53,7 @@ export const generateTokensApi = async (handoff: Handoff) => {
53
53
  * var) to back the registry-mode docs read API. These are non-secret — the connection-string value
54
54
  * itself is never persisted, only resolved from the env var at request time.
55
55
  */
56
- const buildServerRuntimeConfig = (config: Config, modeOverride?: RuntimeMode) => {
56
+ const buildServerRuntimeConfig = (config: ResolvedConfig, modeOverride?: RuntimeMode) => {
57
57
  const assetStorage = resolveAssetStorageFromConfig(config);
58
58
  return {
59
59
  mode: modeOverride ?? config?.runtime?.mode ?? 'workspace',
@@ -100,7 +100,9 @@ export const persistClientConfig = async (handoff: Handoff, options: PersistClie
100
100
  // registry URL resolves — from config/env or a saved device login — so `login` alone surfaces
101
101
  // the publish/checkout UI without also requiring HANDOFF_REGISTRY_URL.
102
102
  const connected =
103
- mode === 'registry' ? false : Boolean((await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath)).url);
103
+ mode === 'registry'
104
+ ? false
105
+ : Boolean((await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile())).url);
104
106
  clientConfig.runtime = { ...clientConfig.runtime, mode, connected };
105
107
 
106
108
  await fs.writeJson(path.resolve(appPath, 'client.config.json'), { config: clientConfig }, { spaces: 2 });
@@ -250,21 +250,31 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState)
250
250
  const handle =
251
251
  entryBefore && strategy ? strategy.capture(handoff, entryBefore.entityId) : undefined;
252
252
 
253
- handoff.reload();
254
- await persistClientConfig(handoff);
253
+ let reloadFailed = false;
254
+ try {
255
+ handoff.reload();
256
+ await persistClientConfig(handoff);
257
+ } catch (e) {
258
+ // A failed reload leaves the last config that loaded on the instance, so the
259
+ // re-arm below still names the file that just broke. Without it the file drops
260
+ // out of the watch set - chokidar stops tracking a watched file once it is
261
+ // deleted - and a later valid save is never seen.
262
+ reloadFailed = true;
263
+ Logger.error('Error reloading runtime configuration:', e);
264
+ }
265
+
255
266
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
256
267
  watchRuntimeConfiguration(handoff, state);
257
268
 
258
- const entryAfter = handoff.getConfigFileEntry(changedFile);
269
+ if (reloadFailed) {
270
+ return;
271
+ }
259
272
 
260
- const normalizedChanged = normalizePathForCompare(changedFile);
261
- const normalizedMainConfig = handoff.getMainConfigFilePath()
262
- ? normalizePathForCompare(handoff.getMainConfigFilePath() as string)
263
- : undefined;
273
+ const entryAfter = handoff.getConfigFileEntry(changedFile);
264
274
 
265
275
  const kindChanged = !!entryBefore && !!entryAfter && entryBefore.kind !== entryAfter.kind;
266
276
 
267
- if (normalizedMainConfig && normalizedChanged === normalizedMainConfig) {
277
+ if (handoff.isMainConfigFile(changedFile)) {
268
278
  await processComponents(handoff);
269
279
  } else if (kindChanged) {
270
280
  // The declaration switched between an implementation and a composition. A targeted
@@ -48,7 +48,11 @@ export interface BuildCache {
48
48
  * Gets the path to the build cache file
49
49
  */
50
50
  export function getCachePath(handoff: Handoff): string {
51
- return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', 'build-cache.json');
51
+ // Two profiles share one project id and can build different output, so the profile has to name
52
+ // the cache file. One shared cache would serve the other profile's components.
53
+ const profile = handoff.getProfile();
54
+ const fileName = profile ? `build-cache.${profile}.json` : 'build-cache.json';
55
+ return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', fileName);
52
56
  }
53
57
 
54
58
  /**
@@ -31,6 +31,8 @@ interface Envelope<T> {
31
31
 
32
32
  export interface LoginOptions {
33
33
  openBrowser?: boolean;
34
+ /** Profile the issued credential is saved under. Omitted, it is the default profile. */
35
+ profile?: string;
34
36
  }
35
37
 
36
38
  const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
@@ -71,7 +73,10 @@ const shouldOpenBrowser = (options: LoginOptions): boolean => {
71
73
  const positiveNumberOr = (value: unknown, fallback: number): number =>
72
74
  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
73
75
 
74
- /** Complete the device grant and persist the one-time plaintext access token for this workspace. */
76
+ /**
77
+ * Complete the device grant and persist the one-time plaintext access token for this workspace,
78
+ * under the selected profile.
79
+ */
75
80
  export const loginWithDevice = async (workingPath: string, remoteUrl: string, options: LoginOptions = {}): Promise<CliAuth> => {
76
81
  const baseUrl = normalizeRegistryUrl(remoteUrl);
77
82
  let deviceResponse: Response;
@@ -98,7 +103,7 @@ export const loginWithDevice = async (workingPath: string, remoteUrl: string, op
98
103
  const verificationUrl = assertRegistryOriginUrl(
99
104
  baseUrl,
100
105
  device.verification_uri_complete ||
101
- `${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}`,
106
+ `${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}`
102
107
  );
103
108
 
104
109
  Logger.log('');
@@ -142,7 +147,7 @@ export const loginWithDevice = async (workingPath: string, remoteUrl: string, op
142
147
  accessToken: token.access_token,
143
148
  expiresAtMs: Date.now() + expiresIn * 1000,
144
149
  };
145
- await writeCliAuth(workingPath, auth);
150
+ await writeCliAuth(workingPath, auth, options.profile);
146
151
  return auth;
147
152
  }
148
153
 
@@ -9,9 +9,17 @@ export interface CliAuth {
9
9
  expiresAtMs: number;
10
10
  }
11
11
 
12
+ /** Name a login is saved under when no profile is selected. */
13
+ export const DEFAULT_LOGIN_PROFILE = 'default';
14
+
12
15
  const AUTH_DIRECTORY = '.handoff';
13
16
  const AUTH_FILE = 'cli-auth.json';
14
17
 
18
+ /** The credential file: one login per profile name, so a workspace can hold several registries. */
19
+ interface CliAuthFile {
20
+ logins: Record<string, CliAuth>;
21
+ }
22
+
15
23
  /**
16
24
  * Canonicalize a registry URL for both HTTP requests and exact credential matching.
17
25
  * Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
@@ -58,38 +66,67 @@ export const assertRegistryOriginUrl = (baseUrl: string, candidate: string): str
58
66
 
59
67
  export const cliAuthFilePath = (workingPath: string): string => path.resolve(workingPath, AUTH_DIRECTORY, AUTH_FILE);
60
68
 
61
- const isCliAuth = (value: unknown): value is CliAuth => {
62
- if (!value || typeof value !== 'object') return false;
69
+ /** An invalid or partial entry reads as no login, so one bad entry cannot hide the others. */
70
+ const toCliAuth = (value: unknown): CliAuth | null => {
71
+ if (!value || typeof value !== 'object') return null;
63
72
  const auth = value as Partial<CliAuth>;
64
- return (
65
- typeof auth.remoteUrl === 'string' &&
66
- typeof auth.accessToken === 'string' &&
67
- auth.accessToken.length > 0 &&
68
- typeof auth.expiresAtMs === 'number' &&
69
- Number.isFinite(auth.expiresAtMs)
70
- );
71
- };
73
+ if (typeof auth.remoteUrl !== 'string') return null;
74
+ if (typeof auth.accessToken !== 'string' || auth.accessToken.length === 0) return null;
75
+ if (typeof auth.expiresAtMs !== 'number' || !Number.isFinite(auth.expiresAtMs)) return null;
72
76
 
73
- /** Invalid or partial credential files are treated as unauthenticated. */
74
- export const readCliAuth = async (workingPath: string): Promise<CliAuth | null> => {
75
77
  try {
76
- const value = (await fs.readJson(cliAuthFilePath(workingPath))) as unknown;
77
- if (!isCliAuth(value)) return null;
78
- return { ...value, remoteUrl: normalizeRegistryUrl(value.remoteUrl) };
78
+ return { remoteUrl: normalizeRegistryUrl(auth.remoteUrl), accessToken: auth.accessToken, expiresAtMs: auth.expiresAtMs };
79
79
  } catch {
80
80
  return null;
81
81
  }
82
82
  };
83
83
 
84
+ const toLogins = (value: unknown): Record<string, CliAuth> => {
85
+ const logins = (value as Partial<CliAuthFile> | null)?.logins;
86
+ if (!logins || typeof logins !== 'object') return {};
87
+
88
+ const result: Record<string, CliAuth> = {};
89
+ for (const [profile, entry] of Object.entries(logins as Record<string, unknown>)) {
90
+ const auth = toCliAuth(entry);
91
+ if (auth) result[profile] = auth;
92
+ }
93
+ return result;
94
+ };
95
+
96
+ /** Every saved login, keyed by profile name. A missing or unreadable file reads as no logins. */
97
+ export const readCliLogins = async (workingPath: string): Promise<Record<string, CliAuth>> => {
98
+ try {
99
+ return toLogins((await fs.readJson(cliAuthFilePath(workingPath))) as unknown);
100
+ } catch {
101
+ return {};
102
+ }
103
+ };
104
+
105
+ /** The login saved under one profile name. Never falls back to another profile. */
106
+ export const readCliAuth = async (workingPath: string, profile: string = DEFAULT_LOGIN_PROFILE): Promise<CliAuth | null> =>
107
+ (await readCliLogins(workingPath))[profile] ?? null;
108
+
109
+ /**
110
+ * Profile names that have a saved login. Synchronous because config loading is, and the loader needs
111
+ * these names to decide whether a selected profile resolves.
112
+ */
113
+ export const loginProfileNames = (workingPath: string): string[] => {
114
+ try {
115
+ return Object.keys(toLogins(fs.readJsonSync(cliAuthFilePath(workingPath)) as unknown));
116
+ } catch {
117
+ return [];
118
+ }
119
+ };
120
+
84
121
  /**
85
122
  * Atomically replace the credential file. Restrictive permissions are best-effort on platforms
86
123
  * whose filesystems do not support POSIX modes.
87
124
  */
88
- export const writeCliAuth = async (workingPath: string, auth: CliAuth): Promise<void> => {
125
+ const writeCliLogins = async (workingPath: string, logins: Record<string, CliAuth>): Promise<void> => {
89
126
  const filePath = cliAuthFilePath(workingPath);
90
127
  const directory = path.dirname(filePath);
91
128
  const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
92
- const value: CliAuth = { ...auth, remoteUrl: normalizeRegistryUrl(auth.remoteUrl) };
129
+ const value: CliAuthFile = { logins };
93
130
 
94
131
  await fs.ensureDir(directory, 0o700);
95
132
  try {
@@ -117,7 +154,27 @@ export const writeCliAuth = async (workingPath: string, auth: CliAuth): Promise<
117
154
  }
118
155
  };
119
156
 
120
- export const clearCliAuth = async (workingPath: string): Promise<void> => {
157
+ /** Save the login for one profile and leave the other profiles in place. */
158
+ export const writeCliAuth = async (workingPath: string, auth: CliAuth, profile: string = DEFAULT_LOGIN_PROFILE): Promise<void> => {
159
+ const logins = await readCliLogins(workingPath);
160
+ logins[profile] = { ...auth, remoteUrl: normalizeRegistryUrl(auth.remoteUrl) };
161
+ await writeCliLogins(workingPath, logins);
162
+ };
163
+
164
+ /** Remove the login for one profile, and the file itself once no login is left. */
165
+ export const clearCliAuth = async (workingPath: string, profile: string = DEFAULT_LOGIN_PROFILE): Promise<void> => {
166
+ const logins = await readCliLogins(workingPath);
167
+ delete logins[profile];
168
+
169
+ if (Object.keys(logins).length === 0) {
170
+ await clearAllCliAuth(workingPath);
171
+ return;
172
+ }
173
+ await writeCliLogins(workingPath, logins);
174
+ };
175
+
176
+ /** Remove every saved login. */
177
+ export const clearAllCliAuth = async (workingPath: string): Promise<void> => {
121
178
  const filePath = cliAuthFilePath(workingPath);
122
179
  if (await fs.pathExists(filePath)) {
123
180
  await fs.remove(filePath);
@@ -1,6 +1,6 @@
1
1
  import { CommandModule } from 'yargs';
2
2
  import { loginWithDevice } from '../../cli/auth/device';
3
- import { cliAuthFilePath } from '../../cli/auth/store';
3
+ import { cliAuthFilePath, DEFAULT_LOGIN_PROFILE, readCliAuth } from '../../cli/auth/store';
4
4
  import { resolveRegistryConnection } from '../../registry/connection';
5
5
  import { Logger } from '../../utils/logger';
6
6
  import { SharedArgs } from '../types';
@@ -11,6 +11,11 @@ export interface LoginArgs extends SharedArgs {
11
11
  browser?: boolean;
12
12
  }
13
13
 
14
+ /**
15
+ * Saves one registry credential per profile, so a workspace can hold a login for every registry it
16
+ * publishes to. The name is independent of the config profiles, and `login` is the one command that
17
+ * accepts a profile nothing else knows yet.
18
+ */
14
19
  const command: CommandModule<{}, LoginArgs> = {
15
20
  command: 'login',
16
21
  describe: 'Sign in to a registry using browser device authorization',
@@ -26,17 +31,26 @@ const command: CommandModule<{}, LoginArgs> = {
26
31
  describe: 'Open the approval URL in the default browser (disable with --no-browser)',
27
32
  }),
28
33
  handler: async (args: LoginArgs) => {
29
- const handoff = createHandoff(args);
34
+ const handoff = createHandoff(args, { profileWithoutConfig: 'any' });
35
+ const profile = handoff.getProfile() ?? DEFAULT_LOGIN_PROFILE;
30
36
  try {
31
37
  const configured = resolveRegistryConnection(handoff.config);
32
- const remoteUrl = args.url?.trim() || configured.url;
38
+ // Resolved in the order publish and checkout use, so a login always targets the registry those
39
+ // commands would reach.
40
+ const saved = await readCliAuth(handoff.workingPath, profile);
41
+ const remoteUrl = args.url?.trim() || configured.url || saved?.remoteUrl;
33
42
  if (!remoteUrl) {
34
43
  throw new Error(`Pass --url <registry-url>, configure runtime.registryConnection.url, or set "${configured.urlEnv}".`);
35
44
  }
36
45
 
37
- await loginWithDevice(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false });
38
- Logger.success(`Logged in. Credentials saved to ${cliAuthFilePath(handoff.workingPath)}.`);
39
- Logger.info('Publish and checkout commands will use this login for the matching registry URL.');
46
+ await loginWithDevice(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false, profile });
47
+ const named = profile === DEFAULT_LOGIN_PROFILE ? '' : ` under profile "${profile}"`;
48
+ Logger.success(`Logged in. Credentials saved to ${cliAuthFilePath(handoff.workingPath)}${named}.`);
49
+ Logger.info(
50
+ profile === DEFAULT_LOGIN_PROFILE
51
+ ? 'Publish and checkout commands will use this login for the matching registry URL.'
52
+ : `Publish and checkout commands will use this login when they run with --profile ${profile}.`
53
+ );
40
54
  } catch (error) {
41
55
  Logger.error(error instanceof Error ? error.message : String(error));
42
56
  process.exitCode = 1;
@@ -1,45 +1,99 @@
1
1
  import { CommandModule } from 'yargs';
2
2
  import { revokeAccessToken } from '../../cli/auth/device';
3
- import { clearCliAuth, cliAuthFilePath, cliAuthMatchesRegistry, normalizeRegistryUrl, readCliAuth } from '../../cli/auth/store';
3
+ import {
4
+ type CliAuth,
5
+ clearAllCliAuth,
6
+ clearCliAuth,
7
+ cliAuthFilePath,
8
+ cliAuthMatchesRegistry,
9
+ DEFAULT_LOGIN_PROFILE,
10
+ normalizeRegistryUrl,
11
+ readCliAuth,
12
+ readCliLogins,
13
+ } from '../../cli/auth/store';
4
14
  import { Logger } from '../../utils/logger';
5
15
  import { SharedArgs } from '../types';
6
16
  import { createHandoff, getSharedOptions } from '../utils';
7
17
 
8
18
  export interface LogoutArgs extends SharedArgs {
9
19
  url?: string;
20
+ all?: boolean;
10
21
  }
11
22
 
23
+ /** Remote revocation is best effort: the local credential goes either way. */
24
+ const revoke = async (auth: CliAuth): Promise<void> => {
25
+ try {
26
+ await revokeAccessToken(auth);
27
+ } catch (error) {
28
+ Logger.warn(
29
+ `Could not revoke the registry token for ${auth.remoteUrl} (${error instanceof Error ? error.message : String(error)}). ` +
30
+ 'It will still be removed from this workspace.'
31
+ );
32
+ }
33
+ };
34
+
35
+ /**
36
+ * `handoff-app logout` removes the login of the selected profile, leaving every other profile signed
37
+ * in. `--all` removes them all.
38
+ */
12
39
  const command: CommandModule<{}, LogoutArgs> = {
13
40
  command: 'logout',
14
41
  describe: 'Revoke and remove saved registry credentials',
15
42
  builder: (yargs) =>
16
- getSharedOptions(yargs).option('url', {
17
- type: 'string',
18
- describe: 'Revoke credentials only when they belong to this exact registry URL',
19
- }),
43
+ getSharedOptions(yargs)
44
+ .option('url', {
45
+ type: 'string',
46
+ describe: 'Revoke credentials only when they belong to this exact registry URL',
47
+ })
48
+ .option('all', {
49
+ type: 'boolean',
50
+ describe: 'Revoke and remove the saved credentials for every profile',
51
+ })
52
+ .conflicts('all', 'url'),
20
53
  handler: async (args: LogoutArgs) => {
21
- const handoff = createHandoff(args);
22
- const auth = await readCliAuth(handoff.workingPath);
23
- if (args.url && auth && !cliAuthMatchesRegistry(auth, args.url)) {
24
- Logger.error(`The saved login belongs to ${auth.remoteUrl}, not ${normalizeRegistryUrl(args.url)}. No credentials were changed.`);
25
- process.exitCode = 1;
26
- return;
27
- }
54
+ const handoff = createHandoff(args, { profileWithoutConfig: 'saved-login' });
55
+ const profile = handoff.getProfile() ?? DEFAULT_LOGIN_PROFILE;
56
+ const named = profile === DEFAULT_LOGIN_PROFILE ? '' : ` for profile "${profile}"`;
57
+
58
+ if (args.all) {
59
+ const logins = await readCliLogins(handoff.workingPath);
60
+ const names = Object.keys(logins);
61
+ if (!names.length) {
62
+ Logger.info('No saved logins.');
63
+ return;
64
+ }
28
65
 
29
- if (auth) {
66
+ for (const name of names) {
67
+ await revoke(logins[name]);
68
+ }
30
69
  try {
31
- await revokeAccessToken(auth);
70
+ await clearAllCliAuth(handoff.workingPath);
71
+ Logger.success(`Logged out of every saved profile: ${names.join(', ')}. Removed ${cliAuthFilePath(handoff.workingPath)}.`);
32
72
  } catch (error) {
33
- Logger.warn(
34
- `Could not revoke the registry token remotely (${error instanceof Error ? error.message : String(error)}). ` +
35
- 'It will still be removed from this workspace.'
36
- );
73
+ Logger.error(`Could not remove saved credentials: ${error instanceof Error ? error.message : String(error)}`);
74
+ process.exitCode = 1;
37
75
  }
76
+ return;
77
+ }
78
+
79
+ const auth = await readCliAuth(handoff.workingPath, profile);
80
+ if (!auth) {
81
+ Logger.info(`No saved login${named}.`);
82
+ return;
38
83
  }
84
+ if (args.url && !cliAuthMatchesRegistry(auth, args.url)) {
85
+ Logger.error(
86
+ `The saved login${named} belongs to ${auth.remoteUrl}, not ${normalizeRegistryUrl(args.url)}. No credentials were changed.`
87
+ );
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+
92
+ await revoke(auth);
39
93
 
40
94
  try {
41
- await clearCliAuth(handoff.workingPath);
42
- Logger.success(`Logged out. Removed ${cliAuthFilePath(handoff.workingPath)}.`);
95
+ await clearCliAuth(handoff.workingPath, profile);
96
+ Logger.success(`Logged out of ${auth.remoteUrl}${named}. Removed the saved credentials.`);
43
97
  } catch (error) {
44
98
  Logger.error(`Could not remove saved credentials: ${error instanceof Error ? error.message : String(error)}`);
45
99
  process.exitCode = 1;
@@ -3,6 +3,8 @@ import { Arguments } from 'yargs';
3
3
  export interface SharedArgs extends Arguments {
4
4
  /** Explicit config file, replacing the usual `handoff.config.*` lookup. */
5
5
  config?: string;
6
+ /** Config profile merged onto the base config; overrides `HANDOFF_PROFILE`. */
7
+ profile?: string;
6
8
  force?: boolean;
7
9
  debug?: boolean;
8
10
  /**
@@ -1,5 +1,5 @@
1
1
  import { Argv } from 'yargs';
2
- import Handoff from '../';
2
+ import Handoff, { HandoffOptions } from '../';
3
3
  import { REGISTRY_ENTITY_KINDS } from '../registry/content-kinds';
4
4
  import { Logger } from '../utils/logger';
5
5
  import { SharedArgs } from './types';
@@ -20,22 +20,26 @@ export type RegistryTargetKind = (typeof REGISTRY_TARGET_KINDS)[number];
20
20
  export const COMPAT_TARGET_KINDS = [...REGISTRY_ENTITY_KINDS, 'all'] as const;
21
21
  export type CompatTargetKind = (typeof COMPAT_TARGET_KINDS)[number];
22
22
 
23
- export const createHandoff = (args: SharedArgs): Handoff =>
23
+ export const createHandoff = (args: SharedArgs, options: Pick<HandoffOptions, 'profileWithoutConfig'> = {}): Handoff =>
24
24
  new Handoff({
25
25
  debug: args.debug,
26
26
  force: args.force,
27
27
  configPath: args.config,
28
+ profile: args.profile,
28
29
  dryRun: args.dryRun,
29
30
  // yargs gives `--no-build` as `build: false`; anything else leaves the build in place.
30
31
  skipBuild: args.build === false,
32
+ ...options,
31
33
  });
32
34
 
33
35
  /**
34
- * Run a registry command against a fresh Handoff. Registry failures are the user's to act on, so they
35
- * surface as one actionable line and a non-zero exit rather than a stack trace.
36
+ * Run a registry command against a fresh Handoff. A selected profile may name a saved login instead
37
+ * of a config file, because here the profile selects the registry. Registry failures are the
38
+ * user's to act on, so they surface as one actionable line and a non-zero exit rather than a stack
39
+ * trace.
36
40
  */
37
41
  export const runRegistryCommand = async (args: SharedArgs, run: (handoff: Handoff) => Promise<unknown>): Promise<void> => {
38
- const handoff = createHandoff(args);
42
+ const handoff = createHandoff(args, { profileWithoutConfig: 'saved-login' });
39
43
  try {
40
44
  await run(handoff);
41
45
  } catch (error) {
@@ -61,6 +65,11 @@ export const getSharedOptions = (yargs: Argv) => {
61
65
  type: 'boolean',
62
66
  description: 'Enable debug mode',
63
67
  },
68
+ profile: {
69
+ type: 'string',
70
+ description:
71
+ 'Config profile merged onto the base config (handoff.config.<profile>.*), and the saved login that registry commands use; overrides HANDOFF_PROFILE',
72
+ },
64
73
  });
65
74
  };
66
75
 
@@ -7,10 +7,15 @@ Handles loading, validation, and resolution of handoff configuration.
7
7
  | File | Purpose |
8
8
  |------|---------|
9
9
  | `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
10
- | `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order), warns on conflicts, and merges overrides |
10
+ | `from-env.ts` | JSON-compatible environment markers and public types |
11
+ | `resolve-env.ts` | Resolves merged environment references and guards reference-only properties |
12
+ | `errors.ts` | Shared `HandoffConfigError` |
13
+ | `env.ts` | Reads `.env` at load, then `loadProfileEnv()` applies `.env.<profile>` on top |
14
+ | `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order) plus the selected profile sidecar, warns on conflicts, and merges every layer |
11
15
  | `runtime.ts` | `initRuntimeConfig()` — one pass over every registered catalog directory: loads each declaration, normalizes it into a component or pattern record, skips a declaration with a taken id, resolves SCSS/JS paths and transformer options |
12
16
  | `catalog-include.ts` | `isIncluded()` / `addToCatalog()` — registers a directory under `catalog.include` |
13
17
  | `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
18
+ | `helpers.ts` | `defineConfig()` — typed authoring; maps camelCase keys to the runtime keys |
14
19
  | `index.ts` | Barrel re-exports |
15
20
 
16
21
  ## Usage
@@ -31,3 +36,58 @@ When multiple main config files exist in the project root, Handoff picks the fir
31
36
  2. `handoff.config.js`
32
37
  3. `handoff.config.cjs`
33
38
  4. `handoff.config.json`
39
+
40
+ ## Profiles
41
+
42
+ `--profile <name>`, or `HANDOFF_PROFILE` when the flag is absent, adds one sidecar file. It sits beside the resolved base config: the base file name without its extension, then the profile name, then any config extension. So `handoff.config.ts` pairs with `handoff.config.<name>.*`, and a config named through `-c` keeps its own name. The same four extensions are probed in the same order. A selected profile that does not resolve is a `HandoffConfigError`.
43
+
44
+ `ConfigLoadContext.knownProfiles` widens what resolving means: a listed name loads the base config unchanged instead of failing. The CLI passes the saved registry login names for `login`, `logout`, `publish`, and `checkout`, where a profile selects the registry rather than a config layer. `profileNotFoundHint` is the caller's sentence for a name that matches neither. Everything else keeps the strict rule, so a typo cannot silently run with the base config.
45
+
46
+ ## Environment Files
47
+
48
+ `.env` is read once when `env.ts` loads, before the first `Handoff` is constructed. A selected profile then adds an optional `.env.<profile>` from the same directory. Precedence is the process environment, then the profile env file, then `.env`. The keys present at startup are recorded before `.env` is read and are never overwritten, so an inline `VAR=… handoff-app …` still wins.
49
+
50
+ `loadProfileEnv()` runs twice: in `Handoff.construct()` before `Logger.init()`, so the log level can come from a profile, and in `initConfigWithMetadata()` before the merged environment references resolve. Applying it twice changes nothing.
51
+
52
+ ## Layer Precedence
53
+
54
+ Every loaded file is normalized, then the layers are merged in this order:
55
+
56
+ 1. `defaultConfig()`
57
+ 2. Base config file
58
+ 3. Profile file
59
+ 4. Programmatic override (`HandoffOptions.config`)
60
+
61
+ Plain objects merge recursively. Arrays, scalars, `null`, and functions replace. `undefined` is skipped.
62
+
63
+ ## Environment references
64
+
65
+ Import `fromEnv` from `handoff-app`. `fromEnv('OUTPUT_DIR', { default: 'exported' })` creates
66
+ `{ $env: 'OUTPUT_DIR', default: 'exported' }` without reading the environment. JSON configs use
67
+ that same object. Without a default, an unset variable raises `HandoffConfigError` with the
68
+ property path, variable name, and active profile. Invalid names and rejected literals are never echoed.
69
+
70
+ `EnvValue<T>` accepts a literal or reference. `EnvSecret<T>` accepts only a reference and resolves
71
+ at config load. Secret defaults can only be empty or null, for optional credentials.
72
+ `RuntimeEnvReference<T>` accepts only a reference with no default. Its name survives config loading and
73
+ is read in the deployed process. Database URLs and asset-storage tokens are deferred.
74
+
75
+ The loader resolves references once after all layers merge, before other config guards.
76
+ References replace whole values, including earlier references and their defaults. A profile can
77
+ replace an ordinary reference with a literal. Functions in hooks and pipeline remain functions.
78
+ Defaults retain the existing `HANDOFF_*` seeds: a higher-layer literal beats a seed, and an explicit
79
+ reference beats every lower layer. Only the winning reference reads the environment.
80
+
81
+ Environment values are strings. A numeric or boolean default selects conversion; the built-in
82
+ numeric and boolean properties also retain their default's conversion when a reference omits its
83
+ fallback. Booleans are true only for `true`. Numbers must be finite. A lower-layer fallback never
84
+ becomes the fallback for a replacement reference.
85
+
86
+ `runtime.registry.assetStorage.options` is for non-secret values only. Its resolved contents are
87
+ JSON-encoded into the bundle, so the loader rejects an environment reference anywhere inside it
88
+ (`BAKED_PATHS`). For custom adapter secrets, put a variable name in options and read it through the
89
+ adapter factory's `env` argument at runtime. A per-environment literal belongs in a profile.
90
+
91
+ `ResolvedConfig` narrows eager properties by hand. Adding an eager property requires its `Config`
92
+ member and resolved narrowing. A secret also needs a `SECRET_PATHS` entry for untyped configs.
93
+ Adding a deferred property requires its member and a `DEFERRED_PATHS` entry.
@@ -14,13 +14,13 @@
14
14
 
15
15
  import fs from 'fs-extra';
16
16
  import path from 'path';
17
- import { Config } from '../types/config';
17
+ import { ResolvedConfig } from '../types/config';
18
18
  import { arePathsEqual } from '../utils/path';
19
19
  import { getComponentsForPath } from './runtime';
20
20
 
21
21
  /** Minimal handoff shape needed here; avoids importing the full Handoff class (circular dep). */
22
22
  interface ConfigContext {
23
- config?: Config;
23
+ config?: ResolvedConfig;
24
24
  workingPath: string;
25
25
  }
26
26