@vzn/vx-migrate 0.0.651 → 0.0.653
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 +61 -52
- package/package.json +2 -2
- package/src/migrate-scripts.ts +13 -7
- package/src/migrate-turbo.ts +8 -1
- package/src/migrate.ts +102 -18
- package/src/nx/index.ts +61 -11
- package/src/nx/nx-inputs.ts +48 -24
- package/src/nx/nx-map.ts +204 -20
- package/src/nx/nx-outputs.ts +6 -1
- package/src/nx/nx-upstream.ts +11 -11
- package/src/nx-cache/index.ts +42 -28
- package/src/packages.ts +51 -1
- package/src/remote-refusal.ts +49 -4
- package/src/shared-outputs.ts +86 -25
- package/src/tracked-outputs.ts +59 -20
- package/src/turbo/index.ts +2 -1
- package/src/turbo/turbo-map.ts +165 -29
- package/src/turbo-cache/index.ts +71 -40
- package/src/turbo-cache/oidc.ts +15 -7
- package/src/vite-task/vite-task-map.ts +3 -2
- package/src/workspace-notes.ts +19 -8
- package/src/workspace-plugins.ts +11 -3
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// The consumer decides what a global becomes through `splice`.
|
|
12
12
|
|
|
13
13
|
import path from 'node:path'
|
|
14
|
-
import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
|
|
14
|
+
import { isLiteralPattern, outputsOverlap, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
|
|
15
15
|
import type { PackageMeta } from '../packages.js'
|
|
16
16
|
import { refuseTaskNames } from '../plugin-gaps.js'
|
|
17
17
|
import { apartTakeBacks, literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveSharedOutputs,
|
|
25
25
|
resolveSharedWorkspaceOutputs,
|
|
26
26
|
takingBack,
|
|
27
|
+
uncache,
|
|
27
28
|
unkey,
|
|
28
29
|
wildcardCleanTodo,
|
|
29
30
|
wildcardOutput,
|
|
@@ -279,6 +280,12 @@ export interface MapTurboOptions {
|
|
|
279
280
|
* path among them is keyed by a probe. Absent, none is.
|
|
280
281
|
*/
|
|
281
282
|
ignored?: (rels: readonly string[]) => Promise<ReadonlySet<string>>
|
|
283
|
+
/**
|
|
284
|
+
* What git ignores under the root-relative globs' fixed prefixes
|
|
285
|
+
* (`gitIgnoredUnder`): files, and `dir/` for a directory ignored whole.
|
|
286
|
+
* Absent, an explicit glob keys none of them.
|
|
287
|
+
*/
|
|
288
|
+
ignoredUnder?: (globs: readonly string[]) => Promise<readonly string[]>
|
|
282
289
|
/**
|
|
283
290
|
* The `.tsbuildinfo` files a root-relative package's command writes,
|
|
284
291
|
* relative to the package (`tscBuildInfo`). Absent, none.
|
|
@@ -294,6 +301,12 @@ export interface MapTurboOptions {
|
|
|
294
301
|
interface Literals {
|
|
295
302
|
readonly inputs: Record<string, unknown>
|
|
296
303
|
readonly rels: ReadonlyMap<string, Named>
|
|
304
|
+
/** The explicit globs, root-relative, with the list that holds them. */
|
|
305
|
+
readonly globs: ReadonlyMap<string, 'files' | 'workspaceFiles'>
|
|
306
|
+
/** The global `!` entries, which a preset spread may hide from the lists. */
|
|
307
|
+
readonly taken: readonly string[]
|
|
308
|
+
/** The package, root-relative (`files` entries are relative to it). */
|
|
309
|
+
readonly pkgDir: string
|
|
297
310
|
}
|
|
298
311
|
type Named = readonly ['files' | 'workspaceFiles' | null, string]
|
|
299
312
|
|
|
@@ -312,6 +325,18 @@ function zeroSegments(glob: string): string | null {
|
|
|
312
325
|
return head === '' || isLiteralPattern(head.replace(/\\./g, '_')) ? null : head
|
|
313
326
|
}
|
|
314
327
|
|
|
328
|
+
/**
|
|
329
|
+
* A glob with its `.` and `..` segments collapsed, as Turbo cleans the path
|
|
330
|
+
* before it walks (turborepo-globwalk's `collapse_path`): `src/../../shared/**`
|
|
331
|
+
* is `../shared/**`, `./src/**` is `src/**`. A `$TURBO_ROOT$/` prefix is
|
|
332
|
+
* the root, not a segment.
|
|
333
|
+
*/
|
|
334
|
+
function collapseDots(glob: string): string {
|
|
335
|
+
if (glob.startsWith('$TURBO_ROOT$/'))
|
|
336
|
+
return '$TURBO_ROOT$/' + collapseDots(glob.slice('$TURBO_ROOT$/'.length))
|
|
337
|
+
return path.posix.normalize(glob)
|
|
338
|
+
}
|
|
339
|
+
|
|
315
340
|
/** A glob, `!` or not, and the X its `X/**` also matches in Turbo (`zeroSegments`). */
|
|
316
341
|
function withZeroSegments(glob: string): string[] {
|
|
317
342
|
const neg = glob.startsWith('!') ? '!' : ''
|
|
@@ -362,6 +387,70 @@ function keyIgnored(
|
|
|
362
387
|
inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
|
|
363
388
|
}
|
|
364
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Turbo walks an explicit input glob on disk, gitignored files and all
|
|
392
|
+
* (`$TURBO_DEFAULT$` alone skips them); core's globs filter what git
|
|
393
|
+
* lists, so `src/**` over an ignored `src/gen.ts` keyed nothing and an
|
|
394
|
+
* edit replayed the old output. Each ignored path a glob matches is named
|
|
395
|
+
* outright, which core reads from disk: a file as itself, a directory
|
|
396
|
+
* ignored whole as the glob's rest under it (`src/out/**`). A file a `!`
|
|
397
|
+
* takes back stays out (core refuses a literal one covers).
|
|
398
|
+
*/
|
|
399
|
+
function keyIgnoredGlobbed(
|
|
400
|
+
l: Literals,
|
|
401
|
+
under: readonly string[],
|
|
402
|
+
projectDirs: readonly string[],
|
|
403
|
+
): void {
|
|
404
|
+
// The lists as they stand: a sibling's output taken back since counts.
|
|
405
|
+
const current = (['files', 'workspaceFiles'] as const).flatMap((kind) =>
|
|
406
|
+
((l.inputs[kind] as unknown[] | undefined) ?? []).flatMap((e) =>
|
|
407
|
+
typeof e === 'string' && e.startsWith('!')
|
|
408
|
+
? [kind === 'files' && l.pkgDir !== '' ? `${l.pkgDir}/${e.slice(1)}` : e.slice(1)]
|
|
409
|
+
: [],
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
const taken = [...l.taken, ...current].map((t) => new Bun.Glob(t))
|
|
413
|
+
for (const [glob, kind] of l.globs) {
|
|
414
|
+
const matcher = new Bun.Glob(glob)
|
|
415
|
+
const at = kind === 'files' && l.pkgDir !== '' ? `${l.pkgDir}/` : ''
|
|
416
|
+
for (const e of under) {
|
|
417
|
+
if (!e.startsWith(at)) continue
|
|
418
|
+
// A package nested in this one is another project, which core's
|
|
419
|
+
// globs stop at: its path is named from the workspace root.
|
|
420
|
+
const nested = at !== '' && projectDirs.some((d) => d.startsWith(at) && e.startsWith(`${d}/`))
|
|
421
|
+
const base = nested ? '' : at
|
|
422
|
+
const entry = e.endsWith('/')
|
|
423
|
+
? globUnder(glob.slice(at.length), e.slice(at.length, -1))
|
|
424
|
+
: matcher.match(e) && !taken.some((t) => t.match(e))
|
|
425
|
+
? literalGlob(e.slice(base.length))
|
|
426
|
+
: null
|
|
427
|
+
if (entry === null) continue
|
|
428
|
+
const into = nested ? 'workspaceFiles' : kind
|
|
429
|
+
const named = nested && e.endsWith('/') ? `${l.pkgDir}/${entry}` : entry
|
|
430
|
+
const list = (l.inputs[into] as unknown[] | undefined) ?? []
|
|
431
|
+
if (!list.includes(named)) l.inputs[into] = [...list, named]
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* `glob`'s rest below `dir`, which it reaches into (`a/**\/*.ts` under
|
|
438
|
+
* `a/gen`: `a/gen/**\/*.ts`), else null: the paths of the glob under it.
|
|
439
|
+
*/
|
|
440
|
+
function globUnder(glob: string, dir: string): string | null {
|
|
441
|
+
// A brace holding a `/` spans segments; such a glob is not split here.
|
|
442
|
+
if (/\{[^}]*\//.test(glob)) return null
|
|
443
|
+
const gs = glob.split('/')
|
|
444
|
+
const ds = dir.split('/')
|
|
445
|
+
for (let i = 0; i < ds.length; i++) {
|
|
446
|
+
const g = gs[i]
|
|
447
|
+
if (g === undefined) return null
|
|
448
|
+
if (g === '**') return [...ds.map(literalGlob), ...gs.slice(i)].join('/')
|
|
449
|
+
if (!new Bun.Glob(g).match(ds[i]!)) return null
|
|
450
|
+
}
|
|
451
|
+
return gs.length > ds.length ? [...ds.map(literalGlob), ...gs.slice(ds.length)].join('/') : null
|
|
452
|
+
}
|
|
453
|
+
|
|
365
454
|
async function readTurboJson(file: string, root: string): Promise<TurboJson> {
|
|
366
455
|
const text = await Bun.file(file).text()
|
|
367
456
|
let parsed: unknown
|
|
@@ -1173,6 +1262,8 @@ export async function mapTurboWorkspace(
|
|
|
1173
1262
|
// whose own edges lack `^name` (opencode's `build: { dependsOn: [] }`)
|
|
1174
1263
|
// is key-only too: Turbo's `^build` stops at it, and core's walked past
|
|
1175
1264
|
// it to the builds below, running and keying ones Turbo never waits on.
|
|
1265
|
+
// `cache: false` does not stop it: a key-only node stores nothing, and
|
|
1266
|
+
// Turbo's no-op hashes the package's files cached or not.
|
|
1176
1267
|
const keyOnly = new Map<string, Set<string>>()
|
|
1177
1268
|
for (const meta of metas) {
|
|
1178
1269
|
const scripts = packageScripts(meta)
|
|
@@ -1184,7 +1275,7 @@ export async function mapTurboWorkspace(
|
|
|
1184
1275
|
if (!(caretSelf.has(name) || stops) || !withScript.has(name) || transit.has(name)) continue
|
|
1185
1276
|
if (scripts[name] !== undefined || commandOverride(def) !== undefined) continue
|
|
1186
1277
|
if (sidecarGroups.has(`${meta.name}#${name}`)) continue
|
|
1187
|
-
if (def?.
|
|
1278
|
+
if (def?.persistent === true) continue
|
|
1188
1279
|
emitted.get(meta.name)!.add(name)
|
|
1189
1280
|
runnable.get(meta.name)!.add(name)
|
|
1190
1281
|
let set = keyOnly.get(meta.name)
|
|
@@ -1314,8 +1405,12 @@ export async function mapTurboWorkspace(
|
|
|
1314
1405
|
// Core gives a project with no `build` this very node (a group behind
|
|
1315
1406
|
// `^build`, keyed on the project's files), so writing it is noise:
|
|
1316
1407
|
// solid's three script-less packages each got a key-only `build`.
|
|
1408
|
+
// Written configs only: a bare `build` skips core's node (X-102), so
|
|
1409
|
+
// under turbo(), which writes nothing, `vx run build` scoped to such a
|
|
1410
|
+
// package was refused where Turbo runs its dependencies' builds.
|
|
1317
1411
|
const noopEdges = defFor(name)!.dependsOn ?? []
|
|
1318
1412
|
if (
|
|
1413
|
+
opts.envNames === undefined &&
|
|
1319
1414
|
noop &&
|
|
1320
1415
|
name === 'build' &&
|
|
1321
1416
|
noopEdges.includes('^build') &&
|
|
@@ -1444,7 +1539,7 @@ export async function mapTurboWorkspace(
|
|
|
1444
1539
|
npmScriptEnv(mapped.task, name, command === script, meta, opts)
|
|
1445
1540
|
tasks.push(mapped)
|
|
1446
1541
|
}
|
|
1447
|
-
excludeSiblingOutputs(resolveSharedOutputs(tasks))
|
|
1542
|
+
excludeSiblingOutputs(resolveSharedOutputs(tasks), true)
|
|
1448
1543
|
const tags = packageTags(meta === rootMeta ? rootCfg : files.get(meta.name))
|
|
1449
1544
|
projects.push({ name: meta.name, dir: meta.dir, ...(tags.length > 0 ? { tags } : {}), tasks })
|
|
1450
1545
|
}
|
|
@@ -1511,7 +1606,7 @@ export async function mapTurboWorkspace(
|
|
|
1511
1606
|
if (pm === undefined) continue
|
|
1512
1607
|
const pdef = definitions(pm).defFor(task)
|
|
1513
1608
|
if (pdef?.cache !== false) continue
|
|
1514
|
-
|
|
1609
|
+
unfold.add(`${pm.name}#${task}`)
|
|
1515
1610
|
const at = relPosix(root, pm.dir)
|
|
1516
1611
|
for (const g of dep.globs.length > 0 ? dep.globs : (pdef.outputs ?? []))
|
|
1517
1612
|
if (!g.startsWith('!')) rels.push(at === '' ? g : `${at}/${g}`)
|
|
@@ -1531,11 +1626,15 @@ export async function mapTurboWorkspace(
|
|
|
1531
1626
|
inputs['workspaceRuntime'] = [...probes, probe]
|
|
1532
1627
|
// Folded, the producer's key "covers" what it writes, and core answers
|
|
1533
1628
|
// the probe before it runs: the previous run's bytes. Left out of the
|
|
1534
|
-
// fold, the probe waits for it (core's late probes, X-34).
|
|
1629
|
+
// fold, the probe waits for it (core's late probes, X-34). A `^gen`
|
|
1630
|
+
// edge reaches it too, beside its cached siblings: those stay folded.
|
|
1535
1631
|
const deps = (t.task!['dependsOn'] as string[] | undefined) ?? []
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1632
|
+
const ids = (d: string): string[] =>
|
|
1633
|
+
d.startsWith('^')
|
|
1634
|
+
? metas.filter((m) => declares(meta, m.name)).map((m) => `${m.name}#${d.slice(1)}`)
|
|
1635
|
+
: [d.includes('#') ? d : `${meta.name}#${d}`]
|
|
1636
|
+
if (deps.some((d) => ids(d).some((i) => unfold.has(i))))
|
|
1637
|
+
inputs['tasks'] = [...deps, ...[...unfold].map((i) => `!${i}`)]
|
|
1539
1638
|
}
|
|
1540
1639
|
}
|
|
1541
1640
|
|
|
@@ -1574,6 +1673,14 @@ export async function mapTurboWorkspace(
|
|
|
1574
1673
|
for (const e of opts.splice('inputs', globals.inputs))
|
|
1575
1674
|
if (typeof e !== 'string') opaque.set(JSON.stringify(e), globals.inputs)
|
|
1576
1675
|
excludeWorkspaceOutputs(root, projects, (e) => opaque.get(JSON.stringify(e)) ?? [])
|
|
1676
|
+
// After every take-back: a path one takes back is not named back.
|
|
1677
|
+
if (opts.ignoredUnder !== undefined) {
|
|
1678
|
+
const under = await opts.ignoredUnder([
|
|
1679
|
+
...new Set(literals.tasks.flatMap((l) => [...l.globs.keys()])),
|
|
1680
|
+
])
|
|
1681
|
+
const dirs = projects.map((p) => relPosix(root, p.dir)).filter((d) => d !== '')
|
|
1682
|
+
if (under.length > 0) for (const l of literals.tasks) keyIgnoredGlobbed(l, under, dirs)
|
|
1683
|
+
}
|
|
1577
1684
|
pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
|
|
1578
1685
|
pruneOrphanPersistentNotes(projects, opts.persistentTodo)
|
|
1579
1686
|
refuseTaskNames(projects, 'task')
|
|
@@ -1606,7 +1713,7 @@ function sortOrderFree(projects: readonly TurboMappedProject[]): void {
|
|
|
1606
1713
|
const task = t.task
|
|
1607
1714
|
if (task === null) continue
|
|
1608
1715
|
if (Array.isArray(task['dependsOn'])) task['dependsOn'] = [...task['dependsOn']].sort(byText)
|
|
1609
|
-
const outputs =
|
|
1716
|
+
const outputs = task['outputs'] as Record<string, unknown> | undefined
|
|
1610
1717
|
if (outputs === undefined) continue
|
|
1611
1718
|
for (const k of ['files', 'workspaceFiles'])
|
|
1612
1719
|
if (Array.isArray(outputs[k])) outputs[k] = [...outputs[k]].sort(negationsLast)
|
|
@@ -1822,16 +1929,12 @@ function keyOnlyGroup(t: TurboMappedTask): TurboMappedTask {
|
|
|
1822
1929
|
if (task === null) return t
|
|
1823
1930
|
const exec = task['exec'] as Record<string, unknown>
|
|
1824
1931
|
if (exec['persistent'] !== undefined || exec['interactive'] !== undefined) return t
|
|
1825
|
-
const
|
|
1826
|
-
| { outputs: { files: unknown[]; workspaceFiles?: unknown[] } }
|
|
1827
|
-
| undefined
|
|
1932
|
+
const outputs = task['outputs'] as { files: unknown[]; workspaceFiles?: unknown[] } | undefined
|
|
1828
1933
|
if (task['inputs'] === undefined && task['dependsOn'] === undefined) return t
|
|
1829
|
-
if (
|
|
1830
|
-
cache !== undefined &&
|
|
1831
|
-
(cache.outputs.files.length > 0 || cache.outputs.workspaceFiles !== undefined)
|
|
1832
|
-
)
|
|
1934
|
+
if (outputs !== undefined && (outputs.files.length > 0 || outputs.workspaceFiles !== undefined))
|
|
1833
1935
|
return t
|
|
1834
|
-
const { exec: _exec,
|
|
1936
|
+
const { exec: _exec, ...group } = task
|
|
1937
|
+
uncache(group)
|
|
1835
1938
|
return { ...t, task: group }
|
|
1836
1939
|
}
|
|
1837
1940
|
|
|
@@ -2085,12 +2188,13 @@ function buildTask(
|
|
|
2085
2188
|
// build (item 1031). A positive glob with no safe
|
|
2086
2189
|
// form widens to every package file; a negation with none is dropped
|
|
2087
2190
|
// (it would exclude fewer files, never more).
|
|
2088
|
-
const
|
|
2089
|
-
if (
|
|
2191
|
+
const vx = minimatchToVx(neg ? i.slice(1) : i, neg)
|
|
2192
|
+
if (vx === null) {
|
|
2090
2193
|
todos.push(`input ${JSON.stringify(i)}: glob syntax vx cannot take — map manually`)
|
|
2091
2194
|
if (!neg) files.push('**/*')
|
|
2092
2195
|
continue
|
|
2093
2196
|
}
|
|
2197
|
+
const wax = collapseDots(vx)
|
|
2094
2198
|
const translated = (neg ? '!' : '') + wax
|
|
2095
2199
|
const body = wax
|
|
2096
2200
|
const up = climbed(translated)
|
|
@@ -2175,8 +2279,17 @@ function buildTask(
|
|
|
2175
2279
|
if (keyEnv.length > 0) inputs.env = keyEnv
|
|
2176
2280
|
if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
|
|
2177
2281
|
if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
|
|
2178
|
-
|
|
2179
|
-
|
|
2282
|
+
const globs = new Map<string, 'files' | 'workspaceFiles'>()
|
|
2283
|
+
for (const [rel, [kind]] of rels)
|
|
2284
|
+
if (!isLiteral(rel)) {
|
|
2285
|
+
rels.delete(rel)
|
|
2286
|
+
if (kind !== null) globs.set(rel, kind)
|
|
2287
|
+
}
|
|
2288
|
+
const taken: string[] = []
|
|
2289
|
+
for (const g of globalKept)
|
|
2290
|
+
if (g.startsWith('!')) taken.push(g.slice(1))
|
|
2291
|
+
else if (!isLiteral(g) && !globs.has(g)) globs.set(g, 'workspaceFiles')
|
|
2292
|
+
literals.tasks.push({ inputs, rels, globs, taken, pkgDir: atRoot ? '' : pkgDir })
|
|
2180
2293
|
// Turbo keys every task on its inputs, cached or not, and a dependant
|
|
2181
2294
|
// folds that key: an uncached task's inputs still move its dependants.
|
|
2182
2295
|
task.inputs = inputs
|
|
@@ -2185,6 +2298,8 @@ function buildTask(
|
|
|
2185
2298
|
const outFiles: string[] = []
|
|
2186
2299
|
const zeroed = new Set<string>()
|
|
2187
2300
|
const wsOutFiles: string[] = []
|
|
2301
|
+
// The user's `!` outputs, not the take-backs added below.
|
|
2302
|
+
const ownNegated = new Set<string>()
|
|
2188
2303
|
for (const raw of def.outputs ?? []) {
|
|
2189
2304
|
// Turbo's grammar: an extglob in `dist/**/*.@(js|mjs)` matched
|
|
2190
2305
|
// nothing, so a hit restored nothing (item 1031).
|
|
@@ -2200,12 +2315,13 @@ function buildTask(
|
|
|
2200
2315
|
// `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
|
|
2201
2316
|
// before every run while vx outputs could not exclude.
|
|
2202
2317
|
const neg = mapped.startsWith('!') ? '!' : ''
|
|
2203
|
-
const o = mapped.slice(neg.length)
|
|
2318
|
+
const o = collapseDots(mapped.slice(neg.length))
|
|
2204
2319
|
const signed = (g: string) => {
|
|
2205
2320
|
const head = zeroSegments(g)
|
|
2206
|
-
|
|
2207
|
-
zeroed.add(neg + head)
|
|
2208
|
-
|
|
2321
|
+
const out = head === null ? [neg + g] : [neg + g, neg + head]
|
|
2322
|
+
if (head !== null) zeroed.add(neg + head)
|
|
2323
|
+
if (neg) for (const e of out) ownNegated.add(e)
|
|
2324
|
+
return out
|
|
2209
2325
|
}
|
|
2210
2326
|
if (o.startsWith('$TURBO_ROOT$/')) {
|
|
2211
2327
|
wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
|
|
@@ -2216,7 +2332,7 @@ function buildTask(
|
|
|
2216
2332
|
} else if (o.includes('$TURBO_ROOT$')) {
|
|
2217
2333
|
todos.push(
|
|
2218
2334
|
`output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
|
|
2219
|
-
'prefix (→
|
|
2335
|
+
'prefix (→ outputs.workspaceFiles) — map manually',
|
|
2220
2336
|
)
|
|
2221
2337
|
} else outFiles.push(...signed(o))
|
|
2222
2338
|
}
|
|
@@ -2255,10 +2371,30 @@ function buildTask(
|
|
|
2255
2371
|
const outputs: Record<string, unknown> = { files: takingBack(outFiles).sort(negationsLast) }
|
|
2256
2372
|
const ws = takingBack(wsOutFiles).sort(negationsLast)
|
|
2257
2373
|
if (ws.length > 0) outputs.workspaceFiles = ws
|
|
2258
|
-
task.
|
|
2259
|
-
|
|
2260
|
-
|
|
2374
|
+
task.outputs = outputs
|
|
2375
|
+
// A path a `!` output takes back is still written by the task, often
|
|
2376
|
+
// not gitignored (`dist/skip`): as its own input it changed after the
|
|
2377
|
+
// key was taken, so no run was saved, and no dependant's either. One
|
|
2378
|
+
// over a committed file is source (`*/**` minus `!src/**`, medusa's):
|
|
2379
|
+
// Turbo keys it, and taken back an edit there replayed the old build.
|
|
2380
|
+
// Without git's list, no `!` is known to hold only what the task writes.
|
|
2381
|
+
const subtract = (field: 'files' | 'workspaceFiles', from: readonly string[]) => {
|
|
2382
|
+
const list = inputs[field] as unknown[] | undefined
|
|
2383
|
+
const tracked = opts.tracked?.(field === 'files' ? pkgDir : '').files
|
|
2384
|
+
if (tracked === undefined) return
|
|
2385
|
+
const negs = from.filter(
|
|
2386
|
+
(g) =>
|
|
2387
|
+
ownNegated.has(g) &&
|
|
2388
|
+
!list?.includes(g) &&
|
|
2389
|
+
!tracked.some((f) => outputsOverlap(g.slice(1), f)),
|
|
2390
|
+
)
|
|
2391
|
+
if (list !== undefined && negs.length > 0) inputs[field] = withoutTakenBack([...list, ...negs])
|
|
2261
2392
|
}
|
|
2393
|
+
subtract('files', outputs.files as string[])
|
|
2394
|
+
subtract('workspaceFiles', ws)
|
|
2395
|
+
task.cache = true
|
|
2396
|
+
// In place: the ignored-path passes hold this object.
|
|
2397
|
+
if (rewritesManifest) inputs.stability = { strategy: 'mutable' }
|
|
2262
2398
|
|
|
2263
2399
|
return { name, todos, task, uses }
|
|
2264
2400
|
}
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from '@vzn/vx'
|
|
30
30
|
import { deadline, MAX_TIMER_MS } from '../remote-deadline.js'
|
|
31
31
|
import { checkRemoteUrl } from '../remote-url.js'
|
|
32
|
-
import { refusal } from '../remote-refusal.js'
|
|
32
|
+
import { hideTokens, hidingErrors, refusal } from '../remote-refusal.js'
|
|
33
33
|
import { keyedByRef, readScopes, remoteScope, type RemoteScope } from './scope.js'
|
|
34
34
|
import { OutageBreaker } from '../remote-breaker.js'
|
|
35
35
|
import { Cooldown, withRetry } from '../remote-retry.js'
|
|
@@ -588,8 +588,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
588
588
|
}
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
/** Every bearer a request carried: a refusal that quotes one says `***`. */
|
|
592
|
+
private readonly sent = new Set<string>()
|
|
593
|
+
private readonly hidden = (text: string): string => hideTokens(text, this.sent)
|
|
594
|
+
|
|
591
595
|
private bearer(): string | undefined {
|
|
592
|
-
|
|
596
|
+
const token = this.borrowed?.() ?? this.token
|
|
597
|
+
if (token !== undefined) this.sent.add(token)
|
|
598
|
+
return token
|
|
593
599
|
}
|
|
594
600
|
|
|
595
601
|
private url(pathname: string): string {
|
|
@@ -619,7 +625,9 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
619
625
|
* never reaches a third-party store. The answer's status is not read, as
|
|
620
626
|
* in Turbo. A redirect is not followed: its `Location` is the answer, and
|
|
621
627
|
* the token's verdict stays the API's. Following one let the redirected
|
|
622
|
-
* host's `Access-Control-Allow-Headers` admit the token to itself.
|
|
628
|
+
* host's `Access-Control-Allow-Headers` admit the token to itself. A
|
|
629
|
+
* `Location` that is not http(s), or plain http the token would go to
|
|
630
|
+
* from an https API, is a request error.
|
|
623
631
|
*/
|
|
624
632
|
private async preflight(
|
|
625
633
|
url: string,
|
|
@@ -641,12 +649,21 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
641
649
|
await res.body?.cancel()
|
|
642
650
|
const location = res.headers.get('location')
|
|
643
651
|
const allowed = res.headers.get('access-control-allow-headers') ?? ''
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
652
|
+
const auth =
|
|
653
|
+
allowed.trim() === '*' ||
|
|
654
|
+
allowed.split(',').some((h) => h.trim().toLowerCase() === 'authorization')
|
|
655
|
+
if (location === null) return { url, auth }
|
|
656
|
+
const to = new URL(location, this.config.url)
|
|
657
|
+
// `checkRemoteUrl` saw only the configured URL. A `file:` Location read
|
|
658
|
+
// a local file as the artifact, and a PUT to it "succeeded" storing
|
|
659
|
+
// nothing.
|
|
660
|
+
if (to.protocol !== 'http:' && to.protocol !== 'https:')
|
|
661
|
+
throw new Error(`the preflight pointed at ${to.protocol}, not an http(s) URL`)
|
|
662
|
+
if (auth && to.protocol === 'http:' && new URL(this.config.url).protocol === 'https:')
|
|
663
|
+
throw new Error(
|
|
664
|
+
`the preflight pointed an https API at ${to.origin}, which would send the token in the clear`,
|
|
665
|
+
)
|
|
666
|
+
return { url: to.toString(), auth }
|
|
650
667
|
}
|
|
651
668
|
|
|
652
669
|
/**
|
|
@@ -666,27 +683,30 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
666
683
|
const signal = () => (timeoutMs === 0 ? undefined : deadline(timeoutMs))
|
|
667
684
|
// The batch query is never preflighted, as in Turbo.
|
|
668
685
|
const preflight = this.config.preflight && method !== 'POST'
|
|
669
|
-
const res = await
|
|
670
|
-
(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
686
|
+
const res = await hidingErrors(
|
|
687
|
+
this.breaker.send(
|
|
688
|
+
() =>
|
|
689
|
+
withRetry(
|
|
690
|
+
async () => {
|
|
691
|
+
const extra = init.headers ?? {}
|
|
692
|
+
const to = preflight
|
|
693
|
+
? await this.preflight(this.url(pathname), method, extra, signal)
|
|
694
|
+
: { url: this.url(pathname), auth: true }
|
|
695
|
+
const s = signal()
|
|
696
|
+
return this.fetchImpl(to.url, {
|
|
697
|
+
method,
|
|
698
|
+
headers: this.headers(extra, to.auth),
|
|
699
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
700
|
+
...(s === undefined ? {} : { signal: s }),
|
|
701
|
+
})
|
|
702
|
+
},
|
|
703
|
+
this.config.retries,
|
|
704
|
+
this.wait,
|
|
705
|
+
this.cooldown,
|
|
706
|
+
),
|
|
707
|
+
method === 'GET',
|
|
708
|
+
),
|
|
709
|
+
this.hidden,
|
|
690
710
|
)
|
|
691
711
|
if (method === 'PUT' && res.status === 403) {
|
|
692
712
|
const first = !this.writesRefused
|
|
@@ -695,14 +715,14 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
695
715
|
// The status and the server's own words (`refusal`): a scope it
|
|
696
716
|
// refuses and a token it no longer takes read alike.
|
|
697
717
|
throw new Error(
|
|
698
|
-
`${(await refusal(res)).message}: the upload was refused (a read-only token or server); uploads off for this run, reads go on`,
|
|
718
|
+
`${(await refusal(res, this.hidden)).message}: the upload was refused (a read-only token or server); uploads off for this run, reads go on`,
|
|
699
719
|
)
|
|
700
720
|
}
|
|
701
721
|
if (res.status === 401 || res.status === 403) {
|
|
702
722
|
const first = !this.disabled
|
|
703
723
|
this.disabled = true
|
|
704
724
|
if (!first) return undefined
|
|
705
|
-
const said = (await refusal(res)).message
|
|
725
|
+
const said = (await refusal(res, this.hidden)).message
|
|
706
726
|
if (this.forget === undefined)
|
|
707
727
|
throw new Error(`${said}: the token was refused; remote cache off for this run`)
|
|
708
728
|
await this.forget()
|
|
@@ -764,7 +784,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
764
784
|
await res.body?.cancel()
|
|
765
785
|
return null
|
|
766
786
|
}
|
|
767
|
-
throw await refusal(res, 'batch query answered ')
|
|
787
|
+
throw await refusal(res, this.hidden, 'batch query answered ')
|
|
768
788
|
}
|
|
769
789
|
// An answer per hash is a few hundred bytes of ArtifactInfo; a reply
|
|
770
790
|
// past this bound is no answer, read no further than the bound (L-9).
|
|
@@ -802,21 +822,25 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
802
822
|
return held
|
|
803
823
|
}
|
|
804
824
|
|
|
805
|
-
async get(
|
|
806
|
-
|
|
825
|
+
async get(
|
|
826
|
+
hash: string,
|
|
827
|
+
): Promise<{ body: Response; durationMs: number | undefined } | null | undefined> {
|
|
828
|
+
// Off, or refused: no answer, which core keeps apart from a miss (a
|
|
829
|
+
// null forgot each hit the index kept, the remote never asked).
|
|
830
|
+
if (this.disabled) return undefined
|
|
807
831
|
// A gateway that keys binary media on Accept base64-encodes a `*/*`
|
|
808
832
|
// response, and every hit read as a corrupt artifact (nx#33092's class).
|
|
809
833
|
const res = await this.request('GET', `/${hash}`, {
|
|
810
834
|
headers: { Accept: 'application/octet-stream', ...this.reads },
|
|
811
835
|
})
|
|
812
|
-
if (res === undefined) return
|
|
836
|
+
if (res === undefined) return undefined
|
|
813
837
|
if (res.status !== 200) {
|
|
814
838
|
if (res.status === 404) {
|
|
815
839
|
await res.body?.cancel()
|
|
816
840
|
this.absent.add(hash)
|
|
817
841
|
return null
|
|
818
842
|
}
|
|
819
|
-
throw await refusal(res)
|
|
843
|
+
throw await refusal(res, this.hidden)
|
|
820
844
|
}
|
|
821
845
|
this.served.add(hash)
|
|
822
846
|
const duration = Number(res.headers.get('x-artifact-duration'))
|
|
@@ -870,10 +894,17 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
870
894
|
this.absent.add(hash)
|
|
871
895
|
}
|
|
872
896
|
|
|
873
|
-
async put(
|
|
897
|
+
async put(
|
|
898
|
+
hash: string,
|
|
899
|
+
body: Blob,
|
|
900
|
+
meta: { durationMs: number; replace?: true },
|
|
901
|
+
): Promise<void | false> {
|
|
874
902
|
// Nothing sent, so no upload is counted.
|
|
875
903
|
if (this.disabled || this.writesRefused) return false
|
|
876
|
-
|
|
904
|
+
// `replace`: the copy there did not read back, maybe on another machine
|
|
905
|
+
// of the run (a vx-dte worker), so a HEAD's 200 is the bad copy. Turbo's
|
|
906
|
+
// PUT writes over a held key.
|
|
907
|
+
if (meta.replace !== true && !this.absent.has(hash) && !this.served.has(hash)) {
|
|
877
908
|
// Part of the upload, so under its deadline. Any answer but 200 or 404
|
|
878
909
|
// uploads: a lost store costs more. A server the HEAD cannot reach
|
|
879
910
|
// fails the put, as the PUT would.
|
|
@@ -901,7 +932,7 @@ export class TurboRemoteCache implements RemoteCacheLayer {
|
|
|
901
932
|
if (res === undefined) return false
|
|
902
933
|
// Turbo's client takes any 2xx (`error_for_status`); a server's 201 or
|
|
903
934
|
// 204 stored the artifact and was counted a failed upload.
|
|
904
|
-
if (!res.ok) throw await refusal(res)
|
|
935
|
+
if (!res.ok) throw await refusal(res, this.hidden)
|
|
905
936
|
this.absent.delete(hash)
|
|
906
937
|
this.served.delete(hash)
|
|
907
938
|
}
|
package/src/turbo-cache/oidc.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 2026-10-17); any other server names its token URL.
|
|
6
6
|
|
|
7
7
|
import { deadline } from '../remote-deadline.js'
|
|
8
|
+
import { hideTokens } from '../remote-refusal.js'
|
|
8
9
|
|
|
9
10
|
/** `turboCache({ oidc })`: `true` is Vercel's hosted cache with its defaults. */
|
|
10
11
|
export interface TurboOidcOptions {
|
|
@@ -103,14 +104,15 @@ const where = (url: string) => (URL.canParse(url) ? new URL(url).host : 'the end
|
|
|
103
104
|
* A refusal's own words: RFC 6749's `error` and `error_description`, or
|
|
104
105
|
* GitHub's `message`. Only those fields, which name a fault and never
|
|
105
106
|
* carry a token; any other body is left unread. "answered HTTP 400" alone
|
|
106
|
-
* did not say the audience or the grant was wrong.
|
|
107
|
+
* did not say the audience or the grant was wrong. A server may still
|
|
108
|
+
* quote what it was `sent`: each token is written `***` before the cut.
|
|
107
109
|
*/
|
|
108
|
-
async function refused(res: Response, what: string): Promise<Error> {
|
|
110
|
+
async function refused(res: Response, what: string, sent: readonly string[]): Promise<Error> {
|
|
109
111
|
// quiet: a body that is not JSON says nothing; the status still names the refusal
|
|
110
112
|
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
|
|
111
113
|
const text = (v: unknown): string | undefined =>
|
|
112
114
|
typeof v === 'string' && v.trim() !== ''
|
|
113
|
-
? v.replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
115
|
+
? hideTokens(v, sent).replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
114
116
|
: undefined
|
|
115
117
|
const said = [text(body?.['error']), text(body?.['error_description']) ?? text(body?.['message'])]
|
|
116
118
|
.filter((s) => s !== undefined)
|
|
@@ -118,8 +120,13 @@ async function refused(res: Response, what: string): Promise<Error> {
|
|
|
118
120
|
return new Error(`${what} answered HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
async function field(
|
|
122
|
-
|
|
123
|
+
async function field(
|
|
124
|
+
res: Response,
|
|
125
|
+
name: string,
|
|
126
|
+
what: string,
|
|
127
|
+
sent: readonly string[],
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
if (!res.ok) throw await refused(res, what, sent)
|
|
123
130
|
const value = ((await res.json().catch(() => null)) as Record<string, unknown> | null)?.[name]
|
|
124
131
|
if (typeof value !== 'string' || value === '') throw new Error(`${what} sent no ${name}`)
|
|
125
132
|
return value
|
|
@@ -148,6 +155,7 @@ export async function exchangeOidc(
|
|
|
148
155
|
),
|
|
149
156
|
'value',
|
|
150
157
|
`GitHub's OIDC endpoint (${where(cfg.idTokenUrl)})`,
|
|
158
|
+
[cfg.idTokenBearer],
|
|
151
159
|
)
|
|
152
160
|
const res = await send(
|
|
153
161
|
fetchImpl,
|
|
@@ -162,7 +170,7 @@ export async function exchangeOidc(
|
|
|
162
170
|
wait,
|
|
163
171
|
)
|
|
164
172
|
const what = `the token exchange (${where(cfg.tokenUrl)})`
|
|
165
|
-
if (!res.ok) throw await refused(res, what)
|
|
173
|
+
if (!res.ok) throw await refused(res, what, [idToken])
|
|
166
174
|
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null
|
|
167
175
|
const token = body?.['access_token']
|
|
168
176
|
if (typeof token !== 'string' || token === '') throw new Error(`${what} sent no access_token`)
|
|
@@ -192,6 +200,6 @@ export async function revokeOidc(
|
|
|
192
200
|
}),
|
|
193
201
|
wait,
|
|
194
202
|
)
|
|
195
|
-
if (!res.ok) throw await refused(res, `revoking at ${where(cfg.revokeUrl)}
|
|
203
|
+
if (!res.ok) throw await refused(res, `revoking at ${where(cfg.revokeUrl)}`, [token])
|
|
196
204
|
await res.body?.cancel()
|
|
197
205
|
}
|
|
@@ -396,7 +396,7 @@ function autoTodo(what: string, known?: Record<string, unknown>): string {
|
|
|
396
396
|
const have = known === undefined ? '' : ` (declared so far: \`${JSON.stringify(known)}\`)`
|
|
397
397
|
return (
|
|
398
398
|
`cache: Vite Task traced this task's ${what} and vx infers none — add ` +
|
|
399
|
-
`\`inputs: { files: [...] },
|
|
399
|
+
`\`inputs: { files: [...] }, outputs: { files: [...] }, cache: true\` with the real ones${have}; ` +
|
|
400
400
|
'without it the task always runs'
|
|
401
401
|
)
|
|
402
402
|
}
|
|
@@ -481,7 +481,8 @@ function mapTask(name: string, def: TaskDef, ctx: TaskCtx): GeneratedTask {
|
|
|
481
481
|
todos.push(ownFileTodo(ownFile))
|
|
482
482
|
return { name, todos, task }
|
|
483
483
|
}
|
|
484
|
-
task['
|
|
484
|
+
task['outputs'] = outputs.lists
|
|
485
|
+
task['cache'] = true
|
|
485
486
|
return { name, todos, task }
|
|
486
487
|
}
|
|
487
488
|
|