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
|
@@ -120,7 +120,9 @@ function RegistryTokensPage() {
|
|
|
120
120
|
<Card>
|
|
121
121
|
<CardHeader>
|
|
122
122
|
<CardTitle>Create access token</CardTitle>
|
|
123
|
-
<CardDescription>
|
|
123
|
+
<CardDescription>
|
|
124
|
+
Connect a workspace, a CI job, or an integration. This is the same credential the CLI login issues.
|
|
125
|
+
</CardDescription>
|
|
124
126
|
</CardHeader>
|
|
125
127
|
<form onSubmit={create}>
|
|
126
128
|
<CardContent className="space-y-4">
|
package/src/app-builder/build.ts
CHANGED
|
@@ -443,13 +443,21 @@ After migrations and deployment, open \`/install\` immediately and create the in
|
|
|
443
443
|
The installer verifies the deployment but never changes the schema. An exposed, uninstalled registry
|
|
444
444
|
can be claimed by its first visitor, so do not leave this step unattended.
|
|
445
445
|
|
|
446
|
-
Once installation completes, sign in and authorize a workspace
|
|
446
|
+
Once installation completes, sign in and authorize a workspace. Either approve the CLI in the
|
|
447
|
+
browser:
|
|
447
448
|
|
|
448
449
|
\`\`\`bash
|
|
449
450
|
handoff-app login --url https://registry.example.com
|
|
450
451
|
\`\`\`
|
|
451
452
|
|
|
452
|
-
|
|
453
|
+
or create a token under Account > Access tokens and set it in the workspace environment:
|
|
454
|
+
|
|
455
|
+
\`\`\`bash
|
|
456
|
+
HANDOFF_REGISTRY_URL=https://registry.example.com
|
|
457
|
+
HANDOFF_REGISTRY_ACCESS_TOKEN=hnd_…
|
|
458
|
+
\`\`\`
|
|
459
|
+
|
|
460
|
+
Both produce the same revocable, user-owned credential. The former
|
|
453
461
|
\`HANDOFF_REGISTRY_API_TOKEN\` fixed server secret is no longer accepted.
|
|
454
462
|
|
|
455
463
|
## Run it (self-hosting)
|
|
@@ -5,7 +5,7 @@ import { getClientConfig, isMcpEnabled } from '../config';
|
|
|
5
5
|
import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
|
|
6
6
|
import { resolveAuthenticatedRegistryConnection } from '../registry/connection';
|
|
7
7
|
import { resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
|
|
8
|
-
import type {
|
|
8
|
+
import type { ResolvedConfig, RuntimeMode } from '../types/config';
|
|
9
9
|
import { getAppPath } from './paths';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -53,7 +53,7 @@ export const generateTokensApi = async (handoff: Handoff) => {
|
|
|
53
53
|
* var) to back the registry-mode docs read API. These are non-secret — the connection-string value
|
|
54
54
|
* itself is never persisted, only resolved from the env var at request time.
|
|
55
55
|
*/
|
|
56
|
-
const buildServerRuntimeConfig = (config:
|
|
56
|
+
const buildServerRuntimeConfig = (config: ResolvedConfig, modeOverride?: RuntimeMode) => {
|
|
57
57
|
const assetStorage = resolveAssetStorageFromConfig(config);
|
|
58
58
|
return {
|
|
59
59
|
mode: modeOverride ?? config?.runtime?.mode ?? 'workspace',
|
|
@@ -100,7 +100,9 @@ export const persistClientConfig = async (handoff: Handoff, options: PersistClie
|
|
|
100
100
|
// registry URL resolves — from config/env or a saved device login — so `login` alone surfaces
|
|
101
101
|
// the publish/checkout UI without also requiring HANDOFF_REGISTRY_URL.
|
|
102
102
|
const connected =
|
|
103
|
-
mode === 'registry'
|
|
103
|
+
mode === 'registry'
|
|
104
|
+
? false
|
|
105
|
+
: Boolean((await resolveAuthenticatedRegistryConnection(handoff.config, handoff.workingPath, handoff.getProfile())).url);
|
|
104
106
|
clientConfig.runtime = { ...clientConfig.runtime, mode, connected };
|
|
105
107
|
|
|
106
108
|
await fs.writeJson(path.resolve(appPath, 'client.config.json'), { config: clientConfig }, { spaces: 2 });
|
|
@@ -250,21 +250,31 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState)
|
|
|
250
250
|
const handle =
|
|
251
251
|
entryBefore && strategy ? strategy.capture(handoff, entryBefore.entityId) : undefined;
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
253
|
+
let reloadFailed = false;
|
|
254
|
+
try {
|
|
255
|
+
handoff.reload();
|
|
256
|
+
await persistClientConfig(handoff);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
// A failed reload leaves the last config that loaded on the instance, so the
|
|
259
|
+
// re-arm below still names the file that just broke. Without it the file drops
|
|
260
|
+
// out of the watch set - chokidar stops tracking a watched file once it is
|
|
261
|
+
// deleted - and a later valid save is never seen.
|
|
262
|
+
reloadFailed = true;
|
|
263
|
+
Logger.error('Error reloading runtime configuration:', e);
|
|
264
|
+
}
|
|
265
|
+
|
|
255
266
|
watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
|
|
256
267
|
watchRuntimeConfiguration(handoff, state);
|
|
257
268
|
|
|
258
|
-
|
|
269
|
+
if (reloadFailed) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
259
272
|
|
|
260
|
-
const
|
|
261
|
-
const normalizedMainConfig = handoff.getMainConfigFilePath()
|
|
262
|
-
? normalizePathForCompare(handoff.getMainConfigFilePath() as string)
|
|
263
|
-
: undefined;
|
|
273
|
+
const entryAfter = handoff.getConfigFileEntry(changedFile);
|
|
264
274
|
|
|
265
275
|
const kindChanged = !!entryBefore && !!entryAfter && entryBefore.kind !== entryAfter.kind;
|
|
266
276
|
|
|
267
|
-
if (
|
|
277
|
+
if (handoff.isMainConfigFile(changedFile)) {
|
|
268
278
|
await processComponents(handoff);
|
|
269
279
|
} else if (kindChanged) {
|
|
270
280
|
// The declaration switched between an implementation and a composition. A targeted
|
package/src/cache/build-cache.ts
CHANGED
|
@@ -48,7 +48,11 @@ export interface BuildCache {
|
|
|
48
48
|
* Gets the path to the build cache file
|
|
49
49
|
*/
|
|
50
50
|
export function getCachePath(handoff: Handoff): string {
|
|
51
|
-
|
|
51
|
+
// Two profiles share one project id and can build different output, so the profile has to name
|
|
52
|
+
// the cache file. One shared cache would serve the other profile's components.
|
|
53
|
+
const profile = handoff.getProfile();
|
|
54
|
+
const fileName = profile ? `build-cache.${profile}.json` : 'build-cache.json';
|
|
55
|
+
return path.resolve(handoff.modulePath, '.handoff', handoff.getProjectId(), '.cache', fileName);
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/**
|
package/src/cli/auth/device.ts
CHANGED
|
@@ -31,6 +31,8 @@ interface Envelope<T> {
|
|
|
31
31
|
|
|
32
32
|
export interface LoginOptions {
|
|
33
33
|
openBrowser?: boolean;
|
|
34
|
+
/** Profile the issued credential is saved under. Omitted, it is the default profile. */
|
|
35
|
+
profile?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
@@ -71,7 +73,10 @@ const shouldOpenBrowser = (options: LoginOptions): boolean => {
|
|
|
71
73
|
const positiveNumberOr = (value: unknown, fallback: number): number =>
|
|
72
74
|
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
73
75
|
|
|
74
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Complete the device grant and persist the one-time plaintext access token for this workspace,
|
|
78
|
+
* under the selected profile.
|
|
79
|
+
*/
|
|
75
80
|
export const loginWithDevice = async (workingPath: string, remoteUrl: string, options: LoginOptions = {}): Promise<CliAuth> => {
|
|
76
81
|
const baseUrl = normalizeRegistryUrl(remoteUrl);
|
|
77
82
|
let deviceResponse: Response;
|
|
@@ -98,7 +103,7 @@ export const loginWithDevice = async (workingPath: string, remoteUrl: string, op
|
|
|
98
103
|
const verificationUrl = assertRegistryOriginUrl(
|
|
99
104
|
baseUrl,
|
|
100
105
|
device.verification_uri_complete ||
|
|
101
|
-
`${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}
|
|
106
|
+
`${device.verification_uri}${device.verification_uri.includes('?') ? '&' : '?'}user_code=${encodeURIComponent(device.user_code)}`
|
|
102
107
|
);
|
|
103
108
|
|
|
104
109
|
Logger.log('');
|
|
@@ -142,7 +147,7 @@ export const loginWithDevice = async (workingPath: string, remoteUrl: string, op
|
|
|
142
147
|
accessToken: token.access_token,
|
|
143
148
|
expiresAtMs: Date.now() + expiresIn * 1000,
|
|
144
149
|
};
|
|
145
|
-
await writeCliAuth(workingPath, auth);
|
|
150
|
+
await writeCliAuth(workingPath, auth, options.profile);
|
|
146
151
|
return auth;
|
|
147
152
|
}
|
|
148
153
|
|
package/src/cli/auth/store.ts
CHANGED
|
@@ -9,9 +9,17 @@ export interface CliAuth {
|
|
|
9
9
|
expiresAtMs: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Name a login is saved under when no profile is selected. */
|
|
13
|
+
export const DEFAULT_LOGIN_PROFILE = 'default';
|
|
14
|
+
|
|
12
15
|
const AUTH_DIRECTORY = '.handoff';
|
|
13
16
|
const AUTH_FILE = 'cli-auth.json';
|
|
14
17
|
|
|
18
|
+
/** The credential file: one login per profile name, so a workspace can hold several registries. */
|
|
19
|
+
interface CliAuthFile {
|
|
20
|
+
logins: Record<string, CliAuth>;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* Canonicalize a registry URL for both HTTP requests and exact credential matching.
|
|
17
25
|
* Base paths are retained; query strings, fragments, credentials, and trailing slashes are not.
|
|
@@ -58,38 +66,67 @@ export const assertRegistryOriginUrl = (baseUrl: string, candidate: string): str
|
|
|
58
66
|
|
|
59
67
|
export const cliAuthFilePath = (workingPath: string): string => path.resolve(workingPath, AUTH_DIRECTORY, AUTH_FILE);
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
/** An invalid or partial entry reads as no login, so one bad entry cannot hide the others. */
|
|
70
|
+
const toCliAuth = (value: unknown): CliAuth | null => {
|
|
71
|
+
if (!value || typeof value !== 'object') return null;
|
|
63
72
|
const auth = value as Partial<CliAuth>;
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
auth.accessToken.length > 0 &&
|
|
68
|
-
typeof auth.expiresAtMs === 'number' &&
|
|
69
|
-
Number.isFinite(auth.expiresAtMs)
|
|
70
|
-
);
|
|
71
|
-
};
|
|
73
|
+
if (typeof auth.remoteUrl !== 'string') return null;
|
|
74
|
+
if (typeof auth.accessToken !== 'string' || auth.accessToken.length === 0) return null;
|
|
75
|
+
if (typeof auth.expiresAtMs !== 'number' || !Number.isFinite(auth.expiresAtMs)) return null;
|
|
72
76
|
|
|
73
|
-
/** Invalid or partial credential files are treated as unauthenticated. */
|
|
74
|
-
export const readCliAuth = async (workingPath: string): Promise<CliAuth | null> => {
|
|
75
77
|
try {
|
|
76
|
-
|
|
77
|
-
if (!isCliAuth(value)) return null;
|
|
78
|
-
return { ...value, remoteUrl: normalizeRegistryUrl(value.remoteUrl) };
|
|
78
|
+
return { remoteUrl: normalizeRegistryUrl(auth.remoteUrl), accessToken: auth.accessToken, expiresAtMs: auth.expiresAtMs };
|
|
79
79
|
} catch {
|
|
80
80
|
return null;
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
const toLogins = (value: unknown): Record<string, CliAuth> => {
|
|
85
|
+
const logins = (value as Partial<CliAuthFile> | null)?.logins;
|
|
86
|
+
if (!logins || typeof logins !== 'object') return {};
|
|
87
|
+
|
|
88
|
+
const result: Record<string, CliAuth> = {};
|
|
89
|
+
for (const [profile, entry] of Object.entries(logins as Record<string, unknown>)) {
|
|
90
|
+
const auth = toCliAuth(entry);
|
|
91
|
+
if (auth) result[profile] = auth;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Every saved login, keyed by profile name. A missing or unreadable file reads as no logins. */
|
|
97
|
+
export const readCliLogins = async (workingPath: string): Promise<Record<string, CliAuth>> => {
|
|
98
|
+
try {
|
|
99
|
+
return toLogins((await fs.readJson(cliAuthFilePath(workingPath))) as unknown);
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The login saved under one profile name. Never falls back to another profile. */
|
|
106
|
+
export const readCliAuth = async (workingPath: string, profile: string = DEFAULT_LOGIN_PROFILE): Promise<CliAuth | null> =>
|
|
107
|
+
(await readCliLogins(workingPath))[profile] ?? null;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Profile names that have a saved login. Synchronous because config loading is, and the loader needs
|
|
111
|
+
* these names to decide whether a selected profile resolves.
|
|
112
|
+
*/
|
|
113
|
+
export const loginProfileNames = (workingPath: string): string[] => {
|
|
114
|
+
try {
|
|
115
|
+
return Object.keys(toLogins(fs.readJsonSync(cliAuthFilePath(workingPath)) as unknown));
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
84
121
|
/**
|
|
85
122
|
* Atomically replace the credential file. Restrictive permissions are best-effort on platforms
|
|
86
123
|
* whose filesystems do not support POSIX modes.
|
|
87
124
|
*/
|
|
88
|
-
|
|
125
|
+
const writeCliLogins = async (workingPath: string, logins: Record<string, CliAuth>): Promise<void> => {
|
|
89
126
|
const filePath = cliAuthFilePath(workingPath);
|
|
90
127
|
const directory = path.dirname(filePath);
|
|
91
128
|
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
92
|
-
const value:
|
|
129
|
+
const value: CliAuthFile = { logins };
|
|
93
130
|
|
|
94
131
|
await fs.ensureDir(directory, 0o700);
|
|
95
132
|
try {
|
|
@@ -117,7 +154,27 @@ export const writeCliAuth = async (workingPath: string, auth: CliAuth): Promise<
|
|
|
117
154
|
}
|
|
118
155
|
};
|
|
119
156
|
|
|
120
|
-
|
|
157
|
+
/** Save the login for one profile and leave the other profiles in place. */
|
|
158
|
+
export const writeCliAuth = async (workingPath: string, auth: CliAuth, profile: string = DEFAULT_LOGIN_PROFILE): Promise<void> => {
|
|
159
|
+
const logins = await readCliLogins(workingPath);
|
|
160
|
+
logins[profile] = { ...auth, remoteUrl: normalizeRegistryUrl(auth.remoteUrl) };
|
|
161
|
+
await writeCliLogins(workingPath, logins);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Remove the login for one profile, and the file itself once no login is left. */
|
|
165
|
+
export const clearCliAuth = async (workingPath: string, profile: string = DEFAULT_LOGIN_PROFILE): Promise<void> => {
|
|
166
|
+
const logins = await readCliLogins(workingPath);
|
|
167
|
+
delete logins[profile];
|
|
168
|
+
|
|
169
|
+
if (Object.keys(logins).length === 0) {
|
|
170
|
+
await clearAllCliAuth(workingPath);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await writeCliLogins(workingPath, logins);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Remove every saved login. */
|
|
177
|
+
export const clearAllCliAuth = async (workingPath: string): Promise<void> => {
|
|
121
178
|
const filePath = cliAuthFilePath(workingPath);
|
|
122
179
|
if (await fs.pathExists(filePath)) {
|
|
123
180
|
await fs.remove(filePath);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CommandModule } from 'yargs';
|
|
2
2
|
import { loginWithDevice } from '../../cli/auth/device';
|
|
3
|
-
import { cliAuthFilePath } from '../../cli/auth/store';
|
|
3
|
+
import { cliAuthFilePath, DEFAULT_LOGIN_PROFILE, readCliAuth } from '../../cli/auth/store';
|
|
4
4
|
import { resolveRegistryConnection } from '../../registry/connection';
|
|
5
5
|
import { Logger } from '../../utils/logger';
|
|
6
6
|
import { SharedArgs } from '../types';
|
|
@@ -11,6 +11,11 @@ export interface LoginArgs extends SharedArgs {
|
|
|
11
11
|
browser?: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Saves one registry credential per profile, so a workspace can hold a login for every registry it
|
|
16
|
+
* publishes to. The name is independent of the config profiles, and `login` is the one command that
|
|
17
|
+
* accepts a profile nothing else knows yet.
|
|
18
|
+
*/
|
|
14
19
|
const command: CommandModule<{}, LoginArgs> = {
|
|
15
20
|
command: 'login',
|
|
16
21
|
describe: 'Sign in to a registry using browser device authorization',
|
|
@@ -26,17 +31,26 @@ const command: CommandModule<{}, LoginArgs> = {
|
|
|
26
31
|
describe: 'Open the approval URL in the default browser (disable with --no-browser)',
|
|
27
32
|
}),
|
|
28
33
|
handler: async (args: LoginArgs) => {
|
|
29
|
-
const handoff = createHandoff(args);
|
|
34
|
+
const handoff = createHandoff(args, { profileWithoutConfig: 'any' });
|
|
35
|
+
const profile = handoff.getProfile() ?? DEFAULT_LOGIN_PROFILE;
|
|
30
36
|
try {
|
|
31
37
|
const configured = resolveRegistryConnection(handoff.config);
|
|
32
|
-
|
|
38
|
+
// Resolved in the order publish and checkout use, so a login always targets the registry those
|
|
39
|
+
// commands would reach.
|
|
40
|
+
const saved = await readCliAuth(handoff.workingPath, profile);
|
|
41
|
+
const remoteUrl = args.url?.trim() || configured.url || saved?.remoteUrl;
|
|
33
42
|
if (!remoteUrl) {
|
|
34
43
|
throw new Error(`Pass --url <registry-url>, configure runtime.registryConnection.url, or set "${configured.urlEnv}".`);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
|
-
await loginWithDevice(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false });
|
|
38
|
-
|
|
39
|
-
Logger.
|
|
46
|
+
await loginWithDevice(handoff.workingPath, remoteUrl, { openBrowser: args.browser !== false, profile });
|
|
47
|
+
const named = profile === DEFAULT_LOGIN_PROFILE ? '' : ` under profile "${profile}"`;
|
|
48
|
+
Logger.success(`Logged in. Credentials saved to ${cliAuthFilePath(handoff.workingPath)}${named}.`);
|
|
49
|
+
Logger.info(
|
|
50
|
+
profile === DEFAULT_LOGIN_PROFILE
|
|
51
|
+
? 'Publish and checkout commands will use this login for the matching registry URL.'
|
|
52
|
+
: `Publish and checkout commands will use this login when they run with --profile ${profile}.`
|
|
53
|
+
);
|
|
40
54
|
} catch (error) {
|
|
41
55
|
Logger.error(error instanceof Error ? error.message : String(error));
|
|
42
56
|
process.exitCode = 1;
|
|
@@ -1,45 +1,99 @@
|
|
|
1
1
|
import { CommandModule } from 'yargs';
|
|
2
2
|
import { revokeAccessToken } from '../../cli/auth/device';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type CliAuth,
|
|
5
|
+
clearAllCliAuth,
|
|
6
|
+
clearCliAuth,
|
|
7
|
+
cliAuthFilePath,
|
|
8
|
+
cliAuthMatchesRegistry,
|
|
9
|
+
DEFAULT_LOGIN_PROFILE,
|
|
10
|
+
normalizeRegistryUrl,
|
|
11
|
+
readCliAuth,
|
|
12
|
+
readCliLogins,
|
|
13
|
+
} from '../../cli/auth/store';
|
|
4
14
|
import { Logger } from '../../utils/logger';
|
|
5
15
|
import { SharedArgs } from '../types';
|
|
6
16
|
import { createHandoff, getSharedOptions } from '../utils';
|
|
7
17
|
|
|
8
18
|
export interface LogoutArgs extends SharedArgs {
|
|
9
19
|
url?: string;
|
|
20
|
+
all?: boolean;
|
|
10
21
|
}
|
|
11
22
|
|
|
23
|
+
/** Remote revocation is best effort: the local credential goes either way. */
|
|
24
|
+
const revoke = async (auth: CliAuth): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
await revokeAccessToken(auth);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
Logger.warn(
|
|
29
|
+
`Could not revoke the registry token for ${auth.remoteUrl} (${error instanceof Error ? error.message : String(error)}). ` +
|
|
30
|
+
'It will still be removed from this workspace.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `handoff-app logout` removes the login of the selected profile, leaving every other profile signed
|
|
37
|
+
* in. `--all` removes them all.
|
|
38
|
+
*/
|
|
12
39
|
const command: CommandModule<{}, LogoutArgs> = {
|
|
13
40
|
command: 'logout',
|
|
14
41
|
describe: 'Revoke and remove saved registry credentials',
|
|
15
42
|
builder: (yargs) =>
|
|
16
|
-
getSharedOptions(yargs)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
43
|
+
getSharedOptions(yargs)
|
|
44
|
+
.option('url', {
|
|
45
|
+
type: 'string',
|
|
46
|
+
describe: 'Revoke credentials only when they belong to this exact registry URL',
|
|
47
|
+
})
|
|
48
|
+
.option('all', {
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
describe: 'Revoke and remove the saved credentials for every profile',
|
|
51
|
+
})
|
|
52
|
+
.conflicts('all', 'url'),
|
|
20
53
|
handler: async (args: LogoutArgs) => {
|
|
21
|
-
const handoff = createHandoff(args);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
const handoff = createHandoff(args, { profileWithoutConfig: 'saved-login' });
|
|
55
|
+
const profile = handoff.getProfile() ?? DEFAULT_LOGIN_PROFILE;
|
|
56
|
+
const named = profile === DEFAULT_LOGIN_PROFILE ? '' : ` for profile "${profile}"`;
|
|
57
|
+
|
|
58
|
+
if (args.all) {
|
|
59
|
+
const logins = await readCliLogins(handoff.workingPath);
|
|
60
|
+
const names = Object.keys(logins);
|
|
61
|
+
if (!names.length) {
|
|
62
|
+
Logger.info('No saved logins.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
28
65
|
|
|
29
|
-
|
|
66
|
+
for (const name of names) {
|
|
67
|
+
await revoke(logins[name]);
|
|
68
|
+
}
|
|
30
69
|
try {
|
|
31
|
-
await
|
|
70
|
+
await clearAllCliAuth(handoff.workingPath);
|
|
71
|
+
Logger.success(`Logged out of every saved profile: ${names.join(', ')}. Removed ${cliAuthFilePath(handoff.workingPath)}.`);
|
|
32
72
|
} catch (error) {
|
|
33
|
-
Logger.
|
|
34
|
-
|
|
35
|
-
'It will still be removed from this workspace.'
|
|
36
|
-
);
|
|
73
|
+
Logger.error(`Could not remove saved credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
process.exitCode = 1;
|
|
37
75
|
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const auth = await readCliAuth(handoff.workingPath, profile);
|
|
80
|
+
if (!auth) {
|
|
81
|
+
Logger.info(`No saved login${named}.`);
|
|
82
|
+
return;
|
|
38
83
|
}
|
|
84
|
+
if (args.url && !cliAuthMatchesRegistry(auth, args.url)) {
|
|
85
|
+
Logger.error(
|
|
86
|
+
`The saved login${named} belongs to ${auth.remoteUrl}, not ${normalizeRegistryUrl(args.url)}. No credentials were changed.`
|
|
87
|
+
);
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await revoke(auth);
|
|
39
93
|
|
|
40
94
|
try {
|
|
41
|
-
await clearCliAuth(handoff.workingPath);
|
|
42
|
-
Logger.success(`Logged out
|
|
95
|
+
await clearCliAuth(handoff.workingPath, profile);
|
|
96
|
+
Logger.success(`Logged out of ${auth.remoteUrl}${named}. Removed the saved credentials.`);
|
|
43
97
|
} catch (error) {
|
|
44
98
|
Logger.error(`Could not remove saved credentials: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
99
|
process.exitCode = 1;
|
package/src/commands/types.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Arguments } from 'yargs';
|
|
|
3
3
|
export interface SharedArgs extends Arguments {
|
|
4
4
|
/** Explicit config file, replacing the usual `handoff.config.*` lookup. */
|
|
5
5
|
config?: string;
|
|
6
|
+
/** Config profile merged onto the base config; overrides `HANDOFF_PROFILE`. */
|
|
7
|
+
profile?: string;
|
|
6
8
|
force?: boolean;
|
|
7
9
|
debug?: boolean;
|
|
8
10
|
/**
|
package/src/commands/utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Argv } from 'yargs';
|
|
2
|
-
import Handoff from '../';
|
|
2
|
+
import Handoff, { HandoffOptions } from '../';
|
|
3
3
|
import { REGISTRY_ENTITY_KINDS } from '../registry/content-kinds';
|
|
4
4
|
import { Logger } from '../utils/logger';
|
|
5
5
|
import { SharedArgs } from './types';
|
|
@@ -20,22 +20,26 @@ export type RegistryTargetKind = (typeof REGISTRY_TARGET_KINDS)[number];
|
|
|
20
20
|
export const COMPAT_TARGET_KINDS = [...REGISTRY_ENTITY_KINDS, 'all'] as const;
|
|
21
21
|
export type CompatTargetKind = (typeof COMPAT_TARGET_KINDS)[number];
|
|
22
22
|
|
|
23
|
-
export const createHandoff = (args: SharedArgs): Handoff =>
|
|
23
|
+
export const createHandoff = (args: SharedArgs, options: Pick<HandoffOptions, 'profileWithoutConfig'> = {}): Handoff =>
|
|
24
24
|
new Handoff({
|
|
25
25
|
debug: args.debug,
|
|
26
26
|
force: args.force,
|
|
27
27
|
configPath: args.config,
|
|
28
|
+
profile: args.profile,
|
|
28
29
|
dryRun: args.dryRun,
|
|
29
30
|
// yargs gives `--no-build` as `build: false`; anything else leaves the build in place.
|
|
30
31
|
skipBuild: args.build === false,
|
|
32
|
+
...options,
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* Run a registry command against a fresh Handoff.
|
|
35
|
-
*
|
|
36
|
+
* Run a registry command against a fresh Handoff. A selected profile may name a saved login instead
|
|
37
|
+
* of a config file, because here the profile selects the registry. Registry failures are the
|
|
38
|
+
* user's to act on, so they surface as one actionable line and a non-zero exit rather than a stack
|
|
39
|
+
* trace.
|
|
36
40
|
*/
|
|
37
41
|
export const runRegistryCommand = async (args: SharedArgs, run: (handoff: Handoff) => Promise<unknown>): Promise<void> => {
|
|
38
|
-
const handoff = createHandoff(args);
|
|
42
|
+
const handoff = createHandoff(args, { profileWithoutConfig: 'saved-login' });
|
|
39
43
|
try {
|
|
40
44
|
await run(handoff);
|
|
41
45
|
} catch (error) {
|
|
@@ -61,6 +65,11 @@ export const getSharedOptions = (yargs: Argv) => {
|
|
|
61
65
|
type: 'boolean',
|
|
62
66
|
description: 'Enable debug mode',
|
|
63
67
|
},
|
|
68
|
+
profile: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description:
|
|
71
|
+
'Config profile merged onto the base config (handoff.config.<profile>.*), and the saved login that registry commands use; overrides HANDOFF_PROFILE',
|
|
72
|
+
},
|
|
64
73
|
});
|
|
65
74
|
};
|
|
66
75
|
|
package/src/config/README.md
CHANGED
|
@@ -7,10 +7,15 @@ Handles loading, validation, and resolution of handoff configuration.
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|------|---------|
|
|
9
9
|
| `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
|
|
10
|
-
| `
|
|
10
|
+
| `from-env.ts` | JSON-compatible environment markers and public types |
|
|
11
|
+
| `resolve-env.ts` | Resolves merged environment references and guards reference-only properties |
|
|
12
|
+
| `errors.ts` | Shared `HandoffConfigError` |
|
|
13
|
+
| `env.ts` | Reads `.env` at load, then `loadProfileEnv()` applies `.env.<profile>` on top |
|
|
14
|
+
| `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order) plus the selected profile sidecar, warns on conflicts, and merges every layer |
|
|
11
15
|
| `runtime.ts` | `initRuntimeConfig()` — one pass over every registered catalog directory: loads each declaration, normalizes it into a component or pattern record, skips a declaration with a taken id, resolves SCSS/JS paths and transformer options |
|
|
12
16
|
| `catalog-include.ts` | `isIncluded()` / `addToCatalog()` — registers a directory under `catalog.include` |
|
|
13
17
|
| `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
|
|
18
|
+
| `helpers.ts` | `defineConfig()` — typed authoring; maps camelCase keys to the runtime keys |
|
|
14
19
|
| `index.ts` | Barrel re-exports |
|
|
15
20
|
|
|
16
21
|
## Usage
|
|
@@ -31,3 +36,58 @@ When multiple main config files exist in the project root, Handoff picks the fir
|
|
|
31
36
|
2. `handoff.config.js`
|
|
32
37
|
3. `handoff.config.cjs`
|
|
33
38
|
4. `handoff.config.json`
|
|
39
|
+
|
|
40
|
+
## Profiles
|
|
41
|
+
|
|
42
|
+
`--profile <name>`, or `HANDOFF_PROFILE` when the flag is absent, adds one sidecar file. It sits beside the resolved base config: the base file name without its extension, then the profile name, then any config extension. So `handoff.config.ts` pairs with `handoff.config.<name>.*`, and a config named through `-c` keeps its own name. The same four extensions are probed in the same order. A selected profile that does not resolve is a `HandoffConfigError`.
|
|
43
|
+
|
|
44
|
+
`ConfigLoadContext.knownProfiles` widens what resolving means: a listed name loads the base config unchanged instead of failing. The CLI passes the saved registry login names for `login`, `logout`, `publish`, and `checkout`, where a profile selects the registry rather than a config layer. `profileNotFoundHint` is the caller's sentence for a name that matches neither. Everything else keeps the strict rule, so a typo cannot silently run with the base config.
|
|
45
|
+
|
|
46
|
+
## Environment Files
|
|
47
|
+
|
|
48
|
+
`.env` is read once when `env.ts` loads, before the first `Handoff` is constructed. A selected profile then adds an optional `.env.<profile>` from the same directory. Precedence is the process environment, then the profile env file, then `.env`. The keys present at startup are recorded before `.env` is read and are never overwritten, so an inline `VAR=… handoff-app …` still wins.
|
|
49
|
+
|
|
50
|
+
`loadProfileEnv()` runs twice: in `Handoff.construct()` before `Logger.init()`, so the log level can come from a profile, and in `initConfigWithMetadata()` before the merged environment references resolve. Applying it twice changes nothing.
|
|
51
|
+
|
|
52
|
+
## Layer Precedence
|
|
53
|
+
|
|
54
|
+
Every loaded file is normalized, then the layers are merged in this order:
|
|
55
|
+
|
|
56
|
+
1. `defaultConfig()`
|
|
57
|
+
2. Base config file
|
|
58
|
+
3. Profile file
|
|
59
|
+
4. Programmatic override (`HandoffOptions.config`)
|
|
60
|
+
|
|
61
|
+
Plain objects merge recursively. Arrays, scalars, `null`, and functions replace. `undefined` is skipped.
|
|
62
|
+
|
|
63
|
+
## Environment references
|
|
64
|
+
|
|
65
|
+
Import `fromEnv` from `handoff-app`. `fromEnv('OUTPUT_DIR', { default: 'exported' })` creates
|
|
66
|
+
`{ $env: 'OUTPUT_DIR', default: 'exported' }` without reading the environment. JSON configs use
|
|
67
|
+
that same object. Without a default, an unset variable raises `HandoffConfigError` with the
|
|
68
|
+
property path, variable name, and active profile. Invalid names and rejected literals are never echoed.
|
|
69
|
+
|
|
70
|
+
`EnvValue<T>` accepts a literal or reference. `EnvSecret<T>` accepts only a reference and resolves
|
|
71
|
+
at config load. Secret defaults can only be empty or null, for optional credentials.
|
|
72
|
+
`RuntimeEnvReference<T>` accepts only a reference with no default. Its name survives config loading and
|
|
73
|
+
is read in the deployed process. Database URLs and asset-storage tokens are deferred.
|
|
74
|
+
|
|
75
|
+
The loader resolves references once after all layers merge, before other config guards.
|
|
76
|
+
References replace whole values, including earlier references and their defaults. A profile can
|
|
77
|
+
replace an ordinary reference with a literal. Functions in hooks and pipeline remain functions.
|
|
78
|
+
Defaults retain the existing `HANDOFF_*` seeds: a higher-layer literal beats a seed, and an explicit
|
|
79
|
+
reference beats every lower layer. Only the winning reference reads the environment.
|
|
80
|
+
|
|
81
|
+
Environment values are strings. A numeric or boolean default selects conversion; the built-in
|
|
82
|
+
numeric and boolean properties also retain their default's conversion when a reference omits its
|
|
83
|
+
fallback. Booleans are true only for `true`. Numbers must be finite. A lower-layer fallback never
|
|
84
|
+
becomes the fallback for a replacement reference.
|
|
85
|
+
|
|
86
|
+
`runtime.registry.assetStorage.options` is for non-secret values only. Its resolved contents are
|
|
87
|
+
JSON-encoded into the bundle, so the loader rejects an environment reference anywhere inside it
|
|
88
|
+
(`BAKED_PATHS`). For custom adapter secrets, put a variable name in options and read it through the
|
|
89
|
+
adapter factory's `env` argument at runtime. A per-environment literal belongs in a profile.
|
|
90
|
+
|
|
91
|
+
`ResolvedConfig` narrows eager properties by hand. Adding an eager property requires its `Config`
|
|
92
|
+
member and resolved narrowing. A secret also needs a `SECRET_PATHS` entry for untyped configs.
|
|
93
|
+
Adding a deferred property requires its member and a `DEFERRED_PATHS` entry.
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'fs-extra';
|
|
16
16
|
import path from 'path';
|
|
17
|
-
import {
|
|
17
|
+
import { ResolvedConfig } from '../types/config';
|
|
18
18
|
import { arePathsEqual } from '../utils/path';
|
|
19
19
|
import { getComponentsForPath } from './runtime';
|
|
20
20
|
|
|
21
21
|
/** Minimal handoff shape needed here; avoids importing the full Handoff class (circular dep). */
|
|
22
22
|
interface ConfigContext {
|
|
23
|
-
config?:
|
|
23
|
+
config?: ResolvedConfig;
|
|
24
24
|
workingPath: string;
|
|
25
25
|
}
|
|
26
26
|
|