@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.
- package/README.md +40 -14
- package/package.json +2 -2
- package/src/adopt.ts +15 -0
- package/src/migrate-turbo.ts +3 -0
- package/src/migrate.ts +26 -15
- package/src/nx/nx-map.ts +41 -4
- package/src/nx-cache/index.ts +46 -11
- 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 +11 -2
- package/src/turbo/turbo-map.ts +20 -0
- package/src/turbo-cache/index.ts +141 -64
- package/src/turbo-cache/oidc.ts +171 -0
package/src/remote-token.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/turbo/index.ts
CHANGED
|
@@ -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)
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -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
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
|
|
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
|
|
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
|
-
/**
|
|
397
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
766
|
-
|
|
767
|
-
return
|
|
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
|
}
|