@vzn/vx-migrate 0.0.640 → 0.0.642

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,
@@ -247,6 +248,11 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
247
248
  dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
248
249
  )
249
250
  const envNames = envNamesThatMap(configs, envNamesNow())
251
+ // The tsconfigs a `tsc` script reads are the mapping's inputs too.
252
+ const tsc = onceReader()
253
+ for (const m of metas)
254
+ for (const s of Object.values(packageScripts(m)))
255
+ if (typeof s === 'string' && /\btsc\b/.test(s)) tscBuildInfo(tsc.read, m.dir, s)
250
256
  return {
251
257
  name: 'turbo',
252
258
  spareTracked: true,
@@ -263,8 +269,9 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
263
269
  JSON.stringify(microfrontendsConfigs(root, dirs)),
264
270
  // A config file added beside mapped tasks changes what an output may cover.
265
271
  JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
272
+ JSON.stringify([...tsc.texts]),
266
273
  ],
267
- map: (tracked) => mapAll(root, metas, envNames, tracked),
274
+ map: (tracked) => mapAll(root, metas, envNames, tracked, tsc.read),
268
275
  }
269
276
  }
270
277
 
@@ -279,6 +286,7 @@ async function mapAll(
279
286
  metas: readonly PackageMeta[],
280
287
  envNames: readonly string[],
281
288
  trackedFiles: () => Promise<readonly string[] | null>,
289
+ read: ReadText,
282
290
  ): Promise<AdoptionMapping> {
283
291
  const tracked = await trackedFiles()
284
292
  const configs = new Map(
@@ -296,6 +304,7 @@ async function mapAll(
296
304
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
297
305
  ownConfig: (rel) => configs.get(rel === '.' ? '' : rel) ?? null,
298
306
  ignored: (rels) => gitIgnored(root, rels),
307
+ buildInfo: (rel, command) => tscBuildInfo(read, path.join(root, rel), command),
299
308
  })
300
309
  const byName = new Map<string, TurboMappedProject>()
301
310
  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,8 +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
396
- /** What `TURBO_REMOTE_CACHE_READ_ONLY` and friends leave this run (`turboRemoteAccess`). */
397
- access?: { read: boolean; write: boolean }
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()
398
451
  constructor(
399
452
  private readonly config: TurboCacheConfig,
400
453
  private readonly fetchImpl: typeof fetch = fetch,
@@ -405,6 +458,34 @@ export class TurboRemoteCache implements RemoteCacheLayer {
405
458
  ) {
406
459
  this.key = config.signatureKey === undefined ? undefined : Buffer.from(config.signatureKey)
407
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 })
408
489
  }
409
490
 
410
491
  private url(pathname: string): string {
@@ -419,7 +500,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
419
500
 
420
501
  private headers(extra: Record<string, string> = {}, auth = true): Record<string, string> {
421
502
  return {
422
- ...(auth ? { Authorization: `Bearer ${this.config.token}` } : {}),
503
+ ...(auth ? { Authorization: `Bearer ${this.token}` } : {}),
423
504
  'x-artifact-client-interactive': process.stdout.isTTY ? '1' : '0',
424
505
  ...extra,
425
506
  }
@@ -446,7 +527,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
446
527
  const res = await this.fetchImpl(url, {
447
528
  method: 'OPTIONS',
448
529
  headers: {
449
- Authorization: `Bearer ${this.config.token}`,
530
+ Authorization: `Bearer ${this.token}`,
450
531
  'Access-Control-Request-Method': method === 'HEAD' ? 'GET' : method,
451
532
  'Access-Control-Request-Headers': ['Authorization', ...Object.keys(headers)].join(', '),
452
533
  },
@@ -475,6 +556,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
475
556
  pathname: string,
476
557
  init: { body?: Blob | string; headers?: Record<string, string>; timeoutMs?: number } = {},
477
558
  ): Promise<Response | undefined> {
559
+ if (this.token === undefined) return undefined
478
560
  const timeoutMs = init.timeoutMs ?? this.config.timeoutMs
479
561
  // 0 is Turbo's "no deadline".
480
562
  const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
@@ -498,6 +580,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
498
580
  },
499
581
  this.config.retries,
500
582
  this.wait,
583
+ this.cooldown,
501
584
  ),
502
585
  method === 'GET',
503
586
  )
@@ -513,11 +596,24 @@ export class TurboRemoteCache implements RemoteCacheLayer {
513
596
  const first = !this.disabled
514
597
  this.disabled = true
515
598
  if (!first) return undefined
516
- 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
+ )
517
605
  }
518
606
  return res
519
607
  }
520
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
+
521
617
  async has(hash: string): Promise<boolean> {
522
618
  if (this.disabled) return false
523
619
  const res = await this.request('HEAD', `/${hash}`)
@@ -653,37 +749,6 @@ export class TurboRemoteCache implements RemoteCacheLayer {
653
749
  }
654
750
  }
655
751
 
656
- /**
657
- * What Turbo's own switches leave of the remote cache: `TURBO_CACHE`
658
- * (`--cache-policy`'s syntax, where an omitted source is off) and
659
- * `TURBO_REMOTE_CACHE_READ_ONLY`. A CI that sets either for an untrusted
660
- * pull request kept Turbo from writing the shared cache and not vx. They
661
- * only narrow what `--cache-policy` allows, never widen it.
662
- */
663
- export function turboRemoteAccess(env: Record<string, string | undefined>): {
664
- read: boolean
665
- write: boolean
666
- } {
667
- let read = true
668
- let write = true
669
- const spec = env['TURBO_CACHE']
670
- if (spec !== undefined && spec !== '') {
671
- read = write = false
672
- for (const part of spec.split(',')) {
673
- const m = /^(local|remote):([rw]*)$/.exec(part.trim())
674
- if (m === null)
675
- throw new Error(
676
- `vx/turbo-cache: TURBO_CACHE ${JSON.stringify(spec)} is not --cache-policy's syntax (e.g. local:rw,remote:r)`,
677
- )
678
- if (m[1] !== 'remote') continue
679
- read = m[2]!.includes('r')
680
- write = m[2]!.includes('w')
681
- }
682
- }
683
- if (/^(1|true)$/i.test(env['TURBO_REMOTE_CACHE_READ_ONLY'] ?? '')) write = false
684
- return { read, write }
685
- }
686
-
687
752
  /**
688
753
  * The repo's `.turbo/config.json`, by Turbo's field names and their
689
754
  * aliases (`apiurl`, `ApiUrl`, `APIURL`, …); `{}` when absent or unreadable.
@@ -735,6 +800,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
735
800
  uploadTimeoutMs: 'number',
736
801
  retries: 'number',
737
802
  preflight: 'boolean',
803
+ oidc: 'any',
738
804
  }
739
805
 
740
806
  /**
@@ -744,10 +810,12 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
744
810
  * plugins: [turboCache({ apiUrl: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
745
811
  * ```
746
812
  *
747
- * 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.
748
814
  */
749
815
  export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
750
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
751
819
  return definePlugin(import.meta, {
752
820
  cache(ctx): RemoteCacheLayer | undefined {
753
821
  const config = resolveTurboCacheConfig(
@@ -757,14 +825,23 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
757
825
  localConfigOf(ctx.workspaceRoot),
758
826
  )
759
827
  if (config === undefined) return undefined
760
- const access = turboRemoteAccess(Bun.env)
761
- if (!access.read && !access.write) return undefined
762
828
  // A signed download waits in a temp until its tag checks: in vx's
763
829
  // cache directory, which the sandbox walls, not the shared temp dir a
764
830
  // sandboxed task may read (L-45).
765
- const remote = new TurboRemoteCache(config, fetch, ctx.cacheDir)
766
- remote.access = access
767
- return remote
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
+ )
768
845
  },
769
846
  })
770
847
  }