handoff-app 2.0.0-2 → 2.0.0-4
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 +140 -83
- package/dist/app-builder/build.js +21 -4
- package/dist/app-builder/client-config.js +9 -8
- package/dist/cli/auth/browser.d.ts +2 -0
- package/dist/cli/auth/browser.js +33 -0
- package/dist/cli/auth/device.d.ts +11 -0
- package/dist/cli/auth/device.js +155 -0
- package/dist/cli/auth/store.d.ts +29 -0
- package/dist/cli/auth/store.js +146 -0
- package/dist/cli/scaffold.js +20 -154
- package/dist/cli.js +1 -1
- package/dist/commands/index.js +4 -0
- package/dist/commands/login/index.d.ts +8 -0
- package/dist/commands/login/index.js +53 -0
- package/dist/commands/logout/index.d.ts +7 -0
- package/dist/commands/logout/index.js +54 -0
- package/dist/config/entries.d.ts +47 -0
- package/dist/config/entries.js +162 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +5 -1
- package/dist/config/runtime.d.ts +9 -9
- package/dist/config/runtime.js +15 -15
- package/dist/registry/auth/access-tokens.d.ts +46 -0
- package/dist/registry/auth/access-tokens.js +165 -0
- package/dist/registry/auth/action-tokens.d.ts +66 -0
- package/dist/registry/auth/action-tokens.js +178 -0
- package/dist/registry/auth/crypto.d.ts +4 -0
- package/dist/registry/auth/crypto.js +16 -0
- package/dist/registry/auth/database.d.ts +7 -0
- package/dist/registry/auth/database.js +22 -0
- package/dist/registry/auth/device-authorizations.d.ts +51 -0
- package/dist/registry/auth/device-authorizations.js +170 -0
- package/dist/registry/auth/gravatar.d.ts +1 -0
- package/dist/registry/auth/gravatar.js +8 -0
- package/dist/registry/auth/index.d.ts +11 -0
- package/dist/registry/auth/index.js +72 -0
- package/dist/registry/auth/installation.d.ts +42 -0
- package/dist/registry/auth/installation.js +105 -0
- package/dist/registry/auth/password.d.ts +12 -0
- package/dist/registry/auth/password.js +58 -0
- package/dist/registry/auth/rate-limits.d.ts +22 -0
- package/dist/registry/auth/rate-limits.js +75 -0
- package/dist/registry/auth/types.d.ts +26 -0
- package/dist/registry/auth/types.js +9 -0
- package/dist/registry/auth/user-record.d.ts +13 -0
- package/dist/registry/auth/user-record.js +16 -0
- package/dist/registry/auth/users.d.ts +31 -0
- package/dist/registry/auth/users.js +135 -0
- package/dist/registry/auth/validation.d.ts +3 -0
- package/dist/registry/auth/validation.js +28 -0
- package/dist/registry/checkout/assets.js +4 -2
- package/dist/registry/checkout/index.d.ts +1 -1
- package/dist/registry/checkout/index.js +101 -23
- package/dist/registry/checkout/tokens.js +4 -2
- package/dist/registry/connection.d.ts +9 -0
- package/dist/registry/connection.js +29 -1
- package/dist/registry/db/driver.d.ts +5 -4
- package/dist/registry/db/driver.js +5 -4
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +7 -1
- package/dist/registry/db/schema.d.ts +2521 -750
- package/dist/registry/db/schema.js +126 -1
- package/dist/registry/publish/assets.js +1 -1
- package/dist/registry/publish/errors.js +3 -3
- package/dist/registry/publish/index.d.ts +1 -1
- package/dist/registry/publish/index.js +9 -9
- package/dist/registry/publish/tokens.js +1 -1
- package/dist/types/config.d.ts +4 -1
- package/docs/cli.md +4 -0
- package/drizzle/0005_add_registry_auth.sql +95 -0
- package/drizzle/meta/0005_snapshot.json +1926 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/app/components/Auth/AccountLayout.tsx +87 -0
- package/src/app/components/Auth/AuthControls.tsx +99 -0
- package/src/app/components/Auth/AuthShell.tsx +73 -0
- package/src/app/components/Auth/RegistryInstallGate.tsx +23 -0
- package/src/app/components/Auth/RegistrySessionProvider.tsx +15 -0
- package/src/app/components/Auth/api.ts +20 -0
- package/src/app/components/Layout/Header.tsx +2 -0
- package/src/app/components/Navigation/MobileNav.tsx +2 -0
- package/src/app/lib/auth/api.ts +113 -0
- package/src/app/lib/auth/config.ts +175 -0
- package/src/app/lib/auth/email.ts +34 -0
- package/src/app/lib/docs-api/runtime-config.ts +0 -14
- package/src/app/lib/registry-api/auth.ts +29 -46
- package/src/app/lib/registry-api/errors.ts +2 -2
- package/src/app/lib/registry-api/handler.ts +19 -14
- package/src/app/lib/registry-api/token-transfer.ts +1 -1
- package/src/app/lib/registry-api/transfer.ts +4 -4
- package/src/app/next.config.mjs +12 -3
- package/src/app/pages/_app.tsx +10 -4
- package/src/app/pages/account/index.tsx +153 -0
- package/src/app/pages/account/tokens.tsx +230 -0
- package/src/app/pages/account/users.tsx +310 -0
- package/src/app/pages/api/account/index.ts +26 -0
- package/src/app/pages/api/account/tokens/[id].ts +17 -0
- package/src/app/pages/api/account/tokens/index.ts +44 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +89 -0
- package/src/app/pages/api/admin/users/index.ts +53 -0
- package/src/app/pages/api/auth/[...nextauth].ts +4 -0
- package/src/app/pages/api/auth/request-reset.ts +53 -0
- package/src/app/pages/api/auth/reset-password.ts +29 -0
- package/src/app/pages/api/install.ts +102 -0
- package/src/app/pages/api/oauth/device/approve.ts +23 -0
- package/src/app/pages/api/oauth/device/index.ts +41 -0
- package/src/app/pages/api/oauth/revoke.ts +24 -0
- package/src/app/pages/api/oauth/token.ts +31 -0
- package/src/app/pages/api/registry/health.ts +4 -3
- package/src/app/pages/api/registry/transfer/[kind]/[id].ts +3 -3
- package/src/app/pages/api/registry/transfer/assets/[collection].ts +2 -1
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +2 -1
- package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +2 -2
- package/src/app/pages/cli/device.tsx +143 -0
- package/src/app/pages/install.tsx +336 -0
- package/src/app/pages/login.tsx +117 -0
- package/src/app/pages/reset-password.tsx +159 -0
- package/src/app/types/next-auth.d.ts +27 -0
- package/src/app-builder/build.ts +22 -5
- package/src/app-builder/client-config.ts +10 -9
- package/src/cli/auth/browser.ts +29 -0
- package/src/cli/auth/device.ts +181 -0
- package/src/cli/auth/store.ts +137 -0
- package/src/cli/scaffold.ts +20 -197
- package/src/cli.ts +1 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/login/index.ts +48 -0
- package/src/commands/logout/index.ts +51 -0
- package/src/config/entries.ts +175 -0
- package/src/config/index.ts +4 -0
- package/src/config/runtime.ts +15 -14
- package/src/registry/auth/access-tokens.ts +198 -0
- package/src/registry/auth/action-tokens.ts +225 -0
- package/src/registry/auth/crypto.ts +13 -0
- package/src/registry/auth/database.ts +15 -0
- package/src/registry/auth/device-authorizations.ts +223 -0
- package/src/registry/auth/gravatar.ts +6 -0
- package/src/registry/auth/index.ts +89 -0
- package/src/registry/auth/installation.ts +117 -0
- package/src/registry/auth/password.ts +46 -0
- package/src/registry/auth/rate-limits.ts +84 -0
- package/src/registry/auth/types.ts +34 -0
- package/src/registry/auth/user-record.ts +25 -0
- package/src/registry/auth/users.ts +145 -0
- package/src/registry/auth/validation.ts +24 -0
- package/src/registry/checkout/assets.ts +4 -2
- package/src/registry/checkout/index.ts +114 -24
- package/src/registry/checkout/tokens.ts +4 -2
- package/src/registry/connection.ts +23 -0
- package/src/registry/db/driver.ts +5 -4
- package/src/registry/db/index.ts +12 -0
- package/src/registry/db/schema.ts +174 -1
- package/src/registry/publish/assets.ts +1 -1
- package/src/registry/publish/errors.ts +3 -3
- package/src/registry/publish/index.ts +9 -9
- package/src/registry/publish/tokens.ts +1 -1
- package/src/types/config.ts +4 -1
- package/templates/default/README.md.tpl +18 -0
- package/templates/default/gitignore.tpl +1 -0
- package/templates/default/handoff.config.js.tpl +0 -1
- package/templates/default/handoff.config.ts.tpl +0 -1
- package/templates/default/handoff.config.with-examples.js.tpl +0 -1
- package/templates/default/handoff.config.with-examples.ts.tpl +0 -1
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
import * as p from '@clack/prompts';
|
|
17
17
|
import fs from 'fs-extra';
|
|
18
18
|
import path from 'path';
|
|
19
|
-
import {
|
|
19
|
+
import { type EntryKind, isEntryCovered, writeEntries } from '../../config/entries';
|
|
20
|
+
import { isComponentDirectory, resolveComponentDeclaration } from '../../config/runtime';
|
|
20
21
|
import Handoff from '../../index';
|
|
21
22
|
import type { DeclarationFormat } from '../../types/config';
|
|
22
23
|
import { Logger } from '../../utils/logger';
|
|
23
24
|
import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
|
|
24
|
-
import {
|
|
25
|
+
import { resolveAuthenticatedRegistryConnection } from '../connection';
|
|
25
26
|
import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
|
|
26
27
|
import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
|
|
27
28
|
|
|
@@ -51,7 +52,7 @@ const COMPONENT_EXTENSION = /\.(tsx|jsx|ts|js|cjs|mjs)$/i;
|
|
|
51
52
|
* host has no local workspace to write into) and a resolved registry URL + access token. Throws an
|
|
52
53
|
* actionable {@link CheckoutError} naming the exact misconfiguration.
|
|
53
54
|
*/
|
|
54
|
-
export const resolveConnectionOrThrow = (handoff: Handoff) => {
|
|
55
|
+
export const resolveConnectionOrThrow = async (handoff: Handoff) => {
|
|
55
56
|
const mode = handoff.config?.runtime?.mode ?? 'workspace';
|
|
56
57
|
if (mode !== 'workspace') {
|
|
57
58
|
throw new CheckoutError(
|
|
@@ -60,17 +61,17 @@ export const resolveConnectionOrThrow = (handoff: Handoff) => {
|
|
|
60
61
|
);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
const connection =
|
|
64
|
+
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
|
|
64
65
|
if (!connection.url) {
|
|
65
66
|
throw new CheckoutError(
|
|
66
|
-
`No registry
|
|
67
|
-
|
|
67
|
+
`No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
|
|
68
|
+
`or set the "${connection.urlEnv}" environment variable to the base URL of the registry to checkout from.`
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
if (!connection.accessToken) {
|
|
71
72
|
throw new CheckoutError(
|
|
72
|
-
`No registry access token is configured.
|
|
73
|
-
"
|
|
73
|
+
`No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
|
|
74
|
+
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
|
|
74
75
|
);
|
|
75
76
|
}
|
|
76
77
|
return connection;
|
|
@@ -84,16 +85,68 @@ const describeFetchFailure = (error: RegistryClientError, kind: TransferEntityKi
|
|
|
84
85
|
case 'runtime_mode_conflict':
|
|
85
86
|
return `The registry at ${registryUrl} is not running in registry mode, so it cannot serve a checkout: ${error.message}`;
|
|
86
87
|
case 'unauthorized':
|
|
87
|
-
return `The registry rejected the access token (401).
|
|
88
|
+
return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
|
|
89
|
+
case 'forbidden':
|
|
90
|
+
return `The registry token does not have permission to checkout this content (403). Authorize a token with registry:read access.`;
|
|
88
91
|
default:
|
|
89
92
|
return error.message;
|
|
90
93
|
}
|
|
91
94
|
};
|
|
92
95
|
|
|
93
96
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
+
* Ask the user which collection directory a new entity should land in when the config is ambiguous.
|
|
98
|
+
* Under `--force` (or any non-interactive run) there's no one to ask, so we fail with an actionable
|
|
99
|
+
* message rather than guessing a root.
|
|
100
|
+
*/
|
|
101
|
+
const promptForCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind, roots: string[]): Promise<string> => {
|
|
102
|
+
const relative = (root: string) => path.relative(handoff.workingPath, root) || '.';
|
|
103
|
+
if (handoff.force) {
|
|
104
|
+
throw new CheckoutError(
|
|
105
|
+
`Cannot determine where to checkout the ${kind}: entries.${kind}s declares individual ${kind}s under different ` +
|
|
106
|
+
`directories (${roots.map(relative).join(', ')}). Declare a single collection directory in handoff.config, ` +
|
|
107
|
+
`or run checkout without --force to choose interactively.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const choice = await p.select({
|
|
111
|
+
message: `Where should the new ${kind} be checked out?`,
|
|
112
|
+
options: roots.map((root) => ({ value: root, label: relative(root) })),
|
|
113
|
+
initialValue: roots[0],
|
|
114
|
+
});
|
|
115
|
+
if (p.isCancel(choice)) {
|
|
116
|
+
throw new CheckoutError('Checkout cancelled; no target directory chosen.');
|
|
117
|
+
}
|
|
118
|
+
return choice as string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the collection directory a new entity is cloned into from the configured
|
|
123
|
+
* `entries.{components|patterns}`. Each entry is either a collection directory (used as-is) or a
|
|
124
|
+
* single declared entity directory, in which case its parent is the collection root so the new
|
|
125
|
+
* entity lands as a sibling rather than nested inside. Different parents mean the config is
|
|
126
|
+
* ambiguous, so we ask the user; with nothing configured we fall back to the default subdir.
|
|
127
|
+
*/
|
|
128
|
+
const resolveCollectionRoot = async (handoff: Handoff, kind: TransferEntityKind): Promise<string> => {
|
|
129
|
+
const configuredRoots = kind === 'component' ? handoff.config?.entries?.components : handoff.config?.entries?.patterns;
|
|
130
|
+
if (!configuredRoots?.length) {
|
|
131
|
+
return path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const roots = [
|
|
135
|
+
...new Set(
|
|
136
|
+
configuredRoots.map((entry) => {
|
|
137
|
+
const resolved = path.resolve(handoff.workingPath, entry);
|
|
138
|
+
return isComponentDirectory(resolved) ? path.dirname(resolved) : resolved;
|
|
139
|
+
})
|
|
140
|
+
),
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
return roots.length === 1 ? roots[0] : promptForCollectionRoot(handoff, kind, roots);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the local directory the entity is written into. An already-checked-out or locally-declared
|
|
148
|
+
* entity keeps its existing source directory (matched by stable `id`); otherwise it lands as a
|
|
149
|
+
* sibling under its configured collection root (see {@link resolveCollectionRoot}).
|
|
97
150
|
*/
|
|
98
151
|
const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<string> => {
|
|
99
152
|
const store = kind === 'component' ? handoff.store.components : handoff.store.patterns;
|
|
@@ -103,10 +156,7 @@ const resolveTargetDir = async (handoff: Handoff, kind: TransferEntityKind, id:
|
|
|
103
156
|
return existingDir;
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
const
|
|
107
|
-
const root = configuredRoots?.length
|
|
108
|
-
? path.resolve(handoff.workingPath, configuredRoots[0])
|
|
109
|
-
: path.resolve(handoff.workingPath, DEFAULT_ENTITY_DIR[kind]);
|
|
159
|
+
const root = await resolveCollectionRoot(handoff, kind);
|
|
110
160
|
const targetDir = resolvePathWithin(root, id);
|
|
111
161
|
if (!targetDir) {
|
|
112
162
|
throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
|
|
@@ -542,7 +592,7 @@ const checkoutSingle = async (
|
|
|
542
592
|
id: string,
|
|
543
593
|
client: RegistryClient,
|
|
544
594
|
registryUrl: string
|
|
545
|
-
): Promise<
|
|
595
|
+
): Promise<string | null> => {
|
|
546
596
|
const validId = kind === 'page' ? isSafeRelativePath(id) : isSafePathSegment(id);
|
|
547
597
|
if (!validId) {
|
|
548
598
|
throw new CheckoutError(`Cannot checkout ${kind} with unsafe id "${id}".`);
|
|
@@ -559,10 +609,11 @@ const checkoutSingle = async (
|
|
|
559
609
|
throw error;
|
|
560
610
|
}
|
|
561
611
|
|
|
562
|
-
// Pages round-trip as a single verbatim `.md` with no declaration synthesis
|
|
612
|
+
// Pages round-trip as a single verbatim `.md` with no declaration synthesis, and aren't
|
|
613
|
+
// declared in `entries`, so there's nothing to register.
|
|
563
614
|
if (kind === 'page') {
|
|
564
615
|
await checkoutPage(handoff, id, payload);
|
|
565
|
-
return;
|
|
616
|
+
return null;
|
|
566
617
|
}
|
|
567
618
|
|
|
568
619
|
const targetDir = await resolveTargetDir(handoff, kind, id);
|
|
@@ -581,7 +632,7 @@ const checkoutSingle = async (
|
|
|
581
632
|
const conflicts = [...sourceTargets.map((entry) => entry.absolutePath), declarationPath].filter((file) => fs.existsSync(file));
|
|
582
633
|
if (!(await confirmOverwrite(handoff, conflicts))) {
|
|
583
634
|
Logger.warn(`Checkout of ${kind} "${id}" cancelled; no files were written.`);
|
|
584
|
-
return;
|
|
635
|
+
return null;
|
|
585
636
|
}
|
|
586
637
|
|
|
587
638
|
await fs.ensureDir(targetDir);
|
|
@@ -598,6 +649,35 @@ const checkoutSingle = async (
|
|
|
598
649
|
`Checked out ${kind} "${id}" into ${path.relative(handoff.workingPath, targetDir) || '.'} ` +
|
|
599
650
|
`(${payload.files.length} source file(s) + ${declarationFileName}).`
|
|
600
651
|
);
|
|
652
|
+
return targetDir;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Declare freshly checked-out entities in `entries.{components|patterns}` so the workspace build
|
|
657
|
+
* picks them up. Ones already covered by a collection directory load on their own and are left
|
|
658
|
+
* alone; the rest are added to the config automatically. If the config can't be edited (a computed
|
|
659
|
+
* or unusual `entries` array), we print the paths for the user to add so nothing is silently orphaned.
|
|
660
|
+
*/
|
|
661
|
+
const registerCheckedOut = async (handoff: Handoff, kind: TransferEntityKind, targetDirs: string[]): Promise<void> => {
|
|
662
|
+
if (kind === 'page') {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const entryKind: EntryKind = kind === 'component' ? 'components' : 'patterns';
|
|
666
|
+
const uncovered = targetDirs.filter((dir) => !isEntryCovered(handoff, entryKind, dir));
|
|
667
|
+
if (uncovered.length === 0) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const result = await writeEntries(handoff, entryKind, uncovered);
|
|
672
|
+
if (result.status === 'added') {
|
|
673
|
+
const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) || path.basename(result.configPath) : 'handoff.config';
|
|
674
|
+
Logger.success(`Updated ${where} with ${result.added.length} ${entryKind} path(s).`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const where = result.configPath ? path.relative(handoff.workingPath, result.configPath) : 'handoff.config';
|
|
679
|
+
Logger.warn(`Could not update ${where} automatically. Add these to entries.${entryKind} manually:`);
|
|
680
|
+
result.pending.forEach((rel) => Logger.warn(` - ${rel}`));
|
|
601
681
|
};
|
|
602
682
|
|
|
603
683
|
/**
|
|
@@ -605,9 +685,12 @@ const checkoutSingle = async (
|
|
|
605
685
|
* Overwriting existing local files requires `--force` or an interactive confirmation.
|
|
606
686
|
*/
|
|
607
687
|
export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
|
|
608
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
688
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
609
689
|
const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
|
|
610
|
-
await checkoutSingle(handoff, kind, id, client, connection.url);
|
|
690
|
+
const targetDir = await checkoutSingle(handoff, kind, id, client, connection.url);
|
|
691
|
+
if (targetDir) {
|
|
692
|
+
await registerCheckedOut(handoff, kind, [targetDir]);
|
|
693
|
+
}
|
|
611
694
|
};
|
|
612
695
|
|
|
613
696
|
/**
|
|
@@ -617,7 +700,7 @@ export const checkoutEntity = async (handoff: Handoff, kind: TransferEntityKind,
|
|
|
617
700
|
* prompting is per entity (skipped under `--force`).
|
|
618
701
|
*/
|
|
619
702
|
export const checkoutEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
|
|
620
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
703
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
621
704
|
const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
|
|
622
705
|
|
|
623
706
|
let summaries;
|
|
@@ -637,15 +720,22 @@ export const checkoutEntities = async (handoff: Handoff, kind: TransferEntityKin
|
|
|
637
720
|
|
|
638
721
|
let checkedOut = 0;
|
|
639
722
|
const failed: { id: string; message: string }[] = [];
|
|
723
|
+
const targetDirs: string[] = [];
|
|
640
724
|
for (const { id } of summaries) {
|
|
641
725
|
try {
|
|
642
|
-
await checkoutSingle(handoff, kind, id, client, connection.url);
|
|
726
|
+
const targetDir = await checkoutSingle(handoff, kind, id, client, connection.url);
|
|
727
|
+
if (targetDir) {
|
|
728
|
+
targetDirs.push(targetDir);
|
|
729
|
+
}
|
|
643
730
|
checkedOut += 1;
|
|
644
731
|
} catch (error) {
|
|
645
732
|
failed.push({ id, message: error instanceof Error ? error.message : String(error) });
|
|
646
733
|
}
|
|
647
734
|
}
|
|
648
735
|
|
|
736
|
+
// Register everything checked out in one pass, so the config is edited (and confirmed) once.
|
|
737
|
+
await registerCheckedOut(handoff, kind, targetDirs);
|
|
738
|
+
|
|
649
739
|
Logger.success(
|
|
650
740
|
`${kind[0].toUpperCase()}${kind.slice(1)}s checkout complete — ${checkedOut} checked out${failed.length ? `, ${failed.length} failed` : ''}.`
|
|
651
741
|
);
|
|
@@ -28,7 +28,9 @@ const describeFetchFailure = (error: RegistryClientError, target: string, regist
|
|
|
28
28
|
case 'runtime_mode_conflict':
|
|
29
29
|
return `The registry at ${registryUrl} is not running in registry mode, so it cannot serve a checkout: ${error.message}`;
|
|
30
30
|
case 'unauthorized':
|
|
31
|
-
return `The registry rejected the access token (401).
|
|
31
|
+
return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
|
|
32
|
+
case 'forbidden':
|
|
33
|
+
return `The registry token does not have permission to checkout design tokens (403). Authorize a token with registry:read access.`;
|
|
32
34
|
default:
|
|
33
35
|
return error.message;
|
|
34
36
|
}
|
|
@@ -73,7 +75,7 @@ export const checkoutTokens = async (handoff: Handoff, setId?: string): Promise<
|
|
|
73
75
|
if (setId && !isTokenSetId(setId)) {
|
|
74
76
|
throw new CheckoutError(`Unknown or unsafe token set id "${setId}".`);
|
|
75
77
|
}
|
|
76
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
78
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
77
79
|
const client = createRegistryClient({ baseUrl: connection.url, accessToken: connection.accessToken });
|
|
78
80
|
|
|
79
81
|
let ids: string[];
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Config } from '../types/config';
|
|
12
|
+
import { cliAuthMatchesRegistry, cliAuthTokenIsValid, readCliAuth } from '../cli/auth/store';
|
|
12
13
|
|
|
13
14
|
/** Default env-var name holding the remote registry base URL. */
|
|
14
15
|
export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
|
|
@@ -48,3 +49,25 @@ export const resolveRegistryConnection = (config: Config | null | undefined): Re
|
|
|
48
49
|
accessTokenEnv,
|
|
49
50
|
};
|
|
50
51
|
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the same connection for an authenticated CLI operation, letting a saved device login fill
|
|
55
|
+
* in what the environment does not provide. A valid login supplies both the registry URL and the
|
|
56
|
+
* access token, so `handoff-app login` alone is enough to publish and checkout. Environment values
|
|
57
|
+
* keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config) wins over the
|
|
58
|
+
* saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved token only
|
|
59
|
+
* applies to the URL it was issued for.
|
|
60
|
+
*/
|
|
61
|
+
export const resolveAuthenticatedRegistryConnection = async (
|
|
62
|
+
config: Config | null | undefined,
|
|
63
|
+
workingPath = process.cwd()
|
|
64
|
+
): Promise<ResolvedRegistryConnection> => {
|
|
65
|
+
const connection = resolveRegistryConnection(config);
|
|
66
|
+
|
|
67
|
+
const auth = await readCliAuth(workingPath);
|
|
68
|
+
if (!cliAuthTokenIsValid(auth)) return connection;
|
|
69
|
+
|
|
70
|
+
const url = connection.url || auth!.remoteUrl;
|
|
71
|
+
const accessToken = connection.accessToken || (cliAuthMatchesRegistry(auth, url) ? auth!.accessToken : '');
|
|
72
|
+
return { ...connection, url, accessToken };
|
|
73
|
+
};
|
|
@@ -18,7 +18,7 @@ export type RegistryDatabaseDriver = 'pg' | 'neon';
|
|
|
18
18
|
/** Default env-var name holding the database connection string. */
|
|
19
19
|
export const DEFAULT_DATABASE_URL_ENV = 'DATABASE_URL';
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** @deprecated Fixed registry server secrets are no longer authorized. */
|
|
22
22
|
export const DEFAULT_REGISTRY_API_TOKEN_ENV = 'HANDOFF_REGISTRY_API_TOKEN';
|
|
23
23
|
|
|
24
24
|
/** Default registry database driver when none is configured. */
|
|
@@ -41,9 +41,10 @@ export const resolveDatabaseUrlEnv = (config: Config | null | undefined): string
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Resolve the env-var
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Resolve the legacy fixed-token env-var name for configuration compatibility.
|
|
45
|
+
*
|
|
46
|
+
* @deprecated The returned env var is intentionally ignored by registry authorization. Use a
|
|
47
|
+
* user-issued access token or `handoff-app login`.
|
|
47
48
|
*/
|
|
48
49
|
export const resolveApiTokenEnv = (config: Config | null | undefined): string => {
|
|
49
50
|
const configured = config?.runtime?.registry?.apiTokenEnv?.trim();
|
package/src/registry/db/index.ts
CHANGED
|
@@ -5,10 +5,22 @@ export {
|
|
|
5
5
|
docsArtifacts,
|
|
6
6
|
patternFiles,
|
|
7
7
|
patterns,
|
|
8
|
+
registryAccessTokens,
|
|
9
|
+
registryAuthActionTokens,
|
|
10
|
+
registryAuthRateLimits,
|
|
11
|
+
registryDeviceAuthorizations,
|
|
12
|
+
registryInstallations,
|
|
8
13
|
registrySchema,
|
|
14
|
+
registryUsers,
|
|
9
15
|
type BuildMetadataEntityKind,
|
|
10
16
|
type DocsArtifactEntityKind,
|
|
17
|
+
type RegistryAccessScope,
|
|
18
|
+
type RegistryAuthActionPurpose,
|
|
19
|
+
type RegistryDeviceAuthorizationStatus,
|
|
20
|
+
type RegistryRateLimitBucket,
|
|
11
21
|
type RegistryReviewMetadata,
|
|
22
|
+
type RegistryUserRole,
|
|
23
|
+
type RegistryUserStatus,
|
|
12
24
|
} from './schema';
|
|
13
25
|
|
|
14
26
|
export type { ArtifactBuildStatus as RegistryBuildStatus } from '../../artifacts/types';
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { sql } from 'drizzle-orm';
|
|
26
|
-
import { check, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
|
|
26
|
+
import { check, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
27
27
|
import type { ArtifactBuildStatus, ArtifactKind, ArtifactOwnerKind, ArtifactReference } from '../../artifacts/types';
|
|
28
28
|
import type { RegistryTextFileKind } from '../../store/types';
|
|
29
29
|
import type { TokenSetKind } from '../tokens/sets';
|
|
@@ -43,6 +43,24 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
|
|
43
43
|
export type AssetStorageProvider = 'database' | 'vercel-blob' | (string & {});
|
|
44
44
|
import type { ComponentListObject, PageListObject, PatternComponentEntry, PatternListObject } from '../../transformers/preview/types';
|
|
45
45
|
|
|
46
|
+
/** Registry account role. Administrators may issue write-scoped credentials. */
|
|
47
|
+
export type RegistryUserRole = 'admin' | 'member';
|
|
48
|
+
|
|
49
|
+
/** Registry account lifecycle. Invited and deactivated accounts cannot authenticate. */
|
|
50
|
+
export type RegistryUserStatus = 'invited' | 'active' | 'deactivated';
|
|
51
|
+
|
|
52
|
+
/** One-time account action represented by a hashed secret. */
|
|
53
|
+
export type RegistryAuthActionPurpose = 'invite' | 'password_reset';
|
|
54
|
+
|
|
55
|
+
/** Capability granted to a registry access token. */
|
|
56
|
+
export type RegistryAccessScope = 'registry:read' | 'registry:write';
|
|
57
|
+
|
|
58
|
+
/** State machine for an RFC 8628-style CLI device authorization. */
|
|
59
|
+
export type RegistryDeviceAuthorizationStatus = 'pending' | 'approved' | 'denied' | 'consumed';
|
|
60
|
+
|
|
61
|
+
/** Public endpoint family protected by a database-backed throttling counter. */
|
|
62
|
+
export type RegistryRateLimitBucket = 'login' | 'password_reset' | 'device';
|
|
63
|
+
|
|
46
64
|
/**
|
|
47
65
|
* Entity a docs read-model artifact is associated with. `page` is included only for type-compat with
|
|
48
66
|
* the generic management delete path (which filters `docs_artifacts.entityKind` by kind); pages emit
|
|
@@ -426,6 +444,155 @@ export const assetBlobs = pgTable('asset_blobs', {
|
|
|
426
444
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
427
445
|
});
|
|
428
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Registry users. Browser sessions carry the id and auth version, while protected requests re-read
|
|
449
|
+
* this row so role/status changes take effect without waiting for a cookie to expire.
|
|
450
|
+
*/
|
|
451
|
+
export const registryUsers = pgTable(
|
|
452
|
+
'registry_users',
|
|
453
|
+
{
|
|
454
|
+
id: text('id')
|
|
455
|
+
.primaryKey()
|
|
456
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
457
|
+
email: text('email').notNull(),
|
|
458
|
+
name: text('name'),
|
|
459
|
+
image: text('image'),
|
|
460
|
+
role: text('role').$type<RegistryUserRole>().notNull().default('member'),
|
|
461
|
+
status: text('status').$type<RegistryUserStatus>().notNull().default('invited'),
|
|
462
|
+
passwordHash: text('password_hash'),
|
|
463
|
+
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
|
464
|
+
authVersion: integer('auth_version').notNull().default(1),
|
|
465
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
466
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
467
|
+
},
|
|
468
|
+
(table) => [
|
|
469
|
+
uniqueIndex('registry_users_normalized_email_idx').on(sql`lower(btrim(${table.email}))`),
|
|
470
|
+
index('registry_users_status_role_idx').on(table.status, table.role),
|
|
471
|
+
check('registry_users_email_normalized', sql`${table.email} = lower(btrim(${table.email}))`),
|
|
472
|
+
check('registry_users_role_valid', sql`${table.role} in ('admin', 'member')`),
|
|
473
|
+
check('registry_users_status_valid', sql`${table.status} in ('invited', 'active', 'deactivated')`),
|
|
474
|
+
check('registry_users_auth_version_positive', sql`${table.authVersion} > 0`),
|
|
475
|
+
]
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Permanent one-row installation marker. There is deliberately no reset path: deleting or
|
|
480
|
+
* deactivating users must never reopen the public first-visitor installer.
|
|
481
|
+
*/
|
|
482
|
+
export const registryInstallations = pgTable(
|
|
483
|
+
'registry_installations',
|
|
484
|
+
{
|
|
485
|
+
id: text('id').primaryKey().default('default'),
|
|
486
|
+
status: text('status').notNull().default('installed'),
|
|
487
|
+
schemaVersion: integer('schema_version').notNull(),
|
|
488
|
+
initialAdminUserId: text('initial_admin_user_id')
|
|
489
|
+
.notNull()
|
|
490
|
+
.references(() => registryUsers.id, { onDelete: 'restrict' }),
|
|
491
|
+
installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
|
|
492
|
+
},
|
|
493
|
+
(table) => [
|
|
494
|
+
check('registry_installations_singleton', sql`${table.id} = 'default'`),
|
|
495
|
+
check('registry_installations_status_valid', sql`${table.status} = 'installed'`),
|
|
496
|
+
check('registry_installations_schema_version_positive', sql`${table.schemaVersion} > 0`),
|
|
497
|
+
]
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
/** Hash-only invitation and password-reset secrets. */
|
|
501
|
+
export const registryAuthActionTokens = pgTable(
|
|
502
|
+
'registry_auth_action_tokens',
|
|
503
|
+
{
|
|
504
|
+
id: text('id')
|
|
505
|
+
.primaryKey()
|
|
506
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
507
|
+
userId: text('user_id')
|
|
508
|
+
.notNull()
|
|
509
|
+
.references(() => registryUsers.id, { onDelete: 'cascade' }),
|
|
510
|
+
purpose: text('purpose').$type<RegistryAuthActionPurpose>().notNull(),
|
|
511
|
+
tokenHash: text('token_hash').notNull(),
|
|
512
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
513
|
+
usedAt: timestamp('used_at', { withTimezone: true }),
|
|
514
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
515
|
+
},
|
|
516
|
+
(table) => [
|
|
517
|
+
uniqueIndex('registry_auth_action_tokens_hash_idx').on(table.tokenHash),
|
|
518
|
+
index('registry_auth_action_tokens_user_purpose_idx').on(table.userId, table.purpose),
|
|
519
|
+
check('registry_auth_action_tokens_purpose_valid', sql`${table.purpose} in ('invite', 'password_reset')`),
|
|
520
|
+
]
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
/** Revocable, hash-only registry API credentials owned by one user. */
|
|
524
|
+
export const registryAccessTokens = pgTable(
|
|
525
|
+
'registry_access_tokens',
|
|
526
|
+
{
|
|
527
|
+
/** Public lookup id embedded in the opaque token; not itself a credential. */
|
|
528
|
+
id: text('id').primaryKey(),
|
|
529
|
+
userId: text('user_id')
|
|
530
|
+
.notNull()
|
|
531
|
+
.references(() => registryUsers.id, { onDelete: 'cascade' }),
|
|
532
|
+
name: text('name').notNull(),
|
|
533
|
+
secretHash: text('secret_hash').notNull(),
|
|
534
|
+
scopes: jsonb('scopes').$type<RegistryAccessScope[]>().notNull(),
|
|
535
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
536
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
537
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
538
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
539
|
+
},
|
|
540
|
+
(table) => [
|
|
541
|
+
index('registry_access_tokens_user_idx').on(table.userId),
|
|
542
|
+
index('registry_access_tokens_expires_idx').on(table.expiresAt),
|
|
543
|
+
check('registry_access_tokens_name_not_blank', sql`length(btrim(${table.name})) > 0`),
|
|
544
|
+
check('registry_access_tokens_scopes_array', sql`jsonb_typeof(${table.scopes}) = 'array'`),
|
|
545
|
+
]
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
/** Short-lived authorization used by `handoff-app login`. Plaintext device codes are never stored. */
|
|
549
|
+
export const registryDeviceAuthorizations = pgTable(
|
|
550
|
+
'registry_device_authorizations',
|
|
551
|
+
{
|
|
552
|
+
id: text('id')
|
|
553
|
+
.primaryKey()
|
|
554
|
+
.$defaultFn(() => crypto.randomUUID()),
|
|
555
|
+
deviceCodeHash: text('device_code_hash').notNull(),
|
|
556
|
+
userCode: text('user_code').notNull(),
|
|
557
|
+
status: text('status').$type<RegistryDeviceAuthorizationStatus>().notNull().default('pending'),
|
|
558
|
+
userId: text('user_id').references(() => registryUsers.id, { onDelete: 'set null' }),
|
|
559
|
+
scopes: jsonb('scopes').$type<RegistryAccessScope[]>().notNull(),
|
|
560
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
561
|
+
approvedAt: timestamp('approved_at', { withTimezone: true }),
|
|
562
|
+
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
|
563
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
564
|
+
},
|
|
565
|
+
(table) => [
|
|
566
|
+
uniqueIndex('registry_device_authorizations_device_hash_idx').on(table.deviceCodeHash),
|
|
567
|
+
uniqueIndex('registry_device_authorizations_user_code_idx').on(table.userCode),
|
|
568
|
+
index('registry_device_authorizations_expires_idx').on(table.expiresAt),
|
|
569
|
+
check('registry_device_authorizations_status_valid', sql`${table.status} in ('pending', 'approved', 'denied', 'consumed')`),
|
|
570
|
+
check('registry_device_authorizations_scopes_array', sql`jsonb_typeof(${table.scopes}) = 'array'`),
|
|
571
|
+
]
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Fixed-window throttling counters. Callers pass a hash of the identifying value so raw emails and
|
|
576
|
+
* IP addresses are not retained.
|
|
577
|
+
*/
|
|
578
|
+
export const registryAuthRateLimits = pgTable(
|
|
579
|
+
'registry_auth_rate_limits',
|
|
580
|
+
{
|
|
581
|
+
bucket: text('bucket').$type<RegistryRateLimitBucket>().notNull(),
|
|
582
|
+
identifierHash: text('identifier_hash').notNull(),
|
|
583
|
+
windowStartedAt: timestamp('window_started_at', { withTimezone: true }).notNull(),
|
|
584
|
+
attempts: integer('attempts').notNull().default(1),
|
|
585
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
586
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
587
|
+
},
|
|
588
|
+
(table) => [
|
|
589
|
+
primaryKey({ columns: [table.bucket, table.identifierHash, table.windowStartedAt] }),
|
|
590
|
+
index('registry_auth_rate_limits_expires_idx').on(table.expiresAt),
|
|
591
|
+
check('registry_auth_rate_limits_bucket_valid', sql`${table.bucket} in ('login', 'password_reset', 'device')`),
|
|
592
|
+
check('registry_auth_rate_limits_attempts_positive', sql`${table.attempts} > 0`),
|
|
593
|
+
]
|
|
594
|
+
);
|
|
595
|
+
|
|
429
596
|
/** All registry tables, for typed Drizzle clients and migration tooling. */
|
|
430
597
|
export const registrySchema = {
|
|
431
598
|
components,
|
|
@@ -441,4 +608,10 @@ export const registrySchema = {
|
|
|
441
608
|
assetCollections,
|
|
442
609
|
assets,
|
|
443
610
|
assetBlobs,
|
|
611
|
+
registryUsers,
|
|
612
|
+
registryInstallations,
|
|
613
|
+
registryAuthActionTokens,
|
|
614
|
+
registryAccessTokens,
|
|
615
|
+
registryDeviceAuthorizations,
|
|
616
|
+
registryAuthRateLimits,
|
|
444
617
|
};
|
|
@@ -42,7 +42,7 @@ const toManifestEntry = (asset: AssetMetadata): AssetManifestEntry => ({
|
|
|
42
42
|
* throws at the end if any collection failed.
|
|
43
43
|
*/
|
|
44
44
|
export const publishAssets = async (handoff: Handoff, collection?: string): Promise<void> => {
|
|
45
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
45
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
46
46
|
if (collection && !isAssetCollection(collection)) {
|
|
47
47
|
throw new PublishError(`Unknown asset collection "${collection}". Supported collections: ${ASSET_COLLECTIONS.join(', ')}.`);
|
|
48
48
|
}
|
|
@@ -11,10 +11,10 @@ export const describeUploadFailure = (
|
|
|
11
11
|
switch (error.code) {
|
|
12
12
|
case 'runtime_mode_conflict':
|
|
13
13
|
return `The registry at ${registryUrl} is not running in registry mode, so it cannot accept publishes: ${error.message}`;
|
|
14
|
-
case 'token_not_configured':
|
|
15
|
-
return `The registry at ${registryUrl} has no management token configured, so it is rejecting mutations: ${error.message}`;
|
|
16
14
|
case 'unauthorized':
|
|
17
|
-
return `The registry rejected the access token (401).
|
|
15
|
+
return `The registry rejected the access token (401). Run \`handoff-app login --url ${registryUrl}\` again, or replace the user-issued CI token.`;
|
|
16
|
+
case 'forbidden':
|
|
17
|
+
return `The registry token does not have permission to publish (403). Sign in as an administrator and authorize a token with registry:write access.`;
|
|
18
18
|
case 'bad_request':
|
|
19
19
|
return `The registry rejected the ${rejectedPayload} (400): ${error.message}`;
|
|
20
20
|
default:
|
|
@@ -16,7 +16,7 @@ import processComponents from '../../transformers/preview/component/builder';
|
|
|
16
16
|
import { processPatterns } from '../../transformers/preview/pattern/builder';
|
|
17
17
|
import { Logger } from '../../utils/logger';
|
|
18
18
|
import { createRegistryClient, RegistryClientError } from '../client';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveAuthenticatedRegistryConnection } from '../connection';
|
|
20
20
|
import type { EntitySummary, TransferEntityKind } from '../transfer';
|
|
21
21
|
import { describeUploadFailure } from './errors';
|
|
22
22
|
import { assertRequiredArtifactsPresent, buildPublishPackage, PublishPackageError } from './package';
|
|
@@ -34,7 +34,7 @@ export class PublishError extends Error {
|
|
|
34
34
|
* hosts do not publish) and a resolved registry URL + access token. Throws an actionable
|
|
35
35
|
* {@link PublishError} naming the exact misconfiguration.
|
|
36
36
|
*/
|
|
37
|
-
export const resolveConnectionOrThrow = (handoff: Handoff) => {
|
|
37
|
+
export const resolveConnectionOrThrow = async (handoff: Handoff) => {
|
|
38
38
|
const mode = handoff.config?.runtime?.mode ?? 'workspace';
|
|
39
39
|
if (mode !== 'workspace') {
|
|
40
40
|
throw new PublishError(
|
|
@@ -43,17 +43,17 @@ export const resolveConnectionOrThrow = (handoff: Handoff) => {
|
|
|
43
43
|
);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const connection =
|
|
46
|
+
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
|
|
47
47
|
if (!connection.url) {
|
|
48
48
|
throw new PublishError(
|
|
49
|
-
`No registry
|
|
50
|
-
|
|
49
|
+
`No registry is configured. Run \`handoff-app login --url <registry-url>\`, set runtime.registryConnection.url, ` +
|
|
50
|
+
`or set the "${connection.urlEnv}" environment variable to the base URL of the registry to publish to.`
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
if (!connection.accessToken) {
|
|
54
54
|
throw new PublishError(
|
|
55
|
-
`No registry access token is configured.
|
|
56
|
-
"
|
|
55
|
+
`No registry access token is configured. Run \`handoff-app login --url ${connection.url}\`, or set the ` +
|
|
56
|
+
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.`
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
59
|
return connection;
|
|
@@ -103,7 +103,7 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
|
|
|
103
103
|
* receive a targeted build, while pages are packaged directly from their source.
|
|
104
104
|
*/
|
|
105
105
|
export const publishEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
|
|
106
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
106
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
107
107
|
|
|
108
108
|
Logger.info(kind === 'page' ? `Preparing page "${id}" for publish…` : `Building ${kind} "${id}" for publish…`);
|
|
109
109
|
await runTargetedBuild(handoff, kind, id);
|
|
@@ -160,7 +160,7 @@ const listWorkspaceEntityIds = async (handoff: Handoff, kind: TransferEntityKind
|
|
|
160
160
|
* collected and never aborts the rest; the run throws at the end if any entity failed.
|
|
161
161
|
*/
|
|
162
162
|
export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind): Promise<void> => {
|
|
163
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
163
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
164
164
|
|
|
165
165
|
Logger.info(`Building ${kind}s for publish…`);
|
|
166
166
|
await runBulkBuild(handoff, kind);
|
|
@@ -58,7 +58,7 @@ const buildTokenSetPackage = async (handoff: Handoff, set: TokenSetRecord): Prom
|
|
|
58
58
|
* committed atomically server-side); the run throws at the end if any set failed.
|
|
59
59
|
*/
|
|
60
60
|
export const publishTokens = async (handoff: Handoff, setId?: string): Promise<void> => {
|
|
61
|
-
const connection = resolveConnectionOrThrow(handoff);
|
|
61
|
+
const connection = await resolveConnectionOrThrow(handoff);
|
|
62
62
|
|
|
63
63
|
Logger.info(setId ? `Building tokens to publish "${setId}"…` : 'Building tokens for publish…');
|
|
64
64
|
await handoff.fetch();
|
package/src/types/config.ts
CHANGED
|
@@ -135,7 +135,10 @@ export interface HandoffRuntimeConfig {
|
|
|
135
135
|
registry?: {
|
|
136
136
|
/** Name of the env var holding the database URL. @default "DATABASE_URL" */
|
|
137
137
|
databaseUrlEnv?: string;
|
|
138
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* @deprecated Fixed registry secrets are no longer authorized. This option remains parseable
|
|
140
|
+
* only so existing configuration files do not break.
|
|
141
|
+
*/
|
|
139
142
|
apiTokenEnv?: string;
|
|
140
143
|
database?: {
|
|
141
144
|
/**
|