@vzn/vx-migrate 0.0.522 → 0.0.524

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 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
- - 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.
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. The report names the `@vzn/vx-lockfile` plugin for the repo's lockfile when `vx.workspace.ts` declares none: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims 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.522",
3
+ "version": "0.0.524",
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.522"
42
+ "@vzn/vx": "^0.0.524"
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
- /** The packages a mode's files import that the root neither lists nor has installed. */
89
- export function missingPackages(root: string, mode: AdoptionMode): string[] {
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
- const wanted = mode === 'keep' ? ['@vzn/vx', '@vzn/vx-migrate'] : ['@vzn/vx']
97
- return wanted.filter(
98
- (p) => !listed(p) && !existsSync(path.join(root, 'node_modules', p, 'package.json')),
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(root: string, packages: readonly string[]): Promise<string> {
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`)
@@ -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
- // The same name the configs carry (`verb` in index.ts): `vx migrate` is
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
- const headerNotes = refused ? [] : await prepareRepo(root, 'native', parsed)
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(root: string, mode: AdoptionMode, args: MigrateArgs): Promise<string[]> {
247
+ async function prepareRepo(
248
+ root: string,
249
+ wanted: readonly string[],
250
+ args: MigrateArgs,
251
+ ): Promise<string[]> {
225
252
  const notes: string[] = []
226
- const missing = args.noInstall === true ? [] : missingPackages(root, mode)
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
- for (const note of await prepareRepo(root, 'keep', args)) {
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
- return Bun.spawn(
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
  }
@@ -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 = LOCKFILES.find(([file]) => existsSync(path.join(root, file)))
47
- if (lock !== undefined && !declared.includes('@vzn/vx-lockfile'))
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[0])}; vx keys every task on the whole ` +
50
- `file, so a dependency bump re-runs them all. Declare ${lock[1]}() from @vzn/vx-lockfile ` +
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
+ }