@vzn/vx-migrate 0.0.0 → 0.0.485

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.
@@ -0,0 +1,400 @@
1
+ // Turbo → vx migration: the RENDERING half. The mapping itself lives in
2
+ // `turbo()` (which runs it live); this file turns turbo's global
3
+ // fields into a root vx-preset.ts that each generated config imports and
4
+ // spreads — TypeScript composition replaces turbo's global config.
5
+
6
+ import path from 'node:path'
7
+ import {
8
+ type GeneratedProject,
9
+ type MigrationFormat,
10
+ type MigrationPlan,
11
+ PERSISTENT_TODO,
12
+ type ProjectMeta,
13
+ quoteTsLiteral as quote,
14
+ } from '@vzn/vx'
15
+ import {
16
+ mapTurboWorkspace,
17
+ rootTaskProject,
18
+ type TurboGlobal,
19
+ type TurboMappedProject,
20
+ } from './turbo/turbo-map.js'
21
+ import { relPosix } from './paths.js'
22
+ import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
23
+ import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
24
+ import { adoptedToolNotes } from './workspace-notes.js'
25
+
26
+ /** What a task's `npm_package_*` read: the manifest, so a bump reaches them. */
27
+ const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
28
+
29
+ /** The preset takes the configs' extension: plain arrays either way. */
30
+ function presetFile(format: MigrationFormat): string {
31
+ return `vx-preset.${format}`
32
+ }
33
+
34
+ /** The preset export each global field becomes. */
35
+ const PRESET_NAMES: Record<TurboGlobal, string> = {
36
+ inputs: 'globalInputs',
37
+ env: 'globalEnvInputs',
38
+ pass: 'globalPassThroughEnv',
39
+ }
40
+
41
+ export async function migrateTurbo(
42
+ root: string,
43
+ metas: readonly ProjectMeta[],
44
+ format: MigrationFormat = 'ts',
45
+ ): Promise<MigrationPlan> {
46
+ const tracked = await trackedFiles(root)
47
+ const mapping = await mapTurboWorkspace(root, await withRootProject(root, metas), {
48
+ splice: (kind) => [{ raw: `...${PRESET_NAMES[kind]}` }],
49
+ persistentTodo: PERSISTENT_TODO,
50
+ manifestField: (key) => ({ raw: `pkg.${key}` }),
51
+ ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
52
+ // The file this writes is each task's config.
53
+ ownConfig: () => `vx.config.${format}`,
54
+ sourceNames: (dirs) => spelledNames(root, dirs, tracked),
55
+ ignored: (rels) => gitIgnored(root, rels),
56
+ })
57
+ // Turbo never cleans an output and vx cleans one before every run: a
58
+ // written `dist/**` beside a committed `dist/keep.js` deleted it on the
59
+ // first run. turbo() takes such files back each run; the configs must.
60
+ if (tracked !== null)
61
+ for (const [id, todo] of spareTrackedOutputs(root, mapping.projects, tracked)) {
62
+ const at = id.lastIndexOf('#')
63
+ const p = mapping.projects.find((x) => x.name === id.slice(0, at))
64
+ p?.tasks.find((t) => t.name === id.slice(at + 1))?.todos.push(todo)
65
+ }
66
+
67
+ const shared = hoistTaskEnv(mapping.projects)
68
+ const probes = nameProbes(mapping.projects)
69
+ const projects: GeneratedProject[] = mapping.projects.map((p) => {
70
+ const used = new Set<string>([
71
+ ...(shared.usedBy.get(p.name) ?? []),
72
+ ...(probes.usedBy.get(p.name) ?? []),
73
+ ])
74
+ for (const t of p.tasks) for (const kind of t.uses) used.add(PRESET_NAMES[kind])
75
+ const readsManifest = p.tasks.some((t) => JSON.stringify(t.task ?? {}).includes('"pkg.'))
76
+ return {
77
+ name: p.name,
78
+ dir: p.dir,
79
+ importLines: [
80
+ ...(readsManifest ? [MANIFEST_IMPORT] : []),
81
+ ...presetImportLines(used, root, p.dir, format),
82
+ ],
83
+ tasks: p.tasks.map(({ name, todos, task }) => ({ name, todos, task })),
84
+ }
85
+ })
86
+
87
+ const { inputs, env, pass } = mapping.globals
88
+ const extraFiles: MigrationPlan['extraFiles'] = []
89
+ if (
90
+ inputs.length > 0 ||
91
+ env.length > 0 ||
92
+ pass.length > 0 ||
93
+ shared.lists.length > 0 ||
94
+ probes.named.length > 0
95
+ ) {
96
+ extraFiles.push({
97
+ relPath: presetFile(format),
98
+ contents: renderPreset(inputs, env, pass, shared.lists, probes.named),
99
+ })
100
+ }
101
+
102
+ return {
103
+ headerNotes: await adoptedToolNotes(root, {
104
+ plugin: 'turbo',
105
+ config: 'turbo.json',
106
+ runner: 'turbo',
107
+ keys: (lock) => `Turbo keys each package on its own ${lock} entries`,
108
+ }),
109
+ projects,
110
+ extraFiles,
111
+ notes: mapping.notes,
112
+ }
113
+ }
114
+
115
+ /** Source and env-example files a framework build reads its variables from. */
116
+ const SPELLS_ENV =
117
+ /\.(c|m)?(j|t)sx?$|\.(vue|svelte|astro|html)$|(^|\/)\.env\.(example|sample|template)$/
118
+
119
+ /**
120
+ * The upper-case names the tracked source under `dirs` spells (`NEXT_PUBLIC_API`
121
+ * in `process.env.NEXT_PUBLIC_API` or an `.env.example`), sorted. Without
122
+ * git, none: the note still names the framework's prefix.
123
+ */
124
+ async function spelledNames(
125
+ root: string,
126
+ dirs: readonly string[],
127
+ tracked: readonly string[] | null,
128
+ ): Promise<string[]> {
129
+ if (tracked === null) return []
130
+ const prefixes = dirs.map((dir) => {
131
+ const rel = relPosix(root, dir)
132
+ return rel === '' || rel === '.' ? '' : `${rel}/`
133
+ })
134
+ const names = new Set<string>()
135
+ for (const f of tracked) {
136
+ if (!SPELLS_ENV.test(f) || !prefixes.some((p) => f.startsWith(p))) continue
137
+ const text = await Bun.file(path.join(root, f))
138
+ .text()
139
+ .catch(() => '')
140
+ for (const m of text.matchAll(/\b[A-Z][A-Z0-9_]*_[A-Z0-9_]+\b/g)) names.add(m[0])
141
+ }
142
+ return [...names].sort()
143
+ }
144
+
145
+ /**
146
+ * The workspace root as a project when turbo.json declares `//#` tasks and
147
+ * no glob lists the root: the `vx.config.ts` written there is what makes
148
+ * core read it as one (D-39), as `turbo()`'s `discover` hook does live.
149
+ * Five of eleven real Turbo repos had root
150
+ * tasks, and 36 edges to them were dropped (G-49).
151
+ */
152
+ async function withRootProject(
153
+ root: string,
154
+ metas: readonly ProjectMeta[],
155
+ ): Promise<readonly ProjectMeta[]> {
156
+ const named = await rootTaskProject(root)
157
+ // A root the globs list already is a project of this name; a package
158
+ // that took the name leaves the root none to take.
159
+ if (named === null || metas.some((m) => m.name === named.name)) return metas
160
+ const packageJson = (await Bun.file(path.join(root, 'package.json')).json()) as never
161
+ return [...metas, { ...named, packageJson, configPath: null }]
162
+ }
163
+
164
+ function presetImportLines(
165
+ used: ReadonlySet<string>,
166
+ root: string,
167
+ dir: string,
168
+ format: MigrationFormat,
169
+ ): string[] {
170
+ if (used.size === 0) return []
171
+ // `.js` for the `.ts` file: Bun takes it to the `.ts`, and a user's
172
+ // `tsc` accepts it under every resolution mode, where `.ts` fails
173
+ // without `allowImportingTsExtensions` (TS5097; create-t3-turbo).
174
+ const file = format === 'ts' ? 'vx-preset.js' : presetFile(format)
175
+ const rel = relPosix(dir, path.join(root, file))
176
+ const spec = rel.startsWith('.') ? rel : `./${rel}`
177
+ return [`import { ${[...used].sort().join(', ')} } from '${spec}'`]
178
+ }
179
+
180
+ /** A task's own env names, the same in several packages: one preset export. */
181
+ interface SharedList {
182
+ readonly name: string
183
+ readonly task: string
184
+ readonly values: readonly string[]
185
+ }
186
+
187
+ /** Fewer names than this stay inline: an import costs more than it saves. */
188
+ const HOIST_MIN = 3
189
+
190
+ /**
191
+ * Turbo states a task's `env` once, in the root turbo.json; written inline,
192
+ * vercel/ai's 63 `build` names repeated twice in each of ~100 configs. A
193
+ * task's own `cache.inputs.env` names (after the spliced globals) that two
194
+ * or more configs share become one preset export, spread where they stood
195
+ * and where they lead `exec.env.passThrough` (its `passThroughEnv` names
196
+ * follow): the evaluated arrays, and so every key, are unchanged. A
197
+ * package whose list differs (its own turbo.json) keeps it inline unless
198
+ * that list is shared too.
199
+ */
200
+ function hoistTaskEnv(projects: readonly TurboMappedProject[]): {
201
+ lists: SharedList[]
202
+ usedBy: Map<string, Set<string>>
203
+ } {
204
+ const lists = (t: Task): (unknown[] | undefined)[] => [
205
+ t.cache?.inputs?.env,
206
+ t.exec?.env?.passThrough,
207
+ ]
208
+ // The trailing run of names after the spliced globals, or null when the
209
+ // list is all names or mixes them in.
210
+ const own = (list: unknown[] | undefined): string[] | null => {
211
+ if (list === undefined) return null
212
+ const at = list.findIndex((v) => typeof v === 'string')
213
+ if (at < 0 || list.slice(at).some((v) => typeof v !== 'string')) return null
214
+ return list.slice(at) as string[]
215
+ }
216
+ const keyOf = (task: string, names: readonly string[]) => JSON.stringify([task, names])
217
+ const counts = new Map<string, number>()
218
+ for (const p of projects)
219
+ for (const t of p.tasks) {
220
+ if (t.task === null) continue
221
+ const names = own((t.task as Task).cache?.inputs?.env)
222
+ if (names === null || names.length < HOIST_MIN) continue
223
+ const k = keyOf(t.name, names)
224
+ counts.set(k, (counts.get(k) ?? 0) + 1)
225
+ }
226
+ // Per task, the most used list takes `<task>Env`, the next `<task>Env2`.
227
+ const ranked = [...counts]
228
+ .filter(([, n]) => n >= 2)
229
+ .sort(([a, m], [b, n]) => n - m || (a < b ? -1 : 1))
230
+ const taken = new Set(Object.values(PRESET_NAMES))
231
+ const nameOf = new Map<string, string>()
232
+ const shared: SharedList[] = []
233
+ for (const [k] of ranked) {
234
+ const [task, values] = JSON.parse(k) as [string, string[]]
235
+ const base = `${identifier(task)}Env`
236
+ let name = base
237
+ for (let i = 2; taken.has(name); i++) name = `${base}${i}`
238
+ taken.add(name)
239
+ nameOf.set(k, name)
240
+ shared.push({ name, task, values })
241
+ }
242
+ const usedBy = new Map<string, Set<string>>()
243
+ for (const p of projects)
244
+ for (const t of p.tasks) {
245
+ if (t.task === null) continue
246
+ const env = own((t.task as Task).cache?.inputs?.env)
247
+ const name = env === null ? undefined : nameOf.get(keyOf(t.name, env))
248
+ if (name === undefined) continue
249
+ for (const list of lists(t.task as Task)) {
250
+ const names = own(list)
251
+ if (names === null || list === undefined) continue
252
+ if (!env!.every((n, i) => names[i] === n)) continue
253
+ list.splice(list.length - names.length, env!.length, { raw: `...${name}` })
254
+ let used = usedBy.get(p.name)
255
+ if (used === undefined) usedBy.set(p.name, (used = new Set()))
256
+ used.add(name)
257
+ }
258
+ }
259
+ return { lists: shared.sort((a, b) => (a.name < b.name ? -1 : 1)), usedBy }
260
+ }
261
+
262
+ type Task = {
263
+ cache?: { inputs?: { env?: unknown[] } }
264
+ exec?: { env?: { passThrough?: unknown[] } }
265
+ }
266
+
267
+ /** `test:update` → `testUpdate`; a leading digit gets a `task` prefix. */
268
+ function identifier(task: string): string {
269
+ const words = task.split(/[^A-Za-z0-9]+/).filter((w) => w !== '')
270
+ const id = words
271
+ .map((w, i) => (i === 0 ? w[0]!.toLowerCase() + w.slice(1) : w[0]!.toUpperCase() + w.slice(1)))
272
+ .join('')
273
+ return id === '' || /^[0-9]/.test(id) ? `task${id[0]?.toUpperCase() ?? ''}${id.slice(1)}` : id
274
+ }
275
+
276
+ /**
277
+ * The `.env` probes, by name: Turbo hashes a task's `.env` files although
278
+ * git ignores them, and a glob over git's files sees none, so the mapper
279
+ * keys them through a shell line that prints each file. Inline in every
280
+ * package's config, that line read as noise nobody could review.
281
+ */
282
+ type Probe = { name: string; command: string; doc: string; field: string }
283
+
284
+ const PROBES: readonly Probe[] = [
285
+ {
286
+ name: 'dotenvFiles',
287
+ command: DOTENV_PROBE_TOP,
288
+ doc: "Each `.env` file in the task's directory, name and bytes",
289
+ field: 'runtime',
290
+ },
291
+ {
292
+ name: 'dotenvFilesDeep',
293
+ command: DOTENV_PROBE,
294
+ doc: "Each `.env` file under the task's directory, name and bytes",
295
+ field: 'runtime',
296
+ },
297
+ ]
298
+
299
+ function nameProbes(projects: readonly TurboMappedProject[]): {
300
+ named: Probe[]
301
+ usedBy: Map<string, Set<string>>
302
+ } {
303
+ const named: Probe[] = []
304
+ const rootGlobs: Probe[] = []
305
+ const usedBy = new Map<string, Set<string>>()
306
+ const probeFor = (v: unknown): Probe | undefined => {
307
+ const known = PROBES.find((x) => x.command === v) ?? rootGlobs.find((x) => x.command === v)
308
+ if (known !== undefined || typeof v !== 'string' || !v.startsWith(DOTENV_GLOBS_HEAD))
309
+ return known
310
+ // Each root `.env` glob set is its own line; the globals' is the one most share.
311
+ const probe: Probe = {
312
+ name: `dotenvRootFiles${rootGlobs.length === 0 ? '' : rootGlobs.length + 1}`,
313
+ command: v,
314
+ doc: "The `.env` files the workspace's root globs name, name and bytes",
315
+ field: 'workspaceRuntime',
316
+ }
317
+ rootGlobs.push(probe)
318
+ return probe
319
+ }
320
+ for (const p of projects)
321
+ for (const t of p.tasks) {
322
+ const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
323
+ for (const field of ['runtime', 'workspaceRuntime']) {
324
+ const list = inputs?.[field]
325
+ if (!Array.isArray(list)) continue
326
+ list.forEach((v, i) => {
327
+ const probe = probeFor(v)
328
+ if (probe === undefined) return
329
+ list[i] = { raw: probe.name }
330
+ if (!named.includes(probe)) named.push(probe)
331
+ let used = usedBy.get(p.name)
332
+ if (used === undefined) usedBy.set(p.name, (used = new Set()))
333
+ used.add(probe.name)
334
+ })
335
+ }
336
+ }
337
+ const order = [...PROBES, ...rootGlobs]
338
+ return { named: named.sort((a, b) => order.indexOf(a) - order.indexOf(b)), usedBy }
339
+ }
340
+
341
+ function renderPreset(
342
+ inputs: string[],
343
+ env: string[],
344
+ pass: string[],
345
+ shared: readonly SharedList[],
346
+ probes: readonly Probe[],
347
+ ): string {
348
+ // Escape each entry via the shared `quote()` — a turbo.json global (a file
349
+ // glob, or an env name a user hand-wrote) may contain a `'`/`\`/newline that
350
+ // would otherwise splice into a malformed, unloadable `vx-preset.ts`.
351
+ const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
352
+ const lines = [
353
+ // The same name the configs carry (`verb` in index.ts): `vx migrate` is
354
+ // no verb, and a reader who typed it got `unknown command`.
355
+ '// Generated by `vx-migrate` from turbo.json. TypeScript composition',
356
+ "// replaces turbo's global fields: each vx.config.ts imports these",
357
+ '// arrays and spreads them into the matching task fields.',
358
+ ]
359
+ if (inputs.length > 0) {
360
+ lines.push(
361
+ '',
362
+ '// From globalDependencies and what Turbo adds to them (the packages the',
363
+ '// root depends on, microfrontends configs) — workspace-root-relative,',
364
+ '// spread into each task’s cache.inputs.workspaceFiles.',
365
+ `export const globalInputs = ${arr(inputs)}`,
366
+ )
367
+ }
368
+ if (env.length > 0) {
369
+ lines.push(
370
+ '',
371
+ '// From globalEnv: cache inputs AND passed through to every task',
372
+ '// (vx child environments are isolated; see docs/schema.md).',
373
+ `export const globalEnvInputs = ${arr(env)}`,
374
+ )
375
+ }
376
+ if (pass.length > 0) {
377
+ lines.push(
378
+ '',
379
+ '// From globalPassThroughEnv: forwarded to every task, never hashed.',
380
+ `export const globalPassThroughEnv = ${arr(pass)}`,
381
+ )
382
+ }
383
+ for (const l of shared)
384
+ lines.push(
385
+ '',
386
+ `// turbo.json's \`${l.task}\` env: hashed and passed where a config spreads it.`,
387
+ `export const ${l.name} = ${arr([...l.values])}`,
388
+ )
389
+ for (const probe of probes) {
390
+ lines.push(
391
+ '',
392
+ `// ${probe.doc}. Turbo hashes`,
393
+ "// `.env` files although git ignores them, and a glob over git's files",
394
+ `// sees none: cache.inputs.${probe.field} keys them.`,
395
+ `export const ${probe.name} = ${quote(probe.command)}`,
396
+ )
397
+ }
398
+ lines.push('')
399
+ return lines.join('\n')
400
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,165 @@
1
+ // `vx-migrate [--from turbo|nx] [--dry] [--force] [--mjs]` — one
2
+ // vx.config.ts per workspace package from an existing Turbo or Nx
3
+ // setup. Source auto-detect: turbo.json → Turbo;
4
+ // .nx/workspace-data/project-graph.json or nx.json → Nx (the resolved graph, exported by nx if absent);
5
+ // The mappers return a plan; core's migration seam
6
+ // (`applyMigration`) renders, guards, writes and reports, so what this
7
+ // package writes reads exactly like what `vx init` writes.
8
+
9
+ import { mkdtemp, rm } from 'node:fs/promises'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import {
13
+ applyMigration,
14
+ findWorkspaceRoot,
15
+ isUserError,
16
+ listProjectMetas,
17
+ loadWorkspace,
18
+ type MigrationFormat,
19
+ type MigrationPlan,
20
+ UserError,
21
+ } from '@vzn/vx'
22
+ import { migrateNx, NX_GRAPH_REL } from './migrate-nx.js'
23
+ import { exportGraph } from './nx/export-graph.js'
24
+ import { migrateTurbo } from './migrate-turbo.js'
25
+ import { turboConfigFile } from './turbo/turbo-map.js'
26
+
27
+ export interface MigrateArgs {
28
+ dry: boolean
29
+ force: boolean
30
+ /** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
31
+ mjs: boolean
32
+ from?: 'turbo' | 'nx'
33
+ /** `--help` / `-h`: the usage on stdout, exit 0 (as `nx-env --help`). */
34
+ help?: boolean
35
+ error?: string
36
+ }
37
+
38
+ const USAGE = 'usage: vx-migrate [--from turbo|nx] [--dry] [--force] [--mjs]'
39
+
40
+ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
41
+ const out: MigrateArgs = { dry: false, force: false, mjs: false }
42
+ for (let i = 0; i < args.length; i++) {
43
+ const a = args[i]
44
+ if (a === '--dry') out.dry = true
45
+ else if (a === '--force') out.force = true
46
+ else if (a === '--mjs') out.mjs = true
47
+ else if (a === '--from' || a?.startsWith('--from=')) {
48
+ const v = a === '--from' ? args[++i] : a.slice('--from='.length)
49
+ if (v !== 'turbo' && v !== 'nx') {
50
+ return {
51
+ ...out,
52
+ error: `--from must be turbo or nx (package.json scripts: \`vx init\`)`,
53
+ }
54
+ }
55
+ out.from = v
56
+ } else if (a === '--help' || a === '-h') return { ...out, help: true }
57
+ else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}\n${USAGE}` }
58
+ else return { ...out, error: `unexpected argument: ${a}\n${USAGE}` }
59
+ }
60
+ return out
61
+ }
62
+
63
+ /**
64
+ * Core finds a root by a package manager's workspace file; a Rush repo can
65
+ * have none. Say which tool is there and what vx needs from it.
66
+ */
67
+ async function noRootReason(cwd: string, err: Error): Promise<Error> {
68
+ if (await Bun.file(path.join(cwd, 'rush.json')).exists()) {
69
+ return new UserError(
70
+ 'a Rush workspace: vx does not yet discover projects from rush.json (it finds them through package.json workspaces or pnpm-workspace.yaml)',
71
+ )
72
+ }
73
+ return err
74
+ }
75
+
76
+ /** The command: detect the source, map it, hand the plan to core. Returns the exit code. */
77
+ export async function migrateCmd(args: readonly string[]): Promise<number> {
78
+ const parsed = parseMigrateArgs(args)
79
+ if (parsed.help) {
80
+ process.stdout.write(`${USAGE}\n`)
81
+ return 0
82
+ }
83
+ if (parsed.error) {
84
+ process.stderr.write(`vx-migrate: ${parsed.error}\n`)
85
+ return 1
86
+ }
87
+ const root = await findWorkspaceRoot(process.cwd()).catch(async (err: unknown) => {
88
+ throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
89
+ })
90
+ const metas = await listProjectMetas(await loadWorkspace(root))
91
+
92
+ const turboFile = await turboConfigFile(root)
93
+ const hasTurbo = turboFile !== null
94
+ const hasGraph = await Bun.file(path.join(root, NX_GRAPH_REL)).exists()
95
+ const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
96
+
97
+ // Evaluating teams routinely have two runners checked in — never
98
+ // ask anyone to delete anything; --from disambiguates.
99
+ const found = (
100
+ [
101
+ ['turbo', hasTurbo, 'turbo.json'],
102
+ ['nx', hasGraph || hasNxJson, 'an nx workspace'],
103
+ ] as const
104
+ ).filter(([, present]) => present)
105
+ if (parsed.from === undefined && found.length > 1) {
106
+ const what = found.map(([, , label]) => label)
107
+ const listed =
108
+ what.length === 2
109
+ ? `both ${what[0]} and ${what[1]}`
110
+ : `${what.slice(0, -1).join(', ')} and ${what.at(-1)}`
111
+ throw new UserError(
112
+ `${listed} are present — pass ${found.map(([id]) => `--from ${id}`).join(' or ')}`,
113
+ )
114
+ }
115
+ if (parsed.from === 'turbo' && !hasTurbo) {
116
+ throw new UserError('--from turbo, but no turbo.json at the workspace root')
117
+ }
118
+
119
+ const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
120
+ let source: string
121
+ let plan: MigrationPlan
122
+ if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
123
+ if (hasGraph) {
124
+ source = NX_GRAPH_REL
125
+ plan = await migrateNx(root, metas, format)
126
+ } else if (hasNxJson || parsed.from === 'nx') {
127
+ // Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
128
+ // when exported. The workspace's own nx exports it, as `nx()` does, into
129
+ // a temp file; the user ran that step by hand until 2026-10-01.
130
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'vx-migrate-nx-'))
131
+ try {
132
+ const snapshot = path.join(tmp, 'project-graph.json')
133
+ const why = await exportGraph(root, snapshot)
134
+ if (why !== null) {
135
+ throw new UserError(
136
+ `no resolved Nx graph found, and exporting one failed (${why}) — export it with ` +
137
+ '`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx-migrate',
138
+ )
139
+ }
140
+ source = 'nx graph'
141
+ plan = await migrateNx(root, metas, format, snapshot)
142
+ } finally {
143
+ await rm(tmp, { recursive: true, force: true })
144
+ }
145
+ } else {
146
+ throw new UserError(
147
+ 'nothing to migrate: no turbo.json and no Nx workspace — ' +
148
+ 'for package.json scripts, run `vx init`',
149
+ )
150
+ }
151
+ } else {
152
+ source = path.basename(turboFile ?? 'turbo.json')
153
+ plan = await migrateTurbo(root, metas, format)
154
+ }
155
+ return applyMigration({
156
+ root,
157
+ metas,
158
+ plan,
159
+ source,
160
+ verb: 'vx-migrate',
161
+ dry: parsed.dry,
162
+ force: parsed.force,
163
+ format,
164
+ })
165
+ }
@@ -0,0 +1,29 @@
1
+ // The resolved Nx graph, exported by the workspace's own nx: `nx()` keys and
2
+ // keeps it, `vx-migrate --from nx` maps it once.
3
+
4
+ import { mkdir } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ /**
8
+ * `nx graph --file=<snapshot>` from the workspace's own `nx`. Returns the
9
+ * reason it could not, or null. Nx's daemon setting is the user's: with the
10
+ * daemon up the export is served from memory, without it Nx computes.
11
+ */
12
+ export async function exportGraph(root: string, snapshot: string): Promise<string | null> {
13
+ const bin = path.join(root, 'node_modules', '.bin', 'nx')
14
+ if (!(await Bun.file(bin).exists())) {
15
+ return `no ${path.relative(root, bin)} — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
16
+ }
17
+ // `discover` runs before the run opens (and makes) the cache dir.
18
+ await mkdir(path.dirname(snapshot), { recursive: true })
19
+ const proc = Bun.spawn([bin, 'graph', `--file=${snapshot}`], {
20
+ cwd: root,
21
+ env: process.env,
22
+ stdout: 'pipe',
23
+ stderr: 'pipe',
24
+ })
25
+ const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
26
+ if (code === 0 && (await Bun.file(snapshot).exists())) return null
27
+ const tail = err.trim().split('\n').slice(-3).join(' ')
28
+ return `nx graph --file exited ${code}${tail ? `: ${tail}` : ''}`
29
+ }