@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.
@@ -1,3 +1,5 @@
1
+ import { isSecretUnavailable, type RemoteOpenContext } from '@vzn/vx'
2
+
1
3
  /**
2
4
  * Why a string cannot be an HTTP header value, or null when it can. Bun's
3
5
  * `fetch` refuses a value holding a line break or NUL, or a character past
@@ -16,3 +18,49 @@ export function headerValueFault(value: string): string | null {
16
18
  }
17
19
  return past ? 'a character past Latin-1' : null
18
20
  }
21
+
22
+ /** One remote's keychain entry: `key` names it, `label` and `env` are what a person reads. */
23
+ export interface KeychainRemote {
24
+ readonly key: string
25
+ readonly label: string
26
+ /** The variable that sets the token instead, named in the warning. */
27
+ readonly env: string
28
+ }
29
+
30
+ /**
31
+ * The token from the plugin's OS keychain, asked for on a terminal when
32
+ * missing (core's `ctx.secret`): the last source, after the option, the
33
+ * variable and the config files. A throw is the one warning that leaves the
34
+ * run on the local cache.
35
+ */
36
+ export async function keychainToken(
37
+ ctx: RemoteOpenContext,
38
+ remote: KeychainRemote,
39
+ ): Promise<string> {
40
+ let token: string
41
+ try {
42
+ token = await ctx.secret({
43
+ key: remote.key,
44
+ prompt: `Token for ${remote.label}, saved to the OS keychain: `,
45
+ })
46
+ } catch (err) {
47
+ if (!isSecretUnavailable(err)) throw err
48
+ throw new Error(
49
+ `no token for ${remote.label}: set ${remote.env}, run vx in a terminal to be asked for one (kept in the OS keychain), or pass --cache-policy=remote: to skip the remote; local cache only this run`,
50
+ )
51
+ }
52
+ const fault = headerValueFault(token)
53
+ if (fault !== null) {
54
+ await ctx.forgetSecret({ key: remote.key })
55
+ throw new Error(
56
+ `the OS keychain's token for ${remote.label} holds ${fault}, which no HTTP header can carry (it is not printed); removed it, so the next run asks again`,
57
+ )
58
+ }
59
+ return token.trim()
60
+ }
61
+
62
+ /** How a remote reads in a prompt or a warning: its host, then its team. */
63
+ export function remoteLabel(url: string, team: string | undefined): string {
64
+ const host = URL.canParse(url) ? new URL(url).host : url
65
+ return team === undefined ? host : `${host} (${team})`
66
+ }
@@ -0,0 +1,168 @@
1
+ // Where `tsc` keeps its incremental state. tsc reads its `.tsbuildinfo`
2
+ // and re-emits only the files it calls changed. vx empties a task's
3
+ // outputs before a miss, so a `tsc -b` whose buildinfo sits outside them
4
+ // (rootDir `src`, outDir `dist`: `tsconfig.tsbuildinfo` beside the
5
+ // tsconfig) re-emitted the edited file into an empty `dist/`, and vx cached
6
+ // that partial `dist/`. Turbo never cleans, so its `dist/` stayed whole; Nx
7
+ // infers the buildinfo as an output. A mapped task declares it too.
8
+
9
+ import { readFileSync } from 'node:fs'
10
+ import path from 'node:path'
11
+
12
+ /** A file's text, or null when it cannot be read. */
13
+ export type ReadText = (file: string) => string | null
14
+
15
+ interface Options {
16
+ composite?: unknown
17
+ incremental?: unknown
18
+ tsBuildInfoFile?: unknown
19
+ outDir?: unknown
20
+ rootDir?: unknown
21
+ outFile?: unknown
22
+ }
23
+
24
+ interface TsConfig {
25
+ extends?: unknown
26
+ references?: unknown
27
+ compilerOptions?: Options
28
+ }
29
+
30
+ function parse(read: ReadText, file: string): TsConfig | null {
31
+ const text = read(file)
32
+ if (text === null) return null
33
+ try {
34
+ const v = Bun.JSONC.parse(text)
35
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as TsConfig) : null
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ /** The file an `extends` entry names, as tsc resolves it (a package through `node_modules`). */
42
+ function extended(read: ReadText, from: string, ext: string): string | null {
43
+ const withJson = (p: string) => (p.endsWith('.json') ? p : `${p}.json`)
44
+ if (ext.startsWith('.') || path.isAbsolute(ext)) return withJson(path.resolve(from, ext))
45
+ for (let dir = from; ; dir = path.dirname(dir)) {
46
+ const base = path.join(dir, 'node_modules', ext)
47
+ for (const f of [withJson(base), path.join(base, 'tsconfig.json')])
48
+ if (read(f) !== null) return f
49
+ if (path.dirname(dir) === dir) return null
50
+ }
51
+ }
52
+
53
+ /** The buildinfo path tsc writes for the project `file` (tsc's `getTsBuildInfoEmitOutputFilePath`). */
54
+ function buildInfoOf(read: ReadText, file: string, build: boolean): string | null {
55
+ const configDir = path.dirname(file)
56
+ const opts: { [K in keyof Options]?: string | boolean } = {}
57
+ const seen = new Set<string>()
58
+ // The nearest file that states a field decides it; a path is relative to that file.
59
+ const visit = (at: string, depth: number): void => {
60
+ if (depth > 16 || seen.has(at)) return
61
+ seen.add(at)
62
+ const cfg = parse(read, at)
63
+ if (cfg === null) return
64
+ const o = cfg.compilerOptions ?? {}
65
+ for (const k of ['composite', 'incremental'] as const)
66
+ if (opts[k] === undefined && typeof o[k] === 'boolean') opts[k] = o[k]
67
+ for (const k of ['tsBuildInfoFile', 'outDir', 'rootDir', 'outFile'] as const) {
68
+ const v = o[k]
69
+ if (opts[k] !== undefined || typeof v !== 'string') continue
70
+ opts[k] = v.startsWith('${configDir}')
71
+ ? path.join(configDir, v.slice('${configDir}'.length))
72
+ : path.resolve(path.dirname(at), v)
73
+ }
74
+ // A later entry of an `extends` array wins over an earlier one.
75
+ const exts = typeof cfg.extends === 'string' ? [cfg.extends] : cfg.extends
76
+ if (!Array.isArray(exts)) return
77
+ for (const e of [...exts].reverse()) {
78
+ const next = typeof e === 'string' ? extended(read, path.dirname(at), e) : null
79
+ if (next !== null) visit(next, depth + 1)
80
+ }
81
+ }
82
+ visit(file, 0)
83
+ // No project there: tsc fails, and writes nothing.
84
+ if (parse(read, file) === null) return null
85
+ // tsc 5.6+ writes a buildinfo for every project `-b` builds.
86
+ if (!build && opts.incremental !== true && opts.composite !== true) return null
87
+ if (typeof opts.tsBuildInfoFile === 'string') return opts.tsBuildInfoFile
88
+ const bare = (p: string) => p.slice(0, p.length - path.extname(p).length)
89
+ if (typeof opts.outFile === 'string') return `${bare(opts.outFile)}.tsbuildinfo`
90
+ const config = bare(file)
91
+ const outDir = opts.outDir as string | undefined
92
+ const rootDir = opts.rootDir as string | undefined
93
+ const at =
94
+ outDir === undefined
95
+ ? config
96
+ : rootDir === undefined
97
+ ? path.join(outDir, path.basename(config))
98
+ : path.resolve(outDir, path.relative(rootDir, config))
99
+ return `${at}.tsbuildinfo`
100
+ }
101
+
102
+ /** A project argument's tsconfig: a directory names its `tsconfig.json`. */
103
+ const projectFile = (dir: string, arg: string): string =>
104
+ arg.endsWith('.json') ? path.resolve(dir, arg) : path.resolve(dir, arg, 'tsconfig.json')
105
+
106
+ /** Words that may run `tsc` before it: a runner, or an env assignment. */
107
+ const RUNNER = /^(?:npx|bunx|pnpx|pnpm|yarn|npm|bun|exec|run|-y|--yes|--|[A-Za-z_][\w]*=\S*)$/
108
+
109
+ /**
110
+ * The buildinfo files `command`'s `tsc` runs write, relative to the
111
+ * package `dir` (POSIX); one outside `dir` is another package's.
112
+ */
113
+ export function tscBuildInfo(read: ReadText, dir: string, command: string): string[] {
114
+ const out = new Set<string>()
115
+ for (const segment of command.split(/&&|\|\||[;|&()\n]/)) {
116
+ const words = segment.trim().split(/\s+/)
117
+ const at = words.indexOf('tsc')
118
+ if (at < 0 || !words.slice(0, at).every((w) => RUNNER.test(w))) continue
119
+ const args = words.slice(at + 1)
120
+ if (args.some((a) => /^(?:-w|--watch|--clean)$/.test(a))) continue
121
+ const build = args[0] === '-b' || args[0] === '--build'
122
+ const files: string[] = []
123
+ if (build) {
124
+ const named = args.slice(1).filter((a) => !a.startsWith('-'))
125
+ files.push(...(named.length > 0 ? named : ['.']).map((a) => projectFile(dir, a)))
126
+ } else {
127
+ const p = args.findIndex((a) => a === '-p' || a === '--project')
128
+ files.push(projectFile(dir, p >= 0 && args[p + 1] !== undefined ? args[p + 1]! : '.'))
129
+ }
130
+ // `-b` builds each referenced project first, each with its own buildinfo.
131
+ const queue = [...files]
132
+ const visited = new Set<string>()
133
+ while (queue.length > 0) {
134
+ const file = queue.shift()!
135
+ if (visited.has(file)) continue
136
+ visited.add(file)
137
+ const info = buildInfoOf(read, file, build)
138
+ if (info !== null) {
139
+ const rel = path.relative(dir, info).split(path.sep).join('/')
140
+ if (!rel.startsWith('../') && !path.isAbsolute(rel)) out.add(rel)
141
+ }
142
+ if (!build) continue
143
+ const refs = parse(read, file)?.references
144
+ if (!Array.isArray(refs)) continue
145
+ for (const r of refs) {
146
+ const p = (r as { path?: unknown } | null)?.path
147
+ if (typeof p === 'string') queue.push(projectFile(path.dirname(file), p))
148
+ }
149
+ }
150
+ }
151
+ return [...out]
152
+ }
153
+
154
+ /** A reader that reads each file once, and the texts it read (a mapping's cache key). */
155
+ export function onceReader(): { read: ReadText; texts: Map<string, string | null> } {
156
+ const texts = new Map<string, string | null>()
157
+ const read: ReadText = (file) => {
158
+ if (!texts.has(file)) {
159
+ let text: string | null = null
160
+ try {
161
+ text = readFileSync(file, 'utf8')
162
+ } catch {}
163
+ texts.set(file, text)
164
+ }
165
+ return texts.get(file)!
166
+ }
167
+ return { read, texts }
168
+ }
@@ -19,7 +19,8 @@ import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
19
19
  import type { AdoptionMapping } from '../mapping-cache.js'
20
20
  import { collectGaps } from '../plugin-gaps.js'
21
21
  import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
22
- import { relPosix } from '../paths.js'
22
+ import { packageScripts, relPosix } from '../paths.js'
23
+ import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
23
24
  import {
24
25
  envNamesThatMap,
25
26
  mapTurboWorkspace,
@@ -77,8 +78,7 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
77
78
  const base =
78
79
  Bun.env['TURBO_SCM_BASE']?.trim() ||
79
80
  githubActionsBase(options.root ?? ctx.workspaceRoot, keys.remoteBaseFallback === true)
80
- if (workspace.run?.affectedBase === undefined && base)
81
- workspace.run = { ...workspace.run, affectedBase: base }
81
+ if (base) ctx.setBranch({ base })
82
82
  },
83
83
  // Turbo's packages are the package manager's members, each a project
84
84
  // with no vx.config written. `//#task` keys run on the root, as Turbo
@@ -247,6 +247,11 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
247
247
  dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
248
248
  )
249
249
  const envNames = envNamesThatMap(configs, envNamesNow())
250
+ // The tsconfigs a `tsc` script reads are the mapping's inputs too.
251
+ const tsc = onceReader()
252
+ for (const m of metas)
253
+ for (const s of Object.values(packageScripts(m)))
254
+ if (typeof s === 'string' && /\btsc\b/.test(s)) tscBuildInfo(tsc.read, m.dir, s)
250
255
  return {
251
256
  name: 'turbo',
252
257
  spareTracked: true,
@@ -263,8 +268,9 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
263
268
  JSON.stringify(microfrontendsConfigs(root, dirs)),
264
269
  // A config file added beside mapped tasks changes what an output may cover.
265
270
  JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
271
+ JSON.stringify([...tsc.texts]),
266
272
  ],
267
- map: (tracked) => mapAll(root, metas, envNames, tracked),
273
+ map: (tracked) => mapAll(root, metas, envNames, tracked, tsc.read),
268
274
  }
269
275
  }
270
276
 
@@ -279,6 +285,7 @@ async function mapAll(
279
285
  metas: readonly PackageMeta[],
280
286
  envNames: readonly string[],
281
287
  trackedFiles: () => Promise<readonly string[] | null>,
288
+ read: ReadText,
282
289
  ): Promise<AdoptionMapping> {
283
290
  const tracked = await trackedFiles()
284
291
  const configs = new Map(
@@ -296,6 +303,7 @@ async function mapAll(
296
303
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
297
304
  ownConfig: (rel) => configs.get(rel === '.' ? '' : rel) ?? null,
298
305
  ignored: (rels) => gitIgnored(root, rels),
306
+ buildInfo: (rel, command) => tscBuildInfo(read, path.join(root, rel), command),
299
307
  })
300
308
  const byName = new Map<string, TurboMappedProject>()
301
309
  for (const project of mapped.projects) byName.set(project.name, project)
@@ -349,6 +349,11 @@ export interface MapTurboOptions {
349
349
  * path among them is keyed by a probe. Absent, none is.
350
350
  */
351
351
  ignored?: (rels: readonly string[]) => Promise<ReadonlySet<string>>
352
+ /**
353
+ * The `.tsbuildinfo` files a root-relative package's command writes,
354
+ * relative to the package (`tscBuildInfo`). Absent, none.
355
+ */
356
+ buildInfo?: (rel: string, command: string) => readonly string[]
352
357
  }
353
358
 
354
359
  /**
@@ -384,6 +389,15 @@ function withZeroSegments(glob: string): string[] {
384
389
  return head === null ? [glob] : [glob, neg + head]
385
390
  }
386
391
 
392
+ /** Does a positive glob of `globs` take `rel`, and no `!` give it back? */
393
+ function covers(globs: readonly string[], rel: string): boolean {
394
+ const hit = (g: string) => new Bun.Glob(g).match(rel)
395
+ return (
396
+ globs.some((g) => !g.startsWith('!') && hit(g)) &&
397
+ !globs.some((g) => g.startsWith('!') && hit(g.slice(1)))
398
+ )
399
+ }
400
+
387
401
  /** No glob syntax: one path, which git may ignore. */
388
402
  const isLiteral = (glob: string): boolean => !/[*?[\]{}()!]/.test(glob)
389
403
 
@@ -2141,6 +2155,12 @@ function buildTask(
2141
2155
  return { name, todos, task, uses }
2142
2156
  }
2143
2157
  if (wild !== undefined) todos.push(wildcardCleanTodo(wild))
2158
+ // tsc re-emits only what its buildinfo calls changed: outside the
2159
+ // outputs it survives the clean before a miss, and the miss caches a
2160
+ // partial `dist/` (`tsc-buildinfo.ts`).
2161
+ if (outFiles.some((g) => !g.startsWith('!')))
2162
+ for (const rel of opts.buildInfo?.(pkgDir, command) ?? [])
2163
+ if (!covers(outFiles, rel)) outFiles.push(literalGlob(rel))
2144
2164
  // An output over the project's own package.json (trpc's builds list
2145
2165
  // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
2146
2166
  // nx() does (owner, 2026-10-03). The build rewrites an input the key
@@ -7,8 +7,8 @@
7
7
  // Nothing is on by default: declare `turboCache()` in `.vx/workspace.ts`
8
8
  // (the local store is the floor beneath it) and give it a URL and a token (options, or
9
9
  // Turbo's own environment variables and turbo.json's `remoteCache` so a
10
- // self-hosted setup carries over).
11
- // With neither the plugin DECLINES and the run stays local.
10
+ // self-hosted setup carries over; the OS keychain last). With no URL, team
11
+ // or token the plugin DECLINES and the run stays local.
12
12
  //
13
13
  // Imports core only through the public `@vzn/vx` specifier.
14
14
  import { readFileSync, unlinkSync } from 'node:fs'
@@ -18,20 +18,40 @@ import path from 'node:path'
18
18
  import {
19
19
  definePlugin,
20
20
  type RemoteCacheLayer,
21
+ type RemoteOpenContext,
21
22
  type VxPlugin,
22
23
  refuseUnknownOptions,
23
24
  type PluginOptionKinds,
24
25
  } from '@vzn/vx'
25
26
  import { deadline } from '../remote-deadline.js'
26
27
  import { OutageBreaker } from '../remote-breaker.js'
27
- import { withRetry } from '../remote-retry.js'
28
- import { headerValueFault } from '../remote-token.js'
28
+ import { Cooldown, withRetry } from '../remote-retry.js'
29
+ import {
30
+ headerValueFault,
31
+ type KeychainRemote,
32
+ keychainToken,
33
+ remoteLabel,
34
+ } from '../remote-token.js'
35
+ import {
36
+ exchangeOidc,
37
+ type OidcConfig,
38
+ oidcSettings,
39
+ revokeOidc,
40
+ type TurboOidcOptions,
41
+ } from './oidc.js'
29
42
 
30
43
  export interface TurboCacheOptions {
31
44
  /** Base URL of the cache server (`https://cache.example.com`), or `TURBO_API`; with a token and neither, Vercel's hosted cache, as for `turbo`. */
32
45
  apiUrl?: string
33
- /** Bearer token every request carries, or `TURBO_TOKEN`. */
46
+ /** Bearer token every request carries, or `TURBO_TOKEN`, OIDC, `.turbo/config.json`, then the OS keychain. */
34
47
  token?: string
48
+ /**
49
+ * On GitHub Actions with no token or `TURBO_TOKEN`, exchange the job's
50
+ * OIDC token for a short-lived one, revoked when the run ends. `true`:
51
+ * Vercel's hosted cache with Vercel's endpoints; any other server names
52
+ * its `tokenUrl`. The job needs `permissions: id-token: write`.
53
+ */
54
+ oidc?: true | TurboOidcOptions
35
55
  /** `teamId` query parameter, or `TURBO_TEAMID`. Required with `signatureKey`. */
36
56
  teamId?: string
37
57
  /** `slug` query parameter (a team slug), or `TURBO_TEAM`. */
@@ -84,7 +104,12 @@ export interface TurboLocalConfig {
84
104
 
85
105
  export interface TurboCacheConfig {
86
106
  apiUrl: string
87
- token: string
107
+ /** Undefined: the layer's `open` exchanges OIDC or asks the OS keychain. */
108
+ token?: string
109
+ /** The job's OIDC exchange, when `oidc` is set and GitHub hands the job a token. */
110
+ oidc?: OidcConfig
111
+ /** `oidc` is set on GitHub Actions, but the job has no ID-token permission. */
112
+ oidcMissing?: true
88
113
  teamId?: string
89
114
  teamSlug?: string
90
115
  signatureKey?: string
@@ -223,18 +248,41 @@ export function resolveTurboCacheConfig(
223
248
  !turboPair && env['VERCEL_ARTIFACTS_TOKEN'] && env['VERCEL_ARTIFACTS_OWNER']
224
249
  ? { token: env['VERCEL_ARTIFACTS_TOKEN'], teamId: env['VERCEL_ARTIFACTS_OWNER'] }
225
250
  : { token: undefined, teamId: turboPair ? undefined : env['VERCEL_ARTIFACTS_OWNER'] }
226
- const token = options.token ?? env['TURBO_TOKEN'] ?? vercel.token ?? fromFile(local.token)
227
- // Turbo's own default when a token is set and no `apiUrl` is: Vercel's
228
- // hosted Remote Cache. A token alone is a configured cache, as it is for
229
- // `turbo`; no token at all is the declined, local run.
251
+ // Empty is unset, as Turbo reads it: a fork's pull request gets `''` for a secret.
252
+ const given = (options.token ?? env['TURBO_TOKEN'] ?? vercel.token) || undefined
253
+ // OIDC sits under the variables and over the config files.
254
+ const idTokenUrl = env['ACTIONS_ID_TOKEN_REQUEST_URL'] || undefined
255
+ const idTokenBearer = env['ACTIONS_ID_TOKEN_REQUEST_TOKEN'] || undefined
256
+ const exchanges =
257
+ options.oidc !== undefined &&
258
+ given === undefined &&
259
+ idTokenUrl !== undefined &&
260
+ idTokenBearer !== undefined
261
+ const token = given ?? (exchanges ? undefined : fromFile(local.token) || undefined)
262
+ const teamId =
263
+ options.teamId ??
264
+ env['TURBO_TEAMID'] ??
265
+ vercel.teamId ??
266
+ fromFile(local.teamId) ??
267
+ fromFile(file.teamId)
268
+ const teamSlug =
269
+ options.teamSlug ?? env['TURBO_TEAM'] ?? fromFile(local.teamSlug) ?? fromFile(file.teamSlug)
270
+ // Turbo's own default when a token or a team is set and no `apiUrl` is:
271
+ // Vercel's hosted Remote Cache. A token alone is a configured cache, as it
272
+ // is for `turbo`; a URL or a team without one asks the OS keychain at
273
+ // `open`; none of them is the declined, local run.
230
274
  const apiUrl = (
231
275
  options.apiUrl ??
232
276
  env['TURBO_API'] ??
233
277
  fromFile(local.apiUrl) ??
234
278
  fromFile(file.apiUrl) ??
235
- (token ? VERCEL_API : undefined)
279
+ (token || teamId || teamSlug ? VERCEL_API : undefined)
236
280
  )?.replace(/\/+$/, '')
237
- if (!apiUrl || !token) return undefined
281
+ if (!apiUrl) return undefined
282
+ const oidc =
283
+ options.oidc === undefined
284
+ ? undefined
285
+ : oidcSettings(options.oidc, apiUrl === VERCEL_API, teamId ?? teamSlug)
238
286
  // The URL is printed in every refusal line, so a `user:pass@` in it would
239
287
  // leak to the log. Credentials go in the token.
240
288
  if (URL.canParse(apiUrl)) {
@@ -244,19 +292,11 @@ export function resolveTurboCacheConfig(
244
292
  'vx/turbo-cache: apiUrl carries credentials (user:pass@); pass them as the token instead',
245
293
  )
246
294
  }
247
- const fault = headerValueFault(token)
295
+ const fault = token === undefined ? null : headerValueFault(token)
248
296
  if (fault !== null)
249
297
  throw new Error(
250
298
  `vx/turbo-cache: the token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
251
299
  )
252
- const teamId =
253
- options.teamId ??
254
- env['TURBO_TEAMID'] ??
255
- vercel.teamId ??
256
- fromFile(local.teamId) ??
257
- fromFile(file.teamId)
258
- const teamSlug =
259
- options.teamSlug ?? env['TURBO_TEAM'] ?? fromFile(local.teamSlug) ?? fromFile(file.teamSlug)
260
300
  const preflight =
261
301
  options.preflight ??
262
302
  truthy(env['TURBO_PREFLIGHT'], 'TURBO_PREFLIGHT') ??
@@ -291,7 +331,13 @@ export function resolveTurboCacheConfig(
291
331
  }
292
332
  return {
293
333
  apiUrl,
294
- token,
334
+ ...(token !== undefined ? { token } : {}),
335
+ ...(oidc !== undefined && exchanges
336
+ ? { oidc: { ...oidc, idTokenUrl: idTokenUrl!, idTokenBearer: idTokenBearer! } }
337
+ : {}),
338
+ ...(oidc !== undefined && !exchanges && given === undefined && env['GITHUB_ACTIONS'] === 'true'
339
+ ? { oidcMissing: true as const }
340
+ : {}),
295
341
  ...(teamId ? { teamId } : {}),
296
342
  ...(teamSlug ? { teamSlug } : {}),
297
343
  ...(signatureKey !== undefined ? { signatureKey } : {}),
@@ -393,6 +439,15 @@ export class TurboRemoteCache implements RemoteCacheLayer {
393
439
  private readonly absent = new Set<string>()
394
440
  private readonly key: Uint8Array | undefined
395
441
  readonly endpoint: string
442
+ /** Undefined until `open` reads the keychain; a layer never opened (a plan) asks the server nothing. */
443
+ private token: string | undefined
444
+ /** Set by `open` when the token came from the keychain: a refusal deletes it. */
445
+ private forget: (() => Promise<void>) | undefined
446
+ /** The token an OIDC exchange gave, revoked when the run ends. */
447
+ private exchanged: string | undefined
448
+ private readonly keychainRemote: KeychainRemote
449
+ /** The remote's rate limit, which every request of this client waits out. */
450
+ private readonly cooldown = new Cooldown()
396
451
  constructor(
397
452
  private readonly config: TurboCacheConfig,
398
453
  private readonly fetchImpl: typeof fetch = fetch,
@@ -403,6 +458,34 @@ export class TurboRemoteCache implements RemoteCacheLayer {
403
458
  ) {
404
459
  this.key = config.signatureKey === undefined ? undefined : Buffer.from(config.signatureKey)
405
460
  this.endpoint = `${config.apiUrl}/v8/artifacts`
461
+ this.token = config.token
462
+ const team = config.teamId ?? config.teamSlug
463
+ this.keychainRemote = {
464
+ key: team === undefined ? this.endpoint : `${this.endpoint}#${team}`,
465
+ label: remoteLabel(config.apiUrl, team),
466
+ env: 'TURBO_TOKEN',
467
+ }
468
+ }
469
+
470
+ async open(ctx: RemoteOpenContext): Promise<void> {
471
+ if (this.config.oidcMissing)
472
+ ctx.warn(
473
+ 'oidc: GitHub gave this job no OIDC token: it needs `permissions: id-token: write` (a fork or Dependabot pull request never gets one)',
474
+ )
475
+ if (this.token !== undefined) return
476
+ if (this.config.oidc !== undefined) {
477
+ try {
478
+ this.token = await exchangeOidc(this.config.oidc, this.fetchImpl, this.wait)
479
+ } catch (err) {
480
+ throw new Error(
481
+ `oidc: ${err instanceof Error ? err.message : String(err)}; local cache only this run`,
482
+ )
483
+ }
484
+ this.exchanged = this.token
485
+ return
486
+ }
487
+ this.token = await keychainToken(ctx, this.keychainRemote)
488
+ this.forget = () => ctx.forgetSecret({ key: this.keychainRemote.key })
406
489
  }
407
490
 
408
491
  private url(pathname: string): string {
@@ -417,7 +500,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
417
500
 
418
501
  private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
419
502
  return {
420
- ...(auth ? { Authorization: `Bearer ${this.config.token}` } : {}),
503
+ ...(auth ? { Authorization: `Bearer ${this.token}` } : {}),
421
504
  'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
422
505
  ...extra,
423
506
  }
@@ -444,7 +527,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
444
527
  const res = await this.fetchImpl(url, {
445
528
  method: 'OPTIONS',
446
529
  headers: {
447
- Authorization: `Bearer ${this.config.token}`,
530
+ Authorization: `Bearer ${this.token}`,
448
531
  'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
449
532
  'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
450
533
  },
@@ -473,6 +556,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
473
556
  pathname: string,
474
557
  init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
475
558
  ): Promise<Response | undefined> {
559
+ if (this.token === undefined) return undefined
476
560
  const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
477
561
  // 0 is Turbo's "no deadline".
478
562
  const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
@@ -496,6 +580,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
496
580
  },
497
581
  this.config.retries,
498
582
  this.wait,
583
+ this.cooldown,
499
584
  ),
500
585
  method === 'GET',
501
586
  )
@@ -511,11 +596,24 @@ export class TurboRemoteCache implements RemoteCacheLayer {
511
596
  const first = !this.disabled
512
597
  this.disabled = true
513
598
  if (!first) return undefined
514
- throw new Error(`HTTP ${res.status}: the token was refused; remote cache off for this run`)
599
+ if (this.forget === undefined)
600
+ throw new Error(`HTTP ${res.status}: the token was refused; remote cache off for this run`)
601
+ await this.forget()
602
+ throw new Error(
603
+ `HTTP ${res.status}: the token was refused; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
604
+ )
515
605
  }
516
606
  return res
517
607
  }
518
608
 
609
+ /** Revoke the token `open` exchanged, once; a throw is the caller's warning. */
610
+ async revoke(): Promise<void> {
611
+ const token = this.exchanged
612
+ if (token === undefined) return
613
+ this.exchanged = undefined
614
+ await revokeOidc(this.config.oidc!, token, this.fetchImpl, this.wait)
615
+ }
616
+
519
617
  async has(hash: string): Promise<boolean> {
520
618
  if (this.disabled) return false
521
619
  const res = await this.request('HEAD', `/${hash}`)
@@ -702,6 +800,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
702
800
  uploadTimeoutMs: 'number',
703
801
  retries: 'number',
704
802
  preflight: 'boolean',
803
+ oidc: 'any',
705
804
  }
706
805
 
707
806
  /**
@@ -711,10 +810,12 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
711
810
  * plugins: [turboCache({ apiUrl: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
712
811
  * ```
713
812
  *
714
- * Declines without a URL and a token, so it is safe to leave declared.
813
+ * Declines with no URL, team or token, so it is safe to leave declared.
715
814
  */
716
815
  export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
717
816
  refuseUnknownOptions('turboCache()', options, TURBO_CACHE_KEYS)
817
+ // The run's layer and warn, for `teardown` to revoke an exchanged token.
818
+ let opened: { layer: TurboRemoteCache; warn: (m: string) => void } | undefined
718
819
  return definePlugin(import.meta, {
719
820
  cache(ctx): RemoteCacheLayer | undefined {
720
821
  const config = resolveTurboCacheConfig(
@@ -727,7 +828,20 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
727
828
  // A signed download waits in a temp until its tag checks: in vx's
728
829
  // cache directory, which the sandbox walls, not the shared temp dir a
729
830
  // sandboxed task may read (L-45).
730
- return new TurboRemoteCache(config, fetch, ctx.cacheDir)
831
+ const layer = new TurboRemoteCache(config, fetch, ctx.cacheDir)
832
+ if (config.oidc !== undefined) opened = { layer, warn: ctx.warn }
833
+ return layer
834
+ },
835
+ async teardown() {
836
+ const run = opened
837
+ opened = undefined
838
+ await run?.layer
839
+ .revoke()
840
+ .catch((err: unknown) =>
841
+ run.warn(
842
+ `[vx] plugin '@vzn/vx-migrate': oidc: ${err instanceof Error ? err.message : String(err)}; the token expires on its own`,
843
+ ),
844
+ )
731
845
  },
732
846
  })
733
847
  }