@gjsify/cli 0.35.0 → 0.37.0
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/dist/cli.gjs.mjs +136 -126
- package/lib/actions/build.d.ts +19 -0
- package/lib/actions/build.js +2 -2
- package/lib/bundler-pick.js +72 -98
- package/lib/cli-fail.d.ts +4 -7
- package/lib/cli-fail.js +29 -55
- package/lib/commands/affected.js +161 -286
- package/lib/commands/install.d.ts +63 -66
- package/lib/commands/install.js +372 -536
- package/lib/commands/run.d.ts +1 -0
- package/lib/commands/run.js +37 -0
- package/lib/templates/install.mjs.tmpl +38 -68
- package/lib/types/cli-build-options.d.ts +33 -67
- package/lib/types/command.d.ts +9 -12
- package/lib/types/config-data.d.ts +125 -221
- package/lib/utils/auth-npmrc.d.ts +5 -10
- package/lib/utils/auth-npmrc.js +11 -21
- package/lib/utils/check-system-deps.d.ts +16 -0
- package/lib/utils/check-system-deps.js +49 -0
- package/lib/utils/clear-targets.d.ts +8 -11
- package/lib/utils/clear-targets.js +22 -29
- package/lib/utils/copy-targets.d.ts +13 -19
- package/lib/utils/copy-targets.js +31 -44
- package/lib/utils/dep-aggregation.d.ts +7 -15
- package/lib/utils/dep-aggregation.js +9 -22
- package/lib/utils/detect-native-packages.d.ts +83 -116
- package/lib/utils/detect-native-packages.js +111 -149
- package/lib/utils/dir-link.d.ts +29 -48
- package/lib/utils/dir-link.js +33 -56
- package/lib/utils/discover-showcases.d.ts +6 -7
- package/lib/utils/discover-showcases.js +11 -15
- package/lib/utils/dlx-cache.d.ts +29 -38
- package/lib/utils/dlx-cache.js +53 -63
- package/lib/utils/force-exit.d.ts +0 -3
- package/lib/utils/force-exit.js +13 -24
- package/lib/utils/gi-search-path.d.ts +16 -24
- package/lib/utils/gi-search-path.js +54 -82
- package/lib/utils/gjs-bundle-guard.d.ts +5 -13
- package/lib/utils/gjs-bundle-guard.js +35 -57
- package/lib/utils/gjs-source-escape.d.ts +2 -2
- package/lib/utils/gjs-source-escape.js +21 -39
- package/lib/utils/gjsify-shim.d.ts +26 -8
- package/lib/utils/gjsify-shim.js +128 -80
- package/lib/utils/html-entry.d.ts +8 -12
- package/lib/utils/html-entry.js +19 -28
- package/lib/utils/install-backend-native.d.ts +123 -161
- package/lib/utils/install-backend-native.js +536 -737
- package/lib/utils/install-backend.d.ts +42 -57
- package/lib/utils/install-backend.js +16 -21
- package/lib/utils/install-cache-fs.d.ts +10 -15
- package/lib/utils/install-cache-fs.js +18 -26
- package/lib/utils/install-lock.d.ts +5 -6
- package/lib/utils/install-lock.js +46 -65
- package/lib/utils/install-packument-cache.d.ts +27 -33
- package/lib/utils/install-packument-cache.js +44 -57
- package/lib/utils/install-progress.d.ts +2 -9
- package/lib/utils/install-progress.js +14 -39
- package/lib/utils/install-tarball-cache.d.ts +14 -20
- package/lib/utils/install-tarball-cache.js +41 -59
- package/lib/utils/library-output.d.ts +9 -22
- package/lib/utils/library-output.js +19 -37
- package/lib/utils/load-npmrc.d.ts +5 -6
- package/lib/utils/load-npmrc.js +18 -27
- package/lib/utils/node-bundle-guard.d.ts +4 -8
- package/lib/utils/node-bundle-guard.js +21 -35
- package/lib/utils/node-script.d.ts +12 -0
- package/lib/utils/node-script.js +95 -0
- package/lib/utils/node-version.d.ts +5 -14
- package/lib/utils/node-version.js +15 -24
- package/lib/utils/normalize-bundler-options.d.ts +7 -11
- package/lib/utils/normalize-bundler-options.js +24 -42
- package/lib/utils/npm-oidc.d.ts +23 -37
- package/lib/utils/npm-oidc.js +28 -53
- package/lib/utils/npm-otp-cache.d.ts +4 -4
- package/lib/utils/npm-otp-cache.js +20 -25
- package/lib/utils/npm-otp.d.ts +20 -30
- package/lib/utils/npm-otp.js +32 -51
- package/lib/utils/onboard-probe.d.ts +7 -13
- package/lib/utils/onboard-probe.js +16 -27
- package/lib/utils/parse-spec.d.ts +3 -3
- package/lib/utils/parse-spec.js +4 -5
- package/lib/utils/pkg-json-edit.d.ts +7 -12
- package/lib/utils/pkg-json-edit.js +13 -26
- package/lib/utils/pkg-json.d.ts +11 -18
- package/lib/utils/pkg-json.js +19 -28
- package/lib/utils/prefixed-output.d.ts +3 -4
- package/lib/utils/prefixed-output.js +13 -19
- package/lib/utils/prompt.d.ts +3 -4
- package/lib/utils/prompt.js +14 -18
- package/lib/utils/publish-diagnose.d.ts +11 -16
- package/lib/utils/publish-diagnose.js +15 -28
- package/lib/utils/publish-headers.d.ts +7 -11
- package/lib/utils/publish-headers.js +36 -65
- package/lib/utils/publishable-packages.d.ts +4 -6
- package/lib/utils/publishable-packages.js +14 -18
- package/lib/utils/resolve-gjs-entry.d.ts +5 -8
- package/lib/utils/resolve-gjs-entry.js +10 -27
- package/lib/utils/resolve-npm-package.d.ts +6 -11
- package/lib/utils/resolve-npm-package.js +29 -63
- package/lib/utils/resolve-plugin-by-name.d.ts +11 -19
- package/lib/utils/resolve-plugin-by-name.js +9 -14
- package/lib/utils/run-gjs.d.ts +19 -17
- package/lib/utils/run-gjs.js +37 -51
- package/lib/utils/run-lifecycle-script.d.ts +5 -9
- package/lib/utils/run-lifecycle-script.js +23 -42
- package/lib/utils/run-node.d.ts +23 -33
- package/lib/utils/run-node.js +56 -60
- package/lib/utils/runtimes.d.ts +25 -35
- package/lib/utils/runtimes.js +34 -46
- package/lib/utils/spawn.d.ts +32 -48
- package/lib/utils/spawn.js +51 -84
- package/lib/utils/stdin.d.ts +2 -2
- package/lib/utils/stdin.js +10 -25
- package/lib/utils/suggest.d.ts +6 -7
- package/lib/utils/suggest.js +8 -10
- package/lib/utils/system-gi.d.ts +18 -27
- package/lib/utils/system-gi.js +60 -114
- package/lib/utils/trust-registry.d.ts +4 -8
- package/lib/utils/trust-registry.js +7 -15
- package/lib/utils/win32-command.d.ts +8 -14
- package/lib/utils/win32-command.js +29 -43
- package/lib/utils/workspace-root.js +7 -12
- package/package.json +18 -17
- package/showcases.json +6 -0
package/lib/utils/npm-oidc.d.ts
CHANGED
|
@@ -6,14 +6,12 @@ interface OidcExchangeOptions {
|
|
|
6
6
|
/** Optional verbose logger — receives single-line strings. */
|
|
7
7
|
log?: (msg: string) => void;
|
|
8
8
|
/**
|
|
9
|
-
* Max retries on a TRANSIENT exchange failure (5xx / 429 / network error).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* statuses (404 "package not found", 401/403 misconfig) are NOT retried —
|
|
16
|
-
* they're surfaced immediately so `--tolerate-untrusted-new` etc. still work.
|
|
9
|
+
* Max retries on a TRANSIENT exchange failure (5xx / 429 / network error), default 3. A
|
|
10
|
+
* single npm-side 503 mid-release used to fall straight through to token auth, which in
|
|
11
|
+
* OIDC-only CI has no token → a 404 that broke the release while the workflow stayed green
|
|
12
|
+
* (hit on `@gjsify/process` during v0.7.3). Retrying keeps the publish on the OIDC path.
|
|
13
|
+
* Non-transient statuses (404 "package not found", 401/403 misconfig) are NOT retried, so
|
|
14
|
+
* `--tolerate-untrusted-new` and the misconfig hints still fire on the first response.
|
|
17
15
|
*/
|
|
18
16
|
maxRetries?: number;
|
|
19
17
|
/** Base backoff in ms for transient retries; doubles each attempt (capped 8s). Default 1000. Tests pass 0. */
|
|
@@ -34,55 +32,43 @@ export declare class OidcExchangeError extends Error {
|
|
|
34
32
|
readonly body: string;
|
|
35
33
|
readonly packageName: string;
|
|
36
34
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* 401/403 they tell you precisely which fields the config must carry.
|
|
35
|
+
* Non-secret claims decoded from the rejected JWT (`repository`, `workflow_ref`,
|
|
36
|
+
* `environment`, …) — exactly what npm matches a Trusted Publisher config against, so on
|
|
37
|
+
* a 401/403 they name which fields the config must carry.
|
|
41
38
|
*/
|
|
42
39
|
readonly claims?: Record<string, unknown> | undefined;
|
|
43
40
|
constructor(message: string, status: number, body: string, packageName: string,
|
|
44
41
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* 401/403 they tell you precisely which fields the config must carry.
|
|
42
|
+
* Non-secret claims decoded from the rejected JWT (`repository`, `workflow_ref`,
|
|
43
|
+
* `environment`, …) — exactly what npm matches a Trusted Publisher config against, so on
|
|
44
|
+
* a 401/403 they name which fields the config must carry.
|
|
49
45
|
*/
|
|
50
46
|
claims?: Record<string, unknown> | undefined);
|
|
51
47
|
}
|
|
52
48
|
/**
|
|
53
|
-
* Decode the
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `ref`, `environment`, `sub`, …); the SIGNATURE is what makes it trustworthy.
|
|
57
|
-
* Returns null if the token isn't a well-formed three-part JWT.
|
|
49
|
+
* Decode the non-secret payload of a JWT — the middle base64url segment. A GitHub OIDC
|
|
50
|
+
* id-token's payload is public metadata about the workflow run; the SIGNATURE is what makes it
|
|
51
|
+
* trustworthy. Null when the token isn't a well-formed three-part JWT.
|
|
58
52
|
*/
|
|
59
53
|
export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
|
|
60
54
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* decide between OIDC and token-based auth in auto-detect mode.
|
|
55
|
+
* Whether OIDC publishing is available in this process — a cheap env-var check with no network
|
|
56
|
+
* access, used by `gjsify publish` to choose between OIDC and token auth in auto-detect mode.
|
|
64
57
|
*/
|
|
65
58
|
export declare function hasGithubOidcEnv(): boolean;
|
|
66
59
|
/**
|
|
67
|
-
* Request a GitHub Actions OIDC ID token for
|
|
68
|
-
*
|
|
69
|
-
* (caller can fall back to token auth) or when GitHub rejects the request.
|
|
60
|
+
* Request a GitHub Actions OIDC ID token for `audience`. Throws `OidcUnavailableError` when the
|
|
61
|
+
* env vars are missing (the caller can fall back to token auth) or GitHub rejects the request.
|
|
70
62
|
*/
|
|
71
63
|
export declare function fetchGithubOidcToken(audience: string, log?: (msg: string) => void): Promise<string>;
|
|
72
64
|
/**
|
|
73
|
-
* Exchange a GitHub OIDC JWT for a short-lived npm publish token
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* config — if no Trusted Publisher is configured, or the JWT comes from
|
|
77
|
-
* a repo/workflow that doesn't match, the exchange returns a 4xx with
|
|
78
|
-
* a descriptive body which we propagate as `OidcExchangeError`.
|
|
65
|
+
* Exchange a GitHub OIDC JWT for a short-lived npm publish token. A missing or non-matching
|
|
66
|
+
* Trusted Publisher config comes back as a 4xx with a descriptive body, propagated as
|
|
67
|
+
* `OidcExchangeError`.
|
|
79
68
|
*/
|
|
80
69
|
export declare function exchangeOidcForNpmToken(args: OidcExchangeOptions & {
|
|
81
70
|
idToken: string;
|
|
82
71
|
}): Promise<string>;
|
|
83
|
-
/**
|
|
84
|
-
* End-to-end: probe env → fetch id-token → exchange for npm token.
|
|
85
|
-
* One-shot convenience for `gjsify publish` and the verification command.
|
|
86
|
-
*/
|
|
72
|
+
/** End-to-end: probe env → fetch id-token → exchange for an npm token. */
|
|
87
73
|
export declare function getNpmTrustedToken(opts: OidcExchangeOptions): Promise<OidcExchangeResult>;
|
|
88
74
|
export {};
|
package/lib/utils/npm-oidc.js
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Two-step flow, mirroring `refs/npm-cli/lib/utils/oidc.js`:
|
|
4
4
|
//
|
|
5
|
-
// 1.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// the
|
|
5
|
+
// 1. GET a GitHub Actions OIDC ID token (JWT) from the runner, at
|
|
6
|
+
// `ACTIONS_ID_TOKEN_REQUEST_URL` with the audience `npm:registry.npmjs.org`. Requires
|
|
7
|
+
// `permissions: id-token: write` in the calling workflow.
|
|
8
|
+
// 2. Exchange that JWT at `/-/npm/v1/oidc/token/exchange/package/<name>` for a short-lived
|
|
9
|
+
// (~5 min) npm publish token; npm verifies it against the package's Trusted Publisher
|
|
10
|
+
// (repository + workflow filename + optional environment).
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
// endpoint for a short-lived (~5 min) npm publish token. npm verifies
|
|
13
|
-
// the JWT against the package's configured Trusted Publisher
|
|
14
|
-
// (repository + workflow filename + optional environment) and either
|
|
15
|
-
// issues a token or rejects with an explanatory error.
|
|
16
|
-
//
|
|
17
|
-
// The token returned by step 2 is used for the publish PUT in the same
|
|
18
|
-
// way the long-lived NPM_TOKEN would have been — drop-in replacement.
|
|
12
|
+
// The resulting token is a drop-in replacement for a long-lived NPM_TOKEN on the publish PUT.
|
|
19
13
|
//
|
|
20
14
|
// Reference: refs/npm-cli/lib/utils/oidc.js
|
|
21
15
|
// Original: Copyright (c) npm contributors. Artistic-2.0.
|
|
@@ -37,10 +31,9 @@ export class OidcExchangeError extends Error {
|
|
|
37
31
|
claims;
|
|
38
32
|
constructor(message, status, body, packageName,
|
|
39
33
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* 401/403 they tell you precisely which fields the config must carry.
|
|
34
|
+
* Non-secret claims decoded from the rejected JWT (`repository`, `workflow_ref`,
|
|
35
|
+
* `environment`, …) — exactly what npm matches a Trusted Publisher config against, so on
|
|
36
|
+
* a 401/403 they name which fields the config must carry.
|
|
44
37
|
*/
|
|
45
38
|
claims) {
|
|
46
39
|
super(message);
|
|
@@ -52,11 +45,9 @@ export class OidcExchangeError extends Error {
|
|
|
52
45
|
}
|
|
53
46
|
}
|
|
54
47
|
/**
|
|
55
|
-
* Decode the
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `ref`, `environment`, `sub`, …); the SIGNATURE is what makes it trustworthy.
|
|
59
|
-
* Returns null if the token isn't a well-formed three-part JWT.
|
|
48
|
+
* Decode the non-secret payload of a JWT — the middle base64url segment. A GitHub OIDC
|
|
49
|
+
* id-token's payload is public metadata about the workflow run; the SIGNATURE is what makes it
|
|
50
|
+
* trustworthy. Null when the token isn't a well-formed three-part JWT.
|
|
60
51
|
*/
|
|
61
52
|
export function decodeJwtPayload(jwt) {
|
|
62
53
|
const parts = jwt.split('.');
|
|
@@ -72,17 +63,15 @@ export function decodeJwtPayload(jwt) {
|
|
|
72
63
|
}
|
|
73
64
|
}
|
|
74
65
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* decide between OIDC and token-based auth in auto-detect mode.
|
|
66
|
+
* Whether OIDC publishing is available in this process — a cheap env-var check with no network
|
|
67
|
+
* access, used by `gjsify publish` to choose between OIDC and token auth in auto-detect mode.
|
|
78
68
|
*/
|
|
79
69
|
export function hasGithubOidcEnv() {
|
|
80
70
|
return Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
|
|
81
71
|
}
|
|
82
72
|
/**
|
|
83
|
-
* Request a GitHub Actions OIDC ID token for
|
|
84
|
-
*
|
|
85
|
-
* (caller can fall back to token auth) or when GitHub rejects the request.
|
|
73
|
+
* Request a GitHub Actions OIDC ID token for `audience`. Throws `OidcUnavailableError` when the
|
|
74
|
+
* env vars are missing (the caller can fall back to token auth) or GitHub rejects the request.
|
|
86
75
|
*/
|
|
87
76
|
export async function fetchGithubOidcToken(audience, log) {
|
|
88
77
|
const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
@@ -91,15 +80,11 @@ export async function fetchGithubOidcToken(audience, log) {
|
|
|
91
80
|
throw new OidcUnavailableError('GitHub Actions OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_{URL,TOKEN}) not set. ' +
|
|
92
81
|
'The calling workflow needs `permissions: id-token: write`.', 'no-env');
|
|
93
82
|
}
|
|
94
|
-
// Append the audience by STRING CONCATENATION,
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// instead of `npm:registry.npmjs.org`. npm then rejected the otherwise-valid
|
|
100
|
-
// token with 401 "unauthorized" — the JWT's repository/workflow_ref/sub
|
|
101
|
-
// claims all matched the Trusted Publisher, only `aud` was wrong, which is
|
|
102
|
-
// exactly the failure we saw (curl from the same workflow → 201).
|
|
83
|
+
// Append the audience by STRING CONCATENATION, as @actions/core's getIDToken does. Do NOT
|
|
84
|
+
// use `URL.searchParams.set` + `.href`: under GJS that serialization dropped the `audience`
|
|
85
|
+
// param, so GitHub issued an id-token with the DEFAULT audience (the repo API URL) and npm
|
|
86
|
+
// rejected an otherwise-valid token with 401 — repository/workflow_ref/sub all matched the
|
|
87
|
+
// Trusted Publisher, only `aud` was wrong.
|
|
103
88
|
const sep = url.includes('?') ? '&' : '?';
|
|
104
89
|
const requestUrl = `${url}${sep}audience=${encodeURIComponent(audience)}`;
|
|
105
90
|
log?.(`gjsify oidc: GET ${requestUrl.replace(bearer, '<bearer>')}`);
|
|
@@ -121,12 +106,9 @@ export async function fetchGithubOidcToken(audience, log) {
|
|
|
121
106
|
return json.value;
|
|
122
107
|
}
|
|
123
108
|
/**
|
|
124
|
-
* Exchange a GitHub OIDC JWT for a short-lived npm publish token
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* config — if no Trusted Publisher is configured, or the JWT comes from
|
|
128
|
-
* a repo/workflow that doesn't match, the exchange returns a 4xx with
|
|
129
|
-
* a descriptive body which we propagate as `OidcExchangeError`.
|
|
109
|
+
* Exchange a GitHub OIDC JWT for a short-lived npm publish token. A missing or non-matching
|
|
110
|
+
* Trusted Publisher config comes back as a 4xx with a descriptive body, propagated as
|
|
111
|
+
* `OidcExchangeError`.
|
|
130
112
|
*/
|
|
131
113
|
export async function exchangeOidcForNpmToken(args) {
|
|
132
114
|
const { packageName, registry, idToken, log } = args;
|
|
@@ -154,8 +136,7 @@ export async function exchangeOidcForNpmToken(args) {
|
|
|
154
136
|
'Content-Type': 'application/json',
|
|
155
137
|
Accept: 'application/json',
|
|
156
138
|
},
|
|
157
|
-
//
|
|
158
|
-
// is the proof, no additional claims needed from us.
|
|
139
|
+
// Empty JSON body: the JWT is the proof, no additional claims are needed.
|
|
159
140
|
body: '{}',
|
|
160
141
|
});
|
|
161
142
|
}
|
|
@@ -171,10 +152,7 @@ export async function exchangeOidcForNpmToken(args) {
|
|
|
171
152
|
}
|
|
172
153
|
const text = await res.text().catch(() => '');
|
|
173
154
|
if (!res.ok) {
|
|
174
|
-
//
|
|
175
|
-
// (404/401/403 etc.) immediately so the caller's diagnostics —
|
|
176
|
-
// `--tolerate-untrusted-new`, Trusted-Publisher misconfig hints —
|
|
177
|
-
// still fire on the first response.
|
|
155
|
+
// Only TRANSIENT npm-side failures retry — see `maxRetries`.
|
|
178
156
|
if (TRANSIENT_EXCHANGE_STATUSES.has(res.status) && attempt < maxRetries) {
|
|
179
157
|
const delay = Math.min(retryBaseMs * 2 ** attempt, 8000);
|
|
180
158
|
log?.(`gjsify oidc: transient ${res.status} ${res.statusText} for ${packageName} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`);
|
|
@@ -196,10 +174,7 @@ export async function exchangeOidcForNpmToken(args) {
|
|
|
196
174
|
return json.token;
|
|
197
175
|
}
|
|
198
176
|
}
|
|
199
|
-
/**
|
|
200
|
-
* End-to-end: probe env → fetch id-token → exchange for npm token.
|
|
201
|
-
* One-shot convenience for `gjsify publish` and the verification command.
|
|
202
|
-
*/
|
|
177
|
+
/** End-to-end: probe env → fetch id-token → exchange for an npm token. */
|
|
203
178
|
export async function getNpmTrustedToken(opts) {
|
|
204
179
|
const audience = `npm:${new URL(opts.registry).hostname}`;
|
|
205
180
|
const idToken = await fetchGithubOidcToken(audience, opts.log);
|
|
@@ -14,13 +14,13 @@ export interface OtpCacheOptions {
|
|
|
14
14
|
/** `true` unless `GJSIFY_NO_OTP_CACHE` is set to a truthy value. */
|
|
15
15
|
export declare function isOtpCacheEnabled(): boolean;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* The cached code for `registry`, or `undefined` on a miss / expiry / opt-out. An EXPIRED entry
|
|
18
|
+
* is deleted as a side effect.
|
|
19
19
|
*/
|
|
20
20
|
export declare function readCachedOtp(registry: string, opts?: OtpCacheOptions): string | undefined;
|
|
21
21
|
/**
|
|
22
|
-
* Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out
|
|
23
|
-
*
|
|
22
|
+
* Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out or an empty code;
|
|
23
|
+
* a write failure never throws.
|
|
24
24
|
*/
|
|
25
25
|
export declare function writeCachedOtp(registry: string, otp: string, opts?: OtpCacheOptions): void;
|
|
26
26
|
/** Drop the cached code for a single registry (invalid/consumed OTP, or logout). */
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
// Short-lived, CROSS-INVOCATION cache for the npm 2FA one-time code.
|
|
2
2
|
//
|
|
3
|
-
// A TOTP stays valid for its ~30 s window, so a `login → onboard → publish →
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// under it that survives across invocations.
|
|
3
|
+
// A TOTP stays valid for its ~30 s window, so a `login → onboard → publish → trust` sequence of
|
|
4
|
+
// SEPARATE `gjsify` processes should reuse ONE typed code instead of prompting per command. The
|
|
5
|
+
// in-process `OtpProvider` cache (utils/npm-otp.ts) survives only a single command; this is the
|
|
6
|
+
// layer under it that survives across invocations.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// - Best-effort: a cache miss/error NEVER breaks the command — a stale or
|
|
17
|
-
// unreadable entry degrades to "no cached code" (re-prompt).
|
|
18
|
-
// - Opt-out: `GJSIFY_NO_OTP_CACHE=1` disables it entirely; `gjsify logout`
|
|
19
|
-
// clears it.
|
|
8
|
+
// - A temp file under `$XDG_RUNTIME_DIR`, else the OS temp dir — NEVER the repo — perms 0600,
|
|
9
|
+
// with a stamped absolute `expiresAt`.
|
|
10
|
+
// - TTL = one TOTP window (30 s), deliberately short; a read past expiry ignores AND deletes.
|
|
11
|
+
// - Keyed by REGISTRY URL, so a code for one registry is never replayed against another.
|
|
12
|
+
// - Best-effort: a stale or unreadable entry degrades to "no cached code" (re-prompt), never
|
|
13
|
+
// an error.
|
|
14
|
+
// - `GJSIFY_NO_OTP_CACHE=1` disables it entirely; `gjsify logout` clears it.
|
|
20
15
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
16
|
import { tmpdir } from 'node:os';
|
|
22
17
|
import { join } from 'node:path';
|
|
@@ -50,8 +45,8 @@ function cacheFileFor(registry, opts) {
|
|
|
50
45
|
return join(cacheBaseDir(opts), `otp-${safe || 'default'}.json`);
|
|
51
46
|
}
|
|
52
47
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
48
|
+
* The cached code for `registry`, or `undefined` on a miss / expiry / opt-out. An EXPIRED entry
|
|
49
|
+
* is deleted as a side effect.
|
|
55
50
|
*/
|
|
56
51
|
export function readCachedOtp(registry, opts) {
|
|
57
52
|
if (!isOtpCacheEnabled())
|
|
@@ -70,7 +65,7 @@ export function readCachedOtp(registry, opts) {
|
|
|
70
65
|
if (entry.registry !== normalizeRegistry(registry))
|
|
71
66
|
return undefined;
|
|
72
67
|
if (now >= entry.expiresAt) {
|
|
73
|
-
deleteCacheFile(file);
|
|
68
|
+
deleteCacheFile(file);
|
|
74
69
|
return undefined;
|
|
75
70
|
}
|
|
76
71
|
return entry.otp.length > 0 ? entry.otp : undefined;
|
|
@@ -82,8 +77,8 @@ export function readCachedOtp(registry, opts) {
|
|
|
82
77
|
}
|
|
83
78
|
}
|
|
84
79
|
/**
|
|
85
|
-
* Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out
|
|
86
|
-
*
|
|
80
|
+
* Persist `otp` for `registry` with a fresh TTL, perms 0600. No-op on opt-out or an empty code;
|
|
81
|
+
* a write failure never throws.
|
|
87
82
|
*/
|
|
88
83
|
export function writeCachedOtp(registry, otp, opts) {
|
|
89
84
|
if (!isOtpCacheEnabled())
|
|
@@ -98,8 +93,8 @@ export function writeCachedOtp(registry, otp, opts) {
|
|
|
98
93
|
const file = cacheFileFor(registry, opts);
|
|
99
94
|
const entry = { otp, expiresAt: now + ttl, registry: normalizeRegistry(registry) };
|
|
100
95
|
writeFileSync(file, JSON.stringify(entry), { mode: 0o600 });
|
|
101
|
-
// Enforce 0600 even when the file pre-existed with looser perms
|
|
102
|
-
//
|
|
96
|
+
// Enforce 0600 even when the file pre-existed with looser perms — `writeFileSync` applies
|
|
97
|
+
// `mode` only on create, and the code is a live secret.
|
|
103
98
|
chmodSync(file, 0o600);
|
|
104
99
|
}
|
|
105
100
|
catch {
|
|
@@ -116,7 +111,7 @@ export function clearAllCachedOtp(opts) {
|
|
|
116
111
|
rmSync(cacheBaseDir(opts), { recursive: true, force: true });
|
|
117
112
|
}
|
|
118
113
|
catch {
|
|
119
|
-
/*
|
|
114
|
+
/* best-effort — an unremovable cache dir must not fail a logout */
|
|
120
115
|
}
|
|
121
116
|
}
|
|
122
117
|
/** POSIX file-mode bits of the cache file (test helper); `undefined` if absent. */
|
|
@@ -137,6 +132,6 @@ function deleteCacheFile(file) {
|
|
|
137
132
|
rmSync(file, { force: true });
|
|
138
133
|
}
|
|
139
134
|
catch {
|
|
140
|
-
/*
|
|
135
|
+
/* best-effort — the entry has already been treated as a miss by every caller */
|
|
141
136
|
}
|
|
142
137
|
}
|
package/lib/utils/npm-otp.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ export type OtpPromptFn = (question: string) => Promise<string>;
|
|
|
3
3
|
/**
|
|
4
4
|
* True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
|
|
5
5
|
* header naming `otp`, or a body mentioning a one-time password / `EOTP`).
|
|
6
|
-
* Unifies the detection logic previously inlined in both publish.ts and trust.ts.
|
|
7
6
|
*/
|
|
8
7
|
export declare function isOtpChallenge(res: Response): Promise<boolean>;
|
|
9
8
|
/** Options controlling the cross-invocation file cache an {@link OtpProvider} rides on top of. */
|
|
@@ -12,9 +11,9 @@ export interface OtpProviderOptions {
|
|
|
12
11
|
* Registry URL scoping the SHORT-LIVED cross-invocation OTP cache
|
|
13
12
|
* (`utils/npm-otp-cache.ts`). When set, a provider constructed WITHOUT an
|
|
14
13
|
* explicit seed reads the file-cached code first (so a sibling `gjsify`
|
|
15
|
-
* command's typed OTP is reused within its ~30 s TOTP window), and every
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* command's typed OTP is reused within its ~30 s TOTP window), and every newly
|
|
15
|
+
* typed/seeded code is written back for the next command. Omit for an
|
|
16
|
+
* in-process-only provider.
|
|
18
17
|
*/
|
|
19
18
|
registry?: string;
|
|
20
19
|
}
|
|
@@ -22,12 +21,11 @@ export interface OtpProviderOptions {
|
|
|
22
21
|
* Holds ONE OTP code across a run and knows how to prompt for a fresh one.
|
|
23
22
|
*
|
|
24
23
|
* `current()` returns the cached code (seeded from `--otp`, the cross-invocation
|
|
25
|
-
* file cache, or the last one
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `gjsify` invocations (`login → onboard → publish → trust`).
|
|
24
|
+
* file cache, or the last one typed); `refresh()` prompts for a new one and caches
|
|
25
|
+
* it in-process AND — when a `registry` is set — in the short-lived file cache. One
|
|
26
|
+
* shared instance is what makes `gjsify onboard` need a single OTP entry for a whole
|
|
27
|
+
* sweep; the file cache extends that reuse across SEPARATE invocations
|
|
28
|
+
* (`login → onboard → publish → trust`).
|
|
31
29
|
*/
|
|
32
30
|
export declare class OtpProvider {
|
|
33
31
|
private cached;
|
|
@@ -44,15 +42,14 @@ export declare class OtpProvider {
|
|
|
44
42
|
/** The currently cached OTP code, if any. */
|
|
45
43
|
current(): string | undefined;
|
|
46
44
|
/**
|
|
47
|
-
* Prompt for a fresh code, cache it (in-process + file cache), and return
|
|
48
|
-
*
|
|
49
|
-
* treats empty as "give up").
|
|
45
|
+
* Prompt for a fresh code, cache it (in-process + file cache), and return the
|
|
46
|
+
* trimmed input — `''` when nothing was entered, which callers read as "give up".
|
|
50
47
|
*/
|
|
51
48
|
refresh(question?: string): Promise<string>;
|
|
52
49
|
/**
|
|
53
|
-
* Drop the cached code (in-process AND the cross-invocation file entry)
|
|
54
|
-
*
|
|
55
|
-
*
|
|
50
|
+
* Drop the cached code (in-process AND the cross-invocation file entry) after npm
|
|
51
|
+
* rejects it, so the NEXT command re-prompts instead of replaying a single-use
|
|
52
|
+
* code the registry already burned.
|
|
56
53
|
*/
|
|
57
54
|
invalidate(): void;
|
|
58
55
|
}
|
|
@@ -72,20 +69,13 @@ export interface OtpRetryOptions {
|
|
|
72
69
|
question?: string;
|
|
73
70
|
}
|
|
74
71
|
/**
|
|
75
|
-
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}
|
|
72
|
+
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}:
|
|
73
|
+
* attempt without an OTP (or with the cached code under `seedFirstAttempt`), retry
|
|
74
|
+
* with the cached code on a challenge, then prompt for fresh codes up to
|
|
75
|
+
* `maxPrompts` times. A declined prompt returns the last challenge response for the
|
|
76
|
+
* caller to handle.
|
|
76
77
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* `seedFirstAttempt` is set).
|
|
80
|
-
* 2. On a 2FA challenge, retry with the currently-cached code (unless step 1
|
|
81
|
-
* already sent it).
|
|
82
|
-
* 3. If still challenged (cache empty/rejected/expired), prompt for a fresh
|
|
83
|
-
* code — caching it on the provider so the NEXT operation tries it first —
|
|
84
|
-
* and retry, up to `maxPrompts` times.
|
|
85
|
-
* 4. Return the last response (a challenge the caller must handle) when the
|
|
86
|
-
* user declines to enter a code.
|
|
87
|
-
*
|
|
88
|
-
* The caller owns the actual fetch + all response interpretation; this only
|
|
89
|
-
* owns the OTP dance.
|
|
78
|
+
* The caller owns the fetch and all response interpretation; this owns only the OTP
|
|
79
|
+
* dance.
|
|
90
80
|
*/
|
|
91
81
|
export declare function withOtpRetry(doFetch: (otp?: string) => Promise<Response>, provider: OtpProvider, opts?: OtpRetryOptions): Promise<Response>;
|
package/lib/utils/npm-otp.js
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
1
|
// Shared npm 2FA / OTP handling for the auth-aware CLI commands
|
|
2
2
|
// (`gjsify publish`, `gjsify trust`, `gjsify onboard`).
|
|
3
3
|
//
|
|
4
|
-
// npm signals "OTP required" via HTTP 401 + a `www-authenticate` header
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// refs/npm-cli/node_modules/npm-registry-fetch/lib/check-response.js.
|
|
4
|
+
// npm signals "OTP required" via HTTP 401 + a `www-authenticate` header containing
|
|
5
|
+
// `otp` (or, for malformed responses, a body mentioning "one-time pass" / `EOTP`) —
|
|
6
|
+
// see refs/npm-cli/node_modules/npm-registry-fetch/lib/check-response.js.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// otherwise prompt once per package; with one shared provider the user types
|
|
16
|
-
// an OTP once — the cached code is tried first on every subsequent challenge,
|
|
17
|
-
// and a fresh prompt only happens when npm actually rejects/expires it.
|
|
18
|
-
//
|
|
19
|
-
// Standalone `publish`/`trust` keep their existing single-command UX: each
|
|
20
|
-
// still constructs its own provider and prompts on demand.
|
|
8
|
+
// Detection and prompt-then-retry live here so ONE `OtpProvider` can be injected
|
|
9
|
+
// across many operations in a single run: `gjsify onboard` sweeps every publishable
|
|
10
|
+
// package and would otherwise prompt per package, whereas a shared provider tries the cached
|
|
11
|
+
// code first on every subsequent challenge and only re-prompts when npm actually
|
|
12
|
+
// rejects or expires it. Standalone `publish`/`trust` each construct their own
|
|
13
|
+
// provider and prompt on demand.
|
|
21
14
|
import { promptLine } from './prompt.js';
|
|
22
15
|
import { clearCachedOtp, readCachedOtp, writeCachedOtp } from './npm-otp-cache.js';
|
|
23
16
|
const DEFAULT_QUESTION = 'This operation requires a one-time password.\nEnter OTP: ';
|
|
24
17
|
/**
|
|
25
18
|
* True when a response is an npm OTP / 2FA challenge (401 + a `www-authenticate`
|
|
26
19
|
* header naming `otp`, or a body mentioning a one-time password / `EOTP`).
|
|
27
|
-
* Unifies the detection logic previously inlined in both publish.ts and trust.ts.
|
|
28
20
|
*/
|
|
29
21
|
export async function isOtpChallenge(res) {
|
|
30
22
|
if (res.status !== 401)
|
|
@@ -42,12 +34,11 @@ export async function isOtpChallenge(res) {
|
|
|
42
34
|
* Holds ONE OTP code across a run and knows how to prompt for a fresh one.
|
|
43
35
|
*
|
|
44
36
|
* `current()` returns the cached code (seeded from `--otp`, the cross-invocation
|
|
45
|
-
* file cache, or the last one
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `gjsify` invocations (`login → onboard → publish → trust`).
|
|
37
|
+
* file cache, or the last one typed); `refresh()` prompts for a new one and caches
|
|
38
|
+
* it in-process AND — when a `registry` is set — in the short-lived file cache. One
|
|
39
|
+
* shared instance is what makes `gjsify onboard` need a single OTP entry for a whole
|
|
40
|
+
* sweep; the file cache extends that reuse across SEPARATE invocations
|
|
41
|
+
* (`login → onboard → publish → trust`).
|
|
51
42
|
*/
|
|
52
43
|
export class OtpProvider {
|
|
53
44
|
cached;
|
|
@@ -81,9 +72,8 @@ export class OtpProvider {
|
|
|
81
72
|
return this.cached;
|
|
82
73
|
}
|
|
83
74
|
/**
|
|
84
|
-
* Prompt for a fresh code, cache it (in-process + file cache), and return
|
|
85
|
-
*
|
|
86
|
-
* treats empty as "give up").
|
|
75
|
+
* Prompt for a fresh code, cache it (in-process + file cache), and return the
|
|
76
|
+
* trimmed input — `''` when nothing was entered, which callers read as "give up".
|
|
87
77
|
*/
|
|
88
78
|
async refresh(question = DEFAULT_QUESTION) {
|
|
89
79
|
const code = (await this.promptFn(question)).trim();
|
|
@@ -95,9 +85,9 @@ export class OtpProvider {
|
|
|
95
85
|
return code;
|
|
96
86
|
}
|
|
97
87
|
/**
|
|
98
|
-
* Drop the cached code (in-process AND the cross-invocation file entry)
|
|
99
|
-
*
|
|
100
|
-
*
|
|
88
|
+
* Drop the cached code (in-process AND the cross-invocation file entry) after npm
|
|
89
|
+
* rejects it, so the NEXT command re-prompts instead of replaying a single-use
|
|
90
|
+
* code the registry already burned.
|
|
101
91
|
*/
|
|
102
92
|
invalidate() {
|
|
103
93
|
this.cached = undefined;
|
|
@@ -106,21 +96,14 @@ export class OtpProvider {
|
|
|
106
96
|
}
|
|
107
97
|
}
|
|
108
98
|
/**
|
|
109
|
-
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* 2. On a 2FA challenge, retry with the currently-cached code (unless step 1
|
|
115
|
-
* already sent it).
|
|
116
|
-
* 3. If still challenged (cache empty/rejected/expired), prompt for a fresh
|
|
117
|
-
* code — caching it on the provider so the NEXT operation tries it first —
|
|
118
|
-
* and retry, up to `maxPrompts` times.
|
|
119
|
-
* 4. Return the last response (a challenge the caller must handle) when the
|
|
120
|
-
* user declines to enter a code.
|
|
99
|
+
* Run `doFetch` with cache-first OTP retry against a shared {@link OtpProvider}:
|
|
100
|
+
* attempt without an OTP (or with the cached code under `seedFirstAttempt`), retry
|
|
101
|
+
* with the cached code on a challenge, then prompt for fresh codes up to
|
|
102
|
+
* `maxPrompts` times. A declined prompt returns the last challenge response for the
|
|
103
|
+
* caller to handle.
|
|
121
104
|
*
|
|
122
|
-
* The caller owns the
|
|
123
|
-
*
|
|
105
|
+
* The caller owns the fetch and all response interpretation; this owns only the OTP
|
|
106
|
+
* dance.
|
|
124
107
|
*/
|
|
125
108
|
export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
126
109
|
const maxPrompts = opts.maxPrompts ?? 2;
|
|
@@ -130,20 +113,18 @@ export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
|
130
113
|
let res = await doFetch(seededFirst ? cached : undefined);
|
|
131
114
|
if (!(await isOtpChallenge(res)))
|
|
132
115
|
return res;
|
|
133
|
-
// The seeded
|
|
134
|
-
//
|
|
116
|
+
// The seeded code was rejected/consumed — drop it so it is not replayed and the
|
|
117
|
+
// cross-invocation cache entry is cleared.
|
|
135
118
|
if (seededFirst)
|
|
136
119
|
provider.invalidate();
|
|
137
|
-
// Try the cached code if we haven't already sent it.
|
|
138
120
|
if (!seededFirst && provider.current() !== undefined) {
|
|
139
121
|
res = await doFetch(provider.current());
|
|
140
122
|
if (!(await isOtpChallenge(res)))
|
|
141
123
|
return res;
|
|
142
|
-
//
|
|
143
|
-
//
|
|
124
|
+
// Stale seed, or a sibling command already burned the file-cached single-use
|
|
125
|
+
// code.
|
|
144
126
|
provider.invalidate();
|
|
145
127
|
}
|
|
146
|
-
// Cache empty, rejected, or expired → prompt for a fresh code and retry.
|
|
147
128
|
for (let i = 0; i < maxPrompts; i++) {
|
|
148
129
|
const fresh = await provider.refresh(question);
|
|
149
130
|
if (!fresh)
|
|
@@ -151,8 +132,8 @@ export async function withOtpRetry(doFetch, provider, opts = {}) {
|
|
|
151
132
|
res = await doFetch(fresh);
|
|
152
133
|
if (!(await isOtpChallenge(res)))
|
|
153
134
|
return res;
|
|
154
|
-
//
|
|
155
|
-
//
|
|
135
|
+
// Clear a rejected fresh code before re-prompting, so a wedged sequence never
|
|
136
|
+
// leaves a burned code in the file cache.
|
|
156
137
|
provider.invalidate();
|
|
157
138
|
}
|
|
158
139
|
return res;
|