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/README.md CHANGED
@@ -359,12 +359,12 @@ runtime variables listed above are supplied through the deployment environment.
359
359
 
360
360
  #### Neon PostgreSQL
361
361
 
362
- The Neon connection driver is selected in `handoff.config.ts`:
362
+ Import `fromEnv` from `handoff-app`. Select the Neon connection driver in `handoff.config.ts`:
363
363
 
364
364
  ```ts
365
365
  runtime: {
366
366
  registry: {
367
- databaseUrlEnv: 'DATABASE_URL',
367
+ databaseUrl: fromEnv('DATABASE_URL'),
368
368
  database: {
369
369
  driver: 'neon',
370
370
  },
@@ -404,6 +404,12 @@ should not be left unattended before installation is complete.
404
404
 
405
405
  ### 4. Workspace authorization
406
406
 
407
+ A workspace reaches a registry with a registry URL and an access token. There
408
+ are two ways to supply them. Both use the same kind of token, and both are
409
+ listed and revoked under Account → Access tokens in the registry.
410
+
411
+ #### Device login
412
+
407
413
  CLI authorization is started from the source workspace with:
408
414
 
409
415
  ```bash
@@ -411,9 +417,48 @@ npm run login -- --url http://localhost:4000
411
417
  ```
412
418
 
413
419
  Registry sign-in, entry of the displayed device code, and CLI approval are
414
- completed in the browser. The revocable credential is saved in
420
+ completed in the browser. The issued credential is saved in
415
421
  `.handoff/cli-auth.json` for that exact registry URL.
416
422
 
423
+ One credential is saved per profile, so a workspace can stay signed in to
424
+ several registries at the same time:
425
+
426
+ ```bash
427
+ npm run login -- --profile staging --url https://staging.example.com
428
+ npm run publish -- all --profile staging
429
+ ```
430
+
431
+ A login profile name is free. It needs no `handoff.config.<name>.*` file, and
432
+ `login` is the only command that accepts a new name. A profile without its own
433
+ login falls back to the default login, so one `npm run login` is still enough
434
+ for a workspace with one registry.
435
+
436
+ `npm run logout` revokes and removes the login of the selected profile. The
437
+ `--all` option removes every saved login.
438
+
439
+ #### Environment variables
440
+
441
+ A token is created in the registry under Account → Access tokens. Read and
442
+ write access is required to publish; read access is enough to check out. The
443
+ token and the registry URL are then set in `.env`:
444
+
445
+ ```dotenv
446
+ HANDOFF_REGISTRY_URL=http://localhost:4000
447
+ HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_...
448
+ ```
449
+
450
+ No configuration file entry is needed, because these two variables are the
451
+ defaults for `runtime.registryConnection`. A selected profile reads
452
+ `.env.<profile>` on top of `.env`, so one workspace can address a different
453
+ registry per profile. In CI, the job environment supplies the same two
454
+ variables instead of a file.
455
+
456
+ Environment values win over a saved device login, so a CI job stays
457
+ deterministic on a machine where a developer is signed in. A token is only used
458
+ for the registry URL it was issued for. If the environment names a different
459
+ registry than the login of the selected profile, publish and checkout report
460
+ which login was skipped and why.
461
+
417
462
  ### 5. Content publishing
418
463
 
419
464
  Every kind in the workspace is published in dependency order with:
@@ -462,13 +507,18 @@ After the registry is reloaded, the published catalog items and foundations are
462
507
  visible. Published database records are read by registry pages; the local
463
508
  workspace is never read directly.
464
509
 
465
- For CI, a registry connection and user-issued token are configured:
510
+ For CI, supply `HANDOFF_REGISTRY_URL` and `HANDOFF_REGISTRY_ACCESS_TOKEN`
511
+ through the job environment, as described under
512
+ [Workspace authorization](#4-workspace-authorization). A connection block in
513
+ `handoff.config.ts` is needed only to pin the URL in the repository, or to read
514
+ the values from differently named variables (`fromEnv` is imported from
515
+ `handoff-app`):
466
516
 
467
517
  ```ts
468
518
  runtime: {
469
519
  registryConnection: {
470
520
  url: 'https://registry.example.com',
471
- accessTokenEnv: 'HANDOFF_REGISTRY_ACCESS_TOKEN',
521
+ accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN'),
472
522
  },
473
523
  },
474
524
  ```
@@ -521,17 +571,19 @@ for Claude Code, Cursor and VS Code behind the plug icon in the header. By hand:
521
571
  ## Configuration
522
572
 
523
573
  Configuration is read from `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in
524
- that order. `defineConfig` provides typed authoring. A partial `runtime` block
525
- is deep-merged with defaults.
574
+ that order. `defineConfig` provides typed authoring. Values merge onto the
575
+ defaults: plain objects merge recursively, and arrays, scalars, `null`, and
576
+ functions replace.
526
577
 
527
578
  Useful environment variables:
528
579
 
529
580
  | Variable | Purpose |
530
581
  | --- | --- |
582
+ | `HANDOFF_PROFILE` | Config profile merged onto the base config |
531
583
  | `HANDOFF_FIGMA_PROJECT_ID` | Figma file ID used by `fetch` |
532
584
  | `HANDOFF_DEV_ACCESS_TOKEN` | Figma personal access token used by `fetch` |
533
585
  | `HANDOFF_REGISTRY_URL` | Connected workspace registry URL |
534
- | `HANDOFF_REGISTRY_ACCESS_TOKEN` | User-issued CI token |
586
+ | `HANDOFF_REGISTRY_ACCESS_TOKEN` | Registry access token used by a connected workspace |
535
587
  | `HANDOFF_SYNC_SECRET` | Optional deployment-wide registry credential |
536
588
  | `DATABASE_URL` | Registry PostgreSQL connection string |
537
589
  | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters |
@@ -550,6 +602,52 @@ Vercel Blob or a custom adapter can be selected through
550
602
  selected through `runtime.registry.database.driver`; PostgreSQL is used by
551
603
  both.
552
604
 
605
+ ## Profiles
606
+
607
+ A profile is a sidecar config file that merges onto the base config. It holds
608
+ only what changes between environments, so a project keeps one shared config
609
+ instead of a second complete copy of it.
610
+
611
+ - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. It is the
612
+ only config file loaded when no profile is selected.
613
+ - A profile file is `handoff.config.<profile>.*`, with the same four
614
+ extensions.
615
+ - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
616
+ `--profile` takes precedence over `HANDOFF_PROFILE`.
617
+ - Profile names are not predefined. A name can contain lowercase letters,
618
+ numbers, and hyphens.
619
+ - A selected profile must exist. If no `handoff.config.<name>.*` file is found,
620
+ the command stops with an error.
621
+ - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
622
+ only a saved registry login, because there the profile selects the registry.
623
+ Every other command still needs the config file.
624
+ - Layers resolve as defaults, base config, profile, then programmatic config,
625
+ with the merge rules described under [Configuration](#configuration).
626
+ - A selected profile also reads `.env.<profile>` on top of `.env`, when that
627
+ file exists. Both are read from the directory the command runs in, and a
628
+ variable already set in the environment beats both files.
629
+
630
+ ```bash
631
+ npx handoff-app build --target registry --profile registry
632
+ ```
633
+
634
+ `build --target registry` always packages a registry-mode application, so a
635
+ profile does not need to set `runtime.mode`. This makes a profile a good place
636
+ for the registry build settings: the database driver, the database
637
+ environment-variable name, the asset storage, and whether MCP is enabled. A
638
+ hosting provider or a CI job selects the profile through `HANDOFF_PROFILE`.
639
+
640
+ `defineConfig` types a profile as well as a base config. The generated
641
+ `.gitignore` lists `handoff.config.local.*`, which makes `local` the usual name
642
+ for a profile that stays on one machine.
643
+
644
+ Arrays replace, so a profile that declares `catalog.include` also decides what
645
+ `make` and `checkout` register.
646
+
647
+ `.env.<profile>` is read once at startup rather than watched.
648
+ `HANDOFF_WORKING_PATH` cannot be set from it, because the working path is
649
+ resolved before any profile is known.
650
+
553
651
  ## CLI reference
554
652
 
555
653
  | Command | Description |
@@ -563,10 +661,12 @@ both.
563
661
  | `npm run validate` | Configured components are validated |
564
662
  | `npm run publish -- <kind\|all> [id...]` | Catalog items, pages, tokens, or assets are published |
565
663
  | `npm run checkout -- <kind\|all> [id...]` | Published content is pulled into a workspace |
566
- | `npm run login -- --url <url>` | The CLI is authorized through the registry device flow |
567
- | `npm run logout -- [--url <url>]` | A saved CLI credential is revoked and removed |
664
+ | `npm run login -- [--profile <name>] --url <url>` | The CLI is authorized through the registry device flow |
665
+ | `npm run logout -- [--profile <name>] [--all]` | Saved CLI credentials are revoked and removed |
568
666
 
569
- `publish` and `checkout` additionally accept `--dry-run`, and `publish` accepts `--no-build`.
667
+ Every command except `init` accepts `-c, --config`, `--profile`, `-d, --debug`,
668
+ and `-f, --force`. `publish` and `checkout` additionally accept `--dry-run`, and
669
+ `publish` accepts `--no-build`.
570
670
 
571
671
  Arguments after `--` are forwarded to the local CLI. Exact options can be shown
572
672
  by adding `--help` after the separator. Advanced configuration and hooks are
package/UPGRADE.md CHANGED
@@ -18,10 +18,30 @@ Each feature section identifies the affected projects and the required changes.
18
18
 
19
19
  | Change | Affected projects |
20
20
  | --- | --- |
21
+ | [Figma token environment references](#figma-token-environment-references) | Projects that put a Figma token directly in config |
21
22
  | [Catalog items](#catalog-items) | Projects with component or pattern declarations |
22
23
 
23
24
  <!-- Add future breaking changes as feature sections before "Verify the upgrade". Add each section to this table. -->
24
25
 
26
+ ### Figma token environment references
27
+
28
+ `devAccessToken` and its legacy spelling `dev_access_token` now accept only an environment reference.
29
+ A literal token throws `HandoffConfigError`, including in JavaScript and JSON configs.
30
+ Move the token to the environment and replace the literal:
31
+
32
+ ```ts
33
+ import { defineConfig, fromEnv } from 'handoff-app';
34
+
35
+ export default defineConfig({
36
+ devAccessToken: fromEnv('HANDOFF_DEV_ACCESS_TOKEN'),
37
+ });
38
+ ```
39
+
40
+ For JSON, use `"devAccessToken": { "$env": "HANDOFF_DEV_ACCESS_TOKEN" }`.
41
+ If you already use `HANDOFF_DEV_ACCESS_TOKEN` and omit the config property, no change is required.
42
+ Environment references resolve after profile merging. Higher-layer literals still override default
43
+ environment seeds for properties that allow literals, such as output directories.
44
+
25
45
  ### Catalog items
26
46
 
27
47
  #### Affected projects
@@ -377,13 +377,21 @@ After migrations and deployment, open \`/install\` immediately and create the in
377
377
  The installer verifies the deployment but never changes the schema. An exposed, uninstalled registry
378
378
  can be claimed by its first visitor, so do not leave this step unattended.
379
379
 
380
- Once installation completes, sign in and authorize a workspace:
380
+ Once installation completes, sign in and authorize a workspace. Either approve the CLI in the
381
+ browser:
381
382
 
382
383
  \`\`\`bash
383
384
  handoff-app login --url https://registry.example.com
384
385
  \`\`\`
385
386
 
386
- The browser approval issues a revocable, user-owned credential. The former
387
+ or create a token under Account > Access tokens and set it in the workspace environment:
388
+
389
+ \`\`\`bash
390
+ HANDOFF_REGISTRY_URL=https://registry.example.com
391
+ HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_…
392
+ \`\`\`
393
+
394
+ Both produce the same revocable, user-owned credential. The former
387
395
  \`HANDOFF_REGISTRY_API_TOKEN\` fixed server secret is no longer accepted.
388
396
 
389
397
  ## Run it (self-hosting)
@@ -95,7 +95,9 @@ const persistClientConfig = (handoff_1, ...args_1) => __awaiter(void 0, [handoff
95
95
  // even when packaged from a connected project. Otherwise a workspace is connected whenever a
96
96
  // registry URL resolves — from config/env or a saved device login — so `login` alone surfaces
97
97
  // the publish/checkout UI without also requiring HANDOFF_REGISTRY_URL.
98
- const connected = mode === 'registry' ? false : Boolean((yield (0, connection_1.resolveAuthenticatedRegistryConnection)(handoff.config, handoff.workingPath)).url);
98
+ const connected = mode === 'registry'
99
+ ? false
100
+ : Boolean((yield (0, connection_1.resolveAuthenticatedRegistryConnection)(handoff.config, handoff.workingPath, handoff.getProfile())).url);
99
101
  clientConfig.runtime = Object.assign(Object.assign({}, clientConfig.runtime), { mode, connected });
100
102
  yield fs_extra_1.default.writeJson(path_1.default.resolve(appPath, 'client.config.json'), { config: clientConfig }, { spaces: 2 });
101
103
  yield fs_extra_1.default.writeJson(path_1.default.resolve(appPath, 'runtime.server.json'), buildServerRuntimeConfig(handoff.config, options.runtimeModeOverride), { spaces: 2 });
@@ -264,17 +264,27 @@ const watchRuntimeConfiguration = (handoff, state) => {
264
264
  const entryBefore = handoff.getConfigFileEntry(changedFile);
265
265
  const strategy = entryBefore ? (0, config_diff_1.getStrategy)(entryBefore.kind) : undefined;
266
266
  const handle = entryBefore && strategy ? strategy.capture(handoff, entryBefore.entityId) : undefined;
267
- handoff.reload();
268
- yield (0, client_config_1.persistClientConfig)(handoff);
267
+ let reloadFailed = false;
268
+ try {
269
+ handoff.reload();
270
+ yield (0, client_config_1.persistClientConfig)(handoff);
271
+ }
272
+ catch (e) {
273
+ // A failed reload leaves the last config that loaded on the instance, so the
274
+ // re-arm below still names the file that just broke. Without it the file drops
275
+ // out of the watch set - chokidar stops tracking a watched file once it is
276
+ // deleted - and a later valid save is never seen.
277
+ reloadFailed = true;
278
+ logger_1.Logger.error('Error reloading runtime configuration:', e);
279
+ }
269
280
  (0, exports.watchRuntimeComponents)(handoff, state, (0, component_1.getRuntimeComponentsPathsToWatch)(handoff));
270
281
  (0, exports.watchRuntimeConfiguration)(handoff, state);
282
+ if (reloadFailed) {
283
+ return;
284
+ }
271
285
  const entryAfter = handoff.getConfigFileEntry(changedFile);
272
- const normalizedChanged = (0, path_2.normalizePathForCompare)(changedFile);
273
- const normalizedMainConfig = handoff.getMainConfigFilePath()
274
- ? (0, path_2.normalizePathForCompare)(handoff.getMainConfigFilePath())
275
- : undefined;
276
286
  const kindChanged = !!entryBefore && !!entryAfter && entryBefore.kind !== entryAfter.kind;
277
- if (normalizedMainConfig && normalizedChanged === normalizedMainConfig) {
287
+ if (handoff.isMainConfigFile(changedFile)) {
278
288
  yield (0, builder_1.default)(handoff);
279
289
  }
280
290
  else if (kindChanged) {
@@ -35,7 +35,11 @@ const CACHE_VERSION = '1.0.0';
35
35
  * Gets the path to the build cache file
36
36
  */
37
37
  function getCachePath(handoff) {
38
- return path_1.default.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', 'build-cache.json');
38
+ // Two profiles share one project id and can build different output, so the profile has to name
39
+ // the cache file. One shared cache would serve the other profile's components.
40
+ const profile = handoff.getProfile();
41
+ const fileName = profile ? `build-cache.${profile}.json` : 'build-cache.json';
42
+ return path_1.default.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', fileName);
39
43
  }
40
44
  /**
41
45
  * Loads the build cache from disk
@@ -1,8 +1,13 @@
1
1
  import { type CliAuth } from './store';
2
2
  export interface LoginOptions {
3
3
  openBrowser?: boolean;
4
+ /** Profile the issued credential is saved under. Omitted, it is the default profile. */
5
+ profile?: string;
4
6
  }
5
- /** Complete the device grant and persist the one-time plaintext access token for this workspace. */
7
+ /**
8
+ * Complete the device grant and persist the one-time plaintext access token for this workspace,
9
+ * under the selected profile.
10
+ */
6
11
  export declare const loginWithDevice: (workingPath: string, remoteUrl: string, options?: LoginOptions) => Promise<CliAuth>;
7
12
  /**
8
13
  * Best-effort remote revocation. The caller must clear the local credential regardless of failure.
@@ -53,7 +53,10 @@ const shouldOpenBrowser = (options) => {
53
53
  return process.env.HANDOFF_LOGIN_NO_BROWSER !== '1' && process.env.HANDOFF_LOGIN_NO_BROWSER !== 'true';
54
54
  };
55
55
  const positiveNumberOr = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
56
- /** Complete the device grant and persist the one-time plaintext access token for this workspace. */
56
+ /**
57
+ * Complete the device grant and persist the one-time plaintext access token for this workspace,
58
+ * under the selected profile.
59
+ */
57
60
  const loginWithDevice = (workingPath_1, remoteUrl_1, ...args_1) => __awaiter(void 0, [workingPath_1, remoteUrl_1, ...args_1], void 0, function* (workingPath, remoteUrl, options = {}) {
58
61
  const baseUrl = (0, store_1.normalizeRegistryUrl)(remoteUrl);
59
62
  let deviceResponse;
@@ -115,7 +118,7 @@ const loginWithDevice = (workingPath_1, remoteUrl_1, ...args_1) => __awaiter(voi
115
118
  accessToken: token.access_token,
116
119
  expiresAtMs: Date.now() + expiresIn * 1000,
117
120
  };
118
- yield (0, store_1.writeCliAuth)(workingPath, auth);
121
+ yield (0, store_1.writeCliAuth)(workingPath, auth, options.profile);
119
122
  return auth;
120
123
  }
121
124
  const failure = responseError(token);
@@ -5,6 +5,8 @@ export interface CliAuth {
5
5
  /** Epoch milliseconds after which the token must not be used. */
6
6
  expiresAtMs: number;
7
7
  }
8
+ /** Name a login is saved under when no profile is selected. */
9
+ export declare const DEFAULT_LOGIN_PROFILE = "default";
8
10
  /**
9
11
  * Canonicalize a registry URL for both HTTP requests and exact credential matching.
10
12
  * Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
@@ -17,13 +19,20 @@ export declare const normalizeRegistryUrl: (value: string) => string;
17
19
  */
18
20
  export declare const assertRegistryOriginUrl: (baseUrl: string, candidate: string) => string;
19
21
  export declare const cliAuthFilePath: (workingPath: string) => string;
20
- /** Invalid or partial credential files are treated as unauthenticated. */
21
- export declare const readCliAuth: (workingPath: string) => Promise<CliAuth | null>;
22
+ /** Every saved login, keyed by profile name. A missing or unreadable file reads as no logins. */
23
+ export declare const readCliLogins: (workingPath: string) => Promise<Record<string, CliAuth>>;
24
+ /** The login saved under one profile name. Never falls back to another profile. */
25
+ export declare const readCliAuth: (workingPath: string, profile?: string) => Promise<CliAuth | null>;
22
26
  /**
23
- * Atomically replace the credential file. Restrictive permissions are best-effort on platforms
24
- * whose filesystems do not support POSIX modes.
27
+ * Profile names that have a saved login. Synchronous because config loading is, and the loader needs
28
+ * these names to decide whether a selected profile resolves.
25
29
  */
26
- export declare const writeCliAuth: (workingPath: string, auth: CliAuth) => Promise<void>;
27
- export declare const clearCliAuth: (workingPath: string) => Promise<void>;
30
+ export declare const loginProfileNames: (workingPath: string) => string[];
31
+ /** Save the login for one profile and leave the other profiles in place. */
32
+ export declare const writeCliAuth: (workingPath: string, auth: CliAuth, profile?: string) => Promise<void>;
33
+ /** Remove the login for one profile, and the file itself once no login is left. */
34
+ export declare const clearCliAuth: (workingPath: string, profile?: string) => Promise<void>;
35
+ /** Remove every saved login. */
36
+ export declare const clearAllCliAuth: (workingPath: string) => Promise<void>;
28
37
  export declare const cliAuthMatchesRegistry: (auth: CliAuth | null, remoteUrl: string) => boolean;
29
38
  export declare const cliAuthTokenIsValid: (auth: CliAuth | null, skewMs?: number) => boolean;
@@ -12,9 +12,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.cliAuthTokenIsValid = exports.cliAuthMatchesRegistry = exports.clearCliAuth = exports.writeCliAuth = exports.readCliAuth = exports.cliAuthFilePath = exports.assertRegistryOriginUrl = exports.normalizeRegistryUrl = void 0;
15
+ exports.cliAuthTokenIsValid = exports.cliAuthMatchesRegistry = exports.clearAllCliAuth = exports.clearCliAuth = exports.writeCliAuth = exports.loginProfileNames = exports.readCliAuth = exports.readCliLogins = exports.cliAuthFilePath = exports.assertRegistryOriginUrl = exports.normalizeRegistryUrl = exports.DEFAULT_LOGIN_PROFILE = void 0;
16
16
  const fs_extra_1 = __importDefault(require("fs-extra"));
17
17
  const path_1 = __importDefault(require("path"));
18
+ /** Name a login is saved under when no profile is selected. */
19
+ exports.DEFAULT_LOGIN_PROFILE = 'default';
18
20
  const AUTH_DIRECTORY = '.handoff';
19
21
  const AUTH_FILE = 'cli-auth.json';
20
22
  /**
@@ -63,38 +65,71 @@ const assertRegistryOriginUrl = (baseUrl, candidate) => {
63
65
  exports.assertRegistryOriginUrl = assertRegistryOriginUrl;
64
66
  const cliAuthFilePath = (workingPath) => path_1.default.resolve(workingPath, AUTH_DIRECTORY, AUTH_FILE);
65
67
  exports.cliAuthFilePath = cliAuthFilePath;
66
- const isCliAuth = (value) => {
68
+ /** An invalid or partial entry reads as no login, so one bad entry cannot hide the others. */
69
+ const toCliAuth = (value) => {
67
70
  if (!value || typeof value !== 'object')
68
- return false;
71
+ return null;
69
72
  const auth = value;
70
- return (typeof auth.remoteUrl === 'string' &&
71
- typeof auth.accessToken === 'string' &&
72
- auth.accessToken.length > 0 &&
73
- typeof auth.expiresAtMs === 'number' &&
74
- Number.isFinite(auth.expiresAtMs));
75
- };
76
- /** Invalid or partial credential files are treated as unauthenticated. */
77
- const readCliAuth = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
73
+ if (typeof auth.remoteUrl !== 'string')
74
+ return null;
75
+ if (typeof auth.accessToken !== 'string' || auth.accessToken.length === 0)
76
+ return null;
77
+ if (typeof auth.expiresAtMs !== 'number' || !Number.isFinite(auth.expiresAtMs))
78
+ return null;
78
79
  try {
79
- const value = (yield fs_extra_1.default.readJson((0, exports.cliAuthFilePath)(workingPath)));
80
- if (!isCliAuth(value))
81
- return null;
82
- return Object.assign(Object.assign({}, value), { remoteUrl: (0, exports.normalizeRegistryUrl)(value.remoteUrl) });
80
+ return { remoteUrl: (0, exports.normalizeRegistryUrl)(auth.remoteUrl), accessToken: auth.accessToken, expiresAtMs: auth.expiresAtMs };
83
81
  }
84
82
  catch (_a) {
85
83
  return null;
86
84
  }
85
+ };
86
+ const toLogins = (value) => {
87
+ const logins = value === null || value === void 0 ? void 0 : value.logins;
88
+ if (!logins || typeof logins !== 'object')
89
+ return {};
90
+ const result = {};
91
+ for (const [profile, entry] of Object.entries(logins)) {
92
+ const auth = toCliAuth(entry);
93
+ if (auth)
94
+ result[profile] = auth;
95
+ }
96
+ return result;
97
+ };
98
+ /** Every saved login, keyed by profile name. A missing or unreadable file reads as no logins. */
99
+ const readCliLogins = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
100
+ try {
101
+ return toLogins((yield fs_extra_1.default.readJson((0, exports.cliAuthFilePath)(workingPath))));
102
+ }
103
+ catch (_a) {
104
+ return {};
105
+ }
87
106
  });
107
+ exports.readCliLogins = readCliLogins;
108
+ /** The login saved under one profile name. Never falls back to another profile. */
109
+ const readCliAuth = (workingPath_1, ...args_1) => __awaiter(void 0, [workingPath_1, ...args_1], void 0, function* (workingPath, profile = exports.DEFAULT_LOGIN_PROFILE) { var _a; return (_a = (yield (0, exports.readCliLogins)(workingPath))[profile]) !== null && _a !== void 0 ? _a : null; });
88
110
  exports.readCliAuth = readCliAuth;
111
+ /**
112
+ * Profile names that have a saved login. Synchronous because config loading is, and the loader needs
113
+ * these names to decide whether a selected profile resolves.
114
+ */
115
+ const loginProfileNames = (workingPath) => {
116
+ try {
117
+ return Object.keys(toLogins(fs_extra_1.default.readJsonSync((0, exports.cliAuthFilePath)(workingPath))));
118
+ }
119
+ catch (_a) {
120
+ return [];
121
+ }
122
+ };
123
+ exports.loginProfileNames = loginProfileNames;
89
124
  /**
90
125
  * Atomically replace the credential file. Restrictive permissions are best-effort on platforms
91
126
  * whose filesystems do not support POSIX modes.
92
127
  */
93
- const writeCliAuth = (workingPath, auth) => __awaiter(void 0, void 0, void 0, function* () {
128
+ const writeCliLogins = (workingPath, logins) => __awaiter(void 0, void 0, void 0, function* () {
94
129
  const filePath = (0, exports.cliAuthFilePath)(workingPath);
95
130
  const directory = path_1.default.dirname(filePath);
96
131
  const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
97
- const value = Object.assign(Object.assign({}, auth), { remoteUrl: (0, exports.normalizeRegistryUrl)(auth.remoteUrl) });
132
+ const value = { logins };
98
133
  yield fs_extra_1.default.ensureDir(directory, 0o700);
99
134
  try {
100
135
  yield fs_extra_1.default.chmod(directory, 0o700);
@@ -123,14 +158,32 @@ const writeCliAuth = (workingPath, auth) => __awaiter(void 0, void 0, void 0, fu
123
158
  throw error;
124
159
  }
125
160
  });
161
+ /** Save the login for one profile and leave the other profiles in place. */
162
+ const writeCliAuth = (workingPath_1, auth_1, ...args_1) => __awaiter(void 0, [workingPath_1, auth_1, ...args_1], void 0, function* (workingPath, auth, profile = exports.DEFAULT_LOGIN_PROFILE) {
163
+ const logins = yield (0, exports.readCliLogins)(workingPath);
164
+ logins[profile] = Object.assign(Object.assign({}, auth), { remoteUrl: (0, exports.normalizeRegistryUrl)(auth.remoteUrl) });
165
+ yield writeCliLogins(workingPath, logins);
166
+ });
126
167
  exports.writeCliAuth = writeCliAuth;
127
- const clearCliAuth = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
168
+ /** Remove the login for one profile, and the file itself once no login is left. */
169
+ const clearCliAuth = (workingPath_1, ...args_1) => __awaiter(void 0, [workingPath_1, ...args_1], void 0, function* (workingPath, profile = exports.DEFAULT_LOGIN_PROFILE) {
170
+ const logins = yield (0, exports.readCliLogins)(workingPath);
171
+ delete logins[profile];
172
+ if (Object.keys(logins).length === 0) {
173
+ yield (0, exports.clearAllCliAuth)(workingPath);
174
+ return;
175
+ }
176
+ yield writeCliLogins(workingPath, logins);
177
+ });
178
+ exports.clearCliAuth = clearCliAuth;
179
+ /** Remove every saved login. */
180
+ const clearAllCliAuth = (workingPath) => __awaiter(void 0, void 0, void 0, function* () {
128
181
  const filePath = (0, exports.cliAuthFilePath)(workingPath);
129
182
  if (yield fs_extra_1.default.pathExists(filePath)) {
130
183
  yield fs_extra_1.default.remove(filePath);
131
184
  }
132
185
  });
133
- exports.clearCliAuth = clearCliAuth;
186
+ exports.clearAllCliAuth = clearAllCliAuth;
134
187
  const cliAuthMatchesRegistry = (auth, remoteUrl) => {
135
188
  if (!auth)
136
189
  return false;
@@ -4,5 +4,10 @@ export interface LoginArgs extends SharedArgs {
4
4
  url?: string;
5
5
  browser?: boolean;
6
6
  }
7
+ /**
8
+ * Saves one registry credential per profile, so a workspace can hold a login for every registry it
9
+ * publishes to. The name is independent of the config profiles, and `login` is the one command that
10
+ * accepts a profile nothing else knows yet.
11
+ */
7
12
  declare const command: CommandModule<{}, LoginArgs>;
8
13
  export default command;
@@ -14,6 +14,11 @@ const store_1 = require("../../cli/auth/store");
14
14
  const connection_1 = require("../../registry/connection");
15
15
  const logger_1 = require("../../utils/logger");
16
16
  const utils_1 = require("../utils");
17
+ /**
18
+ * Saves one registry credential per profile, so a workspace can hold a login for every registry it
19
+ * publishes to. The name is independent of the config profiles, and `login` is the one command that
20
+ * accepts a profile nothing else knows yet.
21
+ */
17
22
  const command = {
18
23
  command: 'login',
19
24
  describe: 'Sign in to a registry using browser device authorization',
@@ -28,17 +33,24 @@ const command = {
28
33
  describe: 'Open the approval URL in the default browser (disable with --no-browser)',
29
34
  }),
30
35
  handler: (args) => __awaiter(void 0, void 0, void 0, function* () {
31
- var _a;
32
- const handoff = (0, utils_1.createHandoff)(args);
36
+ var _a, _b;
37
+ const handoff = (0, utils_1.createHandoff)(args, { profileWithoutConfig: 'any' });
38
+ const profile = (_a = handoff.getProfile()) !== null && _a !== void 0 ? _a : store_1.DEFAULT_LOGIN_PROFILE;
33
39
  try {
34
40
  const configured = (0, connection_1.resolveRegistryConnection)(handoff.config);
35
- const remoteUrl = ((_a = args.url) === null || _a === void 0 ? void 0 : _a.trim()) || configured.url;
41
+ // Resolved in the order publish and checkout use, so a login always targets the registry those
42
+ // commands would reach.
43
+ const saved = yield (0, store_1.readCliAuth)(handoff.workingPath, profile);
44
+ const remoteUrl = ((_b = args.url) === null || _b === void 0 ? void 0 : _b.trim()) || configured.url || (saved === null || saved === void 0 ? void 0 : saved.remoteUrl);
36
45
  if (!remoteUrl) {
37
46
  throw new Error(`Pass --url <registry-url>, configure runtime.registryConnection.url, or set "${configured.urlEnv}".`);
38
47
  }
39
- yield (0, device_1.loginWithDevice)(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false });
40
- logger_1.Logger.success(`Logged in. Credentials saved to ${(0, store_1.cliAuthFilePath)(handoff.workingPath)}.`);
41
- logger_1.Logger.info('Publish and checkout commands will use this login for the matching registry URL.');
48
+ yield (0, device_1.loginWithDevice)(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false, profile });
49
+ const named = profile === store_1.DEFAULT_LOGIN_PROFILE ? '' : ` under profile "${profile}"`;
50
+ logger_1.Logger.success(`Logged in. Credentials saved to ${(0, store_1.cliAuthFilePath)(handoff.workingPath)}${named}.`);
51
+ logger_1.Logger.info(profile === store_1.DEFAULT_LOGIN_PROFILE
52
+ ? 'Publish and checkout commands will use this login for the matching registry URL.'
53
+ : `Publish and checkout commands will use this login when they run with --profile ${profile}.`);
42
54
  }
43
55
  catch (error) {
44
56
  logger_1.Logger.error(error instanceof Error ? error.message : String(error));
@@ -2,6 +2,11 @@ import { CommandModule } from 'yargs';
2
2
  import { SharedArgs } from '../types';
3
3
  export interface LogoutArgs extends SharedArgs {
4
4
  url?: string;
5
+ all?: boolean;
5
6
  }
7
+ /**
8
+ * `handoff-app logout` removes the login of the selected profile, leaving every other profile signed
9
+ * in. `--all` removes them all.
10
+ */
6
11
  declare const command: CommandModule<{}, LogoutArgs>;
7
12
  export default command;