@vzn/vx-migrate 0.0.641 → 0.0.643
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 +41 -15
- package/package.json +2 -2
- package/src/migrate-nx.ts +8 -6
- package/src/migrate-turbo.ts +3 -0
- package/src/migrate.ts +12 -7
- package/src/nx/index.ts +12 -11
- package/src/nx/nx-map.ts +41 -4
- package/src/nx-cache/index.ts +46 -7
- package/src/remote-cache.ts +1 -1
- package/src/remote-retry.ts +52 -12
- package/src/remote-token.ts +48 -0
- package/src/tsc-buildinfo.ts +168 -0
- package/src/turbo/index.ts +12 -4
- package/src/turbo/turbo-map.ts +20 -0
- package/src/turbo-cache/index.ts +141 -27
- package/src/turbo-cache/oidc.ts +171 -0
- package/src/workspace-plugins.ts +9 -4
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// A GitHub Actions job's OIDC token, exchanged for a short-lived remote
|
|
2
|
+
// cache token (RFC 8693), and revoked when the run ends (RFC 7009): no
|
|
3
|
+
// long-lived TURBO_TOKEN secret in the repo. Vercel's flow as its own
|
|
4
|
+
// action runs it (vercel/setup-turborepo-remote-cache-action, read
|
|
5
|
+
// 2026-10-17); any other server names its token URL.
|
|
6
|
+
|
|
7
|
+
import { deadline } from '../remote-deadline.js'
|
|
8
|
+
|
|
9
|
+
/** `turboCache({ oidc })`: `true` is Vercel's hosted cache with its defaults. */
|
|
10
|
+
export interface TurboOidcOptions {
|
|
11
|
+
/** The RFC 8693 token endpoint; Vercel's by default with Vercel's cache, required otherwise. */
|
|
12
|
+
tokenUrl?: string
|
|
13
|
+
/** The RFC 7009 revocation endpoint, asked when the run ends; `false`: none. Vercel's by default. */
|
|
14
|
+
revokeUrl?: string | false
|
|
15
|
+
/** The `audience` GitHub mints the OIDC token for. */
|
|
16
|
+
audience?: string
|
|
17
|
+
/** Fields the exchange also sends (Vercel's `policy_id`); over the defaults. */
|
|
18
|
+
params?: Record<string, string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OidcConfig {
|
|
22
|
+
/** GitHub's ID-token endpoint and its bearer: the job's `ACTIONS_ID_TOKEN_REQUEST_*`. */
|
|
23
|
+
idTokenUrl: string
|
|
24
|
+
idTokenBearer: string
|
|
25
|
+
tokenUrl: string
|
|
26
|
+
revokeUrl?: string
|
|
27
|
+
audience?: string
|
|
28
|
+
params: Record<string, string>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const VERCEL_TOKEN_URL = 'https://api.vercel.com/login/oauth/token'
|
|
32
|
+
const VERCEL_REVOKE_URL = 'https://api.vercel.com/login/oauth/token/revoke'
|
|
33
|
+
/** Vercel's public OAuth client for the Remote Cache exchange, as its action sends it. */
|
|
34
|
+
const VERCEL_CLIENT_ID = 'cl_kyUx2zVvA4MGptBohkmtYHJly2XltXzD'
|
|
35
|
+
|
|
36
|
+
const ATTEMPTS = 3
|
|
37
|
+
const TIMEOUT_MS = 30_000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The exchange `oidc` asks for, or undefined when the option is off. A
|
|
41
|
+
* misconfigured one (a server that is not Vercel's with no `tokenUrl`, a
|
|
42
|
+
* Vercel exchange with no team) is refused at config load.
|
|
43
|
+
*/
|
|
44
|
+
export function oidcSettings(
|
|
45
|
+
oidc: true | TurboOidcOptions,
|
|
46
|
+
vercel: boolean,
|
|
47
|
+
team: string | undefined,
|
|
48
|
+
): Omit<OidcConfig, 'idTokenUrl' | 'idTokenBearer'> {
|
|
49
|
+
const o = oidc === true ? {} : oidc
|
|
50
|
+
if (typeof o !== 'object' || o === null)
|
|
51
|
+
throw new Error(
|
|
52
|
+
'vx/turbo-cache: oidc must be true or { tokenUrl, revokeUrl, audience, params }',
|
|
53
|
+
)
|
|
54
|
+
if (!vercel && o.tokenUrl === undefined)
|
|
55
|
+
throw new Error("vx/turbo-cache: oidc needs tokenUrl for a cache that is not Vercel's")
|
|
56
|
+
if (vercel && team === undefined)
|
|
57
|
+
throw new Error('vx/turbo-cache: oidc on Vercel needs teamId or teamSlug (TURBO_TEAM)')
|
|
58
|
+
const revokeUrl = o.revokeUrl ?? (vercel ? VERCEL_REVOKE_URL : undefined)
|
|
59
|
+
return {
|
|
60
|
+
tokenUrl: o.tokenUrl ?? VERCEL_TOKEN_URL,
|
|
61
|
+
...(revokeUrl === false || revokeUrl === undefined ? {} : { revokeUrl }),
|
|
62
|
+
...(o.audience !== undefined ? { audience: o.audience } : {}),
|
|
63
|
+
params: {
|
|
64
|
+
...(vercel ? { client_id: VERCEL_CLIENT_ID, team_id_or_slug: team! } : {}),
|
|
65
|
+
...o.params,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A request, sent again on a 429, a 5xx or no answer: up to three tries, 30 s each. */
|
|
71
|
+
async function send(
|
|
72
|
+
fetchImpl: typeof fetch,
|
|
73
|
+
url: string,
|
|
74
|
+
init: RequestInit,
|
|
75
|
+
wait: (ms: number) => Promise<void>,
|
|
76
|
+
): Promise<Response> {
|
|
77
|
+
for (let attempt = 1; ; attempt++) {
|
|
78
|
+
let res: Response | undefined
|
|
79
|
+
let failure: unknown
|
|
80
|
+
try {
|
|
81
|
+
res = await fetchImpl(url, { ...init, signal: deadline(TIMEOUT_MS) })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
failure = err
|
|
84
|
+
}
|
|
85
|
+
const again = res === undefined || res.status === 429 || res.status >= 500
|
|
86
|
+
if (!again || attempt === ATTEMPTS) {
|
|
87
|
+
if (res === undefined) throw failure
|
|
88
|
+
return res
|
|
89
|
+
}
|
|
90
|
+
await res?.body?.cancel()
|
|
91
|
+
await wait(1000 * attempt)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const form = (fields: Record<string, string>) => ({
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: new URLSearchParams(fields).toString(),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** Where a URL points, never its query: GitHub's carries a runtime id. */
|
|
102
|
+
const where = (url: string) => (URL.canParse(url) ? new URL(url).host : 'the endpoint')
|
|
103
|
+
|
|
104
|
+
async function field(res: Response, name: string, what: string): Promise<string> {
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
await res.body?.cancel()
|
|
107
|
+
throw new Error(`${what} answered HTTP ${res.status}`)
|
|
108
|
+
}
|
|
109
|
+
const value = ((await res.json().catch(() => null)) as Record<string, unknown> | null)?.[name]
|
|
110
|
+
if (typeof value !== 'string' || value === '') throw new Error(`${what} sent no ${name}`)
|
|
111
|
+
return value
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The job's OIDC token, exchanged for the cache's access token. Errors never quote a token. */
|
|
115
|
+
export async function exchangeOidc(
|
|
116
|
+
cfg: OidcConfig,
|
|
117
|
+
fetchImpl: typeof fetch = fetch,
|
|
118
|
+
wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
119
|
+
): Promise<string> {
|
|
120
|
+
const idUrl = new URL(cfg.idTokenUrl)
|
|
121
|
+
if (cfg.audience !== undefined) idUrl.searchParams.set('audience', cfg.audience)
|
|
122
|
+
const idToken = await field(
|
|
123
|
+
await send(
|
|
124
|
+
fetchImpl,
|
|
125
|
+
idUrl.toString(),
|
|
126
|
+
{ headers: { Authorization: `bearer ${cfg.idTokenBearer}` } },
|
|
127
|
+
wait,
|
|
128
|
+
),
|
|
129
|
+
'value',
|
|
130
|
+
`GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
|
|
131
|
+
)
|
|
132
|
+
return field(
|
|
133
|
+
await send(
|
|
134
|
+
fetchImpl,
|
|
135
|
+
cfg.tokenUrl,
|
|
136
|
+
form({
|
|
137
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
138
|
+
subject_token: idToken,
|
|
139
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
|
|
140
|
+
requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
|
141
|
+
...cfg.params,
|
|
142
|
+
}),
|
|
143
|
+
wait,
|
|
144
|
+
),
|
|
145
|
+
'access_token',
|
|
146
|
+
`the token exchange (${where(cfg.tokenUrl)})`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Revoke the exchanged token; a throw is the caller's warning. */
|
|
151
|
+
export async function revokeOidc(
|
|
152
|
+
cfg: OidcConfig,
|
|
153
|
+
token: string,
|
|
154
|
+
fetchImpl: typeof fetch = fetch,
|
|
155
|
+
wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
if (cfg.revokeUrl === undefined) return
|
|
158
|
+
const clientId = cfg.params['client_id']
|
|
159
|
+
const res = await send(
|
|
160
|
+
fetchImpl,
|
|
161
|
+
cfg.revokeUrl,
|
|
162
|
+
form({
|
|
163
|
+
...(clientId !== undefined ? { client_id: clientId } : {}),
|
|
164
|
+
token,
|
|
165
|
+
token_type_hint: 'access_token',
|
|
166
|
+
}),
|
|
167
|
+
wait,
|
|
168
|
+
)
|
|
169
|
+
await res.body?.cancel()
|
|
170
|
+
if (!res.ok) throw new Error(`revoking at ${where(cfg.revokeUrl)} answered HTTP ${res.status}`)
|
|
171
|
+
}
|
package/src/workspace-plugins.ts
CHANGED
|
@@ -42,16 +42,21 @@ function entries(plugins: readonly WorkspacePlugin[], indent: string): string[]
|
|
|
42
42
|
return plugins.map((p) => `${indent}${p.factory}(),`)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** `run.concurrency` and
|
|
45
|
+
/** `baseBranch: 'main',` as its own line; `run.concurrency` and its group's other fields as one `run: { … }` line. */
|
|
46
46
|
function groupLines(
|
|
47
47
|
fields: readonly { readonly field: string; readonly source: string }[],
|
|
48
48
|
): string[] {
|
|
49
|
+
const top: string[] = []
|
|
49
50
|
const groups = new Map<string, string[]>()
|
|
50
51
|
for (const f of fields) {
|
|
51
|
-
const [group, key] = f.field.split('.') as [string, string]
|
|
52
|
-
|
|
52
|
+
const [group, key] = f.field.split('.') as [string, string | undefined]
|
|
53
|
+
if (key === undefined) top.push(` ${group}: ${f.source},`)
|
|
54
|
+
else groups.set(group, [...(groups.get(group) ?? []), `${key}: ${f.source}`])
|
|
53
55
|
}
|
|
54
|
-
return [
|
|
56
|
+
return [
|
|
57
|
+
...top,
|
|
58
|
+
...[...groups].map(([group, entries]) => ` ${group}: { ${entries.join(', ')} },`),
|
|
59
|
+
]
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/** `.vx/workspace.<format>` declaring `plugins`. */
|