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/src/index.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
|
-
import '
|
|
1
|
+
import './config/env';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { Types as CoreTypes, Handoff as HandoffRunner, Providers } from 'handoff-core';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import buildApp, { devApp, watchApp, type BuildPackage, type BuildTarget } from './app-builder';
|
|
6
6
|
import { ejectConfig, ejectPages, ejectTheme } from './cli/eject';
|
|
7
7
|
import { makeComponent, makePage } from './cli/make';
|
|
8
|
-
import {
|
|
8
|
+
import { loginProfileNames } from './cli/auth/store';
|
|
9
|
+
import {
|
|
10
|
+
type ConfigLoadContext,
|
|
11
|
+
initConfigWithMetadata,
|
|
12
|
+
initRuntimeConfig,
|
|
13
|
+
loadProfileEnv,
|
|
14
|
+
resolveProfileSelection,
|
|
15
|
+
validateConfig,
|
|
16
|
+
} from './config';
|
|
9
17
|
import pipeline, { buildComponents, buildPatterns } from './pipeline';
|
|
10
18
|
import { ALL_KIND_ORDER, ENTITY_WIRE_KIND, isRegistryEntityKind, REGISTRY_ENTITY_KINDS, type RegistryEntityKind } from './registry/content-kinds';
|
|
11
19
|
import type { TransferEntityKind } from './registry/transfer';
|
|
12
20
|
import { createFilesystemStore, type HandoffStore } from './store';
|
|
13
21
|
import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
|
|
14
|
-
import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
|
|
22
|
+
import { Config, ResolvedConfig, ConfigFileEntry, RuntimeConfig } from './types/config';
|
|
15
23
|
import { Logger } from './utils/logger';
|
|
16
24
|
import { normalizePathForCompare, resolveWorkingPath } from './utils/path';
|
|
17
25
|
import { generateFilesystemSafeId } from './utils/path';
|
|
@@ -32,6 +40,19 @@ export interface HandoffOptions {
|
|
|
32
40
|
config?: Partial<Config>;
|
|
33
41
|
/** Explicit config file (the CLI's `-c, --config`), resolved from the working path. */
|
|
34
42
|
configPath?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Config profile to merge onto the base config (the CLI's `--profile`). It overrides
|
|
45
|
+
* `HANDOFF_PROFILE`, and a selected profile has to resolve to a `handoff.config.<profile>.*` file
|
|
46
|
+
* unless {@link profileWithoutConfig} widens that.
|
|
47
|
+
*/
|
|
48
|
+
profile?: string;
|
|
49
|
+
/**
|
|
50
|
+
* What a selected profile may resolve to when it has no config file. `'saved-login'` accepts a name
|
|
51
|
+
* a saved registry login uses, so publish and checkout can address a registry that no config file
|
|
52
|
+
* describes. `'any'` accepts any valid name, for the `login` command that creates the login. Unset,
|
|
53
|
+
* a profile must name a config file, so a typo cannot silently run with the base config.
|
|
54
|
+
*/
|
|
55
|
+
profileWithoutConfig?: 'saved-login' | 'any';
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -109,7 +130,7 @@ const runKinds = async (
|
|
|
109
130
|
};
|
|
110
131
|
|
|
111
132
|
class Handoff {
|
|
112
|
-
config:
|
|
133
|
+
config: ResolvedConfig | null;
|
|
113
134
|
debug: boolean = false;
|
|
114
135
|
force: boolean = false;
|
|
115
136
|
dryRun: boolean = false;
|
|
@@ -142,6 +163,8 @@ class Handoff {
|
|
|
142
163
|
private _configFilePaths: string[] = [];
|
|
143
164
|
private _configFileIndex: Map<string, ConfigFileEntry> = new Map();
|
|
144
165
|
private _mainConfigFilePath?: string;
|
|
166
|
+
private _profileConfigFilePath?: string;
|
|
167
|
+
private _profile?: string;
|
|
145
168
|
private _documentationObjectCache?: CoreTypes.IDocumentationObject;
|
|
146
169
|
private _handoffRunner?: ReturnType<typeof HandoffRunner> | null;
|
|
147
170
|
|
|
@@ -151,7 +174,8 @@ class Handoff {
|
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
private construct(options: HandoffOptions) {
|
|
154
|
-
|
|
177
|
+
// Ahead of `Logger.init` so a profile env file can set `HANDOFF_LOG_LEVEL` and `HANDOFF_LOG_SCOPES`.
|
|
178
|
+
loadProfileEnv(resolveProfileSelection(options.profile)?.name);
|
|
155
179
|
this.debug = options.debug ?? false;
|
|
156
180
|
this.force = options.force ?? false;
|
|
157
181
|
this.dryRun = options.dryRun ?? false;
|
|
@@ -160,14 +184,50 @@ class Handoff {
|
|
|
160
184
|
this.init(options.config);
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
/**
|
|
188
|
+
* What a selected profile may resolve to besides a config file, and how to explain a name that
|
|
189
|
+
* resolves to nothing. A command that does not accept a login still names one it found, so the
|
|
190
|
+
* difference between the two kinds of profile is visible rather than puzzling.
|
|
191
|
+
*/
|
|
192
|
+
private profilesWithoutConfig(): Pick<ConfigLoadContext, 'knownProfiles' | 'profileNotFoundHint'> {
|
|
193
|
+
const selected = resolveProfileSelection(this._options.profile)?.name;
|
|
194
|
+
if (!selected) {
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const accepts = this._options.profileWithoutConfig;
|
|
199
|
+
const names = loginProfileNames(this.workingPath);
|
|
200
|
+
if (!accepts) {
|
|
201
|
+
return names.includes(selected)
|
|
202
|
+
? {
|
|
203
|
+
profileNotFoundHint:
|
|
204
|
+
'A saved registry login uses that name, but only login, logout, publish, and checkout accept a profile without a config file.',
|
|
205
|
+
}
|
|
206
|
+
: {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
knownProfiles: accepts === 'any' ? [...names, selected] : names,
|
|
211
|
+
profileNotFoundHint:
|
|
212
|
+
'No saved registry login uses that name either. Run `handoff-app login --profile <name> --url <registry-url>` to create one.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
163
216
|
init(configOverride?: Partial<Config>): Handoff {
|
|
217
|
+
// Resolved before anything is assigned: a reload that throws - a deleted profile file, a
|
|
218
|
+
// broken config - then leaves the last good state in place for the watchers holding this
|
|
219
|
+
// instance.
|
|
164
220
|
const configResult = initConfigWithMetadata(configOverride ?? {}, {
|
|
165
221
|
workingPath: this.workingPath,
|
|
166
222
|
configPath: this._options.configPath,
|
|
223
|
+
profile: this._options.profile,
|
|
224
|
+
...this.profilesWithoutConfig(),
|
|
167
225
|
});
|
|
168
226
|
const config = configResult.config;
|
|
169
227
|
this.config = config;
|
|
170
228
|
this._mainConfigFilePath = configResult.configPath;
|
|
229
|
+
this._profileConfigFilePath = configResult.profileConfigPath;
|
|
230
|
+
this._profile = configResult.profile;
|
|
171
231
|
this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
|
|
172
232
|
this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
|
|
173
233
|
[this.runtimeConfig, this._configFilePaths, this._configFileIndex] = initRuntimeConfig(this);
|
|
@@ -571,8 +631,8 @@ class Handoff {
|
|
|
571
631
|
* @returns {string[]} Array of absolute paths to config files
|
|
572
632
|
*/
|
|
573
633
|
getConfigFilePaths(): string[] {
|
|
574
|
-
const
|
|
575
|
-
return Array.from(new Set(
|
|
634
|
+
const mainPaths = [this._mainConfigFilePath, this._profileConfigFilePath].filter(Boolean) as string[];
|
|
635
|
+
return Array.from(new Set([...mainPaths, ...this._configFilePaths]));
|
|
576
636
|
}
|
|
577
637
|
|
|
578
638
|
/**
|
|
@@ -582,6 +642,21 @@ class Handoff {
|
|
|
582
642
|
return this._mainConfigFilePath;
|
|
583
643
|
}
|
|
584
644
|
|
|
645
|
+
getProfile(): string | undefined {
|
|
646
|
+
return this._profile;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* True for the base config file and for the profile file merged onto it. Both carry project-wide
|
|
651
|
+
* settings, so a change to either one invalidates every entity rather than a single declaration.
|
|
652
|
+
*/
|
|
653
|
+
isMainConfigFile(filePath: string): boolean {
|
|
654
|
+
const normalized = normalizePathForCompare(filePath);
|
|
655
|
+
return [this._mainConfigFilePath, this._profileConfigFilePath]
|
|
656
|
+
.filter(Boolean)
|
|
657
|
+
.some((mainPath) => normalizePathForCompare(mainPath as string) === normalized);
|
|
658
|
+
}
|
|
659
|
+
|
|
585
660
|
getConfigFileEntry(configPath: string): ConfigFileEntry | undefined {
|
|
586
661
|
return this._configFileIndex.get(normalizePathForCompare(configPath));
|
|
587
662
|
}
|
|
@@ -608,8 +683,10 @@ class Handoff {
|
|
|
608
683
|
}
|
|
609
684
|
}
|
|
610
685
|
|
|
611
|
-
export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
|
|
686
|
+
export type { Config, ResolvedConfig, RegisterHandlebarsHelpersContext } from './types/config';
|
|
612
687
|
export { defineConfig } from './config';
|
|
688
|
+
export { fromEnv } from './config/from-env';
|
|
689
|
+
export type { EnvValue, EnvSecret, RuntimeEnvReference } from './config/from-env';
|
|
613
690
|
export type {
|
|
614
691
|
CatalogItem,
|
|
615
692
|
CatalogItemEntries,
|
package/src/pipeline/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const createVercelBlobStorage = (params: { tokenEnv: string; options: Rec
|
|
|
21
21
|
if (!token) {
|
|
22
22
|
throw new Error(
|
|
23
23
|
`Vercel Blob storage is configured but the token env var "${params.tokenEnv}" is not set. ` +
|
|
24
|
-
'Set it to a Blob read/write token, or change runtime.registry.assetStorage.
|
|
24
|
+
'Set it to a Blob read/write token, or change runtime.registry.assetStorage.token.'
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
return token;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* env-var *names* are ever persisted; secret *values* are read from `process.env` at request time.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { ResolvedConfig } from '../../types/config';
|
|
11
11
|
import type { AssetStorageProvider } from '../db/schema';
|
|
12
12
|
|
|
13
13
|
/** Which adapter implementation is active. */
|
|
@@ -68,5 +68,8 @@ export const resolveAssetStorageSettings = (settings: AssetStorageSettings | nul
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
/** Resolve the active asset storage settings from a loaded config (CLI/build side). */
|
|
71
|
-
export const resolveAssetStorageFromConfig = (config:
|
|
72
|
-
resolveAssetStorageSettings(
|
|
71
|
+
export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | undefined): ResolvedAssetStorage =>
|
|
72
|
+
resolveAssetStorageSettings({
|
|
73
|
+
...config?.runtime?.registry?.assetStorage,
|
|
74
|
+
tokenEnv: config?.runtime?.registry?.assetStorage?.token?.$env,
|
|
75
|
+
});
|
|
@@ -36,7 +36,7 @@ import Handoff from '../../index';
|
|
|
36
36
|
import type { DeclarationFormat } from '../../types/config';
|
|
37
37
|
import { Logger } from '../../utils/logger';
|
|
38
38
|
import { createRegistryClient, type RegistryClient, RegistryClientError } from '../client';
|
|
39
|
-
import { resolveAuthenticatedRegistryConnection } from '../connection';
|
|
39
|
+
import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
|
|
40
40
|
import { isSafePathSegment, isSafeRelativePath, resolvePathWithin } from '../path';
|
|
41
41
|
import { quoteIds, selectIds, splitCatalogIds } from '../selection';
|
|
42
42
|
import type { CheckoutPayload, TransferEntityKind, TransferFile } from '../transfer';
|
|
@@ -76,17 +76,17 @@ export const resolveConnectionOrThrow = async (handoff: Handoff) => {
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
|
|
79
|
+
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
|
|
80
80
|
if (!connection.url) {
|
|
81
81
|
throw new CheckoutError(
|
|
82
|
-
`No registry is configured. Run
|
|
82
|
+
`No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
|
|
83
83
|
`or set the "${connection.urlEnv}" environment variable to the base URL of the registry to checkout from.`
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
if (!connection.accessToken) {
|
|
87
87
|
throw new CheckoutError(
|
|
88
|
-
`No registry access token is configured. Run
|
|
89
|
-
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI
|
|
88
|
+
`No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
|
|
89
|
+
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
return connection;
|
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* A connected workspace is `runtime.mode: 'workspace'` plus a `runtime.registryConnection` block
|
|
5
|
-
* pointing at a remote registry — it is not a third runtime mode. The connection is resolved from
|
|
6
|
-
* config + environment: the registry URL may be given inline or via an env-var *name*, and the
|
|
7
|
-
* access token is always read from an env-var name (never persisted as a value). This is the single
|
|
8
|
-
* source the publish (and later checkout) client reads its target + credentials from.
|
|
9
|
-
*
|
|
10
|
-
* The older `HANDOFF_CLOUD_*` names still work as deprecated fallbacks; see
|
|
11
|
-
* {@link DEPRECATED_ENV_ALIASES}.
|
|
2
|
+
* Registry connection values resolve at config load. Saved device login credentials can fill
|
|
3
|
+
* missing values for CLI operations; neither source is persisted in the app's client config.
|
|
12
4
|
*/
|
|
13
5
|
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
6
|
+
import { configEnvName } from '../config/resolve-env';
|
|
7
|
+
import type { ResolvedConfig } from '../types/config';
|
|
8
|
+
import { type CliAuth, cliAuthMatchesRegistry, cliAuthTokenIsValid, DEFAULT_LOGIN_PROFILE, readCliAuth } from '../cli/auth/store';
|
|
16
9
|
// Imported straight from the module, not the `auth` barrel, to keep the database schema off the CLI
|
|
17
10
|
// startup path; this file is resolved for every command.
|
|
18
11
|
import { resolveRegistrySyncSecret } from './auth/sync-secret';
|
|
19
|
-
import { Logger } from '../utils/logger';
|
|
20
12
|
|
|
21
13
|
/** Default env-var name holding the remote registry base URL. */
|
|
22
14
|
export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
|
|
@@ -24,38 +16,11 @@ export const DEFAULT_REGISTRY_URL_ENV = 'HANDOFF_REGISTRY_URL';
|
|
|
24
16
|
/** Default env-var name holding the connected-workspace access token. */
|
|
25
17
|
export const DEFAULT_REGISTRY_ACCESS_TOKEN_ENV = 'HANDOFF_REGISTRY_ACCESS_TOKEN';
|
|
26
18
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
[DEFAULT_REGISTRY_URL_ENV]: 'HANDOFF_CLOUD_URL',
|
|
33
|
-
[DEFAULT_REGISTRY_ACCESS_TOKEN_ENV]: 'HANDOFF_CLOUD_TOKEN',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/** Aliases we've already warned about, so each one warns once per run and not once per lookup. */
|
|
37
|
-
const warnedAliases = new Set<string>();
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Value of the named env var, falling back to its deprecated alias. Only the default names have
|
|
41
|
-
* aliases: a custom `urlEnv`/`accessTokenEnv` is a deliberate choice, so a stray `HANDOFF_CLOUD_*`
|
|
42
|
-
* must not stand in for it. The warning goes through {@link Logger.warn}, so
|
|
43
|
-
* `HANDOFF_LOG_LEVEL=error` silences it in CI.
|
|
44
|
-
*/
|
|
45
|
-
const readConnectionEnv = (name: string): string => {
|
|
46
|
-
const configured = process.env[name]?.trim();
|
|
47
|
-
if (configured) return configured;
|
|
48
|
-
|
|
49
|
-
const alias = DEPRECATED_ENV_ALIASES[name];
|
|
50
|
-
const inherited = alias ? process.env[alias]?.trim() : '';
|
|
51
|
-
if (!inherited) return '';
|
|
52
|
-
|
|
53
|
-
if (!warnedAliases.has(alias)) {
|
|
54
|
-
warnedAliases.add(alias);
|
|
55
|
-
Logger.warn(`${alias} is deprecated and will be removed in a future release; set ${name} instead.`);
|
|
56
|
-
}
|
|
57
|
-
return inherited;
|
|
58
|
-
};
|
|
19
|
+
/** A saved login and the profile it is stored under, which is not always the selected profile. */
|
|
20
|
+
interface SavedLogin {
|
|
21
|
+
profile: string;
|
|
22
|
+
url: string;
|
|
23
|
+
}
|
|
59
24
|
|
|
60
25
|
/** Fully resolved connected-workspace registry connection. */
|
|
61
26
|
export interface ResolvedRegistryConnection {
|
|
@@ -67,55 +32,91 @@ export interface ResolvedRegistryConnection {
|
|
|
67
32
|
urlEnv: string;
|
|
68
33
|
/** Name of the env var the access token is read from. */
|
|
69
34
|
accessTokenEnv: string;
|
|
35
|
+
/** Profile the saved login was looked up under. */
|
|
36
|
+
loginProfile: string;
|
|
37
|
+
/** Set when a saved login was skipped because it was issued for another registry. */
|
|
38
|
+
unmatchedLogin?: SavedLogin;
|
|
70
39
|
}
|
|
71
40
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
* Callers inspect the resolved URL and access token to surface specific configuration errors.
|
|
75
|
-
*
|
|
76
|
-
* `urlEnv` and `accessTokenEnv` always report the canonical name, even when an alias supplied the
|
|
77
|
-
* value, so errors built from them point at the variable to adopt.
|
|
78
|
-
*/
|
|
79
|
-
export const resolveRegistryConnection = (config: Config | null | undefined): ResolvedRegistryConnection => {
|
|
41
|
+
/** Read values resolved at config load, retaining variable names for CLI diagnostics. */
|
|
42
|
+
export const resolveRegistryConnection = (config: ResolvedConfig | null | undefined): ResolvedRegistryConnection => {
|
|
80
43
|
const connection = config?.runtime?.registryConnection;
|
|
81
|
-
const urlEnv =
|
|
82
|
-
const accessTokenEnv =
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const url = inlineUrl || readConnectionEnv(urlEnv);
|
|
86
|
-
const accessToken = readConnectionEnv(accessTokenEnv);
|
|
44
|
+
const urlEnv = configEnvName(config, 'runtime.registryConnection.url') || DEFAULT_REGISTRY_URL_ENV;
|
|
45
|
+
const accessTokenEnv = configEnvName(config, 'runtime.registryConnection.accessToken') || DEFAULT_REGISTRY_ACCESS_TOKEN_ENV;
|
|
46
|
+
const url = connection?.url?.trim() || '';
|
|
47
|
+
const accessToken = connection?.accessToken?.trim() || '';
|
|
87
48
|
|
|
88
49
|
return {
|
|
89
50
|
url,
|
|
90
51
|
accessToken,
|
|
91
52
|
urlEnv,
|
|
92
53
|
accessTokenEnv,
|
|
54
|
+
loginProfile: DEFAULT_LOGIN_PROFILE,
|
|
93
55
|
};
|
|
94
56
|
};
|
|
95
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The valid login to use for a profile. A profile without its own login falls back to the default
|
|
60
|
+
* login, so a workspace that names profiles for other reasons keeps working after one plain
|
|
61
|
+
* `handoff-app login`. The fallback is safe because a token is only ever used for the registry URL
|
|
62
|
+
* it was issued for.
|
|
63
|
+
*/
|
|
64
|
+
const readProfileLogin = async (workingPath: string, profile: string): Promise<{ auth: CliAuth; profile: string } | null> => {
|
|
65
|
+
const own = await readCliAuth(workingPath, profile);
|
|
66
|
+
if (cliAuthTokenIsValid(own)) return { auth: own!, profile };
|
|
67
|
+
|
|
68
|
+
const fallback = profile === DEFAULT_LOGIN_PROFILE ? null : await readCliAuth(workingPath);
|
|
69
|
+
return cliAuthTokenIsValid(fallback) ? { auth: fallback!, profile: DEFAULT_LOGIN_PROFILE } : null;
|
|
70
|
+
};
|
|
71
|
+
|
|
96
72
|
/**
|
|
97
73
|
* Resolve the same connection for an authenticated CLI operation, letting a saved device login fill
|
|
98
74
|
* in what the environment does not provide. A valid login supplies both the registry URL and the
|
|
99
|
-
* access token, so `handoff-app login` alone is enough to publish and checkout.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
75
|
+
* access token, so `handoff-app login` alone is enough to publish and checkout. The login is the one
|
|
76
|
+
* saved under the selected profile, which is how one workspace addresses several registries.
|
|
77
|
+
* Environment values keep precedence for deterministic CI: `HANDOFF_REGISTRY_URL` (or inline config)
|
|
78
|
+
* wins over the saved URL, and `HANDOFF_REGISTRY_ACCESS_TOKEN` wins over the saved token. The saved
|
|
79
|
+
* token only applies to the URL it was issued for.
|
|
103
80
|
*
|
|
104
81
|
* A configured `HANDOFF_SYNC_SECRET` is the last resort. Being deployment-wide rather than personal,
|
|
105
82
|
* it fills in only where there is no explicit token and no login (CI, in practice), instead of quietly
|
|
106
83
|
* speaking for a developer who is signed in.
|
|
107
84
|
*/
|
|
108
85
|
export const resolveAuthenticatedRegistryConnection = async (
|
|
109
|
-
config:
|
|
110
|
-
workingPath = process.cwd()
|
|
86
|
+
config: ResolvedConfig | null | undefined,
|
|
87
|
+
workingPath = process.cwd(),
|
|
88
|
+
profile?: string
|
|
111
89
|
): Promise<ResolvedRegistryConnection> => {
|
|
112
90
|
const connection = resolveRegistryConnection(config);
|
|
91
|
+
const loginProfile = profile?.trim() || DEFAULT_LOGIN_PROFILE;
|
|
113
92
|
|
|
114
|
-
const
|
|
115
|
-
const
|
|
93
|
+
const login = await readProfileLogin(workingPath, loginProfile);
|
|
94
|
+
const url = connection.url || login?.auth.remoteUrl || '';
|
|
95
|
+
const matches = Boolean(login) && cliAuthMatchesRegistry(login!.auth, url);
|
|
96
|
+
const accessToken = connection.accessToken || (matches ? login!.auth.accessToken : '') || resolveRegistrySyncSecret();
|
|
116
97
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
98
|
+
return {
|
|
99
|
+
...connection,
|
|
100
|
+
url,
|
|
101
|
+
accessToken,
|
|
102
|
+
loginProfile,
|
|
103
|
+
unmatchedLogin: login && !matches ? { profile: login.profile, url: login.auth.remoteUrl } : undefined,
|
|
104
|
+
};
|
|
121
105
|
};
|
|
106
|
+
|
|
107
|
+
/** The `handoff-app login` call that would authorize this connection, naming the selected profile. */
|
|
108
|
+
export const loginCommandHint = (connection: ResolvedRegistryConnection): string =>
|
|
109
|
+
[
|
|
110
|
+
'handoff-app login',
|
|
111
|
+
connection.loginProfile === DEFAULT_LOGIN_PROFILE ? '' : ` --profile ${connection.loginProfile}`,
|
|
112
|
+
` --url ${connection.url || '<registry-url>'}`,
|
|
113
|
+
].join('');
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Names a saved login that was skipped because it belongs to another registry. Without it, a run
|
|
117
|
+
* that resolves its URL from the environment reports a missing token while a login is in place.
|
|
118
|
+
*/
|
|
119
|
+
export const unusedLoginNote = (connection: ResolvedRegistryConnection): string =>
|
|
120
|
+
connection.unmatchedLogin
|
|
121
|
+
? ` The login saved for profile "${connection.unmatchedLogin.profile}" is for ${connection.unmatchedLogin.url}, not ${connection.url}.`
|
|
122
|
+
: '';
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the value is never stored in config.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type { ResolvedConfig } from '../../types/config';
|
|
14
14
|
|
|
15
15
|
/** Built-in registry database connection driver. Both ship with the package and target Postgres. */
|
|
16
16
|
export type RegistryDatabaseDriver = 'pg' | 'neon';
|
|
@@ -35,24 +35,13 @@ export interface ResolvedRegistryDatabase {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Resolve the env-var name holding the database URL (defaults to `DATABASE_URL`). */
|
|
38
|
-
export const resolveDatabaseUrlEnv = (config:
|
|
39
|
-
const configured = config?.runtime?.registry?.
|
|
38
|
+
export const resolveDatabaseUrlEnv = (config: ResolvedConfig | null | undefined): string => {
|
|
39
|
+
const configured = config?.runtime?.registry?.databaseUrl?.$env?.trim();
|
|
40
40
|
return configured || DEFAULT_DATABASE_URL_ENV;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
/**
|
|
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`.
|
|
48
|
-
*/
|
|
49
|
-
export const resolveApiTokenEnv = (config: Config | null | undefined): string => {
|
|
50
|
-
const configured = config?.runtime?.registry?.apiTokenEnv?.trim();
|
|
51
|
-
return configured || DEFAULT_REGISTRY_API_TOKEN_ENV;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
43
|
/** Resolve the configured registry database driver (defaults to `pg`). */
|
|
55
|
-
export const resolveRegistryDriver = (config:
|
|
44
|
+
export const resolveRegistryDriver = (config: ResolvedConfig | null | undefined): RegistryDatabaseDriver => {
|
|
56
45
|
return config?.runtime?.registry?.database?.driver ?? DEFAULT_REGISTRY_DRIVER;
|
|
57
46
|
};
|
|
58
47
|
|
|
@@ -61,7 +50,7 @@ export const resolveRegistryDriver = (config: Config | null | undefined): Regist
|
|
|
61
50
|
* when the configured env var is unset so the CLI can surface a clear "missing database URL"
|
|
62
51
|
* message instead of failing deep inside the driver.
|
|
63
52
|
*/
|
|
64
|
-
export const resolveRegistryDatabase = (config:
|
|
53
|
+
export const resolveRegistryDatabase = (config: ResolvedConfig | null | undefined): ResolvedRegistryDatabase => {
|
|
65
54
|
const driver = resolveRegistryDriver(config);
|
|
66
55
|
const databaseUrlEnv = resolveDatabaseUrlEnv(config);
|
|
67
56
|
const connectionString = process.env[databaseUrlEnv]?.trim();
|
|
@@ -69,7 +58,7 @@ export const resolveRegistryDatabase = (config: Config | null | undefined): Reso
|
|
|
69
58
|
if (!connectionString) {
|
|
70
59
|
throw new Error(
|
|
71
60
|
`Registry database URL is not configured. Set the "${databaseUrlEnv}" environment variable to a ` +
|
|
72
|
-
`PostgreSQL connection string (or change "runtime.registry.
|
|
61
|
+
`PostgreSQL connection string (or change "runtime.registry.databaseUrl" to point at the env var you use).`
|
|
73
62
|
);
|
|
74
63
|
}
|
|
75
64
|
|
package/src/registry/db/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import processComponents from '../../transformers/preview/component/builder';
|
|
|
17
17
|
import { processPatterns } from '../../transformers/preview/pattern/builder';
|
|
18
18
|
import { Logger } from '../../utils/logger';
|
|
19
19
|
import { createRegistryClient, RegistryClientError, type RegistryClient } from '../client';
|
|
20
|
-
import { resolveAuthenticatedRegistryConnection } from '../connection';
|
|
20
|
+
import { loginCommandHint, resolveAuthenticatedRegistryConnection, unusedLoginNote } from '../connection';
|
|
21
21
|
import type { EntitySummary, TransferEntityKind } from '../transfer';
|
|
22
22
|
import { quoteIds, selectIds, splitCatalogIds } from '../selection';
|
|
23
23
|
import { describePublishError, describeUploadFailure } from './errors';
|
|
@@ -74,17 +74,17 @@ const assertUniqueCatalogIds = (handoff: Handoff): void => {
|
|
|
74
74
|
export const resolveConnectionOrThrow = async (handoff: Handoff) => {
|
|
75
75
|
assertPublishableWorkspace(handoff);
|
|
76
76
|
|
|
77
|
-
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath);
|
|
77
|
+
const connection = await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile());
|
|
78
78
|
if (!connection.url) {
|
|
79
79
|
throw new PublishError(
|
|
80
|
-
`No registry is configured. Run
|
|
80
|
+
`No registry is configured. Run \`${loginCommandHint(connection)}\`, set runtime.registryConnection.url, ` +
|
|
81
81
|
`or set the "${connection.urlEnv}" environment variable to the base URL of the registry to publish to.`
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
if (!connection.accessToken) {
|
|
85
85
|
throw new PublishError(
|
|
86
|
-
`No registry access token is configured. Run
|
|
87
|
-
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI
|
|
86
|
+
`No registry access token is configured. Run \`${loginCommandHint(connection)}\`, or set the ` +
|
|
87
|
+
`"${connection.accessTokenEnv}" environment variable to a user-issued token for CI.${unusedLoginNote(connection)}`
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
return connection;
|
package/src/store/filesystem.ts
CHANGED
|
@@ -14,7 +14,7 @@ import path from 'path';
|
|
|
14
14
|
import { resolveTokenTransformers, tokenArtifactPathsForSet } from '../pipeline/token-transformers';
|
|
15
15
|
import { deriveTokenSets, isComponentSet, setNameForId } from '../registry/tokens/sets';
|
|
16
16
|
import type { ComponentListObject, PageListObject, PatternListObject } from '../transformers/preview/types';
|
|
17
|
-
import type {
|
|
17
|
+
import type { ResolvedConfig, RuntimeConfig } from '../types/config';
|
|
18
18
|
import { getRelatedSourceFilesForRecord, sourceContentTypeForPath } from './source-files';
|
|
19
19
|
import type {
|
|
20
20
|
ComponentStore,
|
|
@@ -34,7 +34,7 @@ import type {
|
|
|
34
34
|
*/
|
|
35
35
|
export interface FilesystemStoreContext {
|
|
36
36
|
runtimeConfig?: RuntimeConfig | null;
|
|
37
|
-
config?:
|
|
37
|
+
config?: ResolvedConfig | null;
|
|
38
38
|
/** Workspace root (`Handoff.workingPath`), holding `public/` and `fonts/`. */
|
|
39
39
|
workingPath?: string;
|
|
40
40
|
/** Absolute path to the generated `tokens.json` (`Handoff.getTokensFilePath()`). */
|