@vzn/vx-migrate 0.0.521 → 0.0.523
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 +3 -2
- package/package.json +2 -2
- package/src/adopt.ts +40 -9
- package/src/migrate-turbo.ts +1 -4
- package/src/migrate.ts +58 -7
- package/src/workspace-notes.ts +10 -4
- package/src/workspace-plugins.ts +89 -0
package/README.md
CHANGED
|
@@ -212,7 +212,8 @@ bunx @vzn/vx-migrate --help # the usage, exit 0
|
|
|
212
212
|
It is the one command a repo needs (`pnpx @vzn/vx-migrate` in a pnpm repo works the same):
|
|
213
213
|
|
|
214
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
|
-
-
|
|
215
|
+
- With no `vx.workspace.*` yet, it writes one declaring the plugins the repo calls for (`src/workspace-plugins.ts`): the `@vzn/vx-lockfile` factory for the lockfile (`pnpm()`, `bun()`, `npm()`, `yarn()`), `scheduleHistoryPlugin()`, and `github()` when `.github/workflows` exists, from `@vzn/vx-ci`, each installed beside `@vzn/vx` at vx-migrate's own version. Keep adds them to the file `vx init` writes, or to one already in that shape; any other workspace file is the user's and left alone.
|
|
216
|
+
- 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 (the declared plugins are built into the vx binary, so none is installed) (`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 the root both lists and has installed at vx-migrate's own version is left alone; any other is installed at that version. `--no-install` skips it.
|
|
216
217
|
|
|
217
218
|
Your `package.json` scripts are never edited. `--dry` installs nothing and says what it would install.
|
|
218
219
|
|
|
@@ -222,7 +223,7 @@ Your `package.json` scripts are never edited. `--dry` installs nothing and says
|
|
|
222
223
|
|
|
223
224
|
### Turbo
|
|
224
225
|
|
|
225
|
-
Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package `turbo.json` `extends` overlays and each package's scripts, through the same mapper `turbo()` runs live — so the written configs say what the plugin was doing. Turbo's global fields become a generated root `vx-preset.ts` each config imports and spreads: TypeScript composition replaces global config. So does a task's `env` that several configs share (`buildEnv`), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo's `//#` root tasks are written to a `vx.config.ts` at the workspace root, which makes the root a project (core's D-39), and a package task's `//#x` edge reaches it.
|
|
226
|
+
Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package `turbo.json` `extends` overlays and each package's scripts, through the same mapper `turbo()` runs live — so the written configs say what the plugin was doing. Turbo's global fields become a generated root `vx-preset.ts` each config imports and spreads: TypeScript composition replaces global config. So does a task's `env` that several configs share (`buildEnv`), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo's `//#` root tasks are written to a `vx.config.ts` at the workspace root, which makes the root a project (core's D-39), and a package task's `//#x` edge reaches it. When a `vx.workspace.ts` already exists without one, the report names the `@vzn/vx-lockfile` plugin for the repo's lockfile: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.
|
|
226
227
|
|
|
227
228
|
| Turborepo | vx |
|
|
228
229
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------- |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.523",
|
|
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.523"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/adopt.ts
CHANGED
|
@@ -85,26 +85,57 @@ export function installArgv(
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
88
|
+
/**
|
|
89
|
+
* This package's release version; null in the source tree (`0.0.0`), where
|
|
90
|
+
* nothing is pinned. Every @vzn package releases in lockstep, and each
|
|
91
|
+
* plugin's peer is `^<version>`, which on 0.0.x is that version alone.
|
|
92
|
+
*/
|
|
93
|
+
export function ownVersion(): string | null {
|
|
94
|
+
const v = (
|
|
95
|
+
JSON.parse(readText(path.join(import.meta.dir, '..', 'package.json')) || '{}') as {
|
|
96
|
+
version?: unknown
|
|
97
|
+
}
|
|
98
|
+
).version
|
|
99
|
+
return typeof v === 'string' && v !== '0.0.0' ? v : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The `wanted` packages the root does not both list and have installed
|
|
104
|
+
* (at `version`, given one). Either alone is not adopted: a reset
|
|
105
|
+
* package.json leaves node_modules behind, and a listing is not an
|
|
106
|
+
* install; on solidjs/solid vx-migrate 0.0.512 skipped @vzn/vx that
|
|
107
|
+
* package.json did not list (owner, 2026-10-06).
|
|
108
|
+
*/
|
|
109
|
+
export function missingPackages(
|
|
110
|
+
root: string,
|
|
111
|
+
wanted: readonly string[],
|
|
112
|
+
version: string | null = null,
|
|
113
|
+
): string[] {
|
|
90
114
|
const pj = JSON.parse(readText(path.join(root, 'package.json')) || '{}') as Record<
|
|
91
115
|
string,
|
|
92
116
|
Record<string, unknown> | undefined
|
|
93
117
|
>
|
|
94
118
|
const listed = (name: string) =>
|
|
95
119
|
['dependencies', 'devDependencies'].some((f) => pj[f]?.[name] !== undefined)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
return wanted.filter((p) => {
|
|
121
|
+
if (!listed(p)) return true
|
|
122
|
+
const installed = readText(path.join(root, 'node_modules', p, 'package.json'))
|
|
123
|
+
if (installed === '') return true
|
|
124
|
+
if (version === null) return false
|
|
125
|
+
return (JSON.parse(installed) as { version?: unknown }).version !== version
|
|
126
|
+
})
|
|
100
127
|
}
|
|
101
128
|
|
|
102
|
-
/** Install `packages` with the repo's manager, its output on the terminal. */
|
|
103
|
-
export async function install(
|
|
129
|
+
/** Install `packages` (at `version`, given one) with the repo's manager, its output on the terminal. */
|
|
130
|
+
export async function install(
|
|
131
|
+
root: string,
|
|
132
|
+
packages: readonly string[],
|
|
133
|
+
version: string | null = null,
|
|
134
|
+
): Promise<string> {
|
|
104
135
|
const argv = installArgv(
|
|
105
136
|
root,
|
|
106
137
|
packageManagerOf(root, process.env['npm_config_user_agent']),
|
|
107
|
-
packages,
|
|
138
|
+
version === null ? packages : packages.map((p) => `${p}@${version}`),
|
|
108
139
|
)
|
|
109
140
|
const line = argv.join(' ')
|
|
110
141
|
process.stdout.write(`vx-migrate: ${line}\n`)
|
package/src/migrate-turbo.ts
CHANGED
|
@@ -350,10 +350,7 @@ function renderPreset(
|
|
|
350
350
|
// would otherwise splice into a malformed, unloadable `vx-preset.ts`.
|
|
351
351
|
const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
|
|
352
352
|
const lines = [
|
|
353
|
-
//
|
|
354
|
-
// no verb, and a reader who typed it got `unknown command`.
|
|
355
|
-
'// Generated by `vx-migrate` from turbo.json. TypeScript composition',
|
|
356
|
-
"// replaces turbo's global fields: each vx.config.ts imports these",
|
|
353
|
+
"// TypeScript composition replaces turbo's global fields: each vx.config.ts imports these",
|
|
357
354
|
'// arrays and spreads them into the matching task fields.',
|
|
358
355
|
]
|
|
359
356
|
if (inputs.length > 0) {
|
package/src/migrate.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// (`applyMigration`) renders, guards, writes and reports, so what this
|
|
10
10
|
// package writes reads exactly like what `vx init` writes.
|
|
11
11
|
|
|
12
|
-
import { existsSync, realpathSync } from 'node:fs'
|
|
12
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
13
13
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
@@ -30,10 +30,18 @@ import {
|
|
|
30
30
|
install,
|
|
31
31
|
missingPackages,
|
|
32
32
|
MODE_QUESTION,
|
|
33
|
+
ownVersion,
|
|
33
34
|
parseModeAnswer,
|
|
34
35
|
} from './adopt.js'
|
|
35
36
|
import { migrateTurbo } from './migrate-turbo.js'
|
|
36
37
|
import { turboConfigFile } from './turbo/turbo-map.js'
|
|
38
|
+
import {
|
|
39
|
+
extendWorkspaceFile,
|
|
40
|
+
undeclared,
|
|
41
|
+
renderWorkspaceFile,
|
|
42
|
+
workspaceFileAt,
|
|
43
|
+
workspacePlugins,
|
|
44
|
+
} from './workspace-plugins.js'
|
|
37
45
|
|
|
38
46
|
export interface MigrateArgs {
|
|
39
47
|
dry: boolean
|
|
@@ -193,7 +201,22 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
193
201
|
existsSync(path.join(p.dir, configName))),
|
|
194
202
|
) ||
|
|
195
203
|
plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
|
|
196
|
-
|
|
204
|
+
// No workspace file yet: write one declaring the plugins the repo calls
|
|
205
|
+
// for, and drop the note that told the user to declare the lockfile one.
|
|
206
|
+
const plugins = workspaceFileAt(root) === undefined ? workspacePlugins(root) : []
|
|
207
|
+
if (plugins.length > 0) {
|
|
208
|
+
plan = {
|
|
209
|
+
...plan,
|
|
210
|
+
headerNotes: plan.headerNotes.filter((n) => !n.includes('from @vzn/vx-lockfile')),
|
|
211
|
+
extraFiles: [
|
|
212
|
+
...plan.extraFiles,
|
|
213
|
+
{ relPath: `vx.workspace.${format}`, contents: renderWorkspaceFile(plugins, format) },
|
|
214
|
+
],
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const headerNotes = refused
|
|
218
|
+
? []
|
|
219
|
+
: await prepareRepo(root, ['@vzn/vx', ...plugins.map((p) => p.pkg)], parsed)
|
|
197
220
|
return applyMigration({
|
|
198
221
|
root,
|
|
199
222
|
metas,
|
|
@@ -221,12 +244,17 @@ async function askMode(runner: 'turbo' | 'nx', dry: boolean): Promise<AdoptionMo
|
|
|
221
244
|
* Install what the written files import. Returns the report's line;
|
|
222
245
|
* installs nothing under `--dry`.
|
|
223
246
|
*/
|
|
224
|
-
async function prepareRepo(
|
|
247
|
+
async function prepareRepo(
|
|
248
|
+
root: string,
|
|
249
|
+
wanted: readonly string[],
|
|
250
|
+
args: MigrateArgs,
|
|
251
|
+
): Promise<string[]> {
|
|
225
252
|
const notes: string[] = []
|
|
226
|
-
const
|
|
253
|
+
const version = ownVersion()
|
|
254
|
+
const missing = args.noInstall === true ? [] : missingPackages(root, wanted, version)
|
|
227
255
|
if (missing.length > 0) {
|
|
228
256
|
if (args.dry) notes.push(`would install ${missing.join(' ')} (dry run)`)
|
|
229
|
-
else notes.push(`installed ${missing.join(' ')} (${await install(root, missing)})`)
|
|
257
|
+
else notes.push(`installed ${missing.join(' ')} (${await install(root, missing, version)})`)
|
|
230
258
|
}
|
|
231
259
|
return notes
|
|
232
260
|
}
|
|
@@ -248,7 +276,19 @@ async function keep(
|
|
|
248
276
|
'--keep with --from nx: turbo.json is here too, and vx init adopts it first',
|
|
249
277
|
)
|
|
250
278
|
}
|
|
251
|
-
|
|
279
|
+
// The plugins go into the file `vx init` writes, or into one already
|
|
280
|
+
// here in that shape; another shape is the user's, and left alone.
|
|
281
|
+
const existing = workspaceFileAt(root)
|
|
282
|
+
const plugins =
|
|
283
|
+
existing === undefined ||
|
|
284
|
+
extendWorkspaceFile(readFileSync(path.join(root, existing), 'utf8'), []) !== null
|
|
285
|
+
? workspacePlugins(root)
|
|
286
|
+
: []
|
|
287
|
+
for (const note of await prepareRepo(
|
|
288
|
+
root,
|
|
289
|
+
['@vzn/vx', '@vzn/vx-migrate', ...plugins.map((p) => p.pkg)],
|
|
290
|
+
args,
|
|
291
|
+
)) {
|
|
252
292
|
process.stdout.write(`vx-migrate: ${note}\n`)
|
|
253
293
|
}
|
|
254
294
|
// A resolve that misses from a directory with no node_modules is an
|
|
@@ -260,8 +300,19 @@ async function keep(
|
|
|
260
300
|
const flags = [args.dry && '--dry', args.force && '--force', args.mjs && '--mjs'].filter(
|
|
261
301
|
(f): f is string => typeof f === 'string',
|
|
262
302
|
)
|
|
263
|
-
|
|
303
|
+
const code = await Bun.spawn(
|
|
264
304
|
[process.execPath, '--no-install', path.join(path.dirname(core), 'bin.ts'), 'init', ...flags],
|
|
265
305
|
{ cwd: root, stdio: ['inherit', 'inherit', 'inherit'] },
|
|
266
306
|
).exited
|
|
307
|
+
const written = workspaceFileAt(root)
|
|
308
|
+
if (code !== 0 || args.dry || written === undefined || plugins.length === 0) return code
|
|
309
|
+
const file = path.join(root, written)
|
|
310
|
+
const text = readFileSync(file, 'utf8')
|
|
311
|
+
const extended = extendWorkspaceFile(text, plugins)
|
|
312
|
+
if (extended !== null && extended !== text) {
|
|
313
|
+
await Bun.write(file, extended)
|
|
314
|
+
const names = undeclared(text, plugins).map((p) => `${p.factory}()`)
|
|
315
|
+
process.stdout.write(`vx-migrate: declared ${names.join(', ')} in ${written}\n`)
|
|
316
|
+
}
|
|
317
|
+
return code
|
|
267
318
|
}
|
package/src/workspace-notes.ts
CHANGED
|
@@ -18,6 +18,12 @@ const LOCKFILES: ReadonlyArray<readonly [file: string, plugin: string]> = [
|
|
|
18
18
|
['yarn.lock', 'yarn'],
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
+
/** The lockfile at `root` and the `@vzn/vx-lockfile` factory that claims it. */
|
|
22
|
+
export function lockfilePlugin(root: string): { file: string; factory: string } | undefined {
|
|
23
|
+
const lock = LOCKFILES.find(([file]) => existsSync(path.join(root, file)))
|
|
24
|
+
return lock === undefined ? undefined : { file: lock[0], factory: lock[1] }
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export interface AdoptedTool {
|
|
22
28
|
/** The adoption plugin `vx init` declares: `turbo`, `nx`. */
|
|
23
29
|
readonly plugin: string
|
|
@@ -43,11 +49,11 @@ export async function adoptedToolNotes(root: string, tool: AdoptedTool): Promise
|
|
|
43
49
|
)
|
|
44
50
|
break
|
|
45
51
|
}
|
|
46
|
-
const lock =
|
|
47
|
-
if (lock !== undefined && !
|
|
52
|
+
const lock = lockfilePlugin(root)
|
|
53
|
+
if (lock !== undefined && !new RegExp(`\\b${lock.factory}\\s*\\(`).test(declared))
|
|
48
54
|
notes.push(
|
|
49
|
-
`${tool.keys(lock
|
|
50
|
-
`file, so a dependency bump re-runs them all. Declare ${lock
|
|
55
|
+
`${tool.keys(lock.file)}; vx keys every task on the whole ` +
|
|
56
|
+
`file, so a dependency bump re-runs them all. Declare ${lock.factory}() from @vzn/vx-lockfile ` +
|
|
51
57
|
"in vx.workspace.ts to key each task on its package's dependency closure",
|
|
52
58
|
)
|
|
53
59
|
return notes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The plugins a migrated workspace declares, from what the repo shows
|
|
2
|
+
// (owner, 2026-10-06: "install and migrate fully … use the plugins"). Each
|
|
3
|
+
// is inert where it does not apply, so declaring one costs a run nothing,
|
|
4
|
+
// and each is installed beside vx at vx-migrate's own version (plugins
|
|
5
|
+
// pin their core peer to the release they shipped with).
|
|
6
|
+
|
|
7
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { lockfilePlugin } from './workspace-notes.js'
|
|
10
|
+
|
|
11
|
+
export interface WorkspacePlugin {
|
|
12
|
+
readonly pkg: string
|
|
13
|
+
readonly factory: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workspacePlugins(root: string): WorkspacePlugin[] {
|
|
17
|
+
const out: WorkspacePlugin[] = []
|
|
18
|
+
const lock = lockfilePlugin(root)
|
|
19
|
+
if (lock !== undefined) out.push({ pkg: '@vzn/vx-lockfile', factory: lock.factory })
|
|
20
|
+
out.push({ pkg: '@vzn/vx-schedule-history', factory: 'scheduleHistoryPlugin' })
|
|
21
|
+
if (existsSync(path.join(root, '.github', 'workflows')))
|
|
22
|
+
out.push({ pkg: '@vzn/vx-ci', factory: 'github' })
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The workspace file at `root`, in any extension the loader reads. */
|
|
27
|
+
export function workspaceFileAt(root: string): string | undefined {
|
|
28
|
+
return readdirSync(root).find((n) => /^vx\.workspace\.(ts|mts|js|mjs|cts|cjs)$/.test(n))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `plugins` that `text` does not already call. */
|
|
32
|
+
export function undeclared(text: string, plugins: readonly WorkspacePlugin[]): WorkspacePlugin[] {
|
|
33
|
+
return plugins.filter((p) => !new RegExp(`\\b${p.factory}\\s*\\(`).test(text))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function importLines(plugins: readonly WorkspacePlugin[]): string[] {
|
|
37
|
+
return plugins.map((p) => `import { ${p.factory} } from '${p.pkg}'`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function entries(plugins: readonly WorkspacePlugin[], indent: string): string[] {
|
|
41
|
+
return plugins.map((p) => `${indent}${p.factory}(),`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `vx.workspace.<format>` declaring `plugins`. */
|
|
45
|
+
export function renderWorkspaceFile(
|
|
46
|
+
plugins: readonly WorkspacePlugin[],
|
|
47
|
+
format: 'ts' | 'mjs',
|
|
48
|
+
): string {
|
|
49
|
+
return [
|
|
50
|
+
...(format === 'ts' ? ["import type { WorkspaceConfig } from '@vzn/vx/config'"] : []),
|
|
51
|
+
...importLines(plugins),
|
|
52
|
+
'',
|
|
53
|
+
'export default {',
|
|
54
|
+
' plugins: [',
|
|
55
|
+
...entries(plugins, ' '),
|
|
56
|
+
' ],',
|
|
57
|
+
`}${format === 'ts' ? ' satisfies WorkspaceConfig' : ''}`,
|
|
58
|
+
'',
|
|
59
|
+
].join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Add `plugins` to the file `vx init` writes (`export default { plugins:
|
|
64
|
+
* [turbo()] }`); null for any other shape, which is left alone. A plugin
|
|
65
|
+
* the file already calls is not added twice.
|
|
66
|
+
*/
|
|
67
|
+
export function extendWorkspaceFile(
|
|
68
|
+
text: string,
|
|
69
|
+
plugins: readonly WorkspacePlugin[],
|
|
70
|
+
): string | null {
|
|
71
|
+
const m = /^export default \{ plugins: \[([^\]\n]*)\] \}/m.exec(text)
|
|
72
|
+
if (m === null) return null
|
|
73
|
+
const added = undeclared(text, plugins)
|
|
74
|
+
if (added.length === 0) return text
|
|
75
|
+
const head = text.slice(0, m.index).trimEnd()
|
|
76
|
+
const tail = text.slice(m.index + m[0].length)
|
|
77
|
+
const kept = m[1]!.trim()
|
|
78
|
+
return [
|
|
79
|
+
head,
|
|
80
|
+
...importLines(added),
|
|
81
|
+
'',
|
|
82
|
+
'export default {',
|
|
83
|
+
' plugins: [',
|
|
84
|
+
...(kept === '' ? [] : [` ${kept},`]),
|
|
85
|
+
...entries(added, ' '),
|
|
86
|
+
' ],',
|
|
87
|
+
`}${tail}`,
|
|
88
|
+
].join('\n')
|
|
89
|
+
}
|