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
@@ -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
  /**
@@ -30,6 +30,9 @@ owns `fromCSF`, because CSF is a source format of React rather than a framework.
30
30
  The renderer entry points call `createCatalogItem` with their renderer. The pattern entry point calls it without a renderer.
31
31
  `define.ts` handles the shared `implementation` forms: a helper's `{ format, file }`, a file path, or an imported component.
32
32
 
33
+ An entry point re-exports only the preview shape its renderer honors: `react.ts` exports `CatalogPreview` with `render`,
34
+ `handlebars.ts` narrows it to reject `render`, and `pattern.ts` exports neither.
35
+
33
36
  A new renderer needs:
34
37
 
35
38
  - A `RENDERERS` row in `renderers.ts`
@@ -101,6 +101,8 @@ export const normalizeCatalogItem = (moduleExports: Record<string, unknown>, opt
101
101
  entries[entryKey] = path.resolve(directory, source.file);
102
102
  // Existing builders consume template as the primary build input for every renderer.
103
103
  entries.template = entries[entryKey];
104
+ // The renderer and the hydration bundle both read a preview `render` from the declaration.
105
+ if (options.declarationPath) entries.declaration = options.declarationPath;
104
106
  // A wrong renderer builds a broken preview without failing on its own, so a mismatch is fatal.
105
107
  // `config/runtime.ts` turns the throw into a skipped item that retries on the next save.
106
108
  const claim = sourceForFile(source.file);
@@ -5,15 +5,17 @@ import type { CatalogPreview } from './types';
5
5
 
6
6
  /**
7
7
  * A declaration module exports more than previews: the item itself, the implementation it
8
- * re-exports, helper values. Accept only a plain object that owns `args` or `name`, so an exported
9
- * component or helper is never mistaken for a preview.
8
+ * re-exports, helper values. Accept only a plain object that owns one of the preview fields, so an
9
+ * exported component or helper is never mistaken for a preview.
10
10
  *
11
11
  * `getCsfStoryEntries` does not fit here: on a CSF file every named export is a story, and on a
12
12
  * declaration file it is not.
13
13
  */
14
+ const PREVIEW_KEYS = ['args', 'name', 'render'] as const;
15
+
14
16
  const isPreviewDeclaration = (value: unknown): boolean => {
15
17
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
16
- return Object.prototype.hasOwnProperty.call(value, 'args') || Object.prototype.hasOwnProperty.call(value, 'name');
18
+ return PREVIEW_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
17
19
  };
18
20
 
19
21
  /** Matches an exported binding, an export list, or a CommonJS export assignment. */
@@ -104,6 +106,7 @@ export const createCatalogPreviews = (
104
106
  values: { ...(preview.args ?? {}) },
105
107
  url: preview.url ?? '',
106
108
  ...(preview.usage ? { usage: preview.usage } : {}),
109
+ ...(typeof preview.render === 'function' ? { render: preview.render as OptionalPreviewRender['render'] } : {}),
107
110
  };
108
111
  }
109
112
 
@@ -53,6 +53,14 @@ export type CatalogPreview<TArgs = Record<string, unknown>> = {
53
53
  args?: TArgs;
54
54
  usage?: string;
55
55
  url?: string;
56
+ /**
57
+ * Renders this preview in place of the implementation, for props it cannot carry as data: an icon
58
+ * component, a callback, JSX children, or state the preview owns.
59
+ *
60
+ * Rendered as a component with `args`, so it can use hooks. The item still documents the
61
+ * implementation, so a wrapper never becomes the published API.
62
+ */
63
+ render?: (args: TArgs) => unknown;
56
64
  };
57
65
 
58
66
  /**
@@ -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