@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.
@@ -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?.cache === false || def?.persistent === true) continue
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
- if (!sel.startsWith('^')) unfold.add(`${pm.name}#${task}`)
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 id = (d: string) => (d.startsWith('^') || d.includes('#') ? d : `${meta.name}#${d}`)
1537
- if (deps.some((d) => unfold.has(id(d))))
1538
- inputs['tasks'] = deps.filter((d) => !unfold.has(id(d)))
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 = (task['cache'] as { outputs?: Record<string, unknown> } | undefined)?.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 cache = task['cache'] as
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, cache: _cache, ...group } = task
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 wax = minimatchToVx(neg ? i.slice(1) : i, neg)
2089
- if (wax === null) {
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
- for (const rel of rels.keys()) if (!isLiteral(rel)) rels.delete(rel)
2179
- literals.tasks.push({ inputs, rels })
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
- if (head === null) return [neg + g]
2207
- zeroed.add(neg + head)
2208
- return [neg + g, neg + head]
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 (→ cache.outputs.workspaceFiles) — map manually',
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.cache = {
2259
- outputs,
2260
- ...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
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
  }
@@ -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
- return this.borrowed?.() ?? this.token
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
- return {
645
- url: location === null ? url : new URL(location, this.config.url).toString(),
646
- auth:
647
- allowed.trim() === '*' ||
648
- allowed.split(',').some((h) => h.trim().toLowerCase() === 'authorization'),
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 this.breaker.send(
670
- () =>
671
- withRetry(
672
- async () => {
673
- const extra = init.headers ?? {}
674
- const to = preflight
675
- ? await this.preflight(this.url(pathname), method, extra, signal)
676
- : { url: this.url(pathname), auth: true }
677
- const s = signal()
678
- return this.fetchImpl(to.url, {
679
- method,
680
- headers: this.headers(extra, to.auth),
681
- ...(init.body === undefined ? {} : { body: init.body }),
682
- ...(s === undefined ? {} : { signal: s }),
683
- })
684
- },
685
- this.config.retries,
686
- this.wait,
687
- this.cooldown,
688
- ),
689
- method === 'GET',
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(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
806
- if (this.disabled) return null
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 null
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(hash: string, body: Blob, meta: { durationMs: number }): Promise<void | false> {
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
- if (!this.absent.has(hash) && !this.served.has(hash)) {
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
  }
@@ -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(res: Response, name: string, what: string): Promise<string> {
122
- if (!res.ok) throw await refused(res, what)
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: [...] }, cache: { outputs: { files: [...] } }\` with the real ones${have}; ` +
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['cache'] = { outputs: outputs.lists }
484
+ task['outputs'] = outputs.lists
485
+ task['cache'] = true
485
486
  return { name, todos, task }
486
487
  }
487
488