@vzn/vx-migrate 0.0.508 → 0.0.510
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 +11 -1
- package/package.json +2 -2
- package/src/adopt.ts +176 -0
- package/src/migrate.ts +122 -12
- package/src/turbo/turbo-map.ts +5 -0
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Rules:
|
|
|
56
56
|
- **The command is the script body** with its `pre<name>` / `post<name>` hooks folded in, in that order, through core's `foldScriptHooks`: each part in its own subshell, the chain stopping at the first that fails, forwarded `--` args reaching the body alone (npm and pnpm run them around `<name>` without being asked; novu's `prebuild` copies the CSS its `build` inlines), one process less per task than `pnpm run <name>` — except a body that calls yarn's `run` builtin (`run -T rollup -c`, `run clean && run build`; yarn ≥ 2 runs scripts in its own shell), which is `run: command not found` in sh: it runs as `yarn run <name>`, as Turbo and Nx run it, and yarn ≥ 2 runs no hooks, so none are folded there. A Yarn Plug'n'Play workspace (`.yarnrc.yml` with no `nodeLinker`, or `pnp`) has no `node_modules`: a dependency resolves only through `.pnp.cjs` and a bin only through yarn's shims, so there every script runs as `yarn run <name>`, and a `nodeLinker` change maps afresh. Same rules for `nx:run-script` below (strapi and novu, 2026-09-11).
|
|
57
57
|
- **Turbo 2.11's task `command`** (`futureFlags.experimentalTaskCommand`) wins over the script, as Turbo holds it: an argv is the command — each word quoted, run from the package dir, no `pre`/`post` hooks — and gives the package the task even with no script (turborepo's `@turbo/types#build`, `docs#schema`); `null` or `[]` is Turbo's no-op node, no task even where the script exists, its edges passed through; a per-toolchain map applies its `javascript` entry (or `typescript`, Turbo's alias), and without one the script runs. A shape Turbo would refuse keeps the script, with a todo.
|
|
58
58
|
- `dependsOn`: `^x` passes through when some package runs `x`, and is dropped when none does (Turbo gives it no edges; core refuses a `^x` no project declares as a typo); `pkg#task` is kept only when `pkg` emits `task` (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo's no-op node, so its own edges pass through in its place (`test → codegen → ^build` with no `codegen` script is `test → ^build`); another package's `^x` or `pkg#x` reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind's `ui` has no `build` script; its `build` builds `build:styles` and `build:components`, and `web#build` waits on them), and so does one whose `^` edge names another task (rallly's script-less `build` holds `^db:generate`), while one whose only edge is `^` to its own name needs none, since core's `^x` walks past a package without `x`; a no-op node nothing else reaches runs nothing, its own edges included, where Turbo still runs them (kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script; `vx run test --all` does not, and `vx run build test --all` does both); a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. Root `//#task`s run in the workspace root, as under Turbo: `turbo()` names the root a project through core's `discover` stage when turbo.json declares one (no `vx.config` needed; a root no member glob lists), and `//#x` in `dependsOn` is an edge to it, named by the root `package.json` `name`; a root task's globs are the workspace's (`workspaceFiles`), since Turbo hashes a root task over the whole repo; with no root project (a root `package.json` with no `name`, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does `turbo()`; in a single-package repo (no workspaces, Turbo's `non-monorepo` example) turbo.json's plain tasks run on the root package, as under Turbo.
|
|
59
|
-
- A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: it runs `true`, cached and keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants.
|
|
59
|
+
- A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: it runs `true`, cached and keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` running `true`).
|
|
60
60
|
- `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
|
|
61
61
|
- `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none (vx folds each dependency's key). Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `cache.inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `cache.inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`cache.inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
|
|
62
62
|
- `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). An output whose first segment is a wildcard (`**/*.d.ts`, `*/**`) runs the task uncached with a todo: Turbo never cleans an output, vx cleans it before every run, and such a glob reaches the sources. One segment with a literal extension the package tracks no file of (`*.xml`, `*.tsbuildinfo`) reaches only top-level artifacts and stays cached, and so does `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`; `node_modules` is never an output), and so does a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). A wildcard first segment with a literal rest (clerk's `*/package.json`, the subpath stubs it commits) reaches only files of that name: each committed one, up to 16, is taken back with `!` and the task stays cached; more, and it runs uncached. An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) runs the task uncached with a todo: vx would delete the manifest before every run, and core refuses that config. `nx()` takes the manifest back with `!package.json` instead and keeps the cache (owner, 2026-10-03). So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached instead: each committed file it reaches is taken back with `!` (past sixteen, uncached with a todo), and the clean removes the rest, what the task writes (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) is taken back with `!` — Turbo and Nx never clean an output, vx does — so it survives the clean and the task keeps its cache; past sixteen such files the task runs uncached with a todo. `turbo()` and `nx()` both do this, and so do the configs `vx-migrate` writes from turbo.json or an Nx graph (each take-back is a todo there, as it names files a later commit may move).
|
|
@@ -204,9 +204,19 @@ bunx @vzn/vx-migrate # auto-detect: turbo.json, .nx/workspace-data/pro
|
|
|
204
204
|
bunx @vzn/vx-migrate --dry # print the generated files + the report instead of writing
|
|
205
205
|
bunx @vzn/vx-migrate --force # overwrite existing vx.config.* / vx-preset.ts
|
|
206
206
|
bunx @vzn/vx-migrate --from nx # disambiguate when both runners are checked in
|
|
207
|
+
bunx @vzn/vx-migrate --native # write vx.config.ts files without asking
|
|
208
|
+
bunx @vzn/vx-migrate --keep # keep turbo.json / nx.json as the source: turbo() or nx(), as `vx init` writes it
|
|
207
209
|
bunx @vzn/vx-migrate --help # the usage, exit 0
|
|
208
210
|
```
|
|
209
211
|
|
|
212
|
+
It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works the same):
|
|
213
|
+
|
|
214
|
+
- In a terminal it asks which adoption you want: **native** (the default, a `vx.config.ts` per package) or **keep** (the workspace file `vx init` writes, declaring `turbo()` or `nx()`). `--native` / `--keep` answer it; with no terminal (CI, a pipe) it is native.
|
|
215
|
+
- It installs what the written files import with the repo's own manager (`packageManager`, else the lockfile): `@vzn/vx`, plus `@vzn/vx-migrate` for keep (`pnpm add -D -w`, `yarn add -D -W` on Yarn 1 and without `-W` on Yarn 2+, `bun add -d`, `npm install -D`). A package already listed or installed is left alone; `--no-install` skips it.
|
|
216
|
+
- A root `package.json` script that is only `turbo run <tasks>` or `nx run-many -t <tasks>` becomes `vx run <tasks> --all`, so `pnpm run build` runs vx. A script with flags or a chain is left as it is.
|
|
217
|
+
|
|
218
|
+
`--dry` does none of the three, and says what it would install and change.
|
|
219
|
+
|
|
210
220
|
`--dry` prints the files instead of writing them; `--force` overwrites existing ones; `--mjs` writes `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts` — the same objects with no type import and no `satisfies`, for a package whose own `tsconfig` includes every `.ts` under it and would compile the config into its dist (TanStack/query, 2026-09-11).
|
|
211
221
|
|
|
212
222
|
`package.json` scripts are core's own `vx init`. What this package writes reads exactly like what `vx init` writes: both hand a plan to core's migration seam (`applyMigration` from `@vzn/vx`), which renders, refuses to overwrite without `--force`, writes and reports. Anything a source cannot say becomes a `TODO(vx-migrate)` comment, never a silent wrong value.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.510",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
42
|
+
"@vzn/vx": "^0.0.510"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/adopt.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// What `vx-migrate` does around the mapping so the repo runs vx after it:
|
|
2
|
+
// which kind of adoption (native configs, or the runner's config read live),
|
|
3
|
+
// installing vx with the repo's own package manager, and pointing the root
|
|
4
|
+
// scripts that called the runner at vx (solidjs/solid, 2026-10-04: the
|
|
5
|
+
// configs were written, and `pnpm run build` still ran turbo, with no vx
|
|
6
|
+
// installed to run them).
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { UserError } from '@vzn/vx'
|
|
11
|
+
|
|
12
|
+
export type AdoptionMode = 'native' | 'keep'
|
|
13
|
+
|
|
14
|
+
/** The question a TTY run asks; `null` (no answer, EOF) is the default. */
|
|
15
|
+
export const MODE_QUESTION = (runner: 'turbo' | 'nx', source: string) =>
|
|
16
|
+
`${source} found. How should vx run this repo?\n` +
|
|
17
|
+
` 1) native: write a vx.config.ts per package from ${source} (recommended)\n` +
|
|
18
|
+
` 2) keep: keep ${source} as the source; vx reads it on every run through ${runner}()\n` +
|
|
19
|
+
'Choose 1 or 2 [1]:'
|
|
20
|
+
|
|
21
|
+
/** `1`/`native` (or nothing) → native, `2`/`keep` → keep; anything else asks again. */
|
|
22
|
+
export function parseModeAnswer(answer: string | null): AdoptionMode | undefined {
|
|
23
|
+
const a = (answer ?? '').trim().toLowerCase()
|
|
24
|
+
if (a === '' || a === '1' || a === 'native' || a === 'n') return 'native'
|
|
25
|
+
if (a === '2' || a === 'keep' || a === 'k') return 'keep'
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'
|
|
30
|
+
|
|
31
|
+
const LOCKFILES: ReadonlyArray<readonly [string, PackageManager]> = [
|
|
32
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
33
|
+
['yarn.lock', 'yarn'],
|
|
34
|
+
['bun.lock', 'bun'],
|
|
35
|
+
['bun.lockb', 'bun'],
|
|
36
|
+
['package-lock.json', 'npm'],
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function readText(file: string): string {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(file, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The repo's manager: package.json's `packageManager` (corepack's field),
|
|
49
|
+
* else the lockfile, else the one that started this process, else npm.
|
|
50
|
+
*/
|
|
51
|
+
export function packageManagerOf(root: string, userAgent?: string): PackageManager {
|
|
52
|
+
const field = (
|
|
53
|
+
JSON.parse(readText(path.join(root, 'package.json')) || '{}') as { packageManager?: unknown }
|
|
54
|
+
).packageManager
|
|
55
|
+
const declared =
|
|
56
|
+
typeof field === 'string'
|
|
57
|
+
? (/^(pnpm|yarn|bun|npm)@/.exec(field)?.[1] as PackageManager | undefined)
|
|
58
|
+
: undefined
|
|
59
|
+
if (declared !== undefined) return declared
|
|
60
|
+
const locked = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1]
|
|
61
|
+
if (locked !== undefined) return locked
|
|
62
|
+
return (
|
|
63
|
+
(/^(pnpm|yarn|bun|npm)\//.exec(userAgent ?? '')?.[1] as PackageManager | undefined) ?? 'npm'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** argv that adds `packages` as root dev dependencies. */
|
|
68
|
+
export function installArgv(
|
|
69
|
+
root: string,
|
|
70
|
+
pm: PackageManager,
|
|
71
|
+
packages: readonly string[],
|
|
72
|
+
): string[] {
|
|
73
|
+
switch (pm) {
|
|
74
|
+
case 'pnpm':
|
|
75
|
+
return ['pnpm', 'add', '-D', '-w', ...packages]
|
|
76
|
+
case 'yarn':
|
|
77
|
+
// Yarn 1 refuses `add` at a workspace root without `-W`; Berry has no
|
|
78
|
+
// such flag. A Berry lockfile carries `__metadata`.
|
|
79
|
+
return readText(path.join(root, 'yarn.lock')).includes('__metadata:')
|
|
80
|
+
? ['yarn', 'add', '-D', ...packages]
|
|
81
|
+
: ['yarn', 'add', '-D', '-W', ...packages]
|
|
82
|
+
case 'bun':
|
|
83
|
+
return ['bun', 'add', '-d', ...packages]
|
|
84
|
+
case 'npm':
|
|
85
|
+
return ['npm', 'install', '-D', ...packages]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The packages a mode's files import that the root neither lists nor has installed. */
|
|
90
|
+
export function missingPackages(root: string, mode: AdoptionMode): string[] {
|
|
91
|
+
const pj = JSON.parse(readText(path.join(root, 'package.json')) || '{}') as Record<
|
|
92
|
+
string,
|
|
93
|
+
Record<string, unknown> | undefined
|
|
94
|
+
>
|
|
95
|
+
const listed = (name: string) =>
|
|
96
|
+
['dependencies', 'devDependencies'].some((f) => pj[f]?.[name] !== undefined)
|
|
97
|
+
const wanted = mode === 'keep' ? ['@vzn/vx', '@vzn/vx-migrate'] : ['@vzn/vx']
|
|
98
|
+
return wanted.filter(
|
|
99
|
+
(p) => !listed(p) && !existsSync(path.join(root, 'node_modules', p, 'package.json')),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Install `packages` with the repo's manager, its output on the terminal. */
|
|
104
|
+
export async function install(root: string, packages: readonly string[]): Promise<string> {
|
|
105
|
+
const argv = installArgv(
|
|
106
|
+
root,
|
|
107
|
+
packageManagerOf(root, process.env['npm_config_user_agent']),
|
|
108
|
+
packages,
|
|
109
|
+
)
|
|
110
|
+
const line = argv.join(' ')
|
|
111
|
+
process.stdout.write(`vx-migrate: ${line}\n`)
|
|
112
|
+
let code: number
|
|
113
|
+
try {
|
|
114
|
+
code = await Bun.spawn(argv, { cwd: root, stdio: ['inherit', 'inherit', 'inherit'] }).exited
|
|
115
|
+
} catch {
|
|
116
|
+
code = -1
|
|
117
|
+
}
|
|
118
|
+
if (code !== 0) {
|
|
119
|
+
throw new UserError(
|
|
120
|
+
`installing vx failed (${line} exited ${code}); run it, then vx-migrate again`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return line
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A root script that is only `turbo run <tasks>`, or
|
|
128
|
+
* Nx's `nx run-many -t <tasks>`, as the same run under vx. Flags, a chain or
|
|
129
|
+
* anything else is left alone: only what reads the same both ways is moved.
|
|
130
|
+
*/
|
|
131
|
+
export function vxScript(command: string): string | undefined {
|
|
132
|
+
const words = command.trim().split(/\s+/)
|
|
133
|
+
let tasks: string[] | undefined
|
|
134
|
+
// `turbo <tasks>` is left: `turbo watch dev` is a verb, not a task.
|
|
135
|
+
if (words[0] === 'turbo' && words[1] === 'run') {
|
|
136
|
+
const rest = words.slice(2)
|
|
137
|
+
if (rest.length > 0 && rest.every((w) => /^[\w:.@/-]+$/.test(w) && !w.startsWith('-')))
|
|
138
|
+
tasks = rest
|
|
139
|
+
} else if (words[0] === 'nx' && words[1] === 'run-many') {
|
|
140
|
+
const rest = words.slice(2)
|
|
141
|
+
if (
|
|
142
|
+
(rest[0] === '-t' || rest[0] === '--target' || rest[0] === '--targets') &&
|
|
143
|
+
rest.length === 2
|
|
144
|
+
)
|
|
145
|
+
tasks = rest[1]!.split(',')
|
|
146
|
+
else if (rest.length === 1 && /^--targets?=/.test(rest[0]!))
|
|
147
|
+
tasks = rest[0]!.replace(/^--targets?=/, '').split(',')
|
|
148
|
+
if (tasks?.some((t) => !/^[\w:.@/-]+$/.test(t))) tasks = undefined
|
|
149
|
+
}
|
|
150
|
+
return tasks === undefined ? undefined : `vx run ${tasks.join(' ')} --all`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Point the root package.json scripts that call the runner at vx, editing
|
|
155
|
+
* the text in place (key order, indent and the rest kept). Returns each
|
|
156
|
+
* `name: old → new`; writes nothing under `dry`.
|
|
157
|
+
*/
|
|
158
|
+
export async function rewriteRootScripts(root: string, dry: boolean): Promise<string[]> {
|
|
159
|
+
const file = path.join(root, 'package.json')
|
|
160
|
+
let text = readText(file)
|
|
161
|
+
const scripts = (JSON.parse(text || '{}') as { scripts?: Record<string, unknown> }).scripts ?? {}
|
|
162
|
+
const changed: string[] = []
|
|
163
|
+
for (const [name, value] of Object.entries(scripts)) {
|
|
164
|
+
if (typeof value !== 'string') continue
|
|
165
|
+
const next = vxScript(value)
|
|
166
|
+
if (next === undefined) continue
|
|
167
|
+
const key = JSON.stringify(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
168
|
+
const old = JSON.stringify(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
169
|
+
const re = new RegExp(`(${key}\\s*:\\s*)${old}`)
|
|
170
|
+
if (!re.test(text)) continue
|
|
171
|
+
text = text.replace(re, (_, lead: string) => `${lead}${JSON.stringify(next)}`)
|
|
172
|
+
changed.push(`${name}: ${value} → ${next}`)
|
|
173
|
+
}
|
|
174
|
+
if (!dry && changed.length > 0) await Bun.write(file, text)
|
|
175
|
+
return changed
|
|
176
|
+
}
|
package/src/migrate.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
// `vx-migrate [--from turbo|nx] [--
|
|
2
|
-
// vx.config.ts per workspace package from an
|
|
3
|
-
// setup
|
|
1
|
+
// `vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry]
|
|
2
|
+
// [--force] [--mjs]` — one vx.config.ts per workspace package from an
|
|
3
|
+
// existing Turbo or Nx setup (`--keep`: the workspace file that reads it
|
|
4
|
+
// live instead, as `vx init` writes it), vx installed with the repo's
|
|
5
|
+
// manager, and the root scripts that called the runner pointed at vx.
|
|
6
|
+
// Without either mode flag a terminal is asked; anything else is native. Source auto-detect: turbo.json → Turbo;
|
|
4
7
|
// .nx/workspace-data/project-graph.json or nx.json → Nx (the resolved graph, exported by nx if absent);
|
|
5
8
|
// The mappers return a plan; core's migration seam
|
|
6
9
|
// (`applyMigration`) renders, guards, writes and reports, so what this
|
|
7
10
|
// package writes reads exactly like what `vx init` writes.
|
|
8
11
|
|
|
12
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
9
13
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
10
14
|
import os from 'node:os'
|
|
11
15
|
import path from 'node:path'
|
|
@@ -21,6 +25,14 @@ import {
|
|
|
21
25
|
} from '@vzn/vx'
|
|
22
26
|
import { migrateNx, NX_GRAPH_REL } from './migrate-nx.js'
|
|
23
27
|
import { exportGraph } from './nx/export-graph.js'
|
|
28
|
+
import {
|
|
29
|
+
type AdoptionMode,
|
|
30
|
+
install,
|
|
31
|
+
missingPackages,
|
|
32
|
+
MODE_QUESTION,
|
|
33
|
+
parseModeAnswer,
|
|
34
|
+
rewriteRootScripts,
|
|
35
|
+
} from './adopt.js'
|
|
24
36
|
import { migrateTurbo } from './migrate-turbo.js'
|
|
25
37
|
import { turboConfigFile } from './turbo/turbo-map.js'
|
|
26
38
|
|
|
@@ -30,12 +42,17 @@ export interface MigrateArgs {
|
|
|
30
42
|
/** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
|
|
31
43
|
mjs: boolean
|
|
32
44
|
from?: 'turbo' | 'nx'
|
|
45
|
+
/** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
|
|
46
|
+
mode?: AdoptionMode
|
|
47
|
+
/** `--no-install`: leave package.json's dependencies alone. */
|
|
48
|
+
noInstall?: boolean
|
|
33
49
|
/** `--help` / `-h`: the usage on stdout, exit 0 (as `nx-env --help`). */
|
|
34
50
|
help?: boolean
|
|
35
51
|
error?: string
|
|
36
52
|
}
|
|
37
53
|
|
|
38
|
-
const USAGE =
|
|
54
|
+
const USAGE =
|
|
55
|
+
'usage: vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry] [--force] [--mjs]'
|
|
39
56
|
|
|
40
57
|
export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
41
58
|
const out: MigrateArgs = { dry: false, force: false, mjs: false }
|
|
@@ -44,7 +61,14 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
|
44
61
|
if (a === '--dry') out.dry = true
|
|
45
62
|
else if (a === '--force') out.force = true
|
|
46
63
|
else if (a === '--mjs') out.mjs = true
|
|
47
|
-
else if (a === '--
|
|
64
|
+
else if (a === '--no-install') out.noInstall = true
|
|
65
|
+
else if (a === '--native' || a === '--keep') {
|
|
66
|
+
const mode = a === '--native' ? 'native' : 'keep'
|
|
67
|
+
if (out.mode !== undefined && out.mode !== mode) {
|
|
68
|
+
return { ...out, error: '--native and --keep are two answers to one question; pass one' }
|
|
69
|
+
}
|
|
70
|
+
out.mode = mode
|
|
71
|
+
} else if (a === '--from' || a?.startsWith('--from=')) {
|
|
48
72
|
const v = a === '--from' ? args[++i] : a.slice('--from='.length)
|
|
49
73
|
if (v !== 'turbo' && v !== 'nx') {
|
|
50
74
|
return {
|
|
@@ -116,14 +140,25 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
116
140
|
throw new UserError('--from turbo, but no turbo.json at the workspace root')
|
|
117
141
|
}
|
|
118
142
|
|
|
143
|
+
const runner: 'turbo' | 'nx' | undefined =
|
|
144
|
+
parsed.from ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson ? 'nx' : undefined)
|
|
145
|
+
if (runner === undefined) {
|
|
146
|
+
throw new UserError(
|
|
147
|
+
'nothing to migrate: no turbo.json and no Nx workspace — ' +
|
|
148
|
+
'for package.json scripts, run `vx init`',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const mode = parsed.mode ?? (await askMode(runner, parsed.dry))
|
|
152
|
+
if (mode === 'keep') return keep(root, runner, hasTurbo, parsed)
|
|
153
|
+
|
|
119
154
|
const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
|
|
120
155
|
let source: string
|
|
121
156
|
let plan: MigrationPlan
|
|
122
|
-
if (
|
|
157
|
+
if (runner === 'nx') {
|
|
123
158
|
if (hasGraph) {
|
|
124
159
|
source = NX_GRAPH_REL
|
|
125
160
|
plan = await migrateNx(root, metas, format)
|
|
126
|
-
} else
|
|
161
|
+
} else {
|
|
127
162
|
// Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
|
|
128
163
|
// when exported. The workspace's own nx exports it, as `nx()` does, into
|
|
129
164
|
// a temp file; the user ran that step by hand until 2026-10-01.
|
|
@@ -142,24 +177,99 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
142
177
|
} finally {
|
|
143
178
|
await rm(tmp, { recursive: true, force: true })
|
|
144
179
|
}
|
|
145
|
-
} else {
|
|
146
|
-
throw new UserError(
|
|
147
|
-
'nothing to migrate: no turbo.json and no Nx workspace — ' +
|
|
148
|
-
'for package.json scripts, run `vx init`',
|
|
149
|
-
)
|
|
150
180
|
}
|
|
151
181
|
} else {
|
|
152
182
|
source = path.basename(turboFile ?? 'turbo.json')
|
|
153
183
|
plan = await migrateTurbo(root, metas, format)
|
|
154
184
|
}
|
|
185
|
+
// Before the write, so the report's `next:` line runs the installed vx;
|
|
186
|
+
// not when the write will be refused for a config already there.
|
|
187
|
+
const configName = `vx.config.${format}`
|
|
188
|
+
const refused =
|
|
189
|
+
!parsed.force &&
|
|
190
|
+
(plan.projects.some(
|
|
191
|
+
(p) =>
|
|
192
|
+
p.tasks.length > 0 &&
|
|
193
|
+
(metas.some((m) => m.dir === p.dir && m.configPath) ||
|
|
194
|
+
existsSync(path.join(p.dir, configName))),
|
|
195
|
+
) ||
|
|
196
|
+
plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
|
|
197
|
+
const headerNotes = refused ? [] : await prepareRepo(root, 'native', parsed)
|
|
155
198
|
return applyMigration({
|
|
156
199
|
root,
|
|
157
200
|
metas,
|
|
158
201
|
plan,
|
|
159
202
|
source,
|
|
160
203
|
verb: 'vx-migrate',
|
|
204
|
+
notes: headerNotes,
|
|
161
205
|
dry: parsed.dry,
|
|
162
206
|
force: parsed.force,
|
|
163
207
|
format,
|
|
164
208
|
})
|
|
165
209
|
}
|
|
210
|
+
|
|
211
|
+
/** A terminal is asked which adoption it wants; anything else gets native. */
|
|
212
|
+
async function askMode(runner: 'turbo' | 'nx', dry: boolean): Promise<AdoptionMode> {
|
|
213
|
+
if (dry || !process.stdin.isTTY || !process.stdout.isTTY) return 'native'
|
|
214
|
+
const source = runner === 'turbo' ? 'turbo.json' : 'nx.json'
|
|
215
|
+
for (;;) {
|
|
216
|
+
const mode = parseModeAnswer(prompt(MODE_QUESTION(runner, source)))
|
|
217
|
+
if (mode !== undefined) return mode
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Install what the written files import and point the root scripts at vx.
|
|
223
|
+
* Returns the report's lines for both; does neither under `--dry` (the
|
|
224
|
+
* script lines say what would change).
|
|
225
|
+
*/
|
|
226
|
+
async function prepareRepo(root: string, mode: AdoptionMode, args: MigrateArgs): Promise<string[]> {
|
|
227
|
+
const notes: string[] = []
|
|
228
|
+
const missing = args.noInstall === true ? [] : missingPackages(root, mode)
|
|
229
|
+
if (missing.length > 0) {
|
|
230
|
+
if (args.dry) notes.push(`would install ${missing.join(' ')} (dry run)`)
|
|
231
|
+
else notes.push(`installed ${missing.join(' ')} (${await install(root, missing)})`)
|
|
232
|
+
}
|
|
233
|
+
const scripts = await rewriteRootScripts(root, args.dry)
|
|
234
|
+
if (scripts.length > 0) {
|
|
235
|
+
notes.push(
|
|
236
|
+
`${args.dry ? 'would point' : 'pointed'} root package.json scripts at vx: ${scripts.join('; ')}`,
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
return notes
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `--keep`: the workspace file `vx init` writes, declaring `turbo()` or
|
|
244
|
+
* `nx()`, so the runner's own config stays the source. `vx init` is the
|
|
245
|
+
* one writer of that file; this runs the installed one.
|
|
246
|
+
*/
|
|
247
|
+
async function keep(
|
|
248
|
+
root: string,
|
|
249
|
+
runner: 'turbo' | 'nx',
|
|
250
|
+
hasTurbo: boolean,
|
|
251
|
+
args: MigrateArgs,
|
|
252
|
+
): Promise<number> {
|
|
253
|
+
// `vx init` reads turbo.json first.
|
|
254
|
+
if (runner === 'nx' && hasTurbo) {
|
|
255
|
+
throw new UserError(
|
|
256
|
+
'--keep with --from nx: turbo.json is here too, and vx init adopts it first',
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
for (const note of await prepareRepo(root, 'keep', args)) {
|
|
260
|
+
process.stdout.write(`vx-migrate: ${note}\n`)
|
|
261
|
+
}
|
|
262
|
+
// A resolve that misses from a directory with no node_modules is an
|
|
263
|
+
// auto-install under Bun: ask the root only where it has the package.
|
|
264
|
+
const installed = path.join(root, 'node_modules', '@vzn', 'vx')
|
|
265
|
+
const core = existsSync(path.join(installed, 'package.json'))
|
|
266
|
+
? path.join(realpathSync(installed), 'src', 'index.ts')
|
|
267
|
+
: Bun.resolveSync('@vzn/vx', import.meta.dir)
|
|
268
|
+
const flags = [args.dry && '--dry', args.force && '--force', args.mjs && '--mjs'].filter(
|
|
269
|
+
(f): f is string => typeof f === 'string',
|
|
270
|
+
)
|
|
271
|
+
return Bun.spawn(
|
|
272
|
+
[process.execPath, '--no-install', path.join(path.dirname(core), 'bin.ts'), 'init', ...flags],
|
|
273
|
+
{ cwd: root, stdio: ['inherit', 'inherit', 'inherit'] },
|
|
274
|
+
).exited
|
|
275
|
+
}
|
package/src/turbo/turbo-map.ts
CHANGED
|
@@ -1259,6 +1259,11 @@ export async function mapTurboWorkspace(
|
|
|
1259
1259
|
if (override === null) continue
|
|
1260
1260
|
const script = scripts[name]
|
|
1261
1261
|
const noop = keyOnly.get(meta.name)?.has(name) === true
|
|
1262
|
+
// Core gives a project with no `build` this very node (a group behind
|
|
1263
|
+
// `^build`, keyed on the project's files), so writing it is noise:
|
|
1264
|
+
// solid's three script-less packages each got a `build` running `true`.
|
|
1265
|
+
if (noop && name === 'build' && (defFor(name)!.dependsOn ?? []).every((d) => d === '^build'))
|
|
1266
|
+
continue
|
|
1262
1267
|
if (
|
|
1263
1268
|
override === undefined &&
|
|
1264
1269
|
script === undefined &&
|