@vzn/vx-migrate 0.0.636 → 0.0.638

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,38 @@ import {
19
20
  applyMigration,
20
21
  findWorkspaceRoot,
21
22
  isUserError,
22
- listProjectMetas,
23
- loadWorkspace,
23
+ VERSION,
24
24
  type MigrationFormat,
25
25
  type MigrationPlan,
26
26
  UserError,
27
+ ui,
27
28
  } from '@vzn/vx'
29
+ import { migrateScripts } from './migrate-scripts.js'
30
+ import type { LoadReads } from './package-fs.js'
31
+ import {
32
+ discoverPackages,
33
+ findManifestRoot,
34
+ loadPackageWorkspace,
35
+ type PackageMeta,
36
+ type PackageWorkspace,
37
+ reachesManifest,
38
+ unreachedHint,
39
+ unreachedPackages,
40
+ } from './packages.js'
41
+ import { relPosix } from './paths.js'
42
+ import { REMOTE_CACHE, remoteCacheNote } from './remote-cache.js'
28
43
  import { migrateNx, NX_GRAPH_REL, nxWorkspaceFields } from './migrate-nx.js'
29
- import { exportGraph } from './nx/export-graph.js'
44
+ import { exportGraph, nxBin } from './nx/export-graph.js'
30
45
  import { readNxJson } from './nx/nx-map.js'
31
46
  import {
32
47
  type AdoptionMode,
33
48
  install,
49
+ installArgv,
34
50
  listedPackages,
35
51
  missingPackages,
36
52
  MODE_QUESTION,
37
53
  ownVersion,
54
+ type PackageManager,
38
55
  packageManagerOf,
39
56
  parseModeAnswer,
40
57
  uninstall,
@@ -42,6 +59,7 @@ import {
42
59
  import { migrateTurbo } from './migrate-turbo.js'
43
60
  import { migrateViteTask } from './migrate-vite-task.js'
44
61
  import { turboConfigFile } from './turbo/turbo-map.js'
62
+ import { lockfilePlugin } from './workspace-notes.js'
45
63
  import {
46
64
  extendWorkspaceFile,
47
65
  generatedPlugins,
@@ -57,7 +75,7 @@ export interface MigrateArgs {
57
75
  force: boolean
58
76
  /** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
59
77
  mjs: boolean
60
- from?: 'turbo' | 'nx' | 'vite-task'
78
+ from?: 'turbo' | 'nx' | 'vite-task' | 'scripts'
61
79
  /** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
62
80
  mode?: AdoptionMode
63
81
  /** `--no-install`: leave package.json's dependencies alone. */
@@ -70,7 +88,7 @@ export interface MigrateArgs {
70
88
  }
71
89
 
72
90
  const USAGE =
73
- 'usage: vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry [--format json]] [--force] [--mjs]'
91
+ 'usage: vx-migrate [--from turbo|nx|vite-task|scripts] [--native|--keep] [--no-install] [--dry [--format json]] [--force] [--mjs]'
74
92
 
75
93
  export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
76
94
  const out: MigrateArgs = { dry: false, force: false, mjs: false }
@@ -94,11 +112,8 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
94
112
  out.mode = mode
95
113
  } else if (a === '--from' || a?.startsWith('--from=')) {
96
114
  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
- }
115
+ if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task' && v !== 'scripts') {
116
+ return { ...out, error: '--from must be turbo, nx, vite-task or scripts' }
102
117
  }
103
118
  out.from = v
104
119
  } else if (a === '--help' || a === '-h') return { ...out, help: true }
@@ -134,10 +149,19 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
134
149
  process.stderr.write(`vx-migrate: ${parsed.error}\n`)
135
150
  return 1
136
151
  }
137
- const root = await findWorkspaceRoot(process.cwd()).catch(async (err: unknown) => {
138
- throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
152
+ const reads: LoadReads = new Map()
153
+ // An Nx repo with no package.json (Nx pinned in `.nx/installation`):
154
+ // nothing is installable, so its configs run on core alone.
155
+ let bare = false
156
+ const root = await migrationRoot(process.cwd(), reads).catch(async (err: unknown) => {
157
+ const nx = isUserError(err) ? nxRootWithoutManifest(process.cwd()) : undefined
158
+ if (nx === undefined) throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
159
+ bare = true
160
+ return nx
139
161
  })
140
- const metas = await listProjectMetas(await loadWorkspace(root))
162
+ const workspace = bare ? { root, packageGlobs: [] } : await loadPackageWorkspace(root, reads)
163
+ const nameless: string[] = []
164
+ const metas = bare ? [] : await discoverPackages(workspace, nameless)
141
165
 
142
166
  const turboFile = await turboConfigFile(root)
143
167
  const hasTurbo = turboFile !== null
@@ -168,7 +192,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
168
192
  throw new UserError('--from turbo, but no turbo.json at the workspace root')
169
193
  }
170
194
 
171
- const runner: 'turbo' | 'nx' | 'vite-task' | undefined =
195
+ const runner: 'turbo' | 'nx' | 'vite-task' | 'scripts' =
172
196
  parsed.from ??
173
197
  (hasTurbo
174
198
  ? 'turbo'
@@ -176,23 +200,49 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
176
200
  ? 'nx'
177
201
  : (await hasVitePlus(root))
178
202
  ? 'vite-task'
179
- : undefined)
180
- if (runner === undefined) {
203
+ : 'scripts')
204
+ if (runner === 'scripts') return scripts(root, workspace, metas, nameless, parsed)
205
+ if (runner === 'vite-task' && parsed.mode === 'keep') {
181
206
  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`',
207
+ '--keep: no plugin runs a Vite Task config live; drop --keep to write vx.config files',
184
208
  )
185
209
  }
186
- if (runner === 'vite-task' && parsed.mode === 'keep') {
210
+ const runnerFile =
211
+ runner === 'turbo' ? path.basename(turboFile ?? 'turbo.json') : lerna ? 'lerna.json' : 'nx.json'
212
+ // A second `vx init` after the native migration refused to overwrite
213
+ // the configs it wrote, exit 1: the first-run walk (2026-10-09). Set up
214
+ // is an answer, with the next step.
215
+ const existing = workspaceFileAt(root)
216
+ if (
217
+ runner !== 'vite-task' &&
218
+ existing !== undefined &&
219
+ !parsed.force &&
220
+ !parsed.dry &&
221
+ parsed.mode === undefined &&
222
+ metas.some((m) => m.configPath !== null) &&
223
+ !readFileSync(path.join(root, existing), 'utf8').includes(`${runner}(`)
224
+ ) {
225
+ ui.print(
226
+ 'out',
227
+ ui.report(`vx-migrate ${VERSION}`, [
228
+ ['kept', `${existing} and the projects' vx.config files`],
229
+ ['note', `vx is already set up here; --force regenerates them from ${runnerFile}`],
230
+ null,
231
+ ['next', adoptionNext(root, runner, runnerFile, ['@vzn/vx'])],
232
+ ]),
233
+ )
234
+ return 0
235
+ }
236
+ if (bare && parsed.mode === 'keep') {
187
237
  throw new UserError(
188
- '--keep: no plugin runs a Vite Task config live; drop --keep to write vx.config files',
238
+ '--keep: nx() is a plugin to install, and this Nx repo has no package.json to install it in; drop --keep to write vx.config files',
189
239
  )
190
240
  }
191
241
  const mode =
192
- runner === 'vite-task'
242
+ runner === 'vite-task' || bare
193
243
  ? 'native'
194
244
  : (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)
245
+ if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, runnerFile, parsed)
196
246
 
197
247
  const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
198
248
  // No workspace file yet, or one vx wrote (a `--keep` adoption's): this run
@@ -217,14 +267,14 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
217
267
  } else if (runner === 'nx') {
218
268
  if (hasGraph) {
219
269
  source = NX_GRAPH_REL
220
- plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
270
+ plan = await migrateNx(root, metas, format, undefined, writesWorkspace, bare)
221
271
  } else {
222
272
  // Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
223
273
  // when exported. The workspace's own nx exports it, as `nx()` does, into
224
274
  // a temp file; the user ran that step by hand until 2026-10-01.
225
275
  // A fresh clone: the export's own reason names the plugin's `graph`
226
276
  // option, which the CLI does not take.
227
- if (!existsSync(path.join(root, 'node_modules', '.bin', 'nx'))) {
277
+ if (nxBin(root) === undefined) {
228
278
  throw new UserError(
229
279
  `nx is not installed here (no node_modules/.bin/nx), and vx-migrate reads the graph it exports — ` +
230
280
  `run \`${packageManagerOf(root, process.env['npm_config_user_agent'])} install\`, then vx-migrate again`,
@@ -241,7 +291,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
241
291
  )
242
292
  }
243
293
  source = 'nx graph'
244
- plan = await migrateNx(root, metas, format, snapshot, writesWorkspace)
294
+ plan = await migrateNx(root, metas, format, snapshot, writesWorkspace, bare)
245
295
  } finally {
246
296
  await rm(tmp, { recursive: true, force: true })
247
297
  }
@@ -263,9 +313,15 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
263
313
  ) ||
264
314
  plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
265
315
  const kept = (adopted ?? []).filter((p) => !ADOPTION_PLUGINS.has(`${p.pkg}#${p.factory}`))
266
- const plugins = writesWorkspace
267
- ? [...kept, ...workspacePlugins(root).filter((p) => !kept.some((k) => k.factory === p.factory))]
268
- : []
316
+ const plugins =
317
+ writesWorkspace && !bare
318
+ ? [
319
+ ...kept,
320
+ ...workspacePlugins(root, runner === 'vite-task' ? undefined : runner).filter(
321
+ (p) => !kept.some((k) => k.factory === p.factory),
322
+ ),
323
+ ]
324
+ : []
269
325
  let workspaceText: string | undefined
270
326
  if (writesWorkspace) {
271
327
  const fields =
@@ -284,7 +340,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
284
340
  ),
285
341
  extraFiles:
286
342
  existingWorkspace === undefined
287
- ? [...plan.extraFiles, { relPath: `vx.workspace.${format}`, contents: workspaceText }]
343
+ ? [...plan.extraFiles, { relPath: `.vx/workspace.${format}`, contents: workspaceText }]
288
344
  : plan.extraFiles,
289
345
  }
290
346
  }
@@ -298,36 +354,80 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
298
354
  )
299
355
  // Nothing written imports vx-migrate or runs its bins: a `--keep`
300
356
  // 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
- )
306
- const headerNotes = refused
307
- ? []
308
- : [
309
- ...(existingWorkspace !== undefined && workspaceText !== undefined
310
- ? await rewriteWorkspace(root, existingWorkspace, workspaceText, adopted!, parsed.dry)
311
- : []),
312
- ...(await prepareRepo(
313
- root,
314
- ['@vzn/vx', ...(needsSelf ? ['@vzn/vx-migrate'] : []), ...plugins.map((p) => p.pkg)],
315
- parsed,
316
- needsSelf ? [] : ['@vzn/vx-migrate'],
317
- )),
318
- ]
357
+ const finalWorkspace = workspaceText ?? readFileSync(path.join(root, existingWorkspace!), 'utf8')
358
+ const needsSelf = runsBins || finalWorkspace.includes("'@vzn/vx-migrate'")
359
+ const cacheNote =
360
+ runner === 'vite-task'
361
+ ? undefined
362
+ : remoteCacheNote(root, runner, finalWorkspace.includes(`${REMOTE_CACHE[runner]}(`))
363
+ const steps: Step[] =
364
+ refused || bare
365
+ ? []
366
+ : [
367
+ ...(existingWorkspace !== undefined && workspaceText !== undefined
368
+ ? await rewriteWorkspace(root, existingWorkspace, workspaceText, adopted!, parsed.dry)
369
+ : []),
370
+ ...(await prepareRepo(
371
+ root,
372
+ [
373
+ ...new Set([
374
+ '@vzn/vx',
375
+ ...(needsSelf ? ['@vzn/vx-migrate'] : []),
376
+ ...plugins.map((p) => p.pkg),
377
+ ]),
378
+ ],
379
+ parsed,
380
+ needsSelf ? [] : ['@vzn/vx-migrate'],
381
+ )),
382
+ ]
319
383
  return applyMigration({
320
384
  root,
321
385
  metas,
322
386
  plan,
323
387
  source,
324
388
  verb: 'vx-migrate',
325
- notes: headerNotes,
389
+ steps,
390
+ notes: refused || bare || cacheNote === undefined ? [] : [cacheNote],
326
391
  dry: parsed.dry,
327
392
  json: parsed.json === true,
328
393
  force: parsed.force,
329
394
  format,
395
+ // The written workspace file imports plugins `--no-install` left out.
396
+ ...(parsed.noInstall === true ? {} : { lock: lockWorkspace }),
397
+ })
398
+ }
399
+
400
+ /**
401
+ * Core's `vx lock`, run where the configs were written: the step `vx init`
402
+ * ends with, through the core this repo resolves.
403
+ */
404
+ async function lockWorkspace(root: string): Promise<{ code: number; said: string }> {
405
+ const p = Bun.spawn([process.execPath, '--no-install', coreBin(root), 'lock'], {
406
+ cwd: root,
407
+ stdio: ['ignore', 'pipe', 'pipe'],
330
408
  })
409
+ const [out, err, code] = await Promise.all([
410
+ new Response(p.stdout).text(),
411
+ new Response(p.stderr).text(),
412
+ p.exited,
413
+ ])
414
+ // A success's warnings print above the report; a refusal is said whole.
415
+ if (code === 0) process.stderr.write(err)
416
+ return { code, said: code === 0 ? out : out + err }
417
+ }
418
+
419
+ /** A report row: what the tool did, e.g. `['ran', 'bun add -d …']`. */
420
+ type Step = readonly [label: string, value: string]
421
+
422
+ /** The `vx` entry of the core this repo resolves. */
423
+ function coreBin(root: string): string {
424
+ // A resolve that misses from a directory with no node_modules is an
425
+ // auto-install under Bun: ask the root only where it has the package.
426
+ const installed = path.join(root, 'node_modules', '@vzn', 'vx')
427
+ const core = existsSync(path.join(installed, 'package.json'))
428
+ ? path.join(realpathSync(installed), 'src', 'index.ts')
429
+ : Bun.resolveSync('@vzn/vx', import.meta.dir)
430
+ return path.join(path.dirname(core), 'bin.ts')
331
431
  }
332
432
 
333
433
  /** vite-plus (`vp run`) in the root package.json's dependencies. */
@@ -359,7 +459,7 @@ const ADOPTION_PLUGINS = new Set(['@vzn/vx-migrate#turbo', '@vzn/vx-migrate#nx']
359
459
 
360
460
  /**
361
461
  * A `--keep` adoption's workspace file, written over for a native one.
362
- * Returns the report's line; writes nothing under `--dry`.
462
+ * Returns the report's row; writes nothing under `--dry`.
363
463
  */
364
464
  async function rewriteWorkspace(
365
465
  root: string,
@@ -367,176 +467,377 @@ async function rewriteWorkspace(
367
467
  text: string,
368
468
  before: readonly WorkspacePlugin[],
369
469
  dry: boolean,
370
- ): Promise<string[]> {
470
+ ): Promise<Step[]> {
371
471
  if (readFileSync(path.join(root, file), 'utf8') === text) return []
372
472
  const dropped = before.filter((p) => ADOPTION_PLUGINS.has(`${p.pkg}#${p.factory}`))
373
473
  const names = dropped.map((p) => `${p.factory}()`).join(', ')
374
474
  if (dry)
375
- return [`would ${dropped.length > 0 ? `drop ${names} from` : 'rewrite'} ${file} (dry run)`]
475
+ return [['config', `would ${dropped.length > 0 ? `drop ${names} from` : 'rewrite'} ${file}`]]
376
476
  await Bun.write(path.join(root, file), text)
377
- return [`${dropped.length > 0 ? `dropped ${names} from` : 'rewrote'} ${file}`]
477
+ return [['config', `${dropped.length > 0 ? `dropped ${names} from` : 'rewrote'} ${file}`]]
378
478
  }
379
479
 
380
480
  /**
381
481
  * Install what the written files import and remove what they no longer
382
- * do. Returns the report's lines; changes nothing under `--dry`.
482
+ * do. Returns the report's rows; changes nothing under `--dry`.
383
483
  */
384
484
  async function prepareRepo(
385
485
  root: string,
386
486
  wanted: readonly string[],
387
487
  args: MigrateArgs,
388
488
  unwanted: readonly string[] = [],
389
- ): Promise<string[]> {
489
+ ): Promise<Step[]> {
390
490
  if (args.noInstall === true) return []
391
- const notes: string[] = []
491
+ const steps: Step[] = []
392
492
  const version = ownVersion()
393
493
  const missing = missingPackages(root, wanted, version)
394
494
  if (missing.length > 0) {
395
- if (args.dry) notes.push(`would install ${missing.join(' ')} (dry run)`)
396
- else notes.push(`installed ${missing.join(' ')} (${await install(root, missing, version)})`)
495
+ steps.push(
496
+ args.dry ? ['install', missing.join(' ')] : ['ran', await install(root, missing, version)],
497
+ )
397
498
  }
398
499
  // Last: the package removed may be the one running this.
399
500
  const listed = listedPackages(root, unwanted)
400
501
  if (listed.length > 0) {
401
- if (args.dry) notes.push(`would remove ${listed.join(' ')} (dry run)`)
402
- else notes.push(`removed ${listed.join(' ')} (${await uninstall(root, listed)})`)
502
+ steps.push(args.dry ? ['remove', listed.join(' ')] : ['ran', await uninstall(root, listed)])
403
503
  }
404
- return notes
504
+ return steps
405
505
  }
406
506
 
407
507
  /**
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.
508
+ * `--keep`: `.vx/workspace.ts` declaring `turbo()` or `nx()`, so the
509
+ * runner's own config stays the source, beside the plugins the repo calls
510
+ * for (its remote cache among them), and the one command from here to a
511
+ * run. A workspace file already here is kept when it declares the runner.
411
512
  */
412
513
  async function keep(
413
514
  root: string,
414
515
  runner: 'turbo' | 'nx',
415
- hasTurbo: boolean,
416
- lerna: boolean,
516
+ source: string,
417
517
  args: MigrateArgs,
418
518
  ): Promise<number> {
419
- // `vx init` reads turbo.json first.
420
- if (runner === 'nx' && hasTurbo) {
519
+ const format: MigrationFormat = args.mjs ? 'mjs' : 'ts'
520
+ const name = `.vx/workspace.${format}`
521
+ const all = [{ pkg: '@vzn/vx-migrate', factory: runner }, ...workspacePlugins(root, runner)]
522
+ const existing = workspaceFileAt(root)
523
+ const before =
524
+ existing === undefined || args.force
525
+ ? undefined
526
+ : readFileSync(path.join(root, existing), 'utf8')
527
+ if (before !== undefined && !before.includes(`${runner}(`)) {
421
528
  throw new UserError(
422
- '--keep with --from nx: turbo.json is here too, and vx init adopts it first',
529
+ `${existing} exists; add ${runner}() from @vzn/vx-migrate to its plugins, or --force replaces it`,
423
530
  )
424
531
  }
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
- : []
433
- for (const note of await prepareRepo(
532
+ // A kept file in the one-line shape gains the plugins it lacks; any
533
+ // other shape is the user's, and left alone.
534
+ const extended = before === undefined ? null : extendWorkspaceFile(before, all)
535
+ const text = before === undefined ? renderWorkspaceFile(all, format) : (extended ?? before)
536
+ const declares = before === undefined || extended !== null
537
+ const steps = await prepareRepo(
434
538
  root,
435
- ['@vzn/vx', '@vzn/vx-migrate', ...plugins.map((p) => p.pkg)],
539
+ [...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])])],
436
540
  args,
437
- )) {
438
- ;(args.json ? process.stderr : process.stdout).write(`vx-migrate: ${note}\n`)
541
+ )
542
+ if (args.json)
543
+ for (const [label, value] of steps) process.stderr.write(`vx-migrate: ${label}: ${value}\n`)
544
+ const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
545
+ if (!args.dry) {
546
+ if (before === undefined) {
547
+ if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
548
+ await Bun.write(path.join(root, name), text)
549
+ } else if (text !== before) await Bun.write(path.join(root, existing!), text)
550
+ // vx's own housekeeping, as a run's first cache open writes it.
551
+ if (ignore.length > 0) await Bun.write(path.join(root, '.vx', '.gitignore'), DOT_VX_GITIGNORE)
439
552
  }
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(
553
+ const cache = REMOTE_CACHE[runner]
554
+ const cacheLine =
555
+ before?.includes(`${cache}(`) === true
556
+ ? undefined
557
+ : remoteCacheNote(root, runner, text.includes(`${cache}(`))
558
+ const added = before === undefined ? [] : undeclared(before, all).map((p) => `${p.factory}()`)
559
+ const wrote: Step =
560
+ before === undefined
561
+ ? ['files', [name, ...ignore].join('\n')]
562
+ : text === before
563
+ ? ['kept', `${existing} (declares ${runner}())`]
564
+ : [
565
+ 'config',
566
+ `${args.dry ? 'would declare' : 'declared'} ${added.join(', ')} in ${existing}`,
567
+ ]
568
+ const next = adoptionNext(root, runner, source, [
569
+ ...new Set(['@vzn/vx', '@vzn/vx-migrate', ...(declares ? all.map((p) => p.pkg) : [])]),
570
+ ])
571
+ if (args.json) {
572
+ process.stdout.write(
573
+ `${JSON.stringify({
574
+ dry: true,
575
+ source,
576
+ files: [
577
+ ...(text === before
578
+ ? []
579
+ : [{ path: before === undefined ? name : existing!, contents: text }]),
580
+ ...ignore.map((p) => ({ path: p, contents: DOT_VX_GITIGNORE })),
581
+ ],
582
+ kept: text === before ? [existing] : [],
583
+ replaced:
584
+ before === undefined && existing !== undefined && existing !== name ? [existing] : [],
585
+ todos: [],
586
+ notes: cacheLine === undefined ? [] : [cacheLine],
587
+ next,
588
+ })}\n`,
589
+ )
590
+ return 0
591
+ }
592
+ if (args.dry && before === undefined) process.stdout.write(`── ${name} ──\n${text}\n`)
593
+ // A run reads only the lock; with the plugins installed it is written
594
+ // now, else `next` names `vx lock` after the install.
595
+ const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
596
+ if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
597
+ const rows: ui.FieldRow[] = [
598
+ ['from', `${source}, run by ${runner}() from @vzn/vx-migrate`],
599
+ ...(args.dry ? [['dry run', 'nothing written'] as const] : []),
600
+ ...steps,
601
+ wrote,
602
+ ...(lock === undefined ? [] : [lockRow(lock)]),
456
603
  [
457
- process.execPath,
458
- '--no-install',
459
- path.join(path.dirname(core), 'bin.ts'),
460
- 'init',
461
- '--keep',
462
- ...flags,
604
+ 'note',
605
+ 'a temporary start: vx init --native writes native config',
606
+ ...(cacheLine === undefined ? [] : [cacheLine]),
463
607
  ],
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`)
475
- }
476
- return code
608
+ null,
609
+ [
610
+ 'next',
611
+ lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
612
+ ],
613
+ ]
614
+ ui.print('out', ui.report(`vx-migrate ${VERSION}`, rows))
615
+ return lock?.code ?? 0
616
+ }
617
+
618
+ /** `vx lock`'s outcome as the report's row. */
619
+ function lockRow(lock: { code: number; said: string }): ui.FieldRow {
620
+ return [
621
+ 'lock',
622
+ lock.code === 0
623
+ ? lock.said.trim().replace(/^✓ vx lock\s+/, '')
624
+ : ui.tone('error', 'not written'),
625
+ ]
477
626
  }
478
627
 
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`,
628
+ /** `.vx/.gitignore`'s text, as core writes it: the machine-local cache stays out of git. */
629
+ const DOT_VX_GITIGNORE = '/cache\n'
630
+
631
+ // npx and bunx name the package: their bare `vx` is a stranger's npm package.
632
+ const EXEC: Record<PackageManager, string> = {
633
+ pnpm: 'pnpm vx',
634
+ yarn: 'yarn vx',
635
+ bun: 'bunx @vzn/vx',
636
+ npm: 'npx @vzn/vx',
637
+ }
638
+
639
+ /**
640
+ * Install what the workspace file imports, if missing, then run the repo's
641
+ * build. Pinned to this release: every @vzn package releases in lockstep,
642
+ * and `latest` beside an older vx mismatched them (agent trial 2).
643
+ */
644
+ export function adoptionNext(
645
+ root: string,
646
+ runner: 'turbo' | 'nx',
647
+ source: string,
648
+ needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
649
+ version: string | null = ownVersion(),
650
+ ): string {
651
+ const pm = packageManagerOf(root)
652
+ const missing = needs.filter(
653
+ (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
487
654
  )
488
- return 0
655
+ // A global vx (no runner started this one) runs the workspace's plugins as they are.
656
+ const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : EXEC[pm]
657
+ const run = `${vx} run ${firstTask(path.join(root, source), runner)} --all`
658
+ if (missing.length === 0) return run
659
+ const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
660
+ return `${installArgv(root, pm, pinned).join(' ')} && ${vx} lock && ${run}`
489
661
  }
490
662
 
491
- /** `--keep` in a Lerna repo with no nx.json: the workspace file declaring `nx()`. */
492
- async function keepLerna(
663
+ /** `build` when the runner's config names it, else the first task it names. */
664
+ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
665
+ let text: string
666
+ try {
667
+ text = readFileSync(file, 'utf8')
668
+ } catch {
669
+ return 'build'
670
+ }
671
+ if (/"build"\s*:/.test(text)) return 'build'
672
+ const field = runner === 'turbo' ? '(?:tasks|pipeline)' : 'targetDefaults'
673
+ const first = new RegExp(`"${field}"\\s*:\\s*\\{\\s*"([^"]+)"`).exec(text)?.[1]
674
+ return first ?? 'build'
675
+ }
676
+
677
+ /**
678
+ * package.json scripts → one vx.config.ts per package, and the workspace
679
+ * file declaring the lockfile plugin its lockfile calls for: `vx init`'s
680
+ * mapper until core stopped reading package.json (M, 2026-10-14).
681
+ */
682
+ async function scripts(
493
683
  root: string,
494
- plugins: readonly WorkspacePlugin[],
684
+ workspace: PackageWorkspace,
685
+ metas: readonly PackageMeta[],
686
+ nameless: readonly string[],
495
687
  args: MigrateArgs,
496
688
  ): 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`,
689
+ if (args.mode !== undefined) {
690
+ throw new UserError(
691
+ `--${args.mode} answers a Turbo or Nx repo's question, and no turbo.json or nx.json is at ${relPosix(process.cwd(), root) || '.'}`,
692
+ )
693
+ }
694
+ const unmapped = namelessNotes(root, nameless)
695
+ // Only a workspace file this run writes declares it; one already here is the user's.
696
+ const lockfile = workspaceFileAt(root) === undefined ? lockfilePlugin(root) : undefined
697
+ const plugins =
698
+ lockfile === undefined ? [] : [{ pkg: '@vzn/vx-lockfile', factory: lockfile.factory }]
699
+ const plan = migrateScripts(
700
+ metas,
701
+ metas.some((m) => m.dir === root) ? undefined : await readRootManifest(root),
702
+ root,
703
+ )
704
+ const empty = plan.projects.length === 0
705
+ // Single-project mode with packages the root's missing `workspaces` never
706
+ // reaches: the scripts exist, the globs do not (item 248).
707
+ const unreached = empty ? await unreachedPackages(workspace) : []
708
+ // Globs that reach no package: "no scripts" was true and named nothing
709
+ // to fix (M-42). Asked of the globs, not of `metas`: a nameless or
710
+ // shared-name member is matched yet absent from `metas` (M-46).
711
+ const globs = workspace.packageGlobs.filter((g) => g !== '.')
712
+ const noMembers =
713
+ empty && globs.length > 0 && !(await reachesManifest(workspace))
714
+ ? [
715
+ `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
716
+ ]
717
+ : []
718
+ const installed = await prepareRepo(
719
+ root,
720
+ plugins.map((p) => p.pkg),
721
+ args,
722
+ )
723
+ return applyMigration({
724
+ root,
725
+ metas,
726
+ plan,
727
+ source: 'package.json scripts',
728
+ verb: 'vx-migrate',
729
+ notes: [...noMembers, ...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
730
+ steps: installed,
731
+ unmapped: unmapped.length > 0,
732
+ ...(unreached.length > 0 ? { emptyReason: unreachedHint(unreached, root) } : {}),
733
+ dry: args.dry,
734
+ json: args.json === true,
735
+ force: args.force,
736
+ init: true,
737
+ format: args.mjs ? 'mjs' : 'ts',
738
+ plugins,
739
+ ...(args.noInstall === true ? {} : { lock: lockWorkspace }),
740
+ })
741
+ }
742
+
743
+ /**
744
+ * A member whose package.json has no `name` is no project, so its scripts
745
+ * map to nothing, and init said nothing of them (D-106).
746
+ */
747
+ function namelessNotes(root: string, dirs: readonly string[]): string[] {
748
+ const withScripts = dirs
749
+ .filter((dir) => {
750
+ const scripts = (
751
+ JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as { scripts?: unknown }
752
+ ).scripts
753
+ return (
754
+ typeof scripts === 'object' &&
755
+ scripts !== null &&
756
+ Object.values(scripts).some((v) => typeof v === 'string' && v !== '')
521
757
  )
522
- return 0
758
+ })
759
+ .map((dir) => relPosix(root, dir))
760
+ .sort()
761
+ // The root's own manifest is the empty relative path: a nameless
762
+ // single-package repo read `not mapped: — its package.json…` (M-54).
763
+ .map((rel) => (rel === '' ? 'the root' : rel))
764
+ if (withScripts.length === 0) return []
765
+ const shown =
766
+ withScripts.slice(0, 3).join(', ') +
767
+ (withScripts.length > 3 ? `, and ${withScripts.length - 3} more` : '')
768
+ return [
769
+ `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`,
770
+ ]
771
+ }
772
+
773
+ /**
774
+ * `pnpm-workspace.yaml`'s `packages` decides the members, as pnpm reads it,
775
+ * and a root `workspaces` that lists other globs was dropped without a word:
776
+ * a bun or npm repo with a stale yaml lost those members (M-51).
777
+ */
778
+ async function unreadWorkspaces(root: string, globs: readonly string[]): Promise<string[]> {
779
+ if (!existsSync(path.join(root, 'pnpm-workspace.yaml'))) return []
780
+ const ws = (await readRootManifest(root))['workspaces']
781
+ const list = Array.isArray(ws)
782
+ ? ws
783
+ : typeof ws === 'object' &&
784
+ ws !== null &&
785
+ Array.isArray((ws as { packages?: unknown }).packages)
786
+ ? (ws as { packages: unknown[] }).packages
787
+ : undefined
788
+ if (list === undefined) return []
789
+ const pkg = list.filter((g): g is string => typeof g === 'string')
790
+ if ([...pkg].sort().join('\0') === [...globs].sort().join('\0')) return []
791
+ return [
792
+ `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`,
793
+ ]
794
+ }
795
+
796
+ /** The root package.json as an object, `{}` when unreadable or not one. */
797
+ async function readRootManifest(root: string): Promise<Record<string, unknown>> {
798
+ const pj: unknown = await Bun.file(path.join(root, 'package.json'))
799
+ .json()
800
+ .catch(() => ({}))
801
+ return typeof pj === 'object' && pj !== null && !Array.isArray(pj)
802
+ ? (pj as Record<string, unknown>)
803
+ : {}
804
+ }
805
+
806
+ function readText(file: string): string {
807
+ try {
808
+ return readFileSync(file, 'utf8').replace(/^/, '')
809
+ } catch {
810
+ return ''
523
811
  }
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] : [],
530
- )
812
+ }
813
+
814
+ /**
815
+ * The workspace root, or where this run makes one: the package manager's
816
+ * root. A nested workspace the outer one does not list is its own: the
817
+ * outer .vx/workspace.ts made init report the outer configs and write
818
+ * nothing.
819
+ */
820
+ async function migrationRoot(cwd: string, reads: LoadReads): Promise<string> {
821
+ const marked = await findWorkspaceRoot(cwd).catch(() => undefined)
822
+ if (marked === undefined) return findManifestRoot(cwd, reads)
823
+ const manifest = await findManifestRoot(cwd, reads).catch(() => marked)
824
+ if (manifest.startsWith(`${marked}${path.sep}`)) {
825
+ const nested = await loadPackageWorkspace(manifest, reads).catch(() => undefined)
826
+ if (nested !== undefined && nested.packageGlobs.length > 0) return manifest
531
827
  }
532
- if (args.dry) {
533
- process.stdout.write(`── ${name} ──\n${text}\nvx-migrate: would write ${name} (dry run)\n`)
534
- return 0
828
+ return marked
829
+ }
830
+
831
+ /**
832
+ * The nearest directory up to the git top-level holding nx.json, for a
833
+ * repo with no package.json: Nx's `nx init` outside JS pins Nx in
834
+ * `.nx/installation` and finds projects by `project.json`.
835
+ */
836
+ function nxRootWithoutManifest(cwd: string): string | undefined {
837
+ for (let d = cwd; ; d = path.dirname(d)) {
838
+ if (existsSync(path.join(d, 'nx.json'))) return d
839
+ if (existsSync(path.join(d, '.git')) || path.dirname(d) === d) return undefined
535
840
  }
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
540
841
  }
541
842
 
542
843
  /**