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
package/README.md CHANGED
@@ -74,17 +74,19 @@ The declaration supplies stable identity, documentation metadata, source entries
74
74
 
75
75
  ### React
76
76
 
77
- Handoff finds the implementation file from its import, so you do not need to repeat the path.
77
+ Handoff finds the implementation file from its import, so you do not need to repeat the path, and
78
+ builds the item's CSS from the stylesheets that import chain pulls in, descendants included.
78
79
  Previews are named exports. `Preview<typeof item>` gives them the implementation's argument type.
79
80
 
80
81
  ```tsx
81
82
  // components/example/Component.tsx
82
83
  export type ComponentProps = {
83
84
  label: string;
85
+ onSelect?: () => void;
84
86
  };
85
87
 
86
- export default function Component({ label }: ComponentProps) {
87
- return <div>{label}</div>;
88
+ export default function Component({ label, onSelect }: ComponentProps) {
89
+ return <div onClick={onSelect}>{label}</div>;
88
90
  }
89
91
  ```
90
92
 
@@ -112,6 +114,20 @@ export const Default = {
112
114
 
113
115
  The export name is the preview name. Use `name` to set a different display title.
114
116
 
117
+ Arguments are serialized, so a preview that needs an icon, a callback, JSX children, or its own
118
+ state declares `render` instead. It renders as a component, so a capitalized name lets it use hooks, and the item keeps
119
+ documenting the implementation rather than a wrapper. Name a declaration that holds JSX
120
+ `example.handoff.tsx`.
121
+
122
+ ```tsx
123
+ export const Interactive = {
124
+ render: function Interactive() {
125
+ const [label, setLabel] = useState('Example');
126
+ return <Component label={label} onSelect={() => setLabel('Selected')} />;
127
+ },
128
+ } satisfies ComponentPreview;
129
+ ```
130
+
115
131
  ### Handlebars
116
132
 
117
133
  The implementation is the path to the template. Arguments are `Record<string, unknown>` by default;
@@ -359,12 +375,12 @@ runtime variables listed above are supplied through the deployment environment.
359
375
 
360
376
  #### Neon PostgreSQL
361
377
 
362
- The Neon connection driver is selected in `handoff.config.ts`:
378
+ Import `fromEnv` from `handoff-app`. Select the Neon connection driver in `handoff.config.ts`:
363
379
 
364
380
  ```ts
365
381
  runtime: {
366
382
  registry: {
367
- databaseUrlEnv: 'DATABASE_URL',
383
+ databaseUrl: fromEnv('DATABASE_URL'),
368
384
  database: {
369
385
  driver: 'neon',
370
386
  },
@@ -404,6 +420,12 @@ should not be left unattended before installation is complete.
404
420
 
405
421
  ### 4. Workspace authorization
406
422
 
423
+ A workspace reaches a registry with a registry URL and an access token. There
424
+ are two ways to supply them. Both use the same kind of token, and both are
425
+ listed and revoked under Account → Access tokens in the registry.
426
+
427
+ #### Device login
428
+
407
429
  CLI authorization is started from the source workspace with:
408
430
 
409
431
  ```bash
@@ -411,9 +433,48 @@ npm run login -- --url http://localhost:4000
411
433
  ```
412
434
 
413
435
  Registry sign-in, entry of the displayed device code, and CLI approval are
414
- completed in the browser. The revocable credential is saved in
436
+ completed in the browser. The issued credential is saved in
415
437
  `.handoff/cli-auth.json` for that exact registry URL.
416
438
 
439
+ One credential is saved per profile, so a workspace can stay signed in to
440
+ several registries at the same time:
441
+
442
+ ```bash
443
+ npm run login -- --profile staging --url https://staging.example.com
444
+ npm run publish -- all --profile staging
445
+ ```
446
+
447
+ A login profile name is free. It needs no `handoff.config.<name>.*` file, and
448
+ `login` is the only command that accepts a new name. A profile without its own
449
+ login falls back to the default login, so one `npm run login` is still enough
450
+ for a workspace with one registry.
451
+
452
+ `npm run logout` revokes and removes the login of the selected profile. The
453
+ `--all` option removes every saved login.
454
+
455
+ #### Environment variables
456
+
457
+ A token is created in the registry under Account → Access tokens. Read and
458
+ write access is required to publish; read access is enough to check out. The
459
+ token and the registry URL are then set in `.env`:
460
+
461
+ ```dotenv
462
+ HANDOFF_REGISTRY_URL=http://localhost:4000
463
+ HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_...
464
+ ```
465
+
466
+ No configuration file entry is needed, because these two variables are the
467
+ defaults for `runtime.registryConnection`. A selected profile reads
468
+ `.env.<profile>` on top of `.env`, so one workspace can address a different
469
+ registry per profile. In CI, the job environment supplies the same two
470
+ variables instead of a file.
471
+
472
+ Environment values win over a saved device login, so a CI job stays
473
+ deterministic on a machine where a developer is signed in. A token is only used
474
+ for the registry URL it was issued for. If the environment names a different
475
+ registry than the login of the selected profile, publish and checkout report
476
+ which login was skipped and why.
477
+
417
478
  ### 5. Content publishing
418
479
 
419
480
  Every kind in the workspace is published in dependency order with:
@@ -462,13 +523,18 @@ After the registry is reloaded, the published catalog items and foundations are
462
523
  visible. Published database records are read by registry pages; the local
463
524
  workspace is never read directly.
464
525
 
465
- For CI, a registry connection and user-issued token are configured:
526
+ For CI, supply `HANDOFF_REGISTRY_URL` and `HANDOFF_REGISTRY_ACCESS_TOKEN`
527
+ through the job environment, as described under
528
+ [Workspace authorization](#4-workspace-authorization). A connection block in
529
+ `handoff.config.ts` is needed only to pin the URL in the repository, or to read
530
+ the values from differently named variables (`fromEnv` is imported from
531
+ `handoff-app`):
466
532
 
467
533
  ```ts
468
534
  runtime: {
469
535
  registryConnection: {
470
536
  url: 'https://registry.example.com',
471
- accessTokenEnv: 'HANDOFF_REGISTRY_ACCESS_TOKEN',
537
+ accessToken: fromEnv('HANDOFF_REGISTRY_ACCESS_TOKEN'),
472
538
  },
473
539
  },
474
540
  ```
@@ -521,17 +587,19 @@ for Claude Code, Cursor and VS Code behind the plug icon in the header. By hand:
521
587
  ## Configuration
522
588
 
523
589
  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.
590
+ that order. `defineConfig` provides typed authoring. Values merge onto the
591
+ defaults: plain objects merge recursively, and arrays, scalars, `null`, and
592
+ functions replace.
526
593
 
527
594
  Useful environment variables:
528
595
 
529
596
  | Variable | Purpose |
530
597
  | --- | --- |
598
+ | `HANDOFF_PROFILE` | Config profile merged onto the base config |
531
599
  | `HANDOFF_FIGMA_PROJECT_ID` | Figma file ID used by `fetch` |
532
600
  | `HANDOFF_DEV_ACCESS_TOKEN` | Figma personal access token used by `fetch` |
533
601
  | `HANDOFF_REGISTRY_URL` | Connected workspace registry URL |
534
- | `HANDOFF_REGISTRY_ACCESS_TOKEN` | User-issued CI token |
602
+ | `HANDOFF_REGISTRY_ACCESS_TOKEN` | Registry access token used by a connected workspace |
535
603
  | `HANDOFF_SYNC_SECRET` | Optional deployment-wide registry credential |
536
604
  | `DATABASE_URL` | Registry PostgreSQL connection string |
537
605
  | `AUTH_SECRET` | Registry session-signing secret, at least 32 characters |
@@ -550,6 +618,52 @@ Vercel Blob or a custom adapter can be selected through
550
618
  selected through `runtime.registry.database.driver`; PostgreSQL is used by
551
619
  both.
552
620
 
621
+ ## Profiles
622
+
623
+ A profile is a sidecar config file that merges onto the base config. It holds
624
+ only what changes between environments, so a project keeps one shared config
625
+ instead of a second complete copy of it.
626
+
627
+ - The base config is `handoff.config.ts`, `.js`, `.cjs`, or `.json`. It is the
628
+ only config file loaded when no profile is selected.
629
+ - A profile file is `handoff.config.<profile>.*`, with the same four
630
+ extensions.
631
+ - Select a profile with `--profile <name>` or with `HANDOFF_PROFILE`.
632
+ `--profile` takes precedence over `HANDOFF_PROFILE`.
633
+ - Profile names are not predefined. A name can contain lowercase letters,
634
+ numbers, and hyphens.
635
+ - A selected profile must exist. If no `handoff.config.<name>.*` file is found,
636
+ the command stops with an error.
637
+ - `login`, `logout`, `publish`, and `checkout` also accept a profile that has
638
+ only a saved registry login, because there the profile selects the registry.
639
+ Every other command still needs the config file.
640
+ - Layers resolve as defaults, base config, profile, then programmatic config,
641
+ with the merge rules described under [Configuration](#configuration).
642
+ - A selected profile also reads `.env.<profile>` on top of `.env`, when that
643
+ file exists. Both are read from the directory the command runs in, and a
644
+ variable already set in the environment beats both files.
645
+
646
+ ```bash
647
+ npx handoff-app build --target registry --profile registry
648
+ ```
649
+
650
+ `build --target registry` always packages a registry-mode application, so a
651
+ profile does not need to set `runtime.mode`. This makes a profile a good place
652
+ for the registry build settings: the database driver, the database
653
+ environment-variable name, the asset storage, and whether MCP is enabled. A
654
+ hosting provider or a CI job selects the profile through `HANDOFF_PROFILE`.
655
+
656
+ `defineConfig` types a profile as well as a base config. The generated
657
+ `.gitignore` lists `handoff.config.local.*`, which makes `local` the usual name
658
+ for a profile that stays on one machine.
659
+
660
+ Arrays replace, so a profile that declares `catalog.include` also decides what
661
+ `make` and `checkout` register.
662
+
663
+ `.env.<profile>` is read once at startup rather than watched.
664
+ `HANDOFF_WORKING_PATH` cannot be set from it, because the working path is
665
+ resolved before any profile is known.
666
+
553
667
  ## CLI reference
554
668
 
555
669
  | Command | Description |
@@ -563,10 +677,12 @@ both.
563
677
  | `npm run validate` | Configured components are validated |
564
678
  | `npm run publish -- <kind\|all> [id...]` | Catalog items, pages, tokens, or assets are published |
565
679
  | `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 |
680
+ | `npm run login -- [--profile <name>] --url <url>` | The CLI is authorized through the registry device flow |
681
+ | `npm run logout -- [--profile <name>] [--all]` | Saved CLI credentials are revoked and removed |
568
682
 
569
- `publish` and `checkout` additionally accept `--dry-run`, and `publish` accepts `--no-build`.
683
+ Every command except `init` accepts `-c, --config`, `--profile`, `-d, --debug`,
684
+ and `-f, --force`. `publish` and `checkout` additionally accept `--dry-run`, and
685
+ `publish` accepts `--no-build`.
570
686
 
571
687
  Arguments after `--` are forwarded to the local CLI. Exact options can be shown
572
688
  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
@@ -88,6 +88,9 @@ const normalizeCatalogItem = (moduleExports, options) => {
88
88
  entries[entryKey] = path_1.default.resolve(directory, source.file);
89
89
  // Existing builders consume template as the primary build input for every renderer.
90
90
  entries.template = entries[entryKey];
91
+ // The renderer and the hydration bundle both read a preview `render` from the declaration.
92
+ if (options.declarationPath)
93
+ entries.declaration = options.declarationPath;
91
94
  // A wrong renderer builds a broken preview without failing on its own, so a mismatch is fatal.
92
95
  // `config/runtime.ts` turns the throw into a skipped item that retries on the next save.
93
96
  const claim = (0, renderers_1.sourceForFile)(source.file);
@@ -8,16 +8,17 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const lodash_1 = require("lodash");
9
9
  /**
10
10
  * A declaration module exports more than previews: the item itself, the implementation it
11
- * re-exports, helper values. Accept only a plain object that owns `args` or `name`, so an exported
12
- * component or helper is never mistaken for a preview.
11
+ * re-exports, helper values. Accept only a plain object that owns one of the preview fields, so an
12
+ * exported component or helper is never mistaken for a preview.
13
13
  *
14
14
  * `getCsfStoryEntries` does not fit here: on a CSF file every named export is a story, and on a
15
15
  * declaration file it is not.
16
16
  */
17
+ const PREVIEW_KEYS = ['args', 'name', 'render'];
17
18
  const isPreviewDeclaration = (value) => {
18
19
  if (!value || typeof value !== 'object' || Array.isArray(value))
19
20
  return false;
20
- return Object.prototype.hasOwnProperty.call(value, 'args') || Object.prototype.hasOwnProperty.call(value, 'name');
21
+ return PREVIEW_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
21
22
  };
22
23
  /** Matches an exported binding, an export list, or a CommonJS export assignment. */
23
24
  const EXPORT_PATTERNS = [
@@ -99,7 +100,7 @@ const createCatalogPreviews = (moduleExports, declarationPath) => {
99
100
  throw new Error(`Preview "${exportName}" uses removed fields. Use args and name. See UPGRADE.md#catalog-items.`);
100
101
  }
101
102
  const preview = value;
102
- previews[exportName] = Object.assign({ title: preview.name || (0, lodash_1.startCase)(exportName), values: Object.assign({}, ((_a = preview.args) !== null && _a !== void 0 ? _a : {})), url: (_b = preview.url) !== null && _b !== void 0 ? _b : '' }, (preview.usage ? { usage: preview.usage } : {}));
103
+ previews[exportName] = Object.assign(Object.assign({ title: preview.name || (0, lodash_1.startCase)(exportName), values: Object.assign({}, ((_a = preview.args) !== null && _a !== void 0 ? _a : {})), url: (_b = preview.url) !== null && _b !== void 0 ? _b : '' }, (preview.usage ? { usage: preview.usage } : {})), (typeof preview.render === 'function' ? { render: preview.render } : {}));
103
104
  }
104
105
  return declarationPath ? (0, exports.orderPreviews)(previews, (0, exports.readExportOrder)(declarationPath)) : previews;
105
106
  };
@@ -43,6 +43,14 @@ export type CatalogPreview<TArgs = Record<string, unknown>> = {
43
43
  args?: TArgs;
44
44
  usage?: string;
45
45
  url?: string;
46
+ /**
47
+ * Renders this preview in place of the implementation, for props it cannot carry as data: an icon
48
+ * component, a callback, JSX children, or state the preview owns.
49
+ *
50
+ * Rendered as a component with `args`, so it can use hooks. The item still documents the
51
+ * implementation, so a wrapper never becomes the published API.
52
+ */
53
+ render?: (args: TArgs) => unknown;
46
54
  };
47
55
  /**
48
56
  * `__args` exists only in the type system so `Preview<typeof item>` can infer the preview argument type.
@@ -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;