@vzn/vx-migrate 0.0.641 → 0.0.643
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -15
- package/package.json +2 -2
- package/src/migrate-nx.ts +8 -6
- package/src/migrate-turbo.ts +3 -0
- package/src/migrate.ts +12 -7
- package/src/nx/index.ts +12 -11
- package/src/nx/nx-map.ts +41 -4
- package/src/nx-cache/index.ts +46 -7
- package/src/remote-cache.ts +1 -1
- package/src/remote-retry.ts +52 -12
- package/src/remote-token.ts +48 -0
- package/src/tsc-buildinfo.ts +168 -0
- package/src/turbo/index.ts +12 -4
- package/src/turbo/turbo-map.ts +20 -0
- package/src/turbo-cache/index.ts +141 -27
- package/src/turbo-cache/oidc.ts +171 -0
- package/src/workspace-plugins.ts +9 -4
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,
|
|
@@ -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 (
|
|
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)
|
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,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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|