@vzn/vx-migrate 0.0.645 → 0.0.648

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
@@ -3,7 +3,7 @@
3
3
  // existing Turbo or Nx setup (`--keep`: the workspace file that reads it
4
4
  // live instead), from Vite Task, or from package.json scripts, and vx
5
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
+ // package.json here (2026-10-14: core reads no package.json).
7
7
  // Without either mode flag a terminal is asked; anything else is native. Source auto-detect: turbo.json → Turbo;
8
8
  // .nx/workspace-data/project-graph.json, nx.json or a Lerna-on-Nx lerna.json → Nx (the resolved
9
9
  // graph, exported by nx if absent); `vite-plus` in the root package.json → Vite Task:
@@ -19,6 +19,7 @@ import path from 'node:path'
19
19
  import {
20
20
  applyMigration,
21
21
  findWorkspaceRoot,
22
+ isStarterConfig,
22
23
  isUserError,
23
24
  VERSION,
24
25
  type MigrationFormat,
@@ -53,14 +54,13 @@ import {
53
54
  missingPackages,
54
55
  MODE_QUESTION,
55
56
  ownVersion,
56
- type PackageManager,
57
57
  packageManagerOf,
58
58
  parseModeAnswer,
59
59
  uninstall,
60
60
  } from './adopt.js'
61
61
  import { migrateTurbo } from './migrate-turbo.js'
62
62
  import { migrateViteTask } from './migrate-vite-task.js'
63
- import { turboConfigFile } from './turbo/turbo-map.js'
63
+ import { turboConfigFile } from './turbo/turbo-reads.js'
64
64
  import { lockfilePlugin } from './workspace-notes.js'
65
65
  import {
66
66
  extendWorkspaceFile,
@@ -102,8 +102,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
102
102
  else if (a === '--no-install') out.noInstall = true
103
103
  else if (a === '--format' || a?.startsWith('--format=')) {
104
104
  const v = a === '--format' ? args[++i] : a.slice('--format='.length)
105
+ if (v === undefined || v === '')
106
+ return { ...out, error: '--format requires a value: pretty or json' }
105
107
  if (v !== 'json' && v !== 'pretty') {
106
- return { ...out, error: `--format must be pretty or json (got ${v ?? 'nothing'})` }
108
+ return { ...out, error: `--format must be pretty or json (got ${v})` }
107
109
  }
108
110
  out.json = v === 'json'
109
111
  } else if (a === '--native' || a === '--keep') {
@@ -114,8 +116,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
114
116
  out.mode = mode
115
117
  } else if (a === '--from' || a?.startsWith('--from=')) {
116
118
  const v = a === '--from' ? args[++i] : a.slice('--from='.length)
119
+ if (v === undefined || v === '')
120
+ return { ...out, error: '--from requires a value: turbo, nx, vite-task or scripts' }
117
121
  if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task' && v !== 'scripts') {
118
- return { ...out, error: '--from must be turbo, nx, vite-task or scripts' }
122
+ return { ...out, error: `--from must be turbo, nx, vite-task or scripts (got ${v})` }
119
123
  }
120
124
  out.from = v
121
125
  } else if (a === '--help' || a === '-h') return { ...out, help: true }
@@ -144,13 +148,11 @@ async function noRootReason(cwd: string, err: Error): Promise<Error> {
144
148
  export async function migrateCmd(args: readonly string[]): Promise<number> {
145
149
  const parsed = parseMigrateArgs(args)
146
150
  if (parsed.help) {
147
- process.stdout.write(`${USAGE}\n`)
151
+ ui.say('info', USAGE, 'out')
148
152
  return 0
149
153
  }
150
- if (parsed.error) {
151
- process.stderr.write(`vx-migrate: ${parsed.error}\n`)
152
- return 1
153
- }
154
+ // Thrown, so the bin answers it as JSON too when that was asked.
155
+ if (parsed.error) throw new UserError(parsed.error, 'VX_E_USAGE')
154
156
  const reads: LoadReads = new Map()
155
157
  // An Nx repo with no package.json (Nx pinned in `.nx/installation`):
156
158
  // nothing is installable, so its configs run on core alone.
@@ -163,7 +165,13 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
163
165
  })
164
166
  const workspace = bare ? { root, packageGlobs: [] } : await loadPackageWorkspace(root, reads)
165
167
  const nameless: string[] = []
166
- const metas = bare ? [] : await discoverPackages(workspace, nameless)
168
+ // `vx init`'s starter at the root holds no task: mapped as if absent, so
169
+ // the root's own scripts become its tasks.
170
+ const metas = bare
171
+ ? []
172
+ : (await discoverPackages(workspace, nameless)).filter(
173
+ (m) => !(m.dir === root && m.configPath !== null && isStarterConfig(m.configPath)),
174
+ )
167
175
 
168
176
  const turboFile = await turboConfigFile(root)
169
177
  const hasTurbo = turboFile !== null
@@ -358,7 +366,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
358
366
  // adoption's install of it is dropped with turbo() / nx().
359
367
  const finalWorkspace = workspaceText ?? readFileSync(path.join(root, existingWorkspace!), 'utf8')
360
368
  // Imports parsed, not matched: a formatter's double quotes hid it, and
361
- // the file still imported the package removed (owner, 2026-10-16).
369
+ // the file still imported the package removed (2026-10-16).
362
370
  const imported = importedPackages(finalWorkspace)
363
371
  const needsSelf = runsBins || imported.includes('@vzn/vx-migrate')
364
372
  const cacheNote =
@@ -386,7 +394,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
386
394
  needsSelf ? [] : ['@vzn/vx-migrate'],
387
395
  )),
388
396
  ]
389
- return applyMigration({
397
+ const code = await applyMigration({
390
398
  root,
391
399
  metas,
392
400
  plan,
@@ -401,6 +409,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
401
409
  // The written workspace file imports plugins `--no-install` left out.
402
410
  ...(parsed.noInstall === true ? {} : { lock: lockWorkspace }),
403
411
  })
412
+ return exitAfter(code, steps)
404
413
  }
405
414
 
406
415
  /**
@@ -418,8 +427,10 @@ async function lockWorkspace(root: string): Promise<{ code: number; said: string
418
427
  p.exited,
419
428
  ])
420
429
  // A success's warnings print above the report; a refusal is said whole.
430
+ // A success is a report row, matched as text: the colour FORCE_COLOR
431
+ // gave the child would hide its `✓ vx lock` mark from that match.
421
432
  if (code === 0) process.stderr.write(err)
422
- return { code, said: code === 0 ? out : out + err }
433
+ return { code, said: code === 0 ? Bun.stripANSI(out) : out + err }
423
434
  }
424
435
 
425
436
  /** A report row: what the tool did, e.g. `['ran', 'bun add -d …']`. */
@@ -500,17 +511,36 @@ async function prepareRepo(
500
511
  const missing = missingPackages(root, all, version)
501
512
  if (missing.length > 0) {
502
513
  steps.push(
503
- args.dry ? ['install', missing.join(' ')] : ['ran', await install(root, missing, version)],
514
+ args.dry ? ['install', missing.join(' ')] : await ran(() => install(root, missing, version)),
504
515
  )
505
516
  }
506
517
  // Last: the package removed may be the one running this.
507
518
  const listed = listedPackages(root, unwanted)
508
519
  if (listed.length > 0) {
509
- steps.push(args.dry ? ['remove', listed.join(' ')] : ['ran', await uninstall(root, listed)])
520
+ steps.push(args.dry ? ['remove', listed.join(' ')] : await ran(() => uninstall(root, listed)))
510
521
  }
511
522
  return steps
512
523
  }
513
524
 
525
+ /**
526
+ * A manager step's row. Its failure is a `failed` row, not the end of the
527
+ * migration: the configs are still written and the report names what to
528
+ * re-run, where a throw left a repo with neither.
529
+ */
530
+ async function ran(step: () => Promise<string>): Promise<Step> {
531
+ try {
532
+ return ['ran', await step()]
533
+ } catch (e) {
534
+ if (!isUserError(e)) throw e
535
+ return ['failed', e.message]
536
+ }
537
+ }
538
+
539
+ /** The exit a migration ends with: 1 when a manager step failed, whatever the rest did. */
540
+ function exitAfter(code: number, steps: readonly Step[]): number {
541
+ return code === 0 && steps.some(([label]) => label === 'failed') ? 1 : code
542
+ }
543
+
514
544
  /**
515
545
  * `--keep`: `.vx/workspace.ts` declaring `turbo()` or `nx()`, so the
516
546
  * runner's own config stays the source, beside the plugins the repo calls
@@ -554,7 +584,7 @@ async function keep(
554
584
  args,
555
585
  )
556
586
  if (args.json)
557
- for (const [label, value] of steps) process.stderr.write(`vx-migrate: ${label}: ${value}\n`)
587
+ for (const [label, value] of steps) ui.say('info', `vx-migrate: ${label}: ${value}`)
558
588
  const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
559
589
  if (!args.dry) {
560
590
  if (before === undefined) {
@@ -603,7 +633,7 @@ async function keep(
603
633
  )
604
634
  return 0
605
635
  }
606
- if (args.dry && before === undefined) process.stdout.write(`── ${name} ──\n${text}\n`)
636
+ if (args.dry && before === undefined) ui.print('out', ui.excerpt(name, text))
607
637
  // A run reads only the lock; with the plugins installed it is written
608
638
  // now, else `next` names `vx lock` after the install.
609
639
  const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
@@ -626,7 +656,7 @@ async function keep(
626
656
  ],
627
657
  ]
628
658
  ui.print('out', ui.report(`vx-migrate ${VERSION}`, rows))
629
- return lock?.code ?? 0
659
+ return exitAfter(lock?.code ?? 0, steps)
630
660
  }
631
661
 
632
662
  /** `vx lock`'s outcome as the report's row. */
@@ -642,14 +672,6 @@ function lockRow(lock: { code: number; said: string }): ui.FieldRow {
642
672
  /** `.vx/.gitignore`'s text, as core writes it: the machine-local cache and restore staging stay out of git. */
643
673
  const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
644
674
 
645
- // npx and bunx name the package: their bare `vx` is a stranger's npm package.
646
- const EXEC: Record<PackageManager, string> = {
647
- pnpm: 'pnpm vx',
648
- yarn: 'yarn vx',
649
- bun: 'bunx @vzn/vx',
650
- npm: 'npx @vzn/vx',
651
- }
652
-
653
675
  /**
654
676
  * Install what the workspace file imports, if missing, then run the repo's
655
677
  * build. Pinned to this release: every @vzn package releases in lockstep,
@@ -666,12 +688,12 @@ export function adoptionNext(
666
688
  const missing = needs.filter(
667
689
  (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
668
690
  )
669
- // A global vx (no runner started this one) runs the workspace's plugins as they are.
670
- const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : EXEC[pm]
671
- const run = `${vx} run ${firstTask(path.join(root, source), runner)} --all`
691
+ // `vx init` installs vx globally first: vx alone, never a package
692
+ // manager's runner (2026-10-22: vx owns the command).
693
+ const run = `vx run ${firstTask(path.join(root, source), runner)} --all`
672
694
  if (missing.length === 0) return run
673
695
  const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
674
- return `${installArgv(root, pm, pinned).join(' ')} && ${vx} lock && ${run}`
696
+ return `${installArgv(root, pm, pinned).join(' ')} && vx lock && ${run}`
675
697
  }
676
698
 
677
699
  /**
@@ -696,7 +718,7 @@ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
696
718
  /**
697
719
  * package.json scripts → one vx.config.ts per package, and the workspace
698
720
  * file declaring the lockfile plugin its lockfile calls for: `vx init`'s
699
- * mapper until core stopped reading package.json (M, 2026-10-14).
721
+ * mapper until core stopped reading package.json (2026-10-14).
700
722
  */
701
723
  async function scripts(
702
724
  root: string,
@@ -734,12 +756,8 @@ async function scripts(
734
756
  `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
735
757
  ]
736
758
  : []
737
- const installed = await prepareRepo(
738
- root,
739
- plugins.map((p) => p.pkg),
740
- args,
741
- )
742
- return applyMigration({
759
+ const installed = await prepareRepo(root, [...new Set(plugins.map((p) => p.pkg))], args)
760
+ const code = await applyMigration({
743
761
  root,
744
762
  metas,
745
763
  plan,
@@ -757,6 +775,7 @@ async function scripts(
757
775
  plugins,
758
776
  ...(args.noInstall === true ? {} : { lock: lockWorkspace }),
759
777
  })
778
+ return exitAfter(code, installed)
760
779
  }
761
780
 
762
781
  /**
package/src/nx/index.ts CHANGED
@@ -69,7 +69,8 @@ const ROOT_GRAPH_FILE =
69
69
  export interface NxPluginOptions {
70
70
  /**
71
71
  * Where `nx.json` lives. Defaults to the workspace root; a repo whose Nx
72
- * workspace sits elsewhere names the directory here.
72
+ * workspace sits elsewhere names the directory here, relative to the workspace
73
+ * root or absolute.
73
74
  */
74
75
  readonly root?: string
75
76
  /**
@@ -111,6 +112,11 @@ function refuseExecutors(executors: NxExecutors | undefined): void {
111
112
  /** The plugin: the adoption skeleton over `mapNxWorkspace`, one mapping per run. */
112
113
  export function nx(options: NxPluginOptions = {}): VxPlugin {
113
114
  refuseUnknownOptions('nx()', options, NX_PLUGIN_KEYS)
115
+ if (options.root === '') throw new UserError('nx() option "root" is a directory, not empty')
116
+ // Against the workspace root, as vx reads every path: against the cwd,
117
+ // `vx run` from a package dir read another directory.
118
+ const rootOf = (workspaceRoot: string): string =>
119
+ options.root === undefined ? workspaceRoot : path.resolve(workspaceRoot, options.root)
114
120
  refuseExecutors(options.executors)
115
121
  // One graph load per RUN, shared by `discover` and `project`: the run
116
122
  // hands both stages the same projects array (discover's, grown by what
@@ -134,7 +140,7 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
134
140
  return adoptionPlugin(
135
141
  import.meta,
136
142
  async (ctx) => {
137
- const root = options.root ?? ctx.workspaceRoot
143
+ const root = rootOf(ctx.workspaceRoot)
138
144
  return mapAll(
139
145
  root,
140
146
  ctx.projects,
@@ -146,7 +152,7 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
146
152
  options.root === undefined ? ['nx.json'] : [],
147
153
  {
148
154
  async config(workspace, ctx) {
149
- const root = options.root ?? ctx.workspaceRoot
155
+ const root = rootOf(ctx.workspaceRoot)
150
156
  const nxBaseCommit = Bun.env['NX_BASE']?.trim()
151
157
  if (nxBaseCommit) ctx.setBranch({ base: nxBaseCommit })
152
158
  if (workspace.baseBranch === undefined) {
@@ -174,7 +180,7 @@ export function nx(options: NxPluginOptions = {}): VxPlugin {
174
180
  // project, by its package.json name or else its Nx name; a name a
175
181
  // project already holds stays unattached, and the mapping says so.
176
182
  async discover(ctx) {
177
- const root = options.root ?? ctx.workspaceRoot
183
+ const root = rootOf(ctx.workspaceRoot)
178
184
  const members = await memberProjects(ctx.workspaceRoot)
179
185
  // Core's status is the workspace's: a root elsewhere asks git itself.
180
186
  const changes = options.root === undefined ? () => ctx.worktreeChanges() : undefined
@@ -369,6 +375,10 @@ async function nxReads(
369
375
  String(process.env['NX_LOAD_DOT_ENV_FILES']),
370
376
  JSON.stringify(installed),
371
377
  await yarnrcText(root),
378
+ // Whether the manager runs a script's `pre` / `post` hooks (`runsScriptHooks`).
379
+ ...(await Promise.all(
380
+ ['package.json', '.npmrc', 'pnpm-workspace.yaml'].map((f) => textOf(path.join(root, f))),
381
+ )),
372
382
  ...notes,
373
383
  ]
374
384
  }
@@ -414,7 +424,7 @@ async function index(
414
424
  }
415
425
  if (unattached.length > 0) {
416
426
  notes.push(
417
- `Nx project(s) ${unattached.join(', ')} have no workspace package to attach targets to ` +
427
+ `${unattached.length === 1 ? 'Nx project' : 'Nx projects'} ${unattached.join(', ')} ${unattached.length === 1 ? 'has' : 'have'} no workspace package to attach targets to ` +
418
428
  '(a package holds the name, or the directory is gone) — run those targets with nx',
419
429
  )
420
430
  }
@@ -574,10 +584,14 @@ async function graphInputKey(
574
584
  // stray write at the root (a report, a log) is not, and counting it
575
585
  // re-exported the graph on every run after it.
576
586
  // A `project.json` anywhere: a new one is a project no root lists yet.
587
+ // A `.gitignore` anywhere too: Nx's project walk honours each one, so an
588
+ // edit between project roots hides or shows a project.
577
589
  const roots = dirs.map((d) => relPosix(root, d))
578
590
  const graphFile = (p: string): boolean =>
579
591
  p === 'project.json' ||
580
592
  p.endsWith('/project.json') ||
593
+ p === '.gitignore' ||
594
+ p.endsWith('/.gitignore') ||
581
595
  roots.some((r) => r === '' || p.startsWith(`${r}/`)) ||
582
596
  (!p.includes('/') && ROOT_GRAPH_FILE.test(p))
583
597
  const listed = [
@@ -7,8 +7,10 @@
7
7
  // (`dependentTasksOutputFiles`) is nothing; `externalDependencies` is a todo saying
8
8
  // so. Extracted from `buildTask` in item 606.
9
9
 
10
+ import path from 'node:path'
10
11
  import { nxWorkspacePath, underProject, withProjectDir } from './nx-outputs.js'
11
- import { minimatchToVx } from '../glob-grammar.js'
12
+ import type { TrackedKinds } from '../tracked-outputs.js'
13
+ import { gluedGlobstarAsStar, minimatchToVx, nxBareGroups } from '../glob-grammar.js'
12
14
  import { shellQuote } from '../nx-command.js'
13
15
 
14
16
  export interface NxInputs {
@@ -24,8 +26,15 @@ export interface NxInputs {
24
26
  * representable in vx" (walked the Nx path, 2026-09-20).
25
27
  */
26
28
  readonly runtimeCmds: string[]
27
- /** `^name` / `{ input, dependencies | projects }`: whose `name` to fold. */
28
- readonly upstream: Array<{ readonly name: string; readonly of: 'deps' | readonly string[] }>
29
+ /**
30
+ * `^name` / `{ input, dependencies | projects }`: whose `name` to fold;
31
+ * `depsOf`, the listed projects' dependencies' rather than theirs.
32
+ */
33
+ readonly upstream: Array<{
34
+ readonly name: string
35
+ readonly of: 'deps' | readonly string[]
36
+ readonly depsOf?: true
37
+ }>
29
38
  /** `{ dependentTasksOutputFiles }` globs: keyed only through a `dependsOn` edge. */
30
39
  readonly dependentOutputs: string[]
31
40
  }
@@ -69,6 +78,8 @@ export function expandNxInputs(
69
78
  readonly rel: string
70
79
  readonly name: string
71
80
  readonly outputs?: readonly string[]
81
+ /** git's tracked files by directory; unknown, every output counts as ignored. */
82
+ readonly tracked?: (rel: string) => TrackedKinds
72
83
  /** The root package.json's dependency names: every task's key holds them. */
73
84
  readonly rootDeps?: ReadonlySet<string>
74
85
  },
@@ -99,9 +110,24 @@ export function expandNxInputs(
99
110
  }
100
111
  // Nx's file map skips gitignored files, so it hashes nothing here
101
112
  // either: a changed `dist` file is a cache hit under Nx 23.3 (YL-1).
113
+ // An output git tracks (a generator writing into `src`) is hashed,
114
+ // and dropping it keyed `build` on no file (stale hit, 2026-10-28).
102
115
  const lit = withProjectDir(p, at.rel, false)
103
- if (neg === '' && at.outputs?.some((o) => lit === o || lit.startsWith(`${o}/`))) return
104
- const g = minimatchToVx(p, neg !== '')
116
+ const ignored = (o: string): boolean => {
117
+ if (at.tracked === undefined) return true
118
+ const dir = path.posix.dirname(o)
119
+ return (
120
+ at.tracked(o).files.length === 0 &&
121
+ !at.tracked(dir).files.includes(dir === '.' ? o : o.slice(dir.length + 1))
122
+ )
123
+ }
124
+ if (
125
+ neg === '' &&
126
+ at.outputs?.some((o) => (lit === o || lit.startsWith(`${o}/`)) && ignored(o))
127
+ )
128
+ return
129
+ const bare = nxBareGroups(p)
130
+ const g = bare === null ? null : minimatchToVx(gluedGlobstarAsStar(bare), neg !== '')
105
131
  if (g === null) {
106
132
  todos.push(`input ${JSON.stringify(entry)}: glob syntax vx cannot take — map manually`)
107
133
  return
@@ -115,7 +141,7 @@ export function expandNxInputs(
115
141
  ? null
116
142
  : underProject(g, at.rel)
117
143
  if (own === null) into.wsFiles.push(neg + withProjectDir(g, at.rel, true))
118
- else into.files.push(neg + withProjectDir(own, at.rel, true))
144
+ else into.files.push(neg + (own === '' ? '**/*' : withProjectDir(own, at.rel, true)))
119
145
  return
120
146
  }
121
147
  // Bare string = named-input reference.
@@ -199,17 +225,26 @@ export function expandNxInputs(
199
225
  // Nx 23 still reads the pre-17 spellings `projects: "dependencies"`
200
226
  // (`^input`) and `projects: "self"` (the project's own); taken as
201
227
  // project names, each was a todo and the input was dropped.
202
- if (o.dependencies === true || o.projects === 'dependencies') {
228
+ const listed =
229
+ o.projects !== undefined && o.projects !== 'self' && o.projects !== 'dependencies'
230
+ if (o.projects === 'dependencies' || (o.dependencies === true && !listed)) {
203
231
  into.upstream.push({ name: o.input, of: 'deps' })
204
232
  return
205
233
  }
206
- if (o.projects !== undefined && o.projects !== 'self') {
234
+ if (listed) {
207
235
  const of = typeof o.projects === 'string' ? [o.projects] : o.projects
208
236
  if (!Array.isArray(of) || !of.every((p) => typeof p === 'string')) {
209
237
  todos.push(`input ${JSON.stringify(entry)} not representable in vx`)
210
238
  return
211
239
  }
212
- into.upstream.push({ name: o.input, of: of as string[] })
240
+ // Beside `projects`, Nx 24 reads `dependencies: true` as those
241
+ // projects' dependencies and Nx 23 as this project's: read as
242
+ // this project's alone, a listed project's dependency edit was a
243
+ // hit under Nx 24. Both are keyed, never less than either.
244
+ if (o.dependencies === true) {
245
+ into.upstream.push({ name: o.input, of: 'deps' })
246
+ into.upstream.push({ name: o.input, of: of as string[], depsOf: true })
247
+ } else into.upstream.push({ name: o.input, of: of as string[] })
213
248
  return
214
249
  }
215
250
  expand(o.input, seen)