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.
- package/README.md +111 -11
- package/UPGRADE.md +20 -0
- package/dist/app-builder/build.js +10 -2
- package/dist/app-builder/client-config.js +3 -1
- package/dist/app-builder/watchers.js +17 -7
- package/dist/cache/build-cache.js +5 -1
- package/dist/cli/auth/device.d.ts +6 -1
- package/dist/cli/auth/device.js +5 -2
- package/dist/cli/auth/store.d.ts +15 -6
- package/dist/cli/auth/store.js +72 -19
- package/dist/commands/login/index.d.ts +5 -0
- package/dist/commands/login/index.js +18 -6
- package/dist/commands/logout/index.d.ts +5 -0
- package/dist/commands/logout/index.js +54 -15
- package/dist/commands/types.d.ts +2 -0
- package/dist/commands/utils.d.ts +21 -7
- package/dist/commands/utils.js +11 -10
- package/dist/config/catalog-include.d.ts +2 -2
- package/dist/config/defaults.d.ts +3 -3
- package/dist/config/defaults.js +54 -48
- package/dist/config/env.d.ts +9 -0
- package/dist/config/env.js +43 -0
- package/dist/config/errors.d.ts +4 -0
- package/dist/config/errors.js +11 -0
- package/dist/config/from-env.d.ts +24 -0
- package/dist/config/from-env.js +7 -0
- package/dist/config/helpers.d.ts +2 -0
- package/dist/config/helpers.js +3 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.js +6 -1
- package/dist/config/loader.d.ts +29 -11
- package/dist/config/loader.js +119 -46
- package/dist/config/resolve-env.d.ts +5 -0
- package/dist/config/resolve-env.js +102 -0
- package/dist/config/runtime.d.ts +3 -3
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +60 -16
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/registry/asset-storage/adapters/vercel-blob.js +1 -1
- package/dist/registry/asset-storage/resolve.d.ts +2 -2
- package/dist/registry/asset-storage/resolve.js +4 -1
- package/dist/registry/checkout/index.js +4 -4
- package/dist/registry/connection.d.ts +28 -24
- package/dist/registry/connection.js +53 -67
- package/dist/registry/db/driver.d.ts +4 -11
- package/dist/registry/db/driver.js +4 -16
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +1 -2
- package/dist/registry/publish/index.js +4 -4
- package/dist/store/filesystem.d.ts +2 -2
- package/dist/types/config.d.ts +48 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/pages/account/tokens.tsx +3 -1
- package/src/app-builder/build.ts +10 -2
- package/src/app-builder/client-config.ts +5 -3
- package/src/app-builder/watchers.ts +18 -8
- package/src/cache/build-cache.ts +5 -1
- package/src/cli/auth/device.ts +8 -3
- package/src/cli/auth/store.ts +75 -18
- package/src/commands/login/index.ts +20 -6
- package/src/commands/logout/index.ts +74 -20
- package/src/commands/types.ts +2 -0
- package/src/commands/utils.ts +14 -5
- package/src/config/README.md +61 -1
- package/src/config/catalog-include.ts +2 -2
- package/src/config/defaults.ts +24 -14
- package/src/config/env.ts +41 -0
- package/src/config/errors.ts +7 -0
- package/src/config/from-env.ts +26 -0
- package/src/config/helpers.ts +4 -2
- package/src/config/index.ts +4 -1
- package/src/config/loader.ts +156 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +3 -3
- package/src/config/validator.ts +4 -6
- package/src/index.d.ts +2 -2
- package/src/index.ts +85 -8
- package/src/pipeline/index.ts +1 -1
- package/src/registry/asset-storage/adapters/vercel-blob.ts +1 -1
- package/src/registry/asset-storage/resolve.ts +6 -3
- package/src/registry/checkout/index.ts +5 -5
- package/src/registry/connection.ts +72 -71
- package/src/registry/db/driver.ts +6 -17
- package/src/registry/db/index.ts +0 -1
- package/src/registry/publish/index.ts +5 -5
- package/src/store/filesystem.ts +2 -2
- package/src/types/config.ts +45 -29
- package/templates/default/env.tpl +10 -0
- package/templates/default/gitignore.tpl +7 -0
- package/templates/default/handoff.config.js.tpl +21 -172
- package/templates/default/handoff.config.ts.tpl +21 -156
- package/templates/default/handoff.config.with-examples.js.tpl +20 -171
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
525
|
-
|
|
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` |
|
|
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 -- [--
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
268
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
package/dist/cli/auth/device.js
CHANGED
|
@@ -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
|
-
/**
|
|
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);
|
package/dist/cli/auth/store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
export declare const
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
|
27
|
-
|
|
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;
|
package/dist/cli/auth/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
71
|
+
return null;
|
|
69
72
|
const auth = value;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
logger_1.Logger.
|
|
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;
|