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