@vzn/vx 0.0.0 → 0.0.12

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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,364 @@
1
+ // Turbo → vx mapping. Reads turbo.json (tasks in turbo 2, pipeline in
2
+ // turbo 1), per-package turbo.json `extends` overlays, and each
3
+ // package's scripts. A task is emitted for a package only when the
4
+ // package declares the script (turbo semantics). Global fields become
5
+ // a root vx-preset.ts that configs import and spread — TypeScript
6
+ // composition replaces turbo's global config.
7
+
8
+ import path from 'node:path'
9
+ import { relPosix, UserError } from '../util/index.js'
10
+ import type { ProjectMeta } from '../workspace/index.js'
11
+ import type { GeneratedProject, GeneratedTask, MigrationPlan, RawExpr } from './migrate.js'
12
+
13
+ interface TurboTask {
14
+ dependsOn?: string[]
15
+ inputs?: string[]
16
+ outputs?: string[]
17
+ env?: string[]
18
+ passThroughEnv?: string[]
19
+ cache?: boolean
20
+ persistent?: boolean
21
+ [key: string]: unknown
22
+ }
23
+
24
+ interface TurboJson {
25
+ tasks?: Record<string, TurboTask>
26
+ pipeline?: Record<string, TurboTask>
27
+ globalDependencies?: string[]
28
+ globalEnv?: string[]
29
+ globalPassThroughEnv?: string[]
30
+ }
31
+
32
+ const KNOWN_TASK_KEYS = new Set([
33
+ 'dependsOn',
34
+ 'inputs',
35
+ 'outputs',
36
+ 'env',
37
+ 'passThroughEnv',
38
+ 'cache',
39
+ 'persistent',
40
+ 'extends',
41
+ ])
42
+
43
+ const PRESET_FILE = 'vx-preset.ts'
44
+
45
+ interface Globals {
46
+ inputs: boolean
47
+ env: boolean
48
+ pass: boolean
49
+ }
50
+
51
+ async function readTurboJson(file: string, root: string): Promise<TurboJson> {
52
+ const text = await Bun.file(file).text()
53
+ try {
54
+ // turbo.json allows comments + trailing commas.
55
+ return (Bun.JSONC.parse(text) ?? {}) as TurboJson
56
+ } catch (err) {
57
+ const msg = err instanceof Error ? err.message : String(err)
58
+ throw new UserError(`failed to parse ${relPosix(root, file)}: ${msg}`)
59
+ }
60
+ }
61
+
62
+ function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
63
+ return cfg.tasks ?? cfg.pipeline ?? {}
64
+ }
65
+
66
+ function scriptsOf(meta: ProjectMeta): Record<string, string> {
67
+ return (meta.packageJson as unknown as { scripts?: Record<string, string> }).scripts ?? {}
68
+ }
69
+
70
+ /** Declared task names for a package: plain root keys, `pkg#name` keys
71
+ * for this package, and per-package turbo.json keys — in that order. */
72
+ function taskNamesFor(
73
+ pkgName: string,
74
+ rootTasks: Record<string, TurboTask>,
75
+ pkgTasks: Record<string, TurboTask> | undefined,
76
+ ): string[] {
77
+ const names: string[] = []
78
+ const push = (n: string): void => {
79
+ if (!names.includes(n)) names.push(n)
80
+ }
81
+ for (const key of Object.keys(rootTasks)) {
82
+ if (!key.includes('#')) push(key)
83
+ else if (key.startsWith(`${pkgName}#`)) push(key.slice(pkgName.length + 1))
84
+ }
85
+ for (const key of Object.keys(pkgTasks ?? {})) {
86
+ if (!key.includes('#')) push(key)
87
+ }
88
+ return names
89
+ }
90
+
91
+ export async function migrateTurbo(
92
+ root: string,
93
+ metas: readonly ProjectMeta[],
94
+ ): Promise<MigrationPlan> {
95
+ const rootCfg = await readTurboJson(path.join(root, 'turbo.json'), root)
96
+ const rootTasks = tasksOf(rootCfg)
97
+
98
+ const globalInputs = rootCfg.globalDependencies ?? []
99
+ const globalEnv = rootCfg.globalEnv ?? []
100
+ const globalPass = rootCfg.globalPassThroughEnv ?? []
101
+ const globals: Globals = {
102
+ inputs: globalInputs.length > 0,
103
+ env: globalEnv.length > 0,
104
+ pass: globalPass.length > 0,
105
+ }
106
+
107
+ const pkgTasksByName = new Map<string, Record<string, TurboTask>>()
108
+ for (const meta of metas) {
109
+ const file = path.join(meta.dir, 'turbo.json')
110
+ if (await Bun.file(file).exists()) {
111
+ pkgTasksByName.set(meta.name, tasksOf(await readTurboJson(file, root)))
112
+ }
113
+ }
114
+
115
+ const notes: string[] = []
116
+ for (const key of Object.keys(rootTasks)) {
117
+ if (key.startsWith('//#')) {
118
+ notes.push(`note: root task ${key} not migrated — vx has no workspace-root tasks`)
119
+ }
120
+ }
121
+
122
+ // First pass: which tasks does each package emit? Needed so dependsOn
123
+ // edges can be validated/dropped against the real emitted set.
124
+ const emitted = new Map<string, Set<string>>()
125
+ for (const meta of metas) {
126
+ const scripts = scriptsOf(meta)
127
+ const set = new Set<string>()
128
+ for (const name of taskNamesFor(meta.name, rootTasks, pkgTasksByName.get(meta.name))) {
129
+ if (scripts[name] !== undefined) set.add(name)
130
+ }
131
+ emitted.set(meta.name, set)
132
+ }
133
+
134
+ const projects: GeneratedProject[] = []
135
+ for (const meta of metas) {
136
+ const scripts = scriptsOf(meta)
137
+ const pkgTasks = pkgTasksByName.get(meta.name)
138
+ const own = emitted.get(meta.name)!
139
+ const used = new Set<string>()
140
+ const tasks: GeneratedTask[] = []
141
+ for (const name of taskNamesFor(meta.name, rootTasks, pkgTasks)) {
142
+ if (!own.has(name)) continue
143
+ const def: TurboTask = {
144
+ ...rootTasks[name],
145
+ ...rootTasks[`${meta.name}#${name}`],
146
+ ...pkgTasks?.[name],
147
+ }
148
+ tasks.push(buildTask(name, def, scripts[name]!, own, emitted, globals, used))
149
+ }
150
+ projects.push({
151
+ name: meta.name,
152
+ dir: meta.dir,
153
+ importLines: presetImportLines(used, root, meta.dir),
154
+ tasks,
155
+ })
156
+ }
157
+
158
+ const extraFiles: MigrationPlan['extraFiles'] = []
159
+ if (globals.inputs || globals.env || globals.pass) {
160
+ extraFiles.push({
161
+ relPath: PRESET_FILE,
162
+ contents: renderPreset(globalInputs, globalEnv, globalPass),
163
+ })
164
+ }
165
+
166
+ return { headerNotes: [], projects, extraFiles, notes }
167
+ }
168
+
169
+ function buildTask(
170
+ name: string,
171
+ def: TurboTask,
172
+ command: string,
173
+ own: ReadonlySet<string>,
174
+ emitted: ReadonlyMap<string, ReadonlySet<string>>,
175
+ globals: Globals,
176
+ used: Set<string>,
177
+ ): GeneratedTask {
178
+ const todos: string[] = []
179
+ const persistent = def.persistent === true
180
+ const cacheEnabled = def.cache !== false && !persistent
181
+
182
+ for (const [key, value] of Object.entries(def)) {
183
+ if (KNOWN_TASK_KEYS.has(key)) continue
184
+ todos.push(
185
+ `turbo key ${JSON.stringify(key)} (${JSON.stringify(value)}) has no vx equivalent — ` +
186
+ 'map it manually',
187
+ )
188
+ }
189
+
190
+ const deps: string[] = []
191
+ for (const d of def.dependsOn ?? []) {
192
+ if (d.includes('$TURBO_ROOT$')) {
193
+ todos.push(
194
+ `dependsOn ${JSON.stringify(d)} uses $TURBO_ROOT$ — vx has no workspace-root tasks; ` +
195
+ 'restructure manually',
196
+ )
197
+ continue
198
+ }
199
+ if (d.startsWith('^')) {
200
+ deps.push(d)
201
+ continue
202
+ }
203
+ const hashAt = d.indexOf('#')
204
+ if (hashAt !== -1) {
205
+ const pkg = d.slice(0, hashAt)
206
+ const task = d.slice(hashAt + 1)
207
+ if (emitted.get(pkg)?.has(task)) deps.push(d)
208
+ else
209
+ todos.push(
210
+ `dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
211
+ )
212
+ continue
213
+ }
214
+ // Same-project dep on a script this package lacks: turbo silently
215
+ // skips the task there, so the edge simply doesn't exist.
216
+ if (own.has(d)) deps.push(d)
217
+ }
218
+
219
+ const envNames: string[] = []
220
+ for (const e of def.env ?? []) {
221
+ if (/[*?[\]!]/.test(e)) {
222
+ todos.push(
223
+ `env ${JSON.stringify(e)}: wildcards are not supported in vx env names — ` +
224
+ 'list explicit names in cache.inputs.env + exec.env.passThrough',
225
+ )
226
+ } else envNames.push(e)
227
+ }
228
+ const passNames: string[] = []
229
+ for (const e of def.passThroughEnv ?? []) {
230
+ if (/[*?[\]!]/.test(e)) {
231
+ todos.push(
232
+ `passThroughEnv ${JSON.stringify(e)}: wildcards are not supported — list explicit names`,
233
+ )
234
+ } else passNames.push(e)
235
+ }
236
+
237
+ const passThrough: (string | RawExpr)[] = []
238
+ if (globals.env) {
239
+ passThrough.push({ raw: '...globalEnvInputs' })
240
+ used.add('globalEnvInputs')
241
+ }
242
+ if (globals.pass) {
243
+ passThrough.push({ raw: '...globalPassThroughEnv' })
244
+ used.add('globalPassThroughEnv')
245
+ }
246
+ passThrough.push(...envNames, ...passNames)
247
+
248
+ const exec: Record<string, unknown> = { command }
249
+ if (passThrough.length > 0) exec.env = { passThrough }
250
+ if (persistent) {
251
+ exec.persistent = {}
252
+ todos.push(
253
+ 'persistent task — set persistent.readyWhen (regex matched against output) so ' +
254
+ 'dependents unblock on readiness, and consider exec.timeout to bound the wait',
255
+ )
256
+ }
257
+
258
+ const task: Record<string, unknown> = { exec }
259
+ if (deps.length > 0) task.dependsOn = deps
260
+
261
+ if (cacheEnabled) {
262
+ const files: (string | RawExpr)[] = []
263
+ const wsFiles: (string | RawExpr)[] = []
264
+ // globalDependencies are workspace-root-relative by definition —
265
+ // they map to inputs.workspaceFiles, not project-relative files.
266
+ if (globals.inputs) {
267
+ wsFiles.push({ raw: '...globalInputs' })
268
+ used.add('globalInputs')
269
+ }
270
+ if (def.inputs === undefined) {
271
+ // Turbo's default input set is every package file.
272
+ files.push('**/*')
273
+ } else {
274
+ for (const i of def.inputs) {
275
+ if (i === '$TURBO_DEFAULT$') {
276
+ files.push('**/*')
277
+ continue
278
+ }
279
+ const neg = i.startsWith('!')
280
+ const body = neg ? i.slice(1) : i
281
+ if (body.startsWith('$TURBO_ROOT$/')) {
282
+ wsFiles.push((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length))
283
+ } else if (i.includes('$TURBO_ROOT$')) {
284
+ todos.push(
285
+ `input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
286
+ 'prefix (→ cache.inputs.workspaceFiles) — map manually',
287
+ )
288
+ } else files.push(i)
289
+ }
290
+ }
291
+
292
+ const outFiles: string[] = []
293
+ const wsOutFiles: string[] = []
294
+ for (const o of def.outputs ?? []) {
295
+ if (o.startsWith('!')) {
296
+ todos.push(
297
+ `output ${JSON.stringify(o)}: vx outputs have no negation — narrow the positive ` +
298
+ 'globs instead',
299
+ )
300
+ } else if (o.startsWith('$TURBO_ROOT$/')) {
301
+ wsOutFiles.push(o.slice('$TURBO_ROOT$/'.length))
302
+ } else if (o.includes('$TURBO_ROOT$')) {
303
+ todos.push(
304
+ `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
305
+ 'prefix (→ cache.outputs.workspaceFiles) — map manually',
306
+ )
307
+ } else outFiles.push(o)
308
+ }
309
+
310
+ const cacheEnv: (string | RawExpr)[] = []
311
+ if (globals.env) cacheEnv.push({ raw: '...globalEnvInputs' })
312
+ cacheEnv.push(...envNames)
313
+
314
+ const inputs: Record<string, unknown> = { files }
315
+ if (wsFiles.length > 0) inputs.workspaceFiles = wsFiles
316
+ if (cacheEnv.length > 0) inputs.env = cacheEnv
317
+ const outputs: Record<string, unknown> = { files: outFiles }
318
+ if (wsOutFiles.length > 0) outputs.workspaceFiles = wsOutFiles
319
+ task.cache = { inputs, outputs }
320
+ }
321
+
322
+ return { name, todos, task }
323
+ }
324
+
325
+ function presetImportLines(used: ReadonlySet<string>, root: string, dir: string): string[] {
326
+ if (used.size === 0) return []
327
+ const rel = relPosix(dir, path.join(root, PRESET_FILE))
328
+ const spec = rel.startsWith('.') ? rel : `./${rel}`
329
+ return [`import { ${[...used].sort().join(', ')} } from '${spec}'`]
330
+ }
331
+
332
+ function renderPreset(inputs: string[], env: string[], pass: string[]): string {
333
+ const arr = (xs: string[]): string => `[${xs.map((x) => `'${x}'`).join(', ')}]`
334
+ const lines = [
335
+ '// Generated by `vx migrate` from turbo.json. TypeScript composition',
336
+ "// replaces turbo's global fields: each vx.config.ts imports these",
337
+ '// arrays and spreads them into the matching task fields.',
338
+ ]
339
+ if (inputs.length > 0) {
340
+ lines.push(
341
+ '',
342
+ '// From globalDependencies — workspace-root-relative, spread into each',
343
+ '// task’s cache.inputs.workspaceFiles (the $TURBO_ROOT$ equivalent).',
344
+ `export const globalInputs = ${arr(inputs)}`,
345
+ )
346
+ }
347
+ if (env.length > 0) {
348
+ lines.push(
349
+ '',
350
+ '// From globalEnv: cache inputs AND passed through to every task',
351
+ '// (vx child environments are isolated; see docs/schema.md).',
352
+ `export const globalEnvInputs = ${arr(env)}`,
353
+ )
354
+ }
355
+ if (pass.length > 0) {
356
+ lines.push(
357
+ '',
358
+ '// From globalPassThroughEnv: forwarded to every task, never hashed.',
359
+ `export const globalPassThroughEnv = ${arr(pass)}`,
360
+ )
361
+ }
362
+ lines.push('')
363
+ return lines.join('\n')
364
+ }
@@ -0,0 +1,219 @@
1
+ // `vx migrate [--dry] [--force]` — generate per-package vx.config.ts
2
+ // from an existing Turbo or Nx setup. Source auto-detect: turbo.json →
3
+ // Turbo; .nx/workspace-data/project-graph.json → Nx (the resolved
4
+ // snapshot). Mappers live in migrate-turbo.ts / migrate-nx.ts and
5
+ // return an IR; this file owns detection, TS emission, the overwrite
6
+ // guard, and the final report.
7
+
8
+ import path from 'node:path'
9
+ import { relPosix, UserError } from '../util/index.js'
10
+ import { findWorkspaceRoot, listProjects, loadWorkspace } from '../workspace/index.js'
11
+ import { migrateNx } from './migrate-nx.js'
12
+ import { migrateTurbo } from './migrate-turbo.js'
13
+
14
+ export interface MigrateArgs {
15
+ dry: boolean
16
+ force: boolean
17
+ from?: 'turbo' | 'nx'
18
+ error?: string
19
+ }
20
+
21
+ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
22
+ const out: MigrateArgs = { dry: false, force: false }
23
+ for (let i = 0; i < args.length; i++) {
24
+ const a = args[i]
25
+ if (a === '--dry') out.dry = true
26
+ else if (a === '--force') out.force = true
27
+ else if (a === '--from' || a?.startsWith('--from=')) {
28
+ const v = a === '--from' ? args[++i] : a.slice('--from='.length)
29
+ if (v !== 'turbo' && v !== 'nx') return { ...out, error: `--from must be turbo or nx` }
30
+ out.from = v
31
+ } else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
32
+ else return { ...out, error: `unexpected argument: ${a}` }
33
+ }
34
+ return out
35
+ }
36
+
37
+ /** Verbatim TS expression spliced into a generated array (preset spreads). */
38
+ export interface RawExpr {
39
+ readonly raw: string
40
+ }
41
+
42
+ export interface GeneratedTask {
43
+ name: string
44
+ /** Rendered as `// TODO(vx-migrate): …` above the task + listed in the report. */
45
+ todos: string[]
46
+ /** TaskConfig-shaped object; arrays may contain RawExpr splices.
47
+ * null = target has no vx representation (skipped; todos explain). */
48
+ task: Record<string, unknown> | null
49
+ }
50
+
51
+ export interface GeneratedProject {
52
+ /** package.json name */
53
+ name: string
54
+ /** absolute project dir */
55
+ dir: string
56
+ importLines: string[]
57
+ tasks: GeneratedTask[]
58
+ }
59
+
60
+ export interface MigrationPlan {
61
+ /** report lines printed right under the source line */
62
+ headerNotes: string[]
63
+ projects: GeneratedProject[]
64
+ /** extra root-relative files (e.g. the preset) */
65
+ extraFiles: { relPath: string; contents: string }[]
66
+ /** trailing report lines (e.g. implicit Nx deps) */
67
+ notes: string[]
68
+ }
69
+
70
+ export async function migrateCmd(args: readonly string[]): Promise<number> {
71
+ const parsed = parseMigrateArgs(args)
72
+ if (parsed.error) {
73
+ process.stderr.write(`vx migrate: ${parsed.error}\n`)
74
+ return 1
75
+ }
76
+ const root = await findWorkspaceRoot(process.cwd())
77
+ const metas = await listProjects(await loadWorkspace(root))
78
+
79
+ const hasTurbo = await Bun.file(path.join(root, 'turbo.json')).exists()
80
+ const graphRel = path.join('.nx', 'workspace-data', 'project-graph.json')
81
+ const hasGraph = await Bun.file(path.join(root, graphRel)).exists()
82
+ const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
83
+
84
+ // Evaluating teams routinely have both runners checked in — never
85
+ // ask anyone to delete anything; --from disambiguates.
86
+ if (parsed.from === undefined && hasTurbo && (hasGraph || hasNxJson)) {
87
+ throw new UserError(
88
+ 'both turbo.json and an nx workspace are present — pass --from turbo or --from nx',
89
+ )
90
+ }
91
+ if (parsed.from === 'turbo' && !hasTurbo) {
92
+ throw new UserError('--from turbo, but no turbo.json at the workspace root')
93
+ }
94
+
95
+ let source: string
96
+ let plan: MigrationPlan
97
+ if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
98
+ if (hasGraph) {
99
+ source = '.nx/workspace-data/project-graph.json'
100
+ plan = await migrateNx(root, metas)
101
+ } else if (hasNxJson || parsed.from === 'nx') {
102
+ // Modern Nx stores the graph in SQLite — the JSON snapshot only
103
+ // exists when exported explicitly.
104
+ throw new UserError(
105
+ 'no resolved Nx graph found — export one with ' +
106
+ '`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx migrate',
107
+ )
108
+ } else {
109
+ throw new UserError('nothing to migrate: no turbo.json or nx.json at the workspace root')
110
+ }
111
+ } else if (hasTurbo) {
112
+ source = 'turbo.json'
113
+ plan = await migrateTurbo(root, metas)
114
+ } else {
115
+ throw new UserError('nothing to migrate: no turbo.json or nx.json at the workspace root')
116
+ }
117
+
118
+ const files: { relPath: string; abs: string; contents: string }[] = []
119
+ for (const p of plan.projects) {
120
+ if (p.tasks.length === 0) continue
121
+ const abs = path.join(p.dir, 'vx.config.ts')
122
+ files.push({ relPath: relPosix(root, abs), abs, contents: renderConfigFile(source, p) })
123
+ }
124
+ for (const f of plan.extraFiles) {
125
+ files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
126
+ }
127
+
128
+ if (!parsed.dry && !parsed.force) {
129
+ const conflicts: string[] = []
130
+ for (const p of plan.projects) {
131
+ if (p.tasks.length === 0) continue
132
+ const meta = metas.find((m) => m.dir === p.dir)
133
+ if (meta?.configPath) conflicts.push(relPosix(root, meta.configPath))
134
+ }
135
+ for (const f of plan.extraFiles) {
136
+ if (await Bun.file(path.join(root, f.relPath)).exists()) conflicts.push(f.relPath)
137
+ }
138
+ if (conflicts.length > 0) {
139
+ throw new UserError(
140
+ 'refusing to overwrite existing files (pass --force to overwrite):\n' +
141
+ ` ${conflicts.join('\n ')}`,
142
+ )
143
+ }
144
+ }
145
+
146
+ if (parsed.dry) {
147
+ for (const f of files) {
148
+ process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
149
+ }
150
+ } else {
151
+ for (const f of files) await Bun.write(f.abs, f.contents)
152
+ }
153
+
154
+ const todoList: string[] = []
155
+ let clean = 0
156
+ for (const p of plan.projects) {
157
+ for (const t of p.tasks) {
158
+ if (t.todos.length === 0 && t.task !== null) clean++
159
+ for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
160
+ }
161
+ }
162
+ const report: string[] = [`vx migrate: ${source} → vx.config.ts`]
163
+ for (const n of plan.headerNotes) report.push(`note: ${n}`)
164
+ report.push(
165
+ '',
166
+ `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
167
+ `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
168
+ )
169
+ for (const line of todoList) report.push(` ${line}`)
170
+ report.push(...plan.notes)
171
+ report.push(parsed.dry ? 'files (dry run, nothing written):' : 'files written:')
172
+ for (const f of files) report.push(` ${f.relPath}`)
173
+ process.stdout.write(`${report.join('\n')}\n`)
174
+ return 0
175
+ }
176
+
177
+ // ─── TS emission ──────────────────────────────────────────────────────
178
+
179
+ const IDENT = /^[A-Za-z_$][\w$]*$/
180
+
181
+ function quote(s: string): string {
182
+ return `'${s.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
183
+ }
184
+
185
+ function isRawExpr(v: unknown): v is RawExpr {
186
+ return typeof v === 'object' && v !== null && typeof (v as RawExpr).raw === 'string'
187
+ }
188
+
189
+ function renderValue(v: unknown, indent: string): string {
190
+ if (isRawExpr(v)) return v.raw
191
+ if (typeof v === 'string') return quote(v)
192
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
193
+ if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
194
+ const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
195
+ if (entries.length === 0) return '{}'
196
+ const inner = `${indent} `
197
+ const body = entries.map(
198
+ ([k, x]) => `${inner}${IDENT.test(k) ? k : quote(k)}: ${renderValue(x, inner)},`,
199
+ )
200
+ return `{\n${body.join('\n')}\n${indent}}`
201
+ }
202
+
203
+ function renderConfigFile(source: string, p: GeneratedProject): string {
204
+ const lines: string[] = [
205
+ `// Generated by \`vx migrate\` from ${source}. Review the TODO(vx-migrate)`,
206
+ "// comments, then wrap the object in defineProject() from '@vzn/vx' for",
207
+ '// editor type-checking.',
208
+ ]
209
+ if (p.importLines.length > 0) lines.push(...p.importLines)
210
+ lines.push('', 'export default {', ' tasks: {')
211
+ for (const t of p.tasks) {
212
+ for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
213
+ if (t.task === null) continue // skipped target — the TODO above explains
214
+ const key = IDENT.test(t.name) ? t.name : quote(t.name)
215
+ lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
216
+ }
217
+ lines.push(' },', '}', '')
218
+ return lines.join('\n')
219
+ }