@vzn/vx-migrate 0.0.646 → 0.0.649

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/adopt.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // which kind of adoption (native configs, or the runner's config read live),
3
3
  // and installing vx with the repo's own package manager (solidjs/solid,
4
4
  // 2026-10-04: the configs were written with no vx installed to run them).
5
- // The repo's own scripts are never edited (owner, 2026-10-06).
5
+ // The repo's own scripts are never edited (2026-10-06).
6
6
 
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
@@ -63,6 +63,32 @@ export function packageManagerOf(root: string, userAgent?: string): PackageManag
63
63
  )
64
64
  }
65
65
 
66
+ /**
67
+ * pnpm's `-w` where pnpm-workspace.yaml makes `root` a workspace: pnpm
68
+ * refuses it in a single-package repo ("--workspace-root may only be used
69
+ * inside a workspace").
70
+ */
71
+ function pnpmRoot(root: string): string[] {
72
+ return existsSync(path.join(root, 'pnpm-workspace.yaml')) ? ['-w'] : []
73
+ }
74
+
75
+ /**
76
+ * Yarn 2+: its lockfile carries `__metadata`, and before the first install
77
+ * there is none, so `packageManager` names it (`-W` was refused there).
78
+ */
79
+ function berry(root: string): boolean {
80
+ const lock = readText(path.join(root, 'yarn.lock'))
81
+ if (lock !== '') return lock.includes('__metadata:')
82
+ try {
83
+ const pm = (
84
+ JSON.parse(readText(path.join(root, 'package.json')) || '{}') as { packageManager?: unknown }
85
+ ).packageManager
86
+ return typeof pm === 'string' && /^yarn@([2-9]|\d{2,})/.test(pm)
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
66
92
  /** argv that adds `packages` as root dev dependencies. */
67
93
  export function installArgv(
68
94
  root: string,
@@ -71,11 +97,11 @@ export function installArgv(
71
97
  ): string[] {
72
98
  switch (pm) {
73
99
  case 'pnpm':
74
- return ['pnpm', 'add', '-D', '-w', ...packages]
100
+ return ['pnpm', 'add', '-D', ...pnpmRoot(root), ...packages]
75
101
  case 'yarn':
76
102
  // Yarn 1 refuses `add` at a workspace root without `-W`; Berry has no
77
- // such flag. A Berry lockfile carries `__metadata`.
78
- return readText(path.join(root, 'yarn.lock')).includes('__metadata:')
103
+ // such flag.
104
+ return berry(root)
79
105
  ? ['yarn', 'add', '-D', ...packages]
80
106
  : ['yarn', 'add', '-D', '-W', ...packages]
81
107
  case 'bun':
@@ -104,7 +130,7 @@ export function ownVersion(): string | null {
104
130
  * (at `version`, given one). Either alone is not adopted: a reset
105
131
  * package.json leaves node_modules behind, and a listing is not an
106
132
  * install; on solidjs/solid vx-migrate 0.0.512 skipped @vzn/vx that
107
- * package.json did not list (owner, 2026-10-06).
133
+ * package.json did not list (2026-10-06).
108
134
  */
109
135
  export function missingPackages(
110
136
  root: string,
@@ -129,11 +155,9 @@ export function removeArgv(
129
155
  ): string[] {
130
156
  switch (pm) {
131
157
  case 'pnpm':
132
- return ['pnpm', 'remove', '-w', ...packages]
158
+ return ['pnpm', 'remove', ...pnpmRoot(root), ...packages]
133
159
  case 'yarn':
134
- return readText(path.join(root, 'yarn.lock')).includes('__metadata:')
135
- ? ['yarn', 'remove', ...packages]
136
- : ['yarn', 'remove', '-W', ...packages]
160
+ return berry(root) ? ['yarn', 'remove', ...packages] : ['yarn', 'remove', '-W', ...packages]
137
161
  case 'bun':
138
162
  return ['bun', 'remove', ...packages]
139
163
  case 'npm':
@@ -156,7 +180,7 @@ export function listedPackages(root: string, packages: readonly string[]): strin
156
180
  * The `@vzn/vx-*` plugins the root lists from the registry (a `workspace:`
157
181
  * or `file:` link is the repo's own; a platform binary comes with core).
158
182
  * Every @vzn package releases together: an install moves them with core,
159
- * as `vx upgrade` does (M, 2026-10-16: "always install latest and upgrade all").
183
+ * as `vx upgrade` does (2026-10-16: "always install latest and upgrade all").
160
184
  */
161
185
  export function listedPlugins(root: string): string[] {
162
186
  const pj = JSON.parse(readText(path.join(root, 'package.json')) || '{}') as Record<
@@ -179,7 +203,7 @@ export function listedPlugins(root: string): string[] {
179
203
  * The `@vzn/vx*` packages a workspace file imports, parsed, not matched (a
180
204
  * formatter's quotes hid a match). Each is installed with core: a file an
181
205
  * earlier init wrote imports @vzn/vx-lockfile that package.json need not
182
- * list, and the lock could not find it (M, 2026-10-16).
206
+ * list, and the lock could not find it (2026-10-16).
183
207
  */
184
208
  export function importedPackages(text: string): string[] {
185
209
  const out = new Set<string>()
@@ -279,9 +303,26 @@ async function runManager(
279
303
  if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
280
304
  if (code !== 0) {
281
305
  process.stderr.write(said)
306
+ const missing = notServed(said)
282
307
  throw new UserError(
283
- `${what} failed (${line} exited ${code}${failed}); run it, then vx-migrate again${code > 0 ? ranHint : ''}`,
308
+ missing === undefined
309
+ ? `${what} failed (${line} exited ${code}${failed}); run it, then vx lock${code > 0 ? ranHint : ''}`
310
+ : `${what} failed: the npm registry does not serve ${missing} yet (404), as a release can take minutes to reach it; run vx init again`,
284
311
  )
285
312
  }
286
313
  return line
287
314
  }
315
+
316
+ /**
317
+ * The package a manager's output says the registry answered 404 for, as
318
+ * `name@version` (core's `vx init` reads its installs the same way): a
319
+ * release's platform tarball stays unserved for minutes after publish.
320
+ */
321
+ export function notServed(output: string): string | undefined {
322
+ for (const line of output.split('\n')) {
323
+ if (!line.includes('404')) continue
324
+ const m = /https?:\/\/\S+?\/((?:@[^/\s]+\/)?[^/\s]+)\/-\/[^/\s]+?-(\d[^/\s]*?)\.tgz/.exec(line)
325
+ if (m !== null) return `${decodeURIComponent(m[1]!)}@${m[2]}`
326
+ }
327
+ return undefined
328
+ }
package/src/bin.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // 2026-10-16).
11
11
 
12
12
  import path from 'node:path'
13
+ import * as core from '@vzn/vx'
13
14
  import { isUserError, UserError, VERSION } from '@vzn/vx'
14
15
  import { ownVersion } from './adopt.js'
15
16
 
@@ -26,8 +27,27 @@ try {
26
27
  const { migrateCmd } = await import('./migrate.js')
27
28
  process.exitCode = await migrateCmd(process.argv.slice(2))
28
29
  } catch (err) {
30
+ // Asked for JSON (`vx init --dry --format json` hands a Turbo or Nx repo
31
+ // here), a refusal is core's error document on stdout too
32
+ // (schemas/error.json): stdout was empty.
33
+ const args = process.argv.slice(2)
34
+ if (args.some((a, i) => a === '--format=json' || (a === '--format' && args[i + 1] === 'json'))) {
35
+ const message = err instanceof Error ? err.message : String(err)
36
+ const own = isUserError(err) ? (err as { code?: unknown }).code : 'VX_E_INTERNAL'
37
+ const code = typeof own === 'string' ? own : 'VX_E_REFUSED'
38
+ const docs = code.startsWith('VX_E_')
39
+ ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
40
+ : {}
41
+ process.stdout.write(
42
+ `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`,
43
+ )
44
+ }
29
45
  if (isUserError(err)) {
30
- process.stderr.write(`vx-migrate: ${err.message}\n`)
46
+ // `ui` by namespace: a core older than it has none, and its refusal
47
+ // still reaches the reader.
48
+ const ui = (core as Partial<typeof core>).ui
49
+ if (ui === undefined) process.stderr.write(`vx-migrate: ${err.message}\n`)
50
+ else ui.say('error', `vx-migrate: ${err.message}`)
31
51
  process.exitCode = 1
32
52
  } else {
33
53
  throw err
@@ -27,6 +27,51 @@ export function minimatchToVx(glob: string, negated: boolean): string | null {
27
27
  return out.includes('\0') || /[?@+*!]\(/.test(out) ? null : out
28
28
  }
29
29
 
30
+ // A group no extglob kind opens and no backslash escapes.
31
+ const BARE_GROUP = /(?<![\\?@+*!])\(([^()]*)\)/g
32
+
33
+ /**
34
+ * An Nx glob's bare groups in vx's grammar, or null when one has no safe
35
+ * form. Nx's native glob reads `(a|b)` as `{a,b}` and `(a)` as `a`, so
36
+ * `dist/*.(js|map)` is the two extensions there; vx and minimatch read the
37
+ * parentheses as literal text, and the glob matched nothing. Turbo's
38
+ * grammar has no such group, so this is the Nx adapter's alone.
39
+ */
40
+ export function nxBareGroups(glob: string): string | null {
41
+ if (!glob.includes('(')) return glob
42
+ let unsafe = false
43
+ const out = glob.replace(BARE_GROUP, (_whole, body: string) => {
44
+ const alts = body.split('|')
45
+ if (alts.some((a) => /[{},]/.test(a))) unsafe = true
46
+ return alts.length === 1 ? alts[0]! : `{${alts.join(',')}}`
47
+ })
48
+ return unsafe ? null : out
49
+ }
50
+
51
+ /** A segment (or brace arm) holding `**` and other text: `**.ts`, `a**`. */
52
+ const glued = (seg: string): boolean => seg.includes('**') && /[^*]/.test(seg)
53
+
54
+ /** Does `glob` glue `**` to other text, which core refuses? */
55
+ export const hasGluedGlobstar = (glob: string): boolean =>
56
+ glob
57
+ .replace(/^!/, '')
58
+ .split(/[/{},]/)
59
+ .some(glued)
60
+
61
+ /**
62
+ * A glued `**` as minimatch and Nx's matcher (globset, literal separator)
63
+ * read it: `*`, one level.
64
+ */
65
+ export function gluedGlobstarAsStar(glob: string): string {
66
+ const neg = glob.startsWith('!') ? '!' : ''
67
+ return (
68
+ neg +
69
+ glob
70
+ .slice(neg.length)
71
+ .replace(/[^/{},]+/g, (seg) => (glued(seg) ? seg.replace(/\*{2,}/g, '*') : seg))
72
+ )
73
+ }
74
+
30
75
  /**
31
76
  * `list` without a literal that one of its `!` entries takes back, and
32
77
  * without a repeat. Turbo, Nx and vx all subtract an exclusion wherever it
@@ -53,6 +98,43 @@ export function withoutTakenBack<T>(entries: readonly T[]): T[] {
53
98
  })
54
99
  }
55
100
 
101
+ /**
102
+ * Could a path match both globs? False only when, before either reaches a
103
+ * wildcard, a segment of one differs from the other's; a literal also
104
+ * matches its directory's files, as a `!` entry takes them back.
105
+ */
106
+ function mayOverlap(a: string, b: string): boolean {
107
+ const wild = /[*?{}[\]\\]/
108
+ const sa = a.replace(/^\.\//, '').split('/')
109
+ const sb = b.replace(/^\.\//, '').split('/')
110
+ for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
111
+ if (wild.test(sa[i]!) || wild.test(sb[i]!)) return true
112
+ if (sa[i] !== sb[i]) return false
113
+ }
114
+ return true
115
+ }
116
+
117
+ /**
118
+ * Two lists core reads as one, each with its own `!` entries: Turbo
119
+ * subtracts a task's exclusions from the task's own inputs only, and a
120
+ * `globalDependencies` exclusion from the global hash only. One list in
121
+ * vx subtracts every `!` from every entry, so each list loses an exclusion
122
+ * that may reach a positive entry of the other: it keys more files (a
123
+ * missed hit), where kept it keyed fewer (a stale one).
124
+ */
125
+ export function apartTakeBacks<T>(a: readonly T[], b: readonly T[]): [T[], T[]] {
126
+ const positives = (l: readonly T[]) =>
127
+ l.flatMap((e) => (typeof e === 'string' && !e.startsWith('!') ? [e] : []))
128
+ const keep = (l: readonly T[], other: readonly string[]) =>
129
+ l.filter(
130
+ (e) =>
131
+ typeof e !== 'string' ||
132
+ !e.startsWith('!') ||
133
+ !other.some((p) => mayOverlap(e.slice(1), p)),
134
+ )
135
+ return [keep(a, positives(b)), keep(b, positives(a))]
136
+ }
137
+
56
138
  /**
57
139
  * A path from the file system as a task glob that names only itself: a dir
58
140
  * named `my{app}` read as a brace matched `myapp` instead, and `app/[id]` a
package/src/migrate-nx.ts CHANGED
@@ -3,7 +3,12 @@
3
3
 
4
4
  import { stat } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { type GeneratedProject, type MigrationFormat, type MigrationPlan } from '@vzn/vx'
6
+ import {
7
+ type GeneratedProject,
8
+ type MigrationFormat,
9
+ type MigrationPlan,
10
+ quoteTsLiteral,
11
+ } from '@vzn/vx'
7
12
  import type { PackageMeta } from './packages.js'
8
13
  import { mapNxWorkspace, parseNxGraph, readNxJson } from './nx/nx-map.js'
9
14
  import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
@@ -172,8 +177,6 @@ export interface NxWorkspaceField {
172
177
  readonly note: string
173
178
  }
174
179
 
175
- const quoted = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
176
-
177
180
  /**
178
181
  * nx.json's run-wide settings `nx()` applies live: each a field of the
179
182
  * written `.vx/workspace.ts`, or a migrated repo that ran one task at a time
@@ -202,7 +205,7 @@ export function nxWorkspaceFields(json: Record<string, unknown> | undefined): Nx
202
205
  const branch = base.trim().replace(/^origin\//, '')
203
206
  out.push({
204
207
  field: 'baseBranch',
205
- source: quoted(branch),
208
+ source: quoteTsLiteral(branch),
206
209
  note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`baseBranch: ${JSON.stringify(branch)}\` to .vx/workspace.ts`,
207
210
  })
208
211
  }
@@ -39,6 +39,7 @@ import {
39
39
  } from '@vzn/vx'
40
40
  import type { PackageMeta } from './packages.js'
41
41
  import { relPosix } from './paths.js'
42
+ import { type Owner, ownerOf, runsScriptHooks } from './script-command.js'
42
43
 
43
44
  /**
44
45
  * Task names that conventionally never exit. Every mapper guesses the same
@@ -128,19 +129,24 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
128
129
  /**
129
130
  * The script a command delegates to when it is NOTHING but a package-manager
130
131
  * invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
131
- * x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
132
- * make it a real command again, which is left verbatim.
132
+ * x`, plus npm's bare `npm test` / `npm start` / `npm stop`. npm's aliases
133
+ * (`run-script`, `rum`, `urn`; `t`, `tst`) and pnpm's (`run-script`; `t`,
134
+ * `tst`) are the same commands: read verbatim, `npm run-script build` built
135
+ * again beside the `build` task. Flags, arguments or a chain make it a real
136
+ * command again, which is left verbatim.
133
137
  */
134
138
  export function delegatedScript(command: string): string | null {
135
139
  const m =
136
- /^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
140
+ /^(?:(?:npm (?:run|run-script|rum|urn)|(pnpm|pn|yarn|bun)(?: (run|run-script))?) ([^\s&|;<>()$`'"\\]+)|npm (test|t|tst|start|stop))$/.exec(
137
141
  command.trim(),
138
142
  )
139
143
  if (m === null) return null
140
- if (m[4] !== undefined) return m[4]
144
+ if (m[4] !== undefined) return m[4] === 't' || m[4] === 'tst' ? 'test' : m[4]
141
145
  const [, alias, run, name] = m
142
146
  // `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
143
147
  const manager = alias === 'pn' ? 'pnpm' : alias
148
+ if (run === 'run-script' && manager !== 'pnpm') return null
149
+ if (manager === 'pnpm' && run === undefined && (name === 't' || name === 'tst')) return 'test'
144
150
  // Bare, the manager's own command wins over a script of that name:
145
151
  // `bun test` is Bun's test runner and `bun build` its bundler, never the
146
152
  // `test` / `build` script, and a group over the script ran the wrong
@@ -177,104 +183,6 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
177
183
  ),
178
184
  }
179
185
 
180
- /**
181
- * The package manager that owns `dir`, when it runs `pre` / `post` hooks
182
- * around a script; null when it runs none. The manager is the nearest `packageManager` field or
183
- * lockfile, and its settings are read beside it, as each reads them for a
184
- * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
185
- * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
186
- * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
187
- * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
188
- * Yarn 1 run them whatever those say. No manager found is npm's default.
189
- */
190
- function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
191
- const { manager, at } = ownerOf(dir, memo)
192
- const read = (f: string): string | undefined => {
193
- try {
194
- return readFileSync(path.join(at, f), 'utf8')
195
- } catch {
196
- return undefined
197
- }
198
- }
199
- const npmrc = read('.npmrc') ?? ''
200
- const set = (key: string, value: string): boolean =>
201
- new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
202
- const runs =
203
- manager === 'berry'
204
- ? false
205
- : manager === 'npm'
206
- ? !set('ignore-scripts', 'true')
207
- : manager === 'pnpm'
208
- ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
209
- : true
210
- return runs ? manager : null
211
- }
212
-
213
- /**
214
- * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
215
- * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
216
- * which pnpm 10 honours, and folded hooks pnpm does not run.
217
- */
218
- function pnpmSkipsHooks(yaml: string | undefined): boolean {
219
- if (yaml === undefined) return false
220
- try {
221
- const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
222
- return parsed?.enablePrePostScripts === false
223
- } catch {
224
- return false
225
- }
226
- }
227
-
228
- /** The package manager that owns a directory, and the directory that says so. */
229
- interface Owner {
230
- manager: string
231
- at: string
232
- }
233
-
234
- /**
235
- * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
236
- * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
237
- */
238
- function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
239
- const known = memo.get(dir)
240
- if (known !== undefined) return known
241
- const read = (f: string): string | undefined => {
242
- try {
243
- return readFileSync(path.join(dir, f), 'utf8')
244
- } catch {
245
- return undefined
246
- }
247
- }
248
- let manager: string | undefined
249
- try {
250
- // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
251
- // JSON.parse threw on one and the field went unread.
252
- const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
253
- const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
254
- // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
255
- // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
256
- // claim nothing had checked (D-96).
257
- if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
258
- manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
259
- } catch {}
260
- if (manager === undefined) {
261
- const yarnLock = read('yarn.lock')
262
- if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
263
- else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
264
- else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
265
- else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
266
- }
267
- const up = path.dirname(dir)
268
- const owner =
269
- manager !== undefined
270
- ? { manager, at: dir }
271
- : up === dir
272
- ? { manager: 'npm', at: dir }
273
- : ownerOf(up, memo)
274
- memo.set(dir, owner)
275
- return owner
276
- }
277
-
278
186
  /**
279
187
  * Yarn 2+ runs a script in its own shell, where `run <script>` is `yarn
280
188
  * run <script>`; vx's shell has no `run`, and 23 of berry's scripts
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,
@@ -101,8 +102,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
101
102
  else if (a === '--no-install') out.noInstall = true
102
103
  else if (a === '--format' || a?.startsWith('--format=')) {
103
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' }
104
107
  if (v !== 'json' && v !== 'pretty') {
105
- return { ...out, error: `--format must be pretty or json (got ${v ?? 'nothing'})` }
108
+ return { ...out, error: `--format must be pretty or json (got ${v})` }
106
109
  }
107
110
  out.json = v === 'json'
108
111
  } else if (a === '--native' || a === '--keep') {
@@ -113,8 +116,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
113
116
  out.mode = mode
114
117
  } else if (a === '--from' || a?.startsWith('--from=')) {
115
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' }
116
121
  if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task' && v !== 'scripts') {
117
- 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})` }
118
123
  }
119
124
  out.from = v
120
125
  } else if (a === '--help' || a === '-h') return { ...out, help: true }
@@ -143,13 +148,11 @@ async function noRootReason(cwd: string, err: Error): Promise<Error> {
143
148
  export async function migrateCmd(args: readonly string[]): Promise<number> {
144
149
  const parsed = parseMigrateArgs(args)
145
150
  if (parsed.help) {
146
- process.stdout.write(`${USAGE}\n`)
151
+ ui.say('info', USAGE, 'out')
147
152
  return 0
148
153
  }
149
- if (parsed.error) {
150
- process.stderr.write(`vx-migrate: ${parsed.error}\n`)
151
- return 1
152
- }
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')
153
156
  const reads: LoadReads = new Map()
154
157
  // An Nx repo with no package.json (Nx pinned in `.nx/installation`):
155
158
  // nothing is installable, so its configs run on core alone.
@@ -162,7 +165,13 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
162
165
  })
163
166
  const workspace = bare ? { root, packageGlobs: [] } : await loadPackageWorkspace(root, reads)
164
167
  const nameless: string[] = []
165
- 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
+ )
166
175
 
167
176
  const turboFile = await turboConfigFile(root)
168
177
  const hasTurbo = turboFile !== null
@@ -357,7 +366,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
357
366
  // adoption's install of it is dropped with turbo() / nx().
358
367
  const finalWorkspace = workspaceText ?? readFileSync(path.join(root, existingWorkspace!), 'utf8')
359
368
  // Imports parsed, not matched: a formatter's double quotes hid it, and
360
- // the file still imported the package removed (owner, 2026-10-16).
369
+ // the file still imported the package removed (2026-10-16).
361
370
  const imported = importedPackages(finalWorkspace)
362
371
  const needsSelf = runsBins || imported.includes('@vzn/vx-migrate')
363
372
  const cacheNote =
@@ -385,7 +394,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
385
394
  needsSelf ? [] : ['@vzn/vx-migrate'],
386
395
  )),
387
396
  ]
388
- return applyMigration({
397
+ const code = await applyMigration({
389
398
  root,
390
399
  metas,
391
400
  plan,
@@ -400,6 +409,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
400
409
  // The written workspace file imports plugins `--no-install` left out.
401
410
  ...(parsed.noInstall === true ? {} : { lock: lockWorkspace }),
402
411
  })
412
+ return exitAfter(code, steps)
403
413
  }
404
414
 
405
415
  /**
@@ -417,8 +427,10 @@ async function lockWorkspace(root: string): Promise<{ code: number; said: string
417
427
  p.exited,
418
428
  ])
419
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.
420
432
  if (code === 0) process.stderr.write(err)
421
- return { code, said: code === 0 ? out : out + err }
433
+ return { code, said: code === 0 ? Bun.stripANSI(out) : out + err }
422
434
  }
423
435
 
424
436
  /** A report row: what the tool did, e.g. `['ran', 'bun add -d …']`. */
@@ -499,17 +511,36 @@ async function prepareRepo(
499
511
  const missing = missingPackages(root, all, version)
500
512
  if (missing.length > 0) {
501
513
  steps.push(
502
- args.dry ? ['install', missing.join(' ')] : ['ran', await install(root, missing, version)],
514
+ args.dry ? ['install', missing.join(' ')] : await ran(() => install(root, missing, version)),
503
515
  )
504
516
  }
505
517
  // Last: the package removed may be the one running this.
506
518
  const listed = listedPackages(root, unwanted)
507
519
  if (listed.length > 0) {
508
- 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)))
509
521
  }
510
522
  return steps
511
523
  }
512
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
+
513
544
  /**
514
545
  * `--keep`: `.vx/workspace.ts` declaring `turbo()` or `nx()`, so the
515
546
  * runner's own config stays the source, beside the plugins the repo calls
@@ -553,7 +584,7 @@ async function keep(
553
584
  args,
554
585
  )
555
586
  if (args.json)
556
- 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}`)
557
588
  const ignore = existsSync(path.join(root, '.vx', '.gitignore')) ? [] : ['.vx/.gitignore']
558
589
  if (!args.dry) {
559
590
  if (before === undefined) {
@@ -602,7 +633,7 @@ async function keep(
602
633
  )
603
634
  return 0
604
635
  }
605
- 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))
606
637
  // A run reads only the lock; with the plugins installed it is written
607
638
  // now, else `next` names `vx lock` after the install.
608
639
  const lock = !args.dry && !next.includes(' lock && ') ? await lockWorkspace(root) : undefined
@@ -625,7 +656,7 @@ async function keep(
625
656
  ],
626
657
  ]
627
658
  ui.print('out', ui.report(`vx-migrate ${VERSION}`, rows))
628
- return lock?.code ?? 0
659
+ return exitAfter(lock?.code ?? 0, steps)
629
660
  }
630
661
 
631
662
  /** `vx lock`'s outcome as the report's row. */
@@ -658,7 +689,7 @@ export function adoptionNext(
658
689
  (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
659
690
  )
660
691
  // `vx init` installs vx globally first: vx alone, never a package
661
- // manager's runner (owner, 2026-10-22: vx owns the command).
692
+ // manager's runner (2026-10-22: vx owns the command).
662
693
  const run = `vx run ${firstTask(path.join(root, source), runner)} --all`
663
694
  if (missing.length === 0) return run
664
695
  const pinned = version === null ? missing : missing.map((p) => `${p}@${version}`)
@@ -687,7 +718,7 @@ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
687
718
  /**
688
719
  * package.json scripts → one vx.config.ts per package, and the workspace
689
720
  * file declaring the lockfile plugin its lockfile calls for: `vx init`'s
690
- * mapper until core stopped reading package.json (M, 2026-10-14).
721
+ * mapper until core stopped reading package.json (2026-10-14).
691
722
  */
692
723
  async function scripts(
693
724
  root: string,
@@ -725,12 +756,8 @@ async function scripts(
725
756
  `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
726
757
  ]
727
758
  : []
728
- const installed = await prepareRepo(
729
- root,
730
- plugins.map((p) => p.pkg),
731
- args,
732
- )
733
- return applyMigration({
759
+ const installed = await prepareRepo(root, [...new Set(plugins.map((p) => p.pkg))], args)
760
+ const code = await applyMigration({
734
761
  root,
735
762
  metas,
736
763
  plan,
@@ -748,6 +775,7 @@ async function scripts(
748
775
  plugins,
749
776
  ...(args.noInstall === true ? {} : { lock: lockWorkspace }),
750
777
  })
778
+ return exitAfter(code, installed)
751
779
  }
752
780
 
753
781
  /**