handoff-app 2.0.0-7 → 2.0.0-9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -14
- 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/catalog/normalize.js +3 -0
- package/dist/catalog/previews.js +5 -4
- package/dist/catalog/types.d.ts +8 -0
- 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 +121 -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/runtime.js +8 -10
- package/dist/config/validator.d.ts +2 -2
- package/dist/config/validator.js +2 -4
- package/dist/handlebars.d.ts +10 -2
- 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/transformers/plugins/ssr-render.js +40 -15
- package/dist/transformers/preview/component/css.d.ts +1 -0
- package/dist/transformers/preview/component/css.js +85 -47
- package/dist/transformers/preview/pattern/html.js +3 -1
- package/dist/transformers/preview/types.d.ts +13 -0
- package/dist/transformers/utils/build.d.ts +19 -0
- package/dist/transformers/utils/build.js +83 -1
- package/dist/transformers/utils/module.js +2 -1
- package/dist/types/config.d.ts +54 -29
- package/docs/api.md +31 -0
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/src/app/components/Component/PageSliceResolver.tsx +1 -0
- package/src/app/components/Component/Preview.tsx +380 -189
- package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
- 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/catalog/README.md +3 -0
- package/src/catalog/normalize.ts +2 -0
- package/src/catalog/previews.ts +6 -3
- package/src/catalog/types.ts +8 -0
- 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 +158 -50
- package/src/config/resolve-env.ts +94 -0
- package/src/config/runtime.ts +12 -7
- package/src/config/validator.ts +4 -6
- package/src/handlebars.ts +8 -2
- 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/transformers/plugins/ssr-render.ts +53 -16
- package/src/transformers/preview/component/css.ts +93 -50
- package/src/transformers/preview/pattern/html.ts +3 -1
- package/src/transformers/preview/types.ts +14 -0
- package/src/transformers/utils/build.ts +80 -0
- package/src/transformers/utils/module.ts +4 -2
- package/src/types/config.ts +51 -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/catalog/README.md
CHANGED
|
@@ -30,6 +30,9 @@ owns `fromCSF`, because CSF is a source format of React rather than a framework.
|
|
|
30
30
|
The renderer entry points call `createCatalogItem` with their renderer. The pattern entry point calls it without a renderer.
|
|
31
31
|
`define.ts` handles the shared `implementation` forms: a helper's `{ format, file }`, a file path, or an imported component.
|
|
32
32
|
|
|
33
|
+
An entry point re-exports only the preview shape its renderer honors: `react.ts` exports `CatalogPreview` with `render`,
|
|
34
|
+
`handlebars.ts` narrows it to reject `render`, and `pattern.ts` exports neither.
|
|
35
|
+
|
|
33
36
|
A new renderer needs:
|
|
34
37
|
|
|
35
38
|
- A `RENDERERS` row in `renderers.ts`
|
package/src/catalog/normalize.ts
CHANGED
|
@@ -101,6 +101,8 @@ export const normalizeCatalogItem = (moduleExports: Record<string, unknown>, opt
|
|
|
101
101
|
entries[entryKey] = path.resolve(directory, source.file);
|
|
102
102
|
// Existing builders consume template as the primary build input for every renderer.
|
|
103
103
|
entries.template = entries[entryKey];
|
|
104
|
+
// The renderer and the hydration bundle both read a preview `render` from the declaration.
|
|
105
|
+
if (options.declarationPath) entries.declaration = options.declarationPath;
|
|
104
106
|
// A wrong renderer builds a broken preview without failing on its own, so a mismatch is fatal.
|
|
105
107
|
// `config/runtime.ts` turns the throw into a skipped item that retries on the next save.
|
|
106
108
|
const claim = sourceForFile(source.file);
|
package/src/catalog/previews.ts
CHANGED
|
@@ -5,15 +5,17 @@ import type { CatalogPreview } from './types';
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* A declaration module exports more than previews: the item itself, the implementation it
|
|
8
|
-
* re-exports, helper values. Accept only a plain object that owns
|
|
9
|
-
* component or helper is never mistaken for a preview.
|
|
8
|
+
* re-exports, helper values. Accept only a plain object that owns one of the preview fields, so an
|
|
9
|
+
* exported component or helper is never mistaken for a preview.
|
|
10
10
|
*
|
|
11
11
|
* `getCsfStoryEntries` does not fit here: on a CSF file every named export is a story, and on a
|
|
12
12
|
* declaration file it is not.
|
|
13
13
|
*/
|
|
14
|
+
const PREVIEW_KEYS = ['args', 'name', 'render'] as const;
|
|
15
|
+
|
|
14
16
|
const isPreviewDeclaration = (value: unknown): boolean => {
|
|
15
17
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
16
|
-
return
|
|
18
|
+
return PREVIEW_KEYS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
/** Matches an exported binding, an export list, or a CommonJS export assignment. */
|
|
@@ -104,6 +106,7 @@ export const createCatalogPreviews = (
|
|
|
104
106
|
values: { ...(preview.args ?? {}) },
|
|
105
107
|
url: preview.url ?? '',
|
|
106
108
|
...(preview.usage ? { usage: preview.usage } : {}),
|
|
109
|
+
...(typeof preview.render === 'function' ? { render: preview.render as OptionalPreviewRender['render'] } : {}),
|
|
107
110
|
};
|
|
108
111
|
}
|
|
109
112
|
|
package/src/catalog/types.ts
CHANGED
|
@@ -53,6 +53,14 @@ export type CatalogPreview<TArgs = Record<string, unknown>> = {
|
|
|
53
53
|
args?: TArgs;
|
|
54
54
|
usage?: string;
|
|
55
55
|
url?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Renders this preview in place of the implementation, for props it cannot carry as data: an icon
|
|
58
|
+
* component, a callback, JSX children, or state the preview owns.
|
|
59
|
+
*
|
|
60
|
+
* Rendered as a component with `args`, so it can use hooks. The item still documents the
|
|
61
|
+
* implementation, so a wrapper never becomes the published API.
|
|
62
|
+
*/
|
|
63
|
+
render?: (args: TArgs) => unknown;
|
|
56
64
|
};
|
|
57
65
|
|
|
58
66
|
/**
|
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
|
|