@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,364 +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 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
- }
@@ -1,219 +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 { 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
- }
@@ -1,74 +0,0 @@
1
- // The devframe surface for a vx run: one definition that exposes the run
2
- // as a live streaming channel (`vx:events`, the raw WireEvent feed) plus a
3
- // patch-synced shared state (`vx:run`, the reduced RunState aggregate).
4
- // A host adapter (dev server / MCP — see cli wiring) mounts this same
5
- // definition; this module owns only the translation from our in-process
6
- // event bus into devframe's streaming + shared-state primitives.
7
- //
8
- // devframe is touched ONLY through type-only imports here, so core `vx`
9
- // never gains a runtime dependency on it — the host that actually boots a
10
- // devframe server imports the runtime adapter dynamically and is the
11
- // optional, opt-in path. See docs/design/event-stream-2026-06.md.
12
-
13
- import type { DevframeDefinition, DevframeNodeContext, RpcStreamingChannel } from 'devframe'
14
- import { VERSION } from '../version.js'
15
- import { toWireEvent, type EventBus, type WireEvent } from './events.js'
16
- import { initRunState, reduce, type RunState } from './run-state.js'
17
-
18
- // Type the `vx:run` shared-state key so `ctx.rpc.sharedState.get` is
19
- // checked against our aggregate shape.
20
- declare module 'devframe' {
21
- interface DevframeRpcSharedStates {
22
- 'vx:run': RunState
23
- }
24
- }
25
-
26
- export interface VxSurfaceOptions {
27
- /** Stream id for the run's event feed. One run → one stream. */
28
- streamId?: string
29
- }
30
-
31
- /**
32
- * Build a devframe definition that mirrors a single run's bus onto
33
- * devframe's wire. `setup` runs at host boot (before the run emits), so
34
- * it subscribes to the bus, forwards every event onto the `vx:events`
35
- * stream, and folds it into the `vx:run` shared state. The host syncs
36
- * both to any connected client (web SPA, MCP resource).
37
- */
38
- export function createVxSurface(bus: EventBus, options: VxSurfaceOptions = {}): DevframeDefinition {
39
- const streamId = options.streamId ?? 'run'
40
- return {
41
- id: 'vx',
42
- name: 'vx',
43
- version: VERSION,
44
- async setup(ctx: DevframeNodeContext) {
45
- const channel: RpcStreamingChannel<WireEvent> = ctx.rpc.streaming.create('vx:events')
46
- const stream = channel.start({ id: streamId })
47
- const state = await ctx.rpc.sharedState.get('vx:run', { initialValue: initRunState() })
48
-
49
- // Local snapshot fed to `reduce`; the shared state is updated by
50
- // assigning the new snapshot onto its draft (pure reduce → diffable
51
- // patch). Kept beside the shared state rather than read back from it
52
- // so reduction never depends on devframe's immutable accessors.
53
- let snapshot = initRunState()
54
- // `run()` emits `run:end` more than once (the normal end + a `finally`
55
- // safety call) plus summary `run:status` lines AFTER the first
56
- // `run:end`. Once the stream is closed, ignore everything — writing
57
- // to a closed stream throws (StreamClosedError), and the run is over.
58
- let closed = false
59
-
60
- bus.subscribe((event) => {
61
- if (closed) return
62
- stream.write(toWireEvent(event))
63
- snapshot = reduce(snapshot, event)
64
- state.mutate((draft) => {
65
- Object.assign(draft, snapshot)
66
- })
67
- if (event.kind === 'run:end') {
68
- stream.close()
69
- closed = true
70
- }
71
- })
72
- },
73
- }
74
- }