@vzn/vx-migrate 0.0.636 → 0.0.637

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.
@@ -12,24 +12,25 @@
12
12
 
13
13
  import { existsSync, readFileSync } from 'node:fs'
14
14
  import path from 'node:path'
15
- import { buildPackageGraph, pruneOrphanPersistentNotes, type ProjectMeta, UserError } from '@vzn/vx'
15
+ import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
16
+ import type { PackageMeta } from '../packages.js'
16
17
  import { literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
17
18
  import { shellQuote } from '../nx-command.js'
18
19
  import { scriptCommand, yarnPnp } from '../script-command.js'
19
20
  import {
20
- ownFileOutput,
21
- ownFileTodo,
21
+ ownFileTakeBacks,
22
22
  excludeSiblingOutputs,
23
23
  excludeWorkspaceOutputs,
24
24
  resolveSharedOutputs,
25
25
  resolveSharedWorkspaceOutputs,
26
26
  takingBack,
27
- literalTailMatches,
27
+ wildcardCleanTodo,
28
28
  wildcardOutput,
29
29
  wildcardTodo,
30
30
  } from '../shared-outputs.js'
31
+ import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
31
32
  import { isRecord, packageScripts, relPosix } from '../paths.js'
32
- import { MAX_SPARED, type TrackedKinds } from '../tracked-outputs.js'
33
+ import type { TrackedKinds } from '../tracked-outputs.js'
33
34
  import {
34
35
  DOTENV_PROBE,
35
36
  DOTENV_PROBE_TOP,
@@ -76,7 +77,7 @@ const keyable = (name: string): boolean =>
76
77
  * one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
77
78
  * framework table matches, and each core cannot key (a literal one is
78
79
  * dropped only when no variable has it). The kept mapping keyed on every
79
- * name, so any variable that came or went (a CI step's, `VX_TIMING`)
80
+ * name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
80
81
  * remapped the workspace: ~330 ms a run on vercel/ai.
81
82
  */
82
83
  export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
@@ -299,7 +300,7 @@ export interface TurboMapping {
299
300
 
300
301
  export interface MapTurboOptions {
301
302
  /**
302
- * What a task's array holds for one global field: `vx migrate` splices a
303
+ * What a task's array holds for one global field: `bunx @vzn/vx-migrate` splices a
303
304
  * preset import (`{ raw: '...globalInputs' }`), the plugin the values
304
305
  * themselves. Called only for a global that is non-empty.
305
306
  */
@@ -307,7 +308,7 @@ export interface MapTurboOptions {
307
308
  /** The TODO attached to a persistent task, in the consumer's words. */
308
309
  persistentTodo: string
309
310
  /**
310
- * A task's `npm_package_name` / `npm_package_version`: `vx migrate` reads
311
+ * A task's `npm_package_name` / `npm_package_version`: `bunx @vzn/vx-migrate` reads
311
312
  * them from the manifest it imports (`{ raw: 'pkg.version' }`), so a bump
312
313
  * reaches them; absent, the values themselves.
313
314
  */
@@ -315,7 +316,7 @@ export interface MapTurboOptions {
315
316
  /**
316
317
  * The environment's variable names, when the mapping runs where the tasks
317
318
  * will (`turbo()`): an env wildcard (`NEXT_PUBLIC_*`) expands over them.
318
- * Absent (`vx migrate` writes files), a wildcard is a todo.
319
+ * Absent (`bunx @vzn/vx-migrate` writes files), a wildcard is a todo.
319
320
  */
320
321
  envNames?: readonly string[]
321
322
  /**
@@ -360,6 +361,28 @@ interface Literals {
360
361
  }
361
362
  type Named = readonly ['files' | 'workspaceFiles' | null, string]
362
363
 
364
+ /**
365
+ * X for a Turbo glob `X/**` whose X has a wildcard, else null. Turbo's
366
+ * trailing `/**` matches zero segments too (doublestar v4: `**` is zero or
367
+ * more path segments, so `*\/**` matches `tsconfig.tsbuildinfo` at the
368
+ * package root); vx's matches only below X, and medusa's `*\/**` build lost
369
+ * its root `tsconfig.tsbuildinfo` on a restore. A literal X is left as is:
370
+ * it names the output directory, which `X/**` already covers.
371
+ */
372
+ function zeroSegments(glob: string): string | null {
373
+ if (!glob.endsWith('/**')) return null
374
+ const head = glob.slice(0, -'/**'.length)
375
+ // An escaped character (`literalGlob`'s `g\{1\}`) is no wildcard.
376
+ return head === '' || isLiteralPattern(head.replace(/\\./g, '_')) ? null : head
377
+ }
378
+
379
+ /** A glob, `!` or not, and the X its `X/**` also matches in Turbo (`zeroSegments`). */
380
+ function withZeroSegments(glob: string): string[] {
381
+ const neg = glob.startsWith('!') ? '!' : ''
382
+ const head = zeroSegments(glob.slice(neg.length))
383
+ return head === null ? [glob] : [glob, neg + head]
384
+ }
385
+
363
386
  /** No glob syntax: one path, which git may ignore. */
364
387
  const isLiteral = (glob: string): boolean => !/[*?[\]{}()!]/.test(glob)
365
388
 
@@ -504,7 +527,7 @@ function taskAt(tasks: Record<string, TurboTask>, key: string): TurboTask | unde
504
527
  return Object.hasOwn(tasks, key) ? tasks[key] : undefined
505
528
  }
506
529
 
507
- function declares(meta: ProjectMeta, dependency: string): boolean {
530
+ function declares(meta: PackageMeta, dependency: string): boolean {
508
531
  const pj = meta.packageJson as unknown as Record<string, unknown>
509
532
  // Turbo counts every kind but a peer in a monorepo.
510
533
  return ['dependencies', 'devDependencies', 'optionalDependencies'].some((field) => {
@@ -820,11 +843,11 @@ function withGlobal(cfg: TurboJson): TurboJson {
820
843
  */
821
844
  function rootDependencyGlobs(
822
845
  root: string,
823
- rootPkg: ProjectMeta['packageJson'],
824
- metas: readonly ProjectMeta[],
846
+ rootPkg: PackageMeta['packageJson'],
847
+ metas: readonly PackageMeta[],
825
848
  ): string[] {
826
849
  const byName = new Map(metas.map((m) => [m.name, m]))
827
- const depsOf = (pkg: ProjectMeta['packageJson']): string[] =>
850
+ const depsOf = (pkg: PackageMeta['packageJson']): string[] =>
828
851
  (['dependencies', 'devDependencies', 'optionalDependencies'] as const).flatMap((f) => {
829
852
  const d: unknown = pkg[f]
830
853
  return d !== null && typeof d === 'object' ? Object.keys(d) : []
@@ -847,11 +870,11 @@ function rootDependencyGlobs(
847
870
  }
848
871
 
849
872
  /** The root's manifest; `{}` when it is missing or no object. */
850
- async function rootPackageJson(root: string): Promise<ProjectMeta['packageJson']> {
873
+ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']> {
851
874
  const pkg = (await Bun.file(path.join(root, 'package.json'))
852
875
  .json()
853
876
  .catch(() => null)) as unknown
854
- return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as ProjectMeta['packageJson']
877
+ return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
855
878
  }
856
879
 
857
880
  /**
@@ -886,7 +909,7 @@ export function microfrontendsConfigs(
886
909
 
887
910
  export async function mapTurboWorkspace(
888
911
  root: string,
889
- metas: readonly ProjectMeta[],
912
+ metas: readonly PackageMeta[],
890
913
  opts: MapTurboOptions,
891
914
  ): Promise<TurboMapping> {
892
915
  // A workspace with no Turbo config answered with a bare ENOENT and a stack
@@ -894,7 +917,7 @@ export async function mapTurboWorkspace(
894
917
  const rootFile = await turboConfigFile(root)
895
918
  if (rootFile === null) {
896
919
  throw new UserError(
897
- `no turbo.json or turbo.jsonc at the workspace root (${root}): turbo() maps a Turbo repo's config — add one, or remove turbo() from vx.workspace.ts`,
920
+ `no turbo.json or turbo.jsonc at the workspace root (${root}): turbo() maps a Turbo repo's config — add one, or remove turbo() from .vx/workspace.ts`,
898
921
  )
899
922
  }
900
923
  const rootCfg = withGlobal(await readTurboJson(rootFile, root))
@@ -1003,7 +1026,7 @@ export async function mapTurboWorkspace(
1003
1026
  // plain tasks on its root package (its `non-monorepo` example). As a
1004
1027
  // `//#task` holder only, the root planned nothing: "no projects declare".
1005
1028
  const singlePackage = rootMeta !== undefined && metas.length === 1
1006
- const definitions = (meta: ProjectMeta) => {
1029
+ const definitions = (meta: PackageMeta) => {
1007
1030
  if (meta === rootMeta && !singlePackage) {
1008
1031
  const defined = new Set(rootTaskNames)
1009
1032
  const defFor = (name: string): TurboTask | undefined =>
@@ -1082,13 +1105,11 @@ export async function mapTurboWorkspace(
1082
1105
  // package reaches in a package something depends on stays none (a
1083
1106
  // `test: [build]` in a package with no tests adds nothing its
1084
1107
  // dependants' `^build` does not).
1085
- let graph: ReturnType<typeof buildPackageGraph> | undefined
1108
+ let graph: ManifestGraph | undefined
1086
1109
  // The root runs only its `//#` tasks, so its dependencies are no reach
1087
1110
  // (ai's root dev-depends on `konsistent-provider`).
1088
1111
  const depended = (pkg: string): boolean =>
1089
- (graph ??= buildPackageGraph([...metas]))
1090
- .transitiveDependents(pkg)
1091
- .some((d) => d !== rootMeta?.name)
1112
+ (graph ??= manifestGraph(metas)).transitiveDependents(pkg).some((d) => d !== rootMeta?.name)
1092
1113
  const sidecarGroups = new Set<string>()
1093
1114
  for (const meta of metas) {
1094
1115
  const scripts = packageScripts(meta)
@@ -1207,10 +1228,10 @@ export async function mapTurboWorkspace(
1207
1228
  const inferredOf = new Map<string, readonly string[]>()
1208
1229
  // The names a package's files and its workspace dependencies' spell, once.
1209
1230
  const spelledMemo = new Map<string, Promise<readonly string[]>>()
1210
- const spelledBy = (m: ProjectMeta): Promise<readonly string[]> => {
1231
+ const spelledBy = (m: PackageMeta): Promise<readonly string[]> => {
1211
1232
  let names = spelledMemo.get(m.name)
1212
1233
  if (names === undefined) {
1213
- const closure = new Set<ProjectMeta>([m])
1234
+ const closure = new Set<PackageMeta>([m])
1214
1235
  for (const p of closure)
1215
1236
  for (const d of metas) if (!closure.has(d) && declares(p, d.name)) closure.add(d)
1216
1237
  names = opts.sourceNames!([...closure].map((p) => p.dir))
@@ -1257,18 +1278,22 @@ export async function mapTurboWorkspace(
1257
1278
  sourcedOf.set(fw, [...(sourcedOf.get(fw) ?? []), m.name])
1258
1279
  } else usersOf.set(fw, [...(usersOf.get(fw) ?? []), m.name])
1259
1280
  }
1281
+ // One short line per framework: nitro's twelve names and a long package
1282
+ // list ran past any terminal (Growth's first-run funnel F7, 2026-10-13).
1283
+ const few = (list: readonly string[]): string =>
1284
+ list.length <= 3
1285
+ ? list.join(', ')
1286
+ : `${list.slice(0, 3).join(', ')} and ${list.length - 3} more`
1260
1287
  for (const [fw, users] of sourcedOf)
1261
1288
  notes.push(
1262
- `Turbo infers ${fw.slug} in ${users.join(', ')} and hashes and passes ${fw.env.join(', ')} ` +
1263
- 'to its tasks; the configs list the names their files and their workspace ' +
1264
- 'dependencies’ spell — add any only an installed dependency reads to cache.inputs.env ' +
1265
- 'and exec.env.passThrough',
1289
+ `${fw.slug} in ${few(users)}: Turbo passes ${few(fw.env)} implicitly; the configs list ` +
1290
+ 'the names the package and its workspace dependencies spell, so add any an installed ' +
1291
+ 'dependency reads to cache.inputs.env and exec.env.passThrough',
1266
1292
  )
1267
1293
  for (const [fw, users] of usersOf)
1268
1294
  notes.push(
1269
- `Turbo infers ${fw.slug} in ${users.join(', ')} and hashes and passes ${fw.env.join(', ')} to ` +
1270
- 'its tasks; vx env names are explicit — list the ones they read in cache.inputs.env ' +
1271
- 'and exec.env.passThrough',
1295
+ `${fw.slug} in ${few(users)}: Turbo passes ${few(fw.env)} implicitly; list the ones ` +
1296
+ 'they read in cache.inputs.env and exec.env.passThrough',
1272
1297
  )
1273
1298
 
1274
1299
  // A task's own `*` env name, in a written config: expanded over the
@@ -1452,7 +1477,7 @@ export async function mapTurboWorkspace(
1452
1477
  for (const sel of dep.from ?? def!.dependsOn ?? []) {
1453
1478
  if (envDependency(sel) !== null) continue
1454
1479
  const hash = sel.indexOf('#')
1455
- const producers: [ProjectMeta | undefined, string][] = sel.startsWith('^')
1480
+ const producers: [PackageMeta | undefined, string][] = sel.startsWith('^')
1456
1481
  ? metas.filter((m) => declares(meta, m.name)).map((m) => [m, sel.slice(1)])
1457
1482
  : hash === -1
1458
1483
  ? [[meta, sel]]
@@ -1615,7 +1640,7 @@ function reanchor(glob: string, dir: string): string | null {
1615
1640
  */
1616
1641
  function pruneUnreachedPersistentNotes(
1617
1642
  projects: readonly TurboMappedProject[],
1618
- metas: readonly ProjectMeta[],
1643
+ metas: readonly PackageMeta[],
1619
1644
  note: string,
1620
1645
  ): void {
1621
1646
  const byName = new Map(metas.map((m) => [m.name, m]))
@@ -1691,7 +1716,7 @@ function npmScriptEnv(
1691
1716
  task: Record<string, unknown>,
1692
1717
  script: string,
1693
1718
  unfolded: boolean,
1694
- meta: ProjectMeta,
1719
+ meta: PackageMeta,
1695
1720
  opts: MapTurboOptions,
1696
1721
  ): void {
1697
1722
  const exec = task['exec'] as { command: string; env?: Record<string, unknown> }
@@ -2008,11 +2033,11 @@ function buildTask(
2008
2033
  continue
2009
2034
  }
2010
2035
  if (body.startsWith('$TURBO_ROOT$/')) {
2011
- wsFiles.push((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length))
2036
+ wsFiles.push(...withZeroSegments((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length)))
2012
2037
  const rel = body.slice('$TURBO_ROOT$/'.length)
2013
2038
  if (!neg) rels.set(rel, ['workspaceFiles', rel])
2014
2039
  } else if (up !== null) {
2015
- wsFiles.push(up)
2040
+ wsFiles.push(...withZeroSegments(up))
2016
2041
  if (!neg) rels.set(up, ['workspaceFiles', up])
2017
2042
  } else if (body.startsWith('../')) {
2018
2043
  todos.push(`input ${JSON.stringify(i)}: leaves the workspace — map manually`)
@@ -2022,7 +2047,8 @@ function buildTask(
2022
2047
  'prefix (→ cache.inputs.workspaceFiles) — map manually',
2023
2048
  )
2024
2049
  } else {
2025
- files.push(translated)
2050
+ // Unkeyed, a root file edit `*\/**` reaches in Turbo replayed a stale hit.
2051
+ files.push(...withZeroSegments(translated))
2026
2052
  const rel = path.posix.join(atRoot ? '.' : pkgDir, body)
2027
2053
  if (!neg) rels.set(rel, [atRoot ? 'workspaceFiles' : 'files', translated])
2028
2054
  }
@@ -2041,6 +2067,7 @@ function buildTask(
2041
2067
  if (Array.isArray(dotEnv) && dotEnv.some((f) => typeof f === 'string')) pkgDotenv = true
2042
2068
 
2043
2069
  const outFiles: string[] = []
2070
+ const zeroed = new Set<string>()
2044
2071
  const wsOutFiles: string[] = []
2045
2072
  for (const raw of def.outputs ?? []) {
2046
2073
  // The first segment stays a literal (a route directory, item 667);
@@ -2061,10 +2088,16 @@ function buildTask(
2061
2088
  // before every run while vx outputs could not exclude.
2062
2089
  const neg = mapped.startsWith('!') ? '!' : ''
2063
2090
  const o = mapped.slice(neg.length)
2091
+ const signed = (g: string) => {
2092
+ const head = zeroSegments(g)
2093
+ if (head === null) return [neg + g]
2094
+ zeroed.add(neg + head)
2095
+ return [neg + g, neg + head]
2096
+ }
2064
2097
  if (o.startsWith('$TURBO_ROOT$/')) {
2065
- wsOutFiles.push(neg + o.slice('$TURBO_ROOT$/'.length))
2098
+ wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
2066
2099
  } else if (climbed(o) !== null) {
2067
- wsOutFiles.push(neg + climbed(o)!)
2100
+ wsOutFiles.push(...signed(climbed(o)!))
2068
2101
  } else if (o.startsWith('../')) {
2069
2102
  todos.push(`output ${JSON.stringify(o)}: leaves the workspace — map manually`)
2070
2103
  } else if (o.includes('$TURBO_ROOT$')) {
@@ -2072,27 +2105,30 @@ function buildTask(
2072
2105
  `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2073
2106
  'prefix (→ cache.outputs.workspaceFiles) — map manually',
2074
2107
  )
2075
- } else outFiles.push(neg + o)
2108
+ } else outFiles.push(...signed(o))
2076
2109
  }
2077
2110
 
2078
- // A committed file a literal-tail wildcard reaches is taken back, as
2079
- // the runtime spares a tracked output; past the runtime's limit the
2080
- // task stays uncached.
2081
- for (const o of [...outFiles]) {
2082
- const hits = literalTailMatches(o, opts.tracked?.(pkgDir))
2083
- if (hits !== null && hits.length <= MAX_SPARED)
2084
- for (const h of hits) if (!outFiles.includes(`!${h}`)) outFiles.push(`!${h}`)
2085
- }
2086
- const wild = wildcardOutput(outFiles, opts.tracked?.(pkgDir), true)
2087
- if (wild !== undefined) {
2111
+ // With git's file list known, a wildcard-first output stays cached as
2112
+ // nx() keeps it (owner, 2026-10-03; turbo() 2026-10-14): core never
2113
+ // cleans a committed file. Turbo never cleans, so the untracked files
2114
+ // it reaches are named. An X added for `X/**` is judged with it.
2115
+ const wild = wildcardOutput(
2116
+ outFiles.filter((g) => !zeroed.has(g)),
2117
+ opts.tracked?.(pkgDir),
2118
+ true,
2119
+ )
2120
+ if (wild !== undefined && opts.tracked === undefined) {
2088
2121
  todos.push(wildcardTodo(wild))
2089
2122
  return { name, todos, task, uses }
2090
2123
  }
2091
- const own = ownFileOutput(outFiles, opts.ownConfig?.(pkgDir))
2092
- if (own !== undefined) {
2093
- todos.push(ownFileTodo(own))
2094
- return { name, todos, task, uses }
2095
- }
2124
+ if (wild !== undefined) todos.push(wildcardCleanTodo(wild))
2125
+ // An output over the project's own package.json (trpc's builds list
2126
+ // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
2127
+ // nx() does (owner, 2026-10-03). The build rewrites an input the key
2128
+ // folds, so its identical rewrite counts as unchanged ('mutable').
2129
+ const takeBacks = ownFileTakeBacks(outFiles, opts.ownConfig?.(pkgDir))
2130
+ outFiles.push(...takeBacks)
2131
+ const rewritesManifest = takeBacks.includes('!package.json')
2096
2132
 
2097
2133
  const cacheEnv = uniq([...global('env'), ...envNames], hidden('env'))
2098
2134
 
@@ -2115,7 +2151,11 @@ function buildTask(
2115
2151
  const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
2116
2152
  const ws = takingBack(wsOutFiles)
2117
2153
  if (ws.length > 0) outputs.workspaceFiles = ws
2118
- task.cache = { inputs, outputs }
2154
+ task.cache = {
2155
+ inputs,
2156
+ outputs,
2157
+ ...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
2158
+ }
2119
2159
  }
2120
2160
 
2121
2161
  return { name, todos, task, uses }
@@ -4,7 +4,7 @@
4
4
  // spec. The wire is Turbo's; the bytes are vx's own artifacts under vx's
5
5
  // own keys, so the server is storage — a Turbo binary cannot read them.
6
6
  //
7
- // Nothing is on by default: declare `turboCache()` in `vx.workspace.ts`
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
10
  // self-hosted setup carries over).
@@ -17,8 +17,6 @@ import { tmpdir } from 'node:os'
17
17
  import path from 'node:path'
18
18
  import {
19
19
  definePlugin,
20
- LayeredCache,
21
- type CacheLayer,
22
20
  type RemoteCacheLayer,
23
21
  type VxPlugin,
24
22
  refuseUnknownOptions,
@@ -366,7 +364,7 @@ const MAX_SIGNED_BODY = 2 * 1024 ** 3 + ((2 * 1024 ** 3) >> 8) + 64 * 1024
366
364
  /**
367
365
  * The seam implementation: `has` is HEAD, `hasMany` is the batch query,
368
366
  * `get`/`put` carry `x-artifact-duration` (and the tag when signing). An
369
- * auth failure (401/403) throws ONCE — LayeredCache reports it — and then
367
+ * auth failure (401/403) throws ONCE — core reports it — and then
370
368
  * turns the layer off for the rest of the process (an upload's 403 turns off
371
369
  * uploads alone: the read-only token), so a bad token costs one
372
370
  * line, not one per task — the requests already in flight when it lands
@@ -389,6 +387,8 @@ export class TurboRemoteCache implements RemoteCacheLayer {
389
387
  private writesRefused = false
390
388
  private readonly key: Uint8Array | undefined
391
389
  readonly endpoint: string
390
+ /** What `TURBO_REMOTE_CACHE_READ_ONLY` and friends leave this run (`turboRemoteAccess`). */
391
+ access?: { read: boolean; write: boolean }
392
392
  constructor(
393
393
  private readonly config: TurboCacheConfig,
394
394
  private readonly fetchImpl: typeof fetch = fetch,
@@ -630,10 +630,10 @@ export class TurboRemoteCache implements RemoteCacheLayer {
630
630
 
631
631
  /**
632
632
  * What Turbo's own switches leave of the remote cache: `TURBO_CACHE`
633
- * (`--cache`'s syntax, where an omitted source is off) and
633
+ * (`--cache-policy`'s syntax, where an omitted source is off) and
634
634
  * `TURBO_REMOTE_CACHE_READ_ONLY`. A CI that sets either for an untrusted
635
635
  * pull request kept Turbo from writing the shared cache and not vx. They
636
- * only narrow what `--cache` allows, never widen it.
636
+ * only narrow what `--cache-policy` allows, never widen it.
637
637
  */
638
638
  export function turboRemoteAccess(env: Record<string, string | undefined>): {
639
639
  read: boolean
@@ -648,7 +648,7 @@ export function turboRemoteAccess(env: Record<string, string | undefined>): {
648
648
  const m = /^(local|remote):([rw]*)$/.exec(part.trim())
649
649
  if (m === null)
650
650
  throw new Error(
651
- `vx/turbo-cache: TURBO_CACHE ${JSON.stringify(spec)} is not --cache's syntax (e.g. local:rw,remote:r)`,
651
+ `vx/turbo-cache: TURBO_CACHE ${JSON.stringify(spec)} is not --cache-policy's syntax (e.g. local:rw,remote:r)`,
652
652
  )
653
653
  if (m[1] !== 'remote') continue
654
654
  read = m[2]!.includes('r')
@@ -713,7 +713,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
713
713
  }
714
714
 
715
715
  /**
716
- * Declare in `vx.workspace.ts`; the local store stays the floor beneath it:
716
+ * Declare in `.vx/workspace.ts`; the local store stays the floor beneath it:
717
717
  *
718
718
  * ```ts
719
719
  * plugins: [turboCache({ apiUrl: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
@@ -724,7 +724,7 @@ const TURBO_CACHE_KEYS: PluginOptionKinds<TurboCacheOptions> = {
724
724
  export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
725
725
  refuseUnknownOptions('turboCache()', options, TURBO_CACHE_KEYS)
726
726
  return definePlugin(import.meta, {
727
- cache(ctx): CacheLayer | undefined {
727
+ cache(ctx): RemoteCacheLayer | undefined {
728
728
  const config = resolveTurboCacheConfig(
729
729
  options,
730
730
  Bun.env,
@@ -732,20 +732,14 @@ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
732
732
  localConfigOf(ctx.workspaceRoot),
733
733
  )
734
734
  if (config === undefined) return undefined
735
- const remote = turboRemoteAccess(Bun.env)
736
- const policy = {
737
- ...ctx.policy,
738
- remoteRead: ctx.policy.remoteRead && remote.read,
739
- remoteWrite: ctx.policy.remoteWrite && remote.write,
740
- }
741
- if (!policy.remoteRead && !policy.remoteWrite) return undefined
735
+ const access = turboRemoteAccess(Bun.env)
736
+ if (!access.read && !access.write) return undefined
742
737
  // A signed download waits in a temp until its tag checks: in vx's
743
738
  // cache directory, which the sandbox walls, not the shared temp dir a
744
739
  // sandboxed task may read (L-45).
745
- return new LayeredCache(ctx.localCache, new TurboRemoteCache(config, fetch, ctx.cacheDir), {
746
- policy,
747
- onRemoteError: (err) => ctx.warn(`vx/turbo-cache: ${err.message}`),
748
- })
740
+ const remote = new TurboRemoteCache(config, fetch, ctx.cacheDir)
741
+ remote.access = access
742
+ return remote
749
743
  },
750
744
  })
751
745
  }
@@ -7,7 +7,8 @@
7
7
 
8
8
  import { existsSync } from 'node:fs'
9
9
  import path from 'node:path'
10
- import { type GeneratedProject, type GeneratedTask, type ProjectMeta, UserError } from '@vzn/vx'
10
+ import { type GeneratedProject, type GeneratedTask, UserError } from '@vzn/vx'
11
+ import type { PackageMeta } from '../packages.js'
11
12
  import { minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
12
13
  import { packageScripts, relPosix } from '../paths.js'
13
14
  import { scriptCommand, yarnPnp } from '../script-command.js'
@@ -158,8 +159,8 @@ const asStrings = (v: unknown): string[] =>
158
159
  */
159
160
  export async function viteTaskProjects(
160
161
  root: string,
161
- metas: readonly ProjectMeta[],
162
- ): Promise<{ metas: ProjectMeta[]; notes: string[] }> {
162
+ metas: readonly PackageMeta[],
163
+ ): Promise<{ metas: PackageMeta[]; notes: string[] }> {
163
164
  if (metas.some((m) => path.resolve(m.dir) === path.resolve(root)))
164
165
  return { metas: [...metas], notes: [] }
165
166
  const file = viteConfigFile(root)
@@ -186,7 +187,7 @@ export async function viteTaskProjects(
186
187
  /** Every project's Vite Task tasks and package.json scripts as vx tasks. */
187
188
  export async function mapViteTaskWorkspace(
188
189
  root: string,
189
- metas: readonly ProjectMeta[],
190
+ metas: readonly PackageMeta[],
190
191
  opts: ViteTaskOptions = {},
191
192
  ): Promise<ViteTaskMapping> {
192
193
  const rootFile = viteConfigFile(root)
@@ -207,12 +208,12 @@ export async function mapViteTaskWorkspace(
207
208
  : await readRunConfig(file, root),
208
209
  )
209
210
  }
210
- const tasksOf = (m: ProjectMeta): Record<string, unknown> => runs.get(m.name)?.tasks ?? {}
211
- const defines = (m: ProjectMeta, task: string): boolean =>
211
+ const tasksOf = (m: PackageMeta): Record<string, unknown> => runs.get(m.name)?.tasks ?? {}
212
+ const defines = (m: PackageMeta, task: string): boolean =>
212
213
  Object.hasOwn(tasksOf(m), task) || typeof packageScripts(m)[task] === 'string'
213
214
  const byName = new Map(metas.map((m) => [m.name, m]))
214
215
  /** Workspace members `m` lists under each field, by name. */
215
- const depsIn = (m: ProjectMeta, field: string): string[] => {
216
+ const depsIn = (m: PackageMeta, field: string): string[] => {
216
217
  const deps = (m.packageJson as unknown as Record<string, unknown>)[field]
217
218
  if (typeof deps !== 'object' || deps === null) return []
218
219
  return Object.keys(deps).filter((n) => byName.has(n) && n !== m.name)
@@ -225,7 +226,7 @@ export async function mapViteTaskWorkspace(
225
226
  * leading chain of them becomes `dependsOn` edges, and the task a group
226
227
  * when nothing else is left; a form with no edge spelling is a TODO.
227
228
  */
228
- function inlineRuns(t: GeneratedTask, self: ProjectMeta): void {
229
+ function inlineRuns(t: GeneratedTask, self: PackageMeta): void {
229
230
  const exec = t.task?.['exec'] as { command?: unknown } | undefined
230
231
  if (typeof exec?.command !== 'string') return
231
232
  const parts = exec.command.split('&&').map((p) => p.trim())
@@ -252,7 +253,7 @@ export async function mapViteTaskWorkspace(
252
253
  }
253
254
 
254
255
  /** The edges one `vp run` stands for, or null when it has none. */
255
- function runEdges(segment: string, self: ProjectMeta, own: string): string[] | null {
256
+ function runEdges(segment: string, self: PackageMeta, own: string): string[] | null {
256
257
  const words = segment.split(/\s+/)
257
258
  words.splice(0, words.indexOf(words[0] === 'vpr' ? 'vpr' : 'run') + 1)
258
259
  let recursive = false
@@ -279,7 +280,7 @@ export async function mapViteTaskWorkspace(
279
280
  const m = byName.get(spec.slice(0, hash))
280
281
  return recursive || workspace || filters.length > 0 || m === undefined ? null : [spec]
281
282
  }
282
- let picked: ProjectMeta[]
283
+ let picked: PackageMeta[]
283
284
  if (filters.length > 0) {
284
285
  // Package names only: pnpm's directory, glob and graph selectors are not.
285
286
  if (filters.some((f) => !byName.has(f))) return null
@@ -314,7 +315,7 @@ export async function mapViteTaskWorkspace(
314
315
  })
315
316
  let spelled: readonly string[] | undefined
316
317
  if (wild && opts.sourceNames !== undefined) {
317
- const closure = new Set<ProjectMeta>([meta])
318
+ const closure = new Set<PackageMeta>([meta])
318
319
  for (const p of closure)
319
320
  for (const field of [...DEP_FIELDS, 'optionalDependencies'])
320
321
  for (const n of depsIn(p, field)) closure.add(byName.get(n)!)
@@ -342,7 +343,7 @@ export async function mapViteTaskWorkspace(
342
343
  * defines it. `^task` is that set while no member listed under another
343
344
  * field defines it too (vx's `^` follows every field); else the edges.
344
345
  */
345
- function fromDeps(m: ProjectMeta, entry: { task: string; from: unknown }): string[] {
346
+ function fromDeps(m: PackageMeta, entry: { task: string; from: unknown }): string[] {
346
347
  const fields = (Array.isArray(entry.from) ? entry.from : [entry.from]).filter(
347
348
  (f): f is DepField => (DEP_FIELDS as readonly unknown[]).includes(f),
348
349
  )
@@ -1,11 +1,11 @@
1
- // What the written configs leave to vx.workspace.ts, said once by both
1
+ // What the written configs leave to .vx/workspace.ts, said once by both
2
2
  // migrators: the adoption plugin `vx init` declared still reads the old
3
3
  // tool's file every run, and the old tool keyed each package on its own
4
4
  // lockfile entries where core keys every task on the whole lockfile.
5
5
  // Without a lockfile plugin a dependency bump re-ran every task (hey-api:
6
6
  // a `pnpm-lock.yaml` edit re-keyed all 42 builds, none with `pnpm()`).
7
7
 
8
- import { existsSync, readdirSync } from 'node:fs'
8
+ import { existsSync } from 'node:fs'
9
9
  import path from 'node:path'
10
10
 
11
11
  /** Each lockfile and the `@vzn/vx-lockfile` plugin that claims it. */
@@ -35,11 +35,18 @@ interface AdoptedTool {
35
35
  readonly runner: string
36
36
  }
37
37
 
38
+ const WORKSPACE_FILES = ['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((e) => `.vx/workspace.${e}`)
39
+
40
+ /** The workspace file under `root`, in any extension the loader reads. */
41
+ export function workspaceFileAt(root: string): string | undefined {
42
+ return WORKSPACE_FILES.find((n) => existsSync(path.join(root, n)))
43
+ }
44
+
38
45
  export async function adoptedToolNotes(root: string, tool: AdoptedTool): Promise<string[]> {
39
46
  const notes: string[] = []
40
47
  let declared = ''
41
- for (const name of readdirSync(root)) {
42
- if (!/^vx\.workspace\.(ts|mts|js|mjs|cts|cjs)$/.test(name)) continue
48
+ const name = workspaceFileAt(root)
49
+ if (name !== undefined) {
43
50
  declared = await Bun.file(path.join(root, name)).text()
44
51
  if (new RegExp(`\\b${tool.plugin}\\s*\\(`).test(declared))
45
52
  notes.push(
@@ -47,14 +54,13 @@ export async function adoptedToolNotes(root: string, tool: AdoptedTool): Promise
47
54
  'a vx.config does not declare; the configs written here declare them all. Once ' +
48
55
  `\`vx run\` does what ${tool.runner} did, remove ${tool.plugin}() (and its import), then ${tool.config}`,
49
56
  )
50
- break
51
57
  }
52
58
  const lock = lockfilePlugin(root)
53
59
  if (lock !== undefined && !new RegExp(`\\b${lock.factory}\\s*\\(`).test(declared))
54
60
  notes.push(
55
61
  `${tool.keys(lock.file)}; vx keys every task on the whole ` +
56
62
  `file, so a dependency bump re-runs them all. Declare ${lock.factory}() from @vzn/vx-lockfile ` +
57
- "in vx.workspace.ts to key each task on its package's dependency closure",
63
+ "in .vx/workspace.ts to key each task on its package's dependency closure",
58
64
  )
59
65
  return notes
60
66
  }