@vzn/vx-migrate 0.0.636 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/migrate.ts CHANGED
@@ -1,15 +1,16 @@
1
- // `vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry]
1
+ // `vx-migrate [--from turbo|nx|vite-task|scripts] [--native|--keep] [--no-install] [--dry]
2
2
  // [--force] [--mjs]` — one vx.config.ts per workspace package from an
3
3
  // existing Turbo or Nx setup (`--keep`: the workspace file that reads it
4
- // live instead, as `vx init` writes it), and vx installed with the repo's
5
- // manager.
4
+ // live instead), from Vite Task, or from package.json scripts, and vx
5
+ // installed with the repo's manager. `vx init` hands every repo with a
6
+ // package.json here (M, 2026-10-14: core reads no package.json).
6
7
  // Without either mode flag a terminal is asked; anything else is native. Source auto-detect: turbo.json → Turbo;
7
8
  // .nx/workspace-data/project-graph.json, nx.json or a Lerna-on-Nx lerna.json → Nx (the resolved
8
- // graph, exported by nx if absent); `vite-plus` in the root package.json → Vite Task, last:
9
- // vite-plus is a whole toolchain, so beside turbo.json or Nx it is not the task runner.
9
+ // graph, exported by nx if absent); `vite-plus` in the root package.json → Vite Task:
10
+ // vite-plus is a whole toolchain, so beside turbo.json or Nx it is not the task runner;
11
+ // else the package.json scripts.
10
12
  // The mappers return a plan; core's migration seam
11
- // (`applyMigration`) renders, guards, writes and reports, so what this
12
- // package writes reads exactly like what `vx init` writes.
13
+ // (`applyMigration`) renders, guards, writes and reports.
13
14
 
14
15
  import { existsSync, readFileSync, realpathSync } from 'node:fs'
15
16
  import { mkdtemp, rm } from 'node:fs/promises'
@@ -19,22 +20,36 @@ import {
19
20
  applyMigration,
20
21
  findWorkspaceRoot,
21
22
  isUserError,
22
- listProjectMetas,
23
- loadWorkspace,
24
23
  type MigrationFormat,
25
24
  type MigrationPlan,
26
25
  UserError,
27
26
  } from '@vzn/vx'
27
+ import { migrateScripts } from './migrate-scripts.js'
28
+ import type { LoadReads } from './package-fs.js'
29
+ import {
30
+ discoverPackages,
31
+ findManifestRoot,
32
+ loadPackageWorkspace,
33
+ type PackageMeta,
34
+ type PackageWorkspace,
35
+ reachesManifest,
36
+ unreachedHint,
37
+ unreachedPackages,
38
+ } from './packages.js'
39
+ import { relPosix } from './paths.js'
40
+ import { REMOTE_CACHE, remoteCacheNote } from './remote-cache.js'
28
41
  import { migrateNx, NX_GRAPH_REL, nxWorkspaceFields } from './migrate-nx.js'
29
42
  import { exportGraph } from './nx/export-graph.js'
30
43
  import { readNxJson } from './nx/nx-map.js'
31
44
  import {
32
45
  type AdoptionMode,
33
46
  install,
47
+ installArgv,
34
48
  listedPackages,
35
49
  missingPackages,
36
50
  MODE_QUESTION,
37
51
  ownVersion,
52
+ type PackageManager,
38
53
  packageManagerOf,
39
54
  parseModeAnswer,
40
55
  uninstall,
@@ -42,6 +57,7 @@ import {
42
57
  import { migrateTurbo } from './migrate-turbo.js'
43
58
  import { migrateViteTask } from './migrate-vite-task.js'
44
59
  import { turboConfigFile } from './turbo/turbo-map.js'
60
+ import { lockfilePlugin } from './workspace-notes.js'
45
61
  import {
46
62
  extendWorkspaceFile,
47
63
  generatedPlugins,
@@ -57,7 +73,7 @@ export interface MigrateArgs {
57
73
  force: boolean
58
74
  /** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
59
75
  mjs: boolean
60
- from?: 'turbo' | 'nx' | 'vite-task'
76
+ from?: 'turbo' | 'nx' | 'vite-task' | 'scripts'
61
77
  /** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
62
78
  mode?: AdoptionMode
63
79
  /** `--no-install`: leave package.json's dependencies alone. */
@@ -70,7 +86,7 @@ export interface MigrateArgs {
70
86
  }
71
87
 
72
88
  const USAGE =
73
- 'usage: vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry [--format json]] [--force] [--mjs]'
89
+ 'usage: vx-migrate [--from turbo|nx|vite-task|scripts] [--native|--keep] [--no-install] [--dry [--format json]] [--force] [--mjs]'
74
90
 
75
91
  export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
76
92
  const out: MigrateArgs = { dry: false, force: false, mjs: false }
@@ -94,11 +110,8 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
94
110
  out.mode = mode
95
111
  } else if (a === '--from' || a?.startsWith('--from=')) {
96
112
  const v = a === '--from' ? args[++i] : a.slice('--from='.length)
97
- if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task') {
98
- return {
99
- ...out,
100
- error: `--from must be turbo, nx or vite-task (package.json scripts: \`vx init\`)`,
101
- }
113
+ if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task' && v !== 'scripts') {
114
+ return { ...out, error: '--from must be turbo, nx, vite-task or scripts' }
102
115
  }
103
116
  out.from = v
104
117
  } else if (a === '--help' || a === '-h') return { ...out, help: true }
@@ -134,10 +147,13 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
134
147
  process.stderr.write(`vx-migrate: ${parsed.error}\n`)
135
148
  return 1
136
149
  }
137
- const root = await findWorkspaceRoot(process.cwd()).catch(async (err: unknown) => {
150
+ const reads: LoadReads = new Map()
151
+ const root = await migrationRoot(process.cwd(), reads).catch(async (err: unknown) => {
138
152
  throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
139
153
  })
140
- const metas = await listProjectMetas(await loadWorkspace(root))
154
+ const workspace = await loadPackageWorkspace(root, reads)
155
+ const nameless: string[] = []
156
+ const metas = await discoverPackages(workspace, nameless)
141
157
 
142
158
  const turboFile = await turboConfigFile(root)
143
159
  const hasTurbo = turboFile !== null
@@ -168,7 +184,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
168
184
  throw new UserError('--from turbo, but no turbo.json at the workspace root')
169
185
  }
170
186
 
171
- const runner: 'turbo' | 'nx' | 'vite-task' | undefined =
187
+ const runner: 'turbo' | 'nx' | 'vite-task' | 'scripts' =
172
188
  parsed.from ??
173
189
  (hasTurbo
174
190
  ? 'turbo'
@@ -176,23 +192,38 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
176
192
  ? 'nx'
177
193
  : (await hasVitePlus(root))
178
194
  ? 'vite-task'
179
- : undefined)
180
- if (runner === undefined) {
181
- throw new UserError(
182
- 'nothing to migrate: no turbo.json, no Nx workspace and no vite-plus — ' +
183
- 'for package.json scripts, run `vx init`',
184
- )
185
- }
195
+ : 'scripts')
196
+ if (runner === 'scripts') return scripts(root, workspace, metas, nameless, parsed)
186
197
  if (runner === 'vite-task' && parsed.mode === 'keep') {
187
198
  throw new UserError(
188
199
  '--keep: no plugin runs a Vite Task config live; drop --keep to write vx.config files',
189
200
  )
190
201
  }
202
+ const runnerFile =
203
+ runner === 'turbo' ? path.basename(turboFile ?? 'turbo.json') : lerna ? 'lerna.json' : 'nx.json'
204
+ // A second `vx init` after the native migration refused to overwrite
205
+ // the configs it wrote, exit 1: the first-run walk (2026-10-09). Set up
206
+ // is an answer, with the next step.
207
+ const existing = workspaceFileAt(root)
208
+ if (
209
+ runner !== 'vite-task' &&
210
+ existing !== undefined &&
211
+ !parsed.force &&
212
+ !parsed.dry &&
213
+ parsed.mode === undefined &&
214
+ metas.some((m) => m.configPath !== null) &&
215
+ !readFileSync(path.join(root, existing), 'utf8').includes(`${runner}(`)
216
+ ) {
217
+ process.stdout.write(
218
+ `vx is already set up here (${existing} and the projects' vx.config files); --force regenerates them from ${runnerFile}.\n\nnext: ${adoptionNext(root, runner, runnerFile, ['@vzn/vx'])}\n`,
219
+ )
220
+ return 0
221
+ }
191
222
  const mode =
192
223
  runner === 'vite-task'
193
224
  ? 'native'
194
225
  : (parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry)))
195
- if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, hasTurbo, lerna, parsed)
226
+ if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, runnerFile, parsed)
196
227
 
197
228
  const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
198
229
  // No workspace file yet, or one vx wrote (a `--keep` adoption's): this run
@@ -264,7 +295,12 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
264
295
  plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
265
296
  const kept = (adopted ?? []).filter((p) => !ADOPTION_PLUGINS.has(`${p.pkg}#${p.factory}`))
266
297
  const plugins = writesWorkspace
267
- ? [...kept, ...workspacePlugins(root).filter((p) => !kept.some((k) => k.factory === p.factory))]
298
+ ? [
299
+ ...kept,
300
+ ...workspacePlugins(root, runner === 'vite-task' ? undefined : runner).filter(
301
+ (p) => !kept.some((k) => k.factory === p.factory),
302
+ ),
303
+ ]
268
304
  : []
269
305
  let workspaceText: string | undefined
270
306
  if (writesWorkspace) {
@@ -284,7 +320,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
284
320
  ),
285
321
  extraFiles:
286
322
  existingWorkspace === undefined
287
- ? [...plan.extraFiles, { relPath: `vx.workspace.${format}`, contents: workspaceText }]
323
+ ? [...plan.extraFiles, { relPath: `.vx/workspace.${format}`, contents: workspaceText }]
288
324
  : plan.extraFiles,
289
325
  }
290
326
  }
@@ -298,11 +334,12 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
298
334
  )
299
335
  // Nothing written imports vx-migrate or runs its bins: a `--keep`
300
336
  // adoption's install of it is dropped with turbo() / nx().
301
- const needsSelf =
302
- runsBins ||
303
- (workspaceText ?? readFileSync(path.join(root, existingWorkspace!), 'utf8')).includes(
304
- "'@vzn/vx-migrate'",
305
- )
337
+ const finalWorkspace = workspaceText ?? readFileSync(path.join(root, existingWorkspace!), 'utf8')
338
+ const needsSelf = runsBins || finalWorkspace.includes("'@vzn/vx-migrate'")
339
+ const cacheNote =
340
+ runner === 'vite-task'
341
+ ? undefined
342
+ : remoteCacheNote(root, runner, finalWorkspace.includes(`${REMOTE_CACHE[runner]}(`))
306
343
  const headerNotes = refused
307
344
  ? []
308
345
  : [
@@ -311,10 +348,17 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
311
348
  : []),
312
349
  ...(await prepareRepo(
313
350
  root,
314
- ['@vzn/vx', ...(needsSelf ? ['@vzn/vx-migrate'] : []), ...plugins.map((p) => p.pkg)],
351
+ [
352
+ ...new Set([
353
+ '@vzn/vx',
354
+ ...(needsSelf ? ['@vzn/vx-migrate'] : []),
355
+ ...plugins.map((p) => p.pkg),
356
+ ]),
357
+ ],
315
358
  parsed,
316
359
  needsSelf ? [] : ['@vzn/vx-migrate'],
317
360
  )),
361
+ ...(cacheNote === undefined ? [] : [cacheNote]),
318
362
  ]
319
363
  return applyMigration({
320
364
  root,
@@ -327,9 +371,33 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
327
371
  json: parsed.json === true,
328
372
  force: parsed.force,
329
373
  format,
374
+ // The written workspace file imports plugins `--no-install` left out.
375
+ ...(parsed.noInstall === true ? {} : { lock: lockWorkspace }),
330
376
  })
331
377
  }
332
378
 
379
+ /**
380
+ * Core's `vx lock`, run where the configs were written: the step `vx init`
381
+ * ends with, through the core this repo resolves.
382
+ */
383
+ async function lockWorkspace(root: string): Promise<number> {
384
+ return await Bun.spawn([process.execPath, '--no-install', coreBin(root), 'lock'], {
385
+ cwd: root,
386
+ stdio: ['ignore', 'inherit', 'inherit'],
387
+ }).exited
388
+ }
389
+
390
+ /** The `vx` entry of the core this repo resolves. */
391
+ function coreBin(root: string): string {
392
+ // A resolve that misses from a directory with no node_modules is an
393
+ // auto-install under Bun: ask the root only where it has the package.
394
+ const installed = path.join(root, 'node_modules', '@vzn', 'vx')
395
+ const core = existsSync(path.join(installed, 'package.json'))
396
+ ? path.join(realpathSync(installed), 'src', 'index.ts')
397
+ : Bun.resolveSync('@vzn/vx', import.meta.dir)
398
+ return path.join(path.dirname(core), 'bin.ts')
399
+ }
400
+
333
401
  /** vite-plus (`vp run`) in the root package.json's dependencies. */
334
402
  async function hasVitePlus(root: string): Promise<boolean> {
335
403
  const pkg = (await Bun.file(path.join(root, 'package.json'))
@@ -405,138 +473,307 @@ async function prepareRepo(
405
473
  }
406
474
 
407
475
  /**
408
- * `--keep`: the workspace file `vx init` writes, declaring `turbo()` or
409
- * `nx()`, so the runner's own config stays the source. `vx init` is the
410
- * one writer of that file; this runs the installed one.
476
+ * `--keep`: `.vx/workspace.ts` declaring `turbo()` or `nx()`, so the
477
+ * runner's own config stays the source, beside the plugins the repo calls
478
+ * for (its remote cache among them), and the one command from here to a
479
+ * run. A workspace file already here is kept when it declares the runner.
411
480
  */
412
481
  async function keep(
413
482
  root: string,
414
483
  runner: 'turbo' | 'nx',
415
- hasTurbo: boolean,
416
- lerna: boolean,
484
+ source: string,
417
485
  args: MigrateArgs,
418
486
  ): Promise<number> {
419
- // `vx init` reads turbo.json first.
420
- if (runner === 'nx' && hasTurbo) {
487
+ const format: MigrationFormat = args.mjs ? 'mjs' : 'ts'
488
+ const name = `.vx/workspace.${format}`
489
+ const all = [{ pkg: '@vzn/vx-migrate', factory: runner }, ...workspacePlugins(root, runner)]
490
+ const existing = workspaceFileAt(root)
491
+ const before =
492
+ existing === undefined || args.force
493
+ ? undefined
494
+ : readFileSync(path.join(root, existing), 'utf8')
495
+ if (before !== undefined && !before.includes(`${runner}(`)) {
421
496
  throw new UserError(
422
- '--keep with --from nx: turbo.json is here too, and vx init adopts it first',
497
+ `${existing} exists; add ${runner}() from @vzn/vx-migrate to its plugins, or --force replaces it`,
423
498
  )
424
499
  }
425
- // The plugins go into the file `vx init` writes, or into one already
426
- // here in that shape; another shape is the user's, and left alone.
427
- const existing = workspaceFileAt(root)
428
- const plugins =
429
- existing === undefined ||
430
- extendWorkspaceFile(readFileSync(path.join(root, existing), 'utf8'), []) !== null
431
- ? workspacePlugins(root)
432
- : []
500
+ // A kept file in the one-line shape gains the plugins it lacks; any
501
+ // other shape is the user's, and left alone.
502
+ const extended = before === undefined ? null : extendWorkspaceFile(before, all)
503
+ const text = before === undefined ? renderWorkspaceFile(all, format) : (extended ?? before)
504
+ const declares = before === undefined || extended !== null
433
505
  for (const note of await prepareRepo(
434
506
  root,
435
- ['@vzn/vx', '@vzn/vx-migrate', ...plugins.map((p) => p.pkg)],
507
+ [...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])])],
436
508
  args,
437
509
  )) {
438
510
  ;(args.json ? process.stderr : process.stdout).write(`vx-migrate: ${note}\n`)
439
511
  }
440
- // `vx init` adopts nx() by nx.json, and a Lerna repo may have none: it
441
- // would map the scripts instead, so the file is written here.
442
- if (lerna) return keepLerna(root, plugins, args)
443
- // A resolve that misses from a directory with no node_modules is an
444
- // auto-install under Bun: ask the root only where it has the package.
445
- const installed = path.join(root, 'node_modules', '@vzn', 'vx')
446
- const core = existsSync(path.join(installed, 'package.json'))
447
- ? path.join(realpathSync(installed), 'src', 'index.ts')
448
- : Bun.resolveSync('@vzn/vx', import.meta.dir)
449
- const flags = [
450
- args.dry && '--dry',
451
- args.json && '--format=json',
452
- args.force && '--force',
453
- args.mjs && '--mjs',
454
- ].filter((f): f is string => typeof f === 'string')
455
- const code = await Bun.spawn(
456
- [
457
- process.execPath,
458
- '--no-install',
459
- path.join(path.dirname(core), 'bin.ts'),
460
- 'init',
461
- '--keep',
462
- ...flags,
463
- ],
464
- { cwd: root, stdio: ['inherit', 'inherit', 'inherit'] },
465
- ).exited
466
- const written = workspaceFileAt(root)
467
- if (code !== 0 || args.dry || written === undefined || plugins.length === 0) return code
468
- const file = path.join(root, written)
469
- const text = readFileSync(file, 'utf8')
470
- const extended = extendWorkspaceFile(text, plugins)
471
- if (extended !== null && extended !== text) {
472
- await Bun.write(file, extended)
473
- const names = undeclared(text, plugins).map((p) => `${p.factory}()`)
474
- process.stdout.write(`vx-migrate: declared ${names.join(', ')} in ${written}\n`)
512
+ const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
513
+ if (!args.dry) {
514
+ if (before === undefined) {
515
+ if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
516
+ await Bun.write(path.join(root, name), text)
517
+ } else if (text !== before) await Bun.write(path.join(root, existing!), text)
518
+ // vx's own housekeeping, as a run's first cache open writes it.
519
+ if (ignore.length > 0) await Bun.write(path.join(root, '.vx', '.gitignore'), DOT_VX_GITIGNORE)
475
520
  }
521
+ const cache = REMOTE_CACHE[runner]
522
+ const cacheLine =
523
+ before?.includes(`${cache}(`) === true
524
+ ? undefined
525
+ : remoteCacheNote(root, runner, text.includes(`${cache}(`))
526
+ const added = before === undefined ? [] : undeclared(before, all).map((p) => `${p.factory}()`)
527
+ const wrote =
528
+ before === undefined
529
+ ? args.dry
530
+ ? `would write ${name} (dry run, nothing written).`
531
+ : `wrote ${name}.`
532
+ : text === before
533
+ ? `${existing} already declares ${runner}().`
534
+ : `${args.dry ? 'would declare' : 'declared'} ${added.join(', ')} in ${existing}.`
535
+ const next = adoptionNext(root, runner, source, [
536
+ ...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])]),
537
+ ])
538
+ if (args.json) {
539
+ process.stdout.write(
540
+ `${JSON.stringify({
541
+ dry: true,
542
+ source,
543
+ files: [
544
+ ...(text === before
545
+ ? []
546
+ : [{ path: before === undefined ? name : existing!, contents: text }]),
547
+ ...ignore.map((p) => ({ path: p, contents: DOT_VX_GITIGNORE })),
548
+ ],
549
+ kept: text === before ? [existing] : [],
550
+ replaced:
551
+ before === undefined && existing !== undefined && existing !== name ? [existing] : [],
552
+ todos: [],
553
+ notes: cacheLine === undefined ? [] : [cacheLine],
554
+ next,
555
+ })}\n`,
556
+ )
557
+ return 0
558
+ }
559
+ process.stdout.write(
560
+ `${args.dry && before === undefined ? `── ${name} ──\n${text}\n` : ''}` +
561
+ `vx-migrate: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config.\n` +
562
+ `${wrote}\n${cacheLine === undefined ? '' : `${cacheLine}\n`}\n`,
563
+ )
564
+ // A run reads only the lock; with the plugins installed it is written
565
+ // now, else `next` names `vx lock` after the install.
566
+ const locks = !args.dry && !next.includes(' lock && ')
567
+ const code = locks ? await lockWorkspace(root) : 0
568
+ process.stdout.write(`${locks ? '\n' : ''}next: ${next}\n`)
476
569
  return code
477
570
  }
478
571
 
479
- /** `--keep --dry --format json` in a Lerna repo: core's `schemas/init.json` document. */
480
- function lernaPlan(
481
- files: { path: string; contents: string }[],
482
- kept: string[],
483
- replaced: string[],
484
- ): number {
485
- process.stdout.write(
486
- `${JSON.stringify({ dry: true, source: 'lerna.json', files, kept, replaced, todos: [], notes: [], next: null })}\n`,
572
+ /** `.vx/.gitignore`'s text, as core writes it: the machine-local cache stays out of git. */
573
+ const DOT_VX_GITIGNORE = '/cache\n'
574
+
575
+ // npx and bunx name the package: their bare `vx` is a stranger's npm package.
576
+ const EXEC: Record<PackageManager, string> = {
577
+ pnpm: 'pnpm vx',
578
+ yarn: 'yarn vx',
579
+ bun: 'bunx @vzn/vx',
580
+ npm: 'npx @vzn/vx',
581
+ }
582
+
583
+ /**
584
+ * Install what the workspace file imports, if missing, then run the repo's
585
+ * build. Pinned to this release: every @vzn package releases in lockstep,
586
+ * and `latest` beside an older vx mismatched them (agent trial 2).
587
+ */
588
+ export function adoptionNext(
589
+ root: string,
590
+ runner: 'turbo' | 'nx',
591
+ source: string,
592
+ needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
593
+ version: string | null = ownVersion(),
594
+ ): string {
595
+ const pm = packageManagerOf(root)
596
+ const missing = needs.filter(
597
+ (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
487
598
  )
488
- return 0
599
+ // A global vx (no runner started this one) runs the workspace's plugins as they are.
600
+ const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : EXEC[pm]
601
+ const run = `${vx} run ${firstTask(path.join(root, source), runner)} --all`
602
+ if (missing.length === 0) return run
603
+ const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
604
+ return `${installArgv(root, pm, pinned).join(' ')} && ${vx} lock && ${run}`
489
605
  }
490
606
 
491
- /** `--keep` in a Lerna repo with no nx.json: the workspace file declaring `nx()`. */
492
- async function keepLerna(
607
+ /** `build` when the runner's config names it, else the first task it names. */
608
+ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
609
+ let text: string
610
+ try {
611
+ text = readFileSync(file, 'utf8')
612
+ } catch {
613
+ return 'build'
614
+ }
615
+ if (/"build"\s*:/.test(text)) return 'build'
616
+ const field = runner === 'turbo' ? '(?:tasks|pipeline)' : 'targetDefaults'
617
+ const first = new RegExp(`"${field}"\\s*:\\s*\\{\\s*"([^"]+)"`).exec(text)?.[1]
618
+ return first ?? 'build'
619
+ }
620
+
621
+ /**
622
+ * package.json scripts → one vx.config.ts per package, and the workspace
623
+ * file declaring the lockfile plugin its lockfile calls for: `vx init`'s
624
+ * mapper until core stopped reading package.json (M, 2026-10-14).
625
+ */
626
+ async function scripts(
493
627
  root: string,
494
- plugins: readonly WorkspacePlugin[],
628
+ workspace: PackageWorkspace,
629
+ metas: readonly PackageMeta[],
630
+ nameless: readonly string[],
495
631
  args: MigrateArgs,
496
632
  ): Promise<number> {
497
- const format = args.mjs ? 'mjs' : 'ts'
498
- const name = `vx.workspace.${format}`
499
- const existing = workspaceFileAt(root)
500
- const all = [{ pkg: '@vzn/vx-migrate', factory: 'nx' }, ...plugins]
501
- if (existing !== undefined && !args.force) {
502
- const text = readFileSync(path.join(root, existing), 'utf8')
503
- const extended = extendWorkspaceFile(text, all)
504
- if (extended === null) {
505
- throw new UserError(
506
- `${existing} exists; add nx() from @vzn/vx-migrate to its plugins, or --force replaces it`,
507
- )
508
- }
509
- if (!args.dry && extended !== text) await Bun.write(path.join(root, existing), extended)
510
- const names = undeclared(text, all).map((p) => `${p.factory}()`)
511
- if (args.json) {
512
- return lernaPlan(
513
- extended === text ? [] : [{ path: existing, contents: extended }],
514
- extended === text ? [existing] : [],
515
- [],
516
- )
517
- }
518
- if (names.length > 0)
519
- process.stdout.write(
520
- `vx-migrate: ${args.dry ? 'would declare' : 'declared'} ${names.join(', ')} in ${existing}\n`,
521
- )
522
- return 0
523
- }
524
- const text = renderWorkspaceFile(all, format)
525
- if (args.json) {
526
- return lernaPlan(
527
- [{ path: name, contents: text }],
528
- [],
529
- existing !== undefined && existing !== name ? [existing] : [],
633
+ if (args.mode !== undefined) {
634
+ throw new UserError(
635
+ `--${args.mode} answers a Turbo or Nx repo's question, and no turbo.json or nx.json is at ${relPosix(process.cwd(), root) || '.'}`,
530
636
  )
531
637
  }
532
- if (args.dry) {
533
- process.stdout.write(`── ${name} ──\n${text}\nvx-migrate: would write ${name} (dry run)\n`)
534
- return 0
638
+ const unmapped = namelessNotes(root, nameless)
639
+ // Only a workspace file this run writes declares it; one already here is the user's.
640
+ const lockfile = workspaceFileAt(root) === undefined ? lockfilePlugin(root) : undefined
641
+ const plugins =
642
+ lockfile === undefined ? [] : [{ pkg: '@vzn/vx-lockfile', factory: lockfile.factory }]
643
+ const plan = migrateScripts(
644
+ metas,
645
+ metas.some((m) => m.dir === root) ? undefined : await readRootManifest(root),
646
+ root,
647
+ )
648
+ const empty = plan.projects.length === 0
649
+ // Single-project mode with packages the root's missing `workspaces` never
650
+ // reaches: the scripts exist, the globs do not (item 248).
651
+ const unreached = empty ? await unreachedPackages(workspace) : []
652
+ // Globs that reach no package: "no scripts" was true and named nothing
653
+ // to fix (M-42). Asked of the globs, not of `metas`: a nameless or
654
+ // shared-name member is matched yet absent from `metas` (M-46).
655
+ const globs = workspace.packageGlobs.filter((g) => g !== '.')
656
+ const noMembers =
657
+ empty && globs.length > 0 && !(await reachesManifest(workspace))
658
+ ? [
659
+ `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
660
+ ]
661
+ : []
662
+ const installed = await prepareRepo(
663
+ root,
664
+ plugins.map((p) => p.pkg),
665
+ args,
666
+ )
667
+ return applyMigration({
668
+ root,
669
+ metas,
670
+ plan,
671
+ source: 'package.json scripts',
672
+ verb: 'vx-migrate',
673
+ notes: [
674
+ ...noMembers,
675
+ ...(await unreadWorkspaces(root, workspace.packageGlobs)),
676
+ ...unmapped,
677
+ ...installed,
678
+ ],
679
+ unmapped: unmapped.length > 0,
680
+ ...(unreached.length > 0 ? { emptyReason: unreachedHint(unreached, root) } : {}),
681
+ dry: args.dry,
682
+ json: args.json === true,
683
+ force: args.force,
684
+ init: true,
685
+ format: args.mjs ? 'mjs' : 'ts',
686
+ plugins,
687
+ ...(args.noInstall === true ? {} : { lock: lockWorkspace }),
688
+ })
689
+ }
690
+
691
+ /**
692
+ * A member whose package.json has no `name` is no project, so its scripts
693
+ * map to nothing, and init said nothing of them (D-106).
694
+ */
695
+ function namelessNotes(root: string, dirs: readonly string[]): string[] {
696
+ const withScripts = dirs
697
+ .filter((dir) => {
698
+ const scripts = (
699
+ JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as { scripts?: unknown }
700
+ ).scripts
701
+ return (
702
+ typeof scripts === 'object' &&
703
+ scripts !== null &&
704
+ Object.values(scripts).some((v) => typeof v === 'string' && v !== '')
705
+ )
706
+ })
707
+ .map((dir) => relPosix(root, dir))
708
+ .sort()
709
+ // The root's own manifest is the empty relative path: a nameless
710
+ // single-package repo read `not mapped: — its package.json…` (M-54).
711
+ .map((rel) => (rel === '' ? 'the root' : rel))
712
+ if (withScripts.length === 0) return []
713
+ const shown =
714
+ withScripts.slice(0, 3).join(', ') +
715
+ (withScripts.length > 3 ? `, and ${withScripts.length - 3} more` : '')
716
+ return [
717
+ `not mapped: ${shown} — ${withScripts.length === 1 ? 'its package.json has' : 'their package.json files have'} no "name", and vx names a project by it; give ${withScripts.length === 1 ? 'it one' : 'each one'} and run \`vx init\` again`,
718
+ ]
719
+ }
720
+
721
+ /**
722
+ * `pnpm-workspace.yaml`'s `packages` decides the members, as pnpm reads it,
723
+ * and a root `workspaces` that lists other globs was dropped without a word:
724
+ * a bun or npm repo with a stale yaml lost those members (M-51).
725
+ */
726
+ async function unreadWorkspaces(root: string, globs: readonly string[]): Promise<string[]> {
727
+ if (!existsSync(path.join(root, 'pnpm-workspace.yaml'))) return []
728
+ const ws = (await readRootManifest(root))['workspaces']
729
+ const list = Array.isArray(ws)
730
+ ? ws
731
+ : typeof ws === 'object' &&
732
+ ws !== null &&
733
+ Array.isArray((ws as { packages?: unknown }).packages)
734
+ ? (ws as { packages: unknown[] }).packages
735
+ : undefined
736
+ if (list === undefined) return []
737
+ const pkg = list.filter((g): g is string => typeof g === 'string')
738
+ if ([...pkg].sort().join('\0') === [...globs].sort().join('\0')) return []
739
+ return [
740
+ `pnpm-workspace.yaml's \`packages\` lists the members, as pnpm reads them; package.json's \`workspaces\` (${pkg.map((g) => `"${g}"`).join(', ')}) is not read: if bun, npm or yarn installs this repo, copy those globs into pnpm-workspace.yaml's \`packages\`, or delete that key so package.json decides`,
741
+ ]
742
+ }
743
+
744
+ /** The root package.json as an object, `{}` when unreadable or not one. */
745
+ async function readRootManifest(root: string): Promise<Record<string, unknown>> {
746
+ const pj: unknown = await Bun.file(path.join(root, 'package.json'))
747
+ .json()
748
+ .catch(() => ({}))
749
+ return typeof pj === 'object' && pj !== null && !Array.isArray(pj)
750
+ ? (pj as Record<string, unknown>)
751
+ : {}
752
+ }
753
+
754
+ function readText(file: string): string {
755
+ try {
756
+ return readFileSync(file, 'utf8').replace(/^/, '')
757
+ } catch {
758
+ return ''
759
+ }
760
+ }
761
+
762
+ /**
763
+ * The workspace root, or where this run makes one: the package manager's
764
+ * root. A nested workspace the outer one does not list is its own: the
765
+ * outer .vx/workspace.ts made init report the outer configs and write
766
+ * nothing.
767
+ */
768
+ async function migrationRoot(cwd: string, reads: LoadReads): Promise<string> {
769
+ const marked = await findWorkspaceRoot(cwd).catch(() => undefined)
770
+ if (marked === undefined) return findManifestRoot(cwd, reads)
771
+ const manifest = await findManifestRoot(cwd, reads).catch(() => marked)
772
+ if (manifest.startsWith(`${marked}${path.sep}`)) {
773
+ const nested = await loadPackageWorkspace(manifest, reads).catch(() => undefined)
774
+ if (nested !== undefined && nested.packageGlobs.length > 0) return manifest
535
775
  }
536
- if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
537
- await Bun.write(path.join(root, name), text)
538
- process.stdout.write(`vx-migrate: lerna.json found — wrote ${name} declaring nx()\n`)
539
- return 0
776
+ return marked
540
777
  }
541
778
 
542
779
  /**