@vzn/vx-migrate 0.0.646 → 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/README.md +68 -61
- package/package.json +3 -3
- package/src/adopt.ts +53 -12
- package/src/bin.ts +21 -1
- package/src/glob-grammar.ts +82 -0
- package/src/migrate-nx.ts +7 -4
- package/src/migrate-scripts.ts +10 -102
- package/src/migrate.ts +53 -25
- package/src/nx/index.ts +19 -5
- package/src/nx/nx-inputs.ts +44 -9
- package/src/nx/nx-map.ts +36 -11
- package/src/nx/nx-outputs.ts +16 -6
- package/src/nx/nx-upstream.ts +10 -2
- package/src/nx-cache/index.ts +60 -50
- package/src/nx-exec.cjs +2 -2
- package/src/package-fs.ts +6 -1
- package/src/packages.ts +9 -7
- package/src/plugin-gaps.ts +32 -6
- package/src/remote-cache.ts +5 -1
- package/src/remote-deadline.ts +7 -1
- package/src/remote-refusal.ts +31 -0
- package/src/remote-retry.ts +9 -4
- package/src/remote-url.ts +28 -0
- package/src/script-command.ts +110 -4
- package/src/shared-outputs.ts +8 -2
- package/src/tracked-outputs.ts +1 -1
- package/src/turbo/index.ts +18 -8
- package/src/turbo/turbo-map.ts +89 -15
- package/src/turbo-cache/index.ts +205 -89
- package/src/turbo-cache/oidc.ts +25 -14
- package/src/turbo-cache/scope.ts +5 -3
- package/src/vite-task/vite-task-map.ts +11 -6
- package/src/workspace-plugins.ts +5 -2
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 (
|
|
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',
|
|
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.
|
|
78
|
-
return
|
|
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 (
|
|
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',
|
|
158
|
+
return ['pnpm', 'remove', ...pnpmRoot(root), ...packages]
|
|
133
159
|
case 'yarn':
|
|
134
|
-
return
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/glob-grammar.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
}
|
package/src/migrate-scripts.ts
CHANGED
|
@@ -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
|
|
132
|
-
*
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
-
|
|
151
|
+
ui.say('info', USAGE, 'out')
|
|
147
152
|
return 0
|
|
148
153
|
}
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(' ')] :
|
|
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(' ')] :
|
|
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)
|
|
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)
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
/**
|