@vzn/vx-migrate 0.0.635 → 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.
- package/README.md +294 -42
- package/package.json +3 -3
- package/src/adopt.ts +23 -8
- package/src/manifest-graph.ts +65 -0
- package/src/manifests.ts +52 -0
- package/src/mapping-cache.ts +2 -8
- package/src/migrate-nx.ts +17 -16
- package/src/migrate-scripts.ts +1261 -0
- package/src/migrate-turbo.ts +6 -7
- package/src/migrate-vite-task.ts +5 -4
- package/src/migrate.ts +376 -139
- package/src/nx/index.ts +77 -20
- package/src/nx/nx-boundaries.ts +191 -0
- package/src/nx/nx-deps.ts +3 -3
- package/src/nx/nx-map.ts +37 -41
- package/src/nx/nx-upstream.ts +3 -2
- package/src/nx-cache/index.ts +5 -11
- package/src/package-fs.ts +152 -0
- package/src/packages.ts +806 -0
- package/src/paths.ts +2 -2
- package/src/remote-cache.ts +118 -0
- package/src/shared-outputs.ts +63 -19
- package/src/tracked-outputs.ts +54 -35
- package/src/turbo/index.ts +32 -20
- package/src/turbo/turbo-map.ts +96 -56
- package/src/turbo-cache/index.ts +14 -20
- package/src/vite-task/vite-task-map.ts +13 -12
- package/src/workspace-notes.ts +12 -6
- package/src/workspace-plugins.ts +41 -11
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -12,24 +12,25 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
|
-
import {
|
|
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
|
-
|
|
21
|
-
ownFileTodo,
|
|
21
|
+
ownFileTakeBacks,
|
|
22
22
|
excludeSiblingOutputs,
|
|
23
23
|
excludeWorkspaceOutputs,
|
|
24
24
|
resolveSharedOutputs,
|
|
25
25
|
resolveSharedWorkspaceOutputs,
|
|
26
26
|
takingBack,
|
|
27
|
-
|
|
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 {
|
|
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, `
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
824
|
-
metas: readonly
|
|
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:
|
|
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<
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 ??=
|
|
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:
|
|
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<
|
|
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
|
-
|
|
1263
|
-
'
|
|
1264
|
-
'
|
|
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
|
-
|
|
1270
|
-
'
|
|
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: [
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
2098
|
+
wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
|
|
2066
2099
|
} else if (climbed(o) !== null) {
|
|
2067
|
-
wsOutFiles.push(
|
|
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(
|
|
2108
|
+
} else outFiles.push(...signed(o))
|
|
2076
2109
|
}
|
|
2077
2110
|
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
for
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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 = {
|
|
2154
|
+
task.cache = {
|
|
2155
|
+
inputs,
|
|
2156
|
+
outputs,
|
|
2157
|
+
...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
|
|
2158
|
+
}
|
|
2119
2159
|
}
|
|
2120
2160
|
|
|
2121
2161
|
return { name, todos, task, uses }
|
package/src/turbo-cache/index.ts
CHANGED
|
@@ -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
|
|
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 —
|
|
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
|
|
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):
|
|
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
|
|
736
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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,
|
|
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
|
|
162
|
-
): Promise<{ metas:
|
|
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
|
|
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:
|
|
211
|
-
const defines = (m:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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<
|
|
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:
|
|
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
|
)
|
package/src/workspace-notes.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// What the written configs leave to vx
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
63
|
+
"in .vx/workspace.ts to key each task on its package's dependency closure",
|
|
58
64
|
)
|
|
59
65
|
return notes
|
|
60
66
|
}
|