@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,401 +0,0 @@
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 { quote } from './migrate-emit.js'
12
- import type { GeneratedProject, GeneratedTask, MigrationPlan, RawExpr } from './migrate.js'
13
-
14
- interface TurboTask {
15
- dependsOn?: string[]
16
- inputs?: string[]
17
- outputs?: string[]
18
- env?: string[]
19
- passThroughEnv?: string[]
20
- cache?: boolean
21
- persistent?: boolean
22
- [key: string]: unknown
23
- }
24
-
25
- interface TurboJson {
26
- tasks?: Record<string, TurboTask>
27
- pipeline?: Record<string, TurboTask>
28
- globalDependencies?: string[]
29
- globalEnv?: string[]
30
- globalPassThroughEnv?: string[]
31
- }
32
-
33
- const KNOWN_TASK_KEYS = new Set([
34
- 'dependsOn',
35
- 'inputs',
36
- 'outputs',
37
- 'env',
38
- 'passThroughEnv',
39
- 'cache',
40
- 'persistent',
41
- 'extends',
42
- ])
43
-
44
- const PRESET_FILE = 'vx-preset.ts'
45
-
46
- interface Globals {
47
- inputs: boolean
48
- env: boolean
49
- pass: boolean
50
- }
51
-
52
- async function readTurboJson(file: string, root: string): Promise<TurboJson> {
53
- const text = await Bun.file(file).text()
54
- try {
55
- // turbo.json allows comments + trailing commas.
56
- return (Bun.JSONC.parse(text) ?? {}) as TurboJson
57
- } catch (err) {
58
- const msg = err instanceof Error ? err.message : String(err)
59
- throw new UserError(`failed to parse ${relPosix(root, file)}: ${msg}`)
60
- }
61
- }
62
-
63
- function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
64
- return cfg.tasks ?? cfg.pipeline ?? {}
65
- }
66
-
67
- function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
68
- // package.json is a system boundary — a script value is whatever the
69
- // file holds, not necessarily a string.
70
- return (meta.packageJson as unknown as { scripts?: Record<string, unknown> }).scripts ?? {}
71
- }
72
-
73
- /** A script value that can become `exec.command` verbatim. */
74
- function usableScript(value: unknown): value is string {
75
- return typeof value === 'string' && value.length > 0
76
- }
77
-
78
- function describeScript(value: unknown): string {
79
- if (value === null) return 'null'
80
- if (typeof value === 'string') return 'an empty string'
81
- if (Array.isArray(value)) return 'an array'
82
- return `a ${typeof value}`
83
- }
84
-
85
- /** Declared task names for a package: plain root keys, `pkg#name` keys
86
- * for this package, and per-package turbo.json keys — in that order. */
87
- function taskNamesFor(
88
- pkgName: string,
89
- rootTasks: Record<string, TurboTask>,
90
- pkgTasks: Record<string, TurboTask> | undefined,
91
- ): string[] {
92
- const names: string[] = []
93
- const push = (n: string): void => {
94
- if (!names.includes(n)) names.push(n)
95
- }
96
- for (const key of Object.keys(rootTasks)) {
97
- if (!key.includes('#')) push(key)
98
- else if (key.startsWith(`${pkgName}#`)) push(key.slice(pkgName.length + 1))
99
- }
100
- for (const key of Object.keys(pkgTasks ?? {})) {
101
- if (!key.includes('#')) push(key)
102
- }
103
- return names
104
- }
105
-
106
- export async function migrateTurbo(
107
- root: string,
108
- metas: readonly ProjectMeta[],
109
- ): Promise<MigrationPlan> {
110
- const rootCfg = await readTurboJson(path.join(root, 'turbo.json'), root)
111
- const rootTasks = tasksOf(rootCfg)
112
-
113
- const globalInputs = rootCfg.globalDependencies ?? []
114
- const globalEnv = rootCfg.globalEnv ?? []
115
- const globalPass = rootCfg.globalPassThroughEnv ?? []
116
- const globals: Globals = {
117
- inputs: globalInputs.length > 0,
118
- env: globalEnv.length > 0,
119
- pass: globalPass.length > 0,
120
- }
121
-
122
- const pkgTasksByName = new Map<string, Record<string, TurboTask>>()
123
- for (const meta of metas) {
124
- const file = path.join(meta.dir, 'turbo.json')
125
- if (await Bun.file(file).exists()) {
126
- pkgTasksByName.set(meta.name, tasksOf(await readTurboJson(file, root)))
127
- }
128
- }
129
-
130
- const notes: string[] = []
131
- for (const key of Object.keys(rootTasks)) {
132
- if (key.startsWith('//#')) {
133
- notes.push(`note: root task ${key} not migrated — vx has no workspace-root tasks`)
134
- }
135
- }
136
-
137
- // First pass: which tasks does each package emit? Needed so dependsOn
138
- // edges can be validated/dropped against the real emitted set.
139
- const emitted = new Map<string, Set<string>>()
140
- for (const meta of metas) {
141
- const scripts = scriptsOf(meta)
142
- const set = new Set<string>()
143
- for (const name of taskNamesFor(meta.name, rootTasks, pkgTasksByName.get(meta.name))) {
144
- if (usableScript(scripts[name])) set.add(name)
145
- }
146
- emitted.set(meta.name, set)
147
- }
148
-
149
- const projects: GeneratedProject[] = []
150
- for (const meta of metas) {
151
- const scripts = scriptsOf(meta)
152
- const pkgTasks = pkgTasksByName.get(meta.name)
153
- const own = emitted.get(meta.name)!
154
- const used = new Set<string>()
155
- const tasks: GeneratedTask[] = []
156
- for (const name of taskNamesFor(meta.name, rootTasks, pkgTasks)) {
157
- const script = scripts[name]
158
- if (!usableScript(script)) {
159
- // The turbo task exists and so does the script KEY, but its value
160
- // can't become a command. Report it instead of emitting
161
- // `command: 42` / `command: null` — the first writes a config that
162
- // fails to load, the second used to abort the whole migration in
163
- // the emitter, and both landed AFTER "migrated clean, 0 TODOs".
164
- // An ABSENT script stays silent: turbo skips the task there too.
165
- if (script !== undefined) {
166
- tasks.push({
167
- name,
168
- todos: [
169
- `package.json script ${JSON.stringify(name)} is ${describeScript(script)}, not a ` +
170
- 'non-empty command string — task skipped; write the command by hand',
171
- ],
172
- task: null,
173
- })
174
- }
175
- continue
176
- }
177
- const def: TurboTask = {
178
- ...rootTasks[name],
179
- ...rootTasks[`${meta.name}#${name}`],
180
- ...pkgTasks?.[name],
181
- }
182
- tasks.push(buildTask(name, def, script, own, emitted, globals, used))
183
- }
184
- projects.push({
185
- name: meta.name,
186
- dir: meta.dir,
187
- importLines: presetImportLines(used, root, meta.dir),
188
- tasks,
189
- })
190
- }
191
-
192
- const extraFiles: MigrationPlan['extraFiles'] = []
193
- if (globals.inputs || globals.env || globals.pass) {
194
- extraFiles.push({
195
- relPath: PRESET_FILE,
196
- contents: renderPreset(globalInputs, globalEnv, globalPass),
197
- })
198
- }
199
-
200
- return { headerNotes: [], projects, extraFiles, notes }
201
- }
202
-
203
- function buildTask(
204
- name: string,
205
- def: TurboTask,
206
- command: string,
207
- own: ReadonlySet<string>,
208
- emitted: ReadonlyMap<string, ReadonlySet<string>>,
209
- globals: Globals,
210
- used: Set<string>,
211
- ): GeneratedTask {
212
- const todos: string[] = []
213
- const persistent = def.persistent === true
214
- const cacheEnabled = def.cache !== false && !persistent
215
-
216
- for (const [key, value] of Object.entries(def)) {
217
- if (KNOWN_TASK_KEYS.has(key)) continue
218
- todos.push(
219
- `turbo key ${JSON.stringify(key)} (${JSON.stringify(value)}) has no vx equivalent — ` +
220
- 'map it manually',
221
- )
222
- }
223
-
224
- const deps: string[] = []
225
- for (const d of def.dependsOn ?? []) {
226
- if (d.includes('$TURBO_ROOT$')) {
227
- todos.push(
228
- `dependsOn ${JSON.stringify(d)} uses $TURBO_ROOT$ — vx has no workspace-root tasks; ` +
229
- 'restructure manually',
230
- )
231
- continue
232
- }
233
- if (d.startsWith('^')) {
234
- deps.push(d)
235
- continue
236
- }
237
- const hashAt = d.indexOf('#')
238
- if (hashAt !== -1) {
239
- const pkg = d.slice(0, hashAt)
240
- const task = d.slice(hashAt + 1)
241
- if (emitted.get(pkg)?.has(task)) deps.push(d)
242
- else
243
- todos.push(
244
- `dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
245
- )
246
- continue
247
- }
248
- // Same-project dep on a script this package lacks: turbo silently
249
- // skips the task there, so the edge simply doesn't exist.
250
- if (own.has(d)) deps.push(d)
251
- }
252
-
253
- const envNames: string[] = []
254
- for (const e of def.env ?? []) {
255
- if (/[*?[\]!]/.test(e)) {
256
- todos.push(
257
- `env ${JSON.stringify(e)}: wildcards are not supported in vx env names — ` +
258
- 'list explicit names in cache.inputs.env + exec.env.passThrough',
259
- )
260
- } else envNames.push(e)
261
- }
262
- const passNames: string[] = []
263
- for (const e of def.passThroughEnv ?? []) {
264
- if (/[*?[\]!]/.test(e)) {
265
- todos.push(
266
- `passThroughEnv ${JSON.stringify(e)}: wildcards are not supported — list explicit names`,
267
- )
268
- } else passNames.push(e)
269
- }
270
-
271
- const passThrough: (string | RawExpr)[] = []
272
- if (globals.env) {
273
- passThrough.push({ raw: '...globalEnvInputs' })
274
- used.add('globalEnvInputs')
275
- }
276
- if (globals.pass) {
277
- passThrough.push({ raw: '...globalPassThroughEnv' })
278
- used.add('globalPassThroughEnv')
279
- }
280
- passThrough.push(...envNames, ...passNames)
281
-
282
- const exec: Record<string, unknown> = { command }
283
- if (passThrough.length > 0) exec.env = { passThrough }
284
- if (persistent) {
285
- exec.persistent = {}
286
- todos.push(
287
- 'persistent task — set persistent.readyWhen (regex matched against output) so ' +
288
- 'dependents unblock on readiness, and consider exec.timeout to bound the wait',
289
- )
290
- }
291
-
292
- const task: Record<string, unknown> = { exec }
293
- if (deps.length > 0) task.dependsOn = deps
294
-
295
- if (cacheEnabled) {
296
- const files: (string | RawExpr)[] = []
297
- const wsFiles: (string | RawExpr)[] = []
298
- // globalDependencies are workspace-root-relative by definition —
299
- // they map to inputs.workspaceFiles, not project-relative files.
300
- if (globals.inputs) {
301
- wsFiles.push({ raw: '...globalInputs' })
302
- used.add('globalInputs')
303
- }
304
- if (def.inputs === undefined) {
305
- // Turbo's default input set is every package file.
306
- files.push('**/*')
307
- } else {
308
- for (const i of def.inputs) {
309
- if (i === '$TURBO_DEFAULT$') {
310
- files.push('**/*')
311
- continue
312
- }
313
- const neg = i.startsWith('!')
314
- const body = neg ? i.slice(1) : i
315
- if (body.startsWith('$TURBO_ROOT$/')) {
316
- wsFiles.push((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length))
317
- } else if (i.includes('$TURBO_ROOT$')) {
318
- todos.push(
319
- `input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
320
- 'prefix (→ cache.inputs.workspaceFiles) — map manually',
321
- )
322
- } else files.push(i)
323
- }
324
- }
325
-
326
- const outFiles: string[] = []
327
- const wsOutFiles: string[] = []
328
- for (const o of def.outputs ?? []) {
329
- if (o.startsWith('!')) {
330
- todos.push(
331
- `output ${JSON.stringify(o)}: vx outputs have no negation — narrow the positive ` +
332
- 'globs instead',
333
- )
334
- } else if (o.startsWith('$TURBO_ROOT$/')) {
335
- wsOutFiles.push(o.slice('$TURBO_ROOT$/'.length))
336
- } else if (o.includes('$TURBO_ROOT$')) {
337
- todos.push(
338
- `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
339
- 'prefix (→ cache.outputs.workspaceFiles) — map manually',
340
- )
341
- } else outFiles.push(o)
342
- }
343
-
344
- const cacheEnv: (string | RawExpr)[] = []
345
- if (globals.env) cacheEnv.push({ raw: '...globalEnvInputs' })
346
- cacheEnv.push(...envNames)
347
-
348
- const inputs: Record<string, unknown> = { files }
349
- if (wsFiles.length > 0) inputs.workspaceFiles = wsFiles
350
- if (cacheEnv.length > 0) inputs.env = cacheEnv
351
- const outputs: Record<string, unknown> = { files: outFiles }
352
- if (wsOutFiles.length > 0) outputs.workspaceFiles = wsOutFiles
353
- task.cache = { inputs, outputs }
354
- }
355
-
356
- return { name, todos, task }
357
- }
358
-
359
- function presetImportLines(used: ReadonlySet<string>, root: string, dir: string): string[] {
360
- if (used.size === 0) return []
361
- const rel = relPosix(dir, path.join(root, PRESET_FILE))
362
- const spec = rel.startsWith('.') ? rel : `./${rel}`
363
- return [`import { ${[...used].sort().join(', ')} } from '${spec}'`]
364
- }
365
-
366
- function renderPreset(inputs: string[], env: string[], pass: string[]): string {
367
- // Escape each entry via the shared `quote()` — a turbo.json global (a file
368
- // glob, or an env name a user hand-wrote) may contain a `'`/`\`/newline that
369
- // would otherwise splice into a malformed, unloadable `vx-preset.ts`.
370
- const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
371
- const lines = [
372
- '// Generated by `vx migrate` from turbo.json. TypeScript composition',
373
- "// replaces turbo's global fields: each vx.config.ts imports these",
374
- '// arrays and spreads them into the matching task fields.',
375
- ]
376
- if (inputs.length > 0) {
377
- lines.push(
378
- '',
379
- '// From globalDependencies — workspace-root-relative, spread into each',
380
- '// task’s cache.inputs.workspaceFiles (the $TURBO_ROOT$ equivalent).',
381
- `export const globalInputs = ${arr(inputs)}`,
382
- )
383
- }
384
- if (env.length > 0) {
385
- lines.push(
386
- '',
387
- '// From globalEnv: cache inputs AND passed through to every task',
388
- '// (vx child environments are isolated; see docs/schema.md).',
389
- `export const globalEnvInputs = ${arr(env)}`,
390
- )
391
- }
392
- if (pass.length > 0) {
393
- lines.push(
394
- '',
395
- '// From globalPassThroughEnv: forwarded to every task, never hashed.',
396
- `export const globalPassThroughEnv = ${arr(pass)}`,
397
- )
398
- }
399
- lines.push('')
400
- return lines.join('\n')
401
- }
@@ -1,305 +0,0 @@
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 { quote } from './migrate-emit.js'
12
- import { migrateNx } from './migrate-nx.js'
13
- import { migrateScripts } from './migrate-scripts.js'
14
- import { migrateTurbo } from './migrate-turbo.js'
15
-
16
- export interface MigrateArgs {
17
- dry: boolean
18
- force: boolean
19
- from?: 'turbo' | 'nx' | 'scripts'
20
- error?: string
21
- }
22
-
23
- export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
24
- const out: MigrateArgs = { dry: false, force: false }
25
- for (let i = 0; i < args.length; i++) {
26
- const a = args[i]
27
- if (a === '--dry') out.dry = true
28
- else if (a === '--force') out.force = true
29
- else if (a === '--from' || a?.startsWith('--from=')) {
30
- const v = a === '--from' ? args[++i] : a.slice('--from='.length)
31
- if (v !== 'turbo' && v !== 'nx' && v !== 'scripts') {
32
- return { ...out, error: `--from must be turbo, nx or scripts` }
33
- }
34
- out.from = v
35
- } else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
36
- else return { ...out, error: `unexpected argument: ${a}` }
37
- }
38
- return out
39
- }
40
-
41
- /** Verbatim TS expression spliced into a generated array (preset spreads). */
42
- export interface RawExpr {
43
- readonly raw: string
44
- }
45
-
46
- export interface GeneratedTask {
47
- name: string
48
- /** Rendered as `// TODO(vx-migrate): …` above the task + listed in the report. */
49
- todos: string[]
50
- /** TaskConfig-shaped object; arrays may contain RawExpr splices.
51
- * null = target has no vx representation (skipped; todos explain). */
52
- task: Record<string, unknown> | null
53
- }
54
-
55
- export interface GeneratedProject {
56
- /** package.json name */
57
- name: string
58
- /** absolute project dir */
59
- dir: string
60
- importLines: string[]
61
- tasks: GeneratedTask[]
62
- }
63
-
64
- export interface MigrationPlan {
65
- /** report lines printed right under the source line */
66
- headerNotes: string[]
67
- projects: GeneratedProject[]
68
- /** extra root-relative files (e.g. the preset) */
69
- extraFiles: { relPath: string; contents: string }[]
70
- /** trailing report lines (e.g. implicit Nx deps) */
71
- notes: string[]
72
- }
73
-
74
- export async function migrateCmd(
75
- args: readonly string[],
76
- opts: { init?: boolean } = {},
77
- ): Promise<number> {
78
- const parsed = parseMigrateArgs(args)
79
- if (parsed.error) {
80
- process.stderr.write(`vx migrate: ${parsed.error}\n`)
81
- return 1
82
- }
83
- const root = await findWorkspaceRoot(process.cwd())
84
- const metas = await listProjects(await loadWorkspace(root))
85
-
86
- const hasTurbo = await Bun.file(path.join(root, 'turbo.json')).exists()
87
- const graphRel = path.join('.nx', 'workspace-data', 'project-graph.json')
88
- const hasGraph = await Bun.file(path.join(root, graphRel)).exists()
89
- const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
90
-
91
- // Evaluating teams routinely have both runners checked in — never
92
- // ask anyone to delete anything; --from disambiguates.
93
- if (parsed.from === undefined && hasTurbo && (hasGraph || hasNxJson)) {
94
- throw new UserError(
95
- 'both turbo.json and an nx workspace are present — pass --from turbo or --from nx',
96
- )
97
- }
98
- if (parsed.from === 'turbo' && !hasTurbo) {
99
- throw new UserError('--from turbo, but no turbo.json at the workspace root')
100
- }
101
-
102
- let source: string
103
- let plan: MigrationPlan
104
- if (parsed.from === 'scripts') {
105
- source = 'package.json scripts'
106
- plan = migrateScripts(metas)
107
- } else if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
108
- if (hasGraph) {
109
- source = '.nx/workspace-data/project-graph.json'
110
- plan = await migrateNx(root, metas)
111
- } else if (hasNxJson || parsed.from === 'nx') {
112
- // Modern Nx stores the graph in SQLite — the JSON snapshot only
113
- // exists when exported explicitly.
114
- throw new UserError(
115
- 'no resolved Nx graph found — export one with ' +
116
- '`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx migrate',
117
- )
118
- } else {
119
- // A workspace from nowhere: the scripts are the source (`vx init`).
120
- source = 'package.json scripts'
121
- plan = migrateScripts(metas)
122
- }
123
- } else {
124
- source = 'turbo.json'
125
- plan = await migrateTurbo(root, metas)
126
- }
127
- // `vx init` on a workspace with no scripts still has a job: the
128
- // workspace file, which every run needs, and a worked example of the
129
- // config the user writes next. `vx migrate` with nothing to convert is
130
- // an error: the user asked for a conversion that has no input.
131
- const empty = plan.projects.length === 0
132
- if (empty && opts.init !== true) {
133
- throw new UserError(
134
- `nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
135
- )
136
- }
137
-
138
- const files: { relPath: string; abs: string; contents: string }[] = []
139
- for (const p of plan.projects) {
140
- if (p.tasks.length === 0) continue
141
- const abs = path.join(p.dir, 'vx.config.ts')
142
- files.push({ relPath: relPosix(root, abs), abs, contents: renderConfigFile(source, p) })
143
- }
144
- for (const f of plan.extraFiles) {
145
- files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
146
- }
147
- // A migrated workspace must declare its executor and cache — nothing is
148
- // applied by default. Emit the workspace file unless the repo already has
149
- // one in any supported extension.
150
- const hasWorkspaceFile = (
151
- await Promise.all(
152
- ['vx.workspace.ts', 'vx.workspace.mjs', 'vx.workspace.js'].map((n) =>
153
- Bun.file(path.join(root, n)).exists(),
154
- ),
155
- )
156
- ).some(Boolean)
157
- if (!hasWorkspaceFile) {
158
- const abs = path.join(root, 'vx.workspace.ts')
159
- files.push({ relPath: relPosix(root, abs), abs, contents: WORKSPACE_FILE })
160
- }
161
-
162
- if (!parsed.dry && !parsed.force) {
163
- const conflicts = new Set<string>()
164
- // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
165
- // so we never shadow a hand-written config with a fresh .ts.
166
- for (const p of plan.projects) {
167
- if (p.tasks.length === 0) continue
168
- const meta = metas.find((m) => m.dir === p.dir)
169
- if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
170
- }
171
- // ALSO stat every actual write target. A SYNTHESIZED project (e.g. the Nx
172
- // workspace-root node, dir === root) has no discovered meta, so the meta
173
- // scan alone would miss an existing vx.config.ts at that path and clobber
174
- // it. This also covers the extraFiles (vx-preset.ts).
175
- for (const f of files) {
176
- if (await Bun.file(f.abs).exists()) conflicts.add(f.relPath)
177
- }
178
- if (conflicts.size > 0) {
179
- throw new UserError(
180
- 'refusing to overwrite existing files (pass --force to overwrite):\n' +
181
- ` ${[...conflicts].join('\n ')}`,
182
- )
183
- }
184
- }
185
-
186
- if (parsed.dry) {
187
- for (const f of files) {
188
- process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
189
- }
190
- } else {
191
- for (const f of files) await Bun.write(f.abs, f.contents)
192
- }
193
-
194
- const todoList: string[] = []
195
- let clean = 0
196
- for (const p of plan.projects) {
197
- for (const t of p.tasks) {
198
- if (t.todos.length === 0 && t.task !== null) clean++
199
- for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
200
- }
201
- }
202
- const report: string[] = []
203
- if (empty) {
204
- report.push(
205
- 'vx init: no package.json scripts to turn into tasks.',
206
- hasWorkspaceFile
207
- ? 'vx.workspace.ts already exists.'
208
- : parsed.dry
209
- ? 'would write vx.workspace.ts (dry run, nothing written).'
210
- : 'wrote vx.workspace.ts.',
211
- 'Declare tasks in a vx.config.ts beside a package.json — your own command, for example:',
212
- '',
213
- ...EXAMPLE_CONFIG.trimEnd()
214
- .split('\n')
215
- .map((l) => ` ${l}`),
216
- )
217
- } else {
218
- report.push(`vx migrate: ${source} → vx.config.ts`)
219
- for (const n of plan.headerNotes) report.push(`note: ${n}`)
220
- report.push(
221
- '',
222
- `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
223
- `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
224
- )
225
- for (const line of todoList) report.push(` ${line}`)
226
- report.push(...plan.notes)
227
- report.push(parsed.dry ? 'files (dry run, nothing written):' : 'files written:')
228
- for (const f of files) report.push(` ${f.relPath}`)
229
- }
230
- const firstTask =
231
- plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
232
- plan.projects[0]?.tasks[0]?.name ??
233
- 'build'
234
- report.push('', `next: vx run ${firstTask} --all`)
235
- process.stdout.write(`${report.join('\n')}\n`)
236
- return 0
237
- }
238
-
239
- const EXAMPLE_CONFIG = `import type { ProjectConfig } from '@vzn/vx'
240
-
241
- export default {
242
- tasks: {
243
- build: {
244
- exec: { command: 'tsc -b' },
245
- dependsOn: ['^build'],
246
- cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
247
- },
248
- },
249
- } satisfies ProjectConfig
250
- `
251
-
252
- // ─── TS emission ──────────────────────────────────────────────────────
253
-
254
- const WORKSPACE_FILE = `import { defineWorkspace } from '@vzn/vx'
255
- import { localExecutorPlugin } from '@vzn/vx/plugins/local-executor'
256
- import { localCachePlugin } from '@vzn/vx/plugins/local-cache'
257
-
258
- // Nothing runs that is not declared here. Put a remote executor or cache
259
- // plugin BEFORE the local one to prefer it.
260
- export default defineWorkspace({ plugins: [localExecutorPlugin(), localCachePlugin()] })
261
- `
262
-
263
- const IDENT = /^[A-Za-z_$][\w$]*$/
264
-
265
- function isRawExpr(v: unknown): v is RawExpr {
266
- return typeof v === 'object' && v !== null && typeof (v as RawExpr).raw === 'string'
267
- }
268
-
269
- function renderValue(v: unknown, indent: string): string {
270
- if (isRawExpr(v)) return v.raw
271
- // `Object.entries(null)` throws, which would abort the whole migration
272
- // with a raw stack mid-write. Mappers no longer produce a null, but a
273
- // literal is a readable thing to leave behind if one ever does.
274
- if (v === null) return 'null'
275
- if (typeof v === 'string') return quote(v)
276
- if (typeof v === 'number' || typeof v === 'boolean') return String(v)
277
- if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
278
- const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
279
- if (entries.length === 0) return '{}'
280
- const inner = `${indent} `
281
- const body = entries.map(
282
- ([k, x]) => `${inner}${IDENT.test(k) ? k : quote(k)}: ${renderValue(x, inner)},`,
283
- )
284
- return `{\n${body.join('\n')}\n${indent}}`
285
- }
286
-
287
- function renderConfigFile(source: string, p: GeneratedProject): string {
288
- // A type-only import: the editor type-checks against the installed
289
- // package, and Bun erases it, so the config loads in a workspace that
290
- // runs the vx binary without the package installed.
291
- const lines: string[] = [
292
- `// Generated by \`vx migrate\` from ${source}. Review the TODO(vx-migrate) comments.`,
293
- "import type { ProjectConfig } from '@vzn/vx'",
294
- ]
295
- if (p.importLines.length > 0) lines.push(...p.importLines)
296
- lines.push('', 'export default {', ' tasks: {')
297
- for (const t of p.tasks) {
298
- for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
299
- if (t.task === null) continue // skipped target — the TODO above explains
300
- const key = IDENT.test(t.name) ? t.name : quote(t.name)
301
- lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
302
- }
303
- lines.push(' },', '} satisfies ProjectConfig', '')
304
- return lines.join('\n')
305
- }