@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
@@ -0,0 +1,187 @@
1
+ // package.json scripts → vx tasks: the source for a workspace that comes
2
+ // from nowhere (no turbo.json, no nx). `vx init` is this mapper.
3
+ //
4
+ // Scripts carry a command and nothing else, so the mapping is honest about
5
+ // what it cannot know: every task gets `exec.command` verbatim; `build`
6
+ // gets the conventional `dependsOn: ['^build']` and `test` / `typecheck`
7
+ // wait for `build` when the package has one (`lint` does not); a dev-server
8
+ // shaped script becomes persistent. Caching is opt-in and needs declared
9
+ // inputs AND outputs, which a script cannot tell us — so NO task gets a
10
+ // cache block; `build` carries a TODO showing the block to add. Until
11
+ // 2026-09-04 `build` was emitted with whole-project inputs and EMPTY
12
+ // outputs "to fill in": that is not an uncached task but a no-output one —
13
+ // it hits on unchanged inputs and skips the build with nothing to restore,
14
+ // so a deleted `dist` stayed deleted under a green `up-to-date` run
15
+ // (reproduced on the init walkthrough). A block that guessed `dist/**`
16
+ // would restore the wrong tree for every package that writes somewhere
17
+ // else, and a wrong restore is the worst failure vx has.
18
+ //
19
+ // Two npm conventions are mapped rather than copied, because copying them
20
+ // loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
21
+ // without being named, are folded into `x`'s command in that order (a
22
+ // standalone `prebuild` task is one `vx run build` never runs — and the
23
+ // hook is usually `rimraf dist`); and a script that is nothing but
24
+ // `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
25
+ // dependency instead of a package-manager subprocess it cannot cache.
26
+
27
+ import type { ProjectMeta } from './workspace.js'
28
+ import {
29
+ foldScriptHooks,
30
+ type GeneratedProject,
31
+ type GeneratedTask,
32
+ type MigrationPlan,
33
+ PERSISTENT_TASK_NAMES,
34
+ PERSISTENT_TODO,
35
+ } from './migration.js'
36
+
37
+ // `lint` is not here: a linter reads sources, and an edge to `build`
38
+ // serialises the two for nothing (the init walkthrough, 2026-09-04).
39
+ const AFTER_BUILD = new Set(['test', 'typecheck', 'check', 'e2e'])
40
+ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
41
+
42
+ /**
43
+ * The script a command delegates to when it is NOTHING but a package-manager
44
+ * invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
45
+ * x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
46
+ * make it a real command again, which is left verbatim.
47
+ */
48
+ export function delegatedScript(command: string): string | null {
49
+ const m =
50
+ /^(?:(?:npm run|(pnpm|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
51
+ command.trim(),
52
+ )
53
+ if (m === null) return null
54
+ if (m[4] !== undefined) return m[4]
55
+ const [, manager, run, name] = m
56
+ // Bare, the manager's own command wins over a script of that name:
57
+ // `bun test` is Bun's test runner and `bun build` its bundler, never the
58
+ // `test` / `build` script, and a group over the script ran the wrong
59
+ // thing (item 908). `pnpm test` and `yarn test` do run the script.
60
+ if (manager !== undefined && run === undefined && OWN_COMMANDS[manager]!.has(name!)) return null
61
+ return name!
62
+ }
63
+
64
+ /** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
65
+ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
66
+ // `bun --help`, Bun 1.4.2, with the short aliases.
67
+ bun: new Set(
68
+ 'run test x repl exec install i add a remove rm update audit outdated link unlink publish patch pm info why build init create c upgrade feedback lint'.split(
69
+ ' ',
70
+ ),
71
+ ),
72
+ pnpm: new Set(
73
+ 'add install i update up remove rm link unlink import rebuild prune fetch patch audit list ls outdated why exec dlx create init publish pack store env setup config root bin licenses deploy doctor'.split(
74
+ ' ',
75
+ ),
76
+ ),
77
+ yarn: new Set(
78
+ 'add install remove upgrade up info init link unlink pack publish why workspace workspaces config cache dlx exec bin node plugin set version constraints npm patch explain dedupe'.split(
79
+ ' ',
80
+ ),
81
+ ),
82
+ }
83
+
84
+ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
85
+ // package.json is a boundary: `scripts` is whatever the file holds. A
86
+ // string or an array would enumerate its indices as script names.
87
+ const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
88
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
89
+ return raw as Record<string, unknown>
90
+ }
91
+
92
+ /**
93
+ * A `build` that only delegates (`build: pnpm run compile`) is a group over
94
+ * its target, and the `^build` edge every `build` carries belongs on the
95
+ * task that does the work: the group ran `compile` with no edge at all, so
96
+ * a package compiled before the ones it imports had built (item 907). On
97
+ * the group itself it would not hold — the group waits on both, `compile`
98
+ * on neither. Followed through a chain of groups to the first command.
99
+ */
100
+ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
101
+ const byName = new Map(tasks.map((t) => [t.name, t]))
102
+ const seen = new Set<string>()
103
+ let at = byName.get('build')
104
+ while (at?.task !== undefined && at.task !== null && !('exec' in at.task)) {
105
+ if (seen.has(at.name)) return
106
+ seen.add(at.name)
107
+ const deps = at.task['dependsOn']
108
+ at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
109
+ }
110
+ if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
111
+ const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
112
+ if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
113
+ }
114
+
115
+ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
116
+ const projects: GeneratedProject[] = []
117
+ for (const meta of metas) {
118
+ const scripts = scriptsOf(meta)
119
+ const names = Object.keys(scripts).filter(
120
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '',
121
+ )
122
+ if (names.length === 0) continue
123
+ const hasBuild = names.includes('build')
124
+ const has = (n: string): boolean => names.includes(n)
125
+ const tasks: GeneratedTask[] = []
126
+ for (const name of names) {
127
+ // Lifecycle hooks are npm's, not tasks anyone runs by name.
128
+ if (LIFECYCLE.test(name)) continue
129
+ // A hook of a script that exists rides inside that script's command.
130
+ const hookOf = /^(pre|post)(.+)$/.exec(name)
131
+ if (hookOf !== null && has(hookOf[2]!) && !LIFECYCLE.test(hookOf[2]!)) continue
132
+
133
+ const todos: string[] = []
134
+ const own = scripts[name] as string
135
+ const delegate = delegatedScript(own)
136
+ // npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
137
+ // package manager and never ride inside a task — `pack` stays alone.
138
+ const hook = (h: string): boolean => has(h) && !LIFECYCLE.test(h)
139
+ const hooks = [`pre${name}`, `post${name}`].filter(hook)
140
+ if (delegate !== null && has(delegate) && delegate !== name && hooks.length === 0) {
141
+ // A group: no exec, the graph runs the target. `npm test` calling
142
+ // `vitest` through `npm run test:unit` is two tasks, not a subprocess.
143
+ const task: Record<string, unknown> = { dependsOn: [delegate] }
144
+ if (AFTER_BUILD.has(name) && hasBuild && delegate !== 'build') {
145
+ task['dependsOn'] = [delegate, 'build']
146
+ }
147
+ tasks.push({ name, todos, task })
148
+ continue
149
+ }
150
+
151
+ const command = foldScriptHooks(
152
+ hook(`pre${name}`) ? (scripts[`pre${name}`] as string) : undefined,
153
+ own,
154
+ hook(`post${name}`) ? (scripts[`post${name}`] as string) : undefined,
155
+ )
156
+ if (hooks.length > 0) {
157
+ todos.push(
158
+ `npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
159
+ )
160
+ }
161
+ const task: Record<string, unknown> = { exec: { command } }
162
+ if (PERSISTENT_TASK_NAMES.has(name)) {
163
+ task['exec'] = { command, persistent: {} }
164
+ todos.push(PERSISTENT_TODO)
165
+ }
166
+ if (name === 'build') {
167
+ task['dependsOn'] = ['^build']
168
+ todos.push(
169
+ "cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task",
170
+ )
171
+ } else if (AFTER_BUILD.has(name) && hasBuild) {
172
+ task['dependsOn'] = ['build']
173
+ }
174
+ tasks.push({ name, todos, task })
175
+ }
176
+ upstreamBuildOnWorker(tasks)
177
+ if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
178
+ }
179
+ return {
180
+ headerNotes: [
181
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build` carries a TODO showing the one to add',
182
+ ],
183
+ projects,
184
+ extraFiles: [],
185
+ notes: [],
186
+ }
187
+ }
@@ -0,0 +1,353 @@
1
+ // The migration seam: what any adoption tool needs once it has a plan —
2
+ // the plan's shape, TS emission, the overwrite guard, the writes and the
3
+ // report. `vx init` (package.json scripts, in core) and `@vzn/vx-migrate`
4
+ // (Turbo, Nx) share it, so a generated config reads the same whichever
5
+ // tool wrote it. Core knows no source format here: a mapper returns a
6
+ // `MigrationPlan` and this file does the rest.
7
+
8
+ import path from 'node:path'
9
+ import { relPosix, UserError } from '../util/index.js'
10
+ import type { ProjectMeta } from './workspace.js'
11
+ import { loadWorkspace, unreachedHint, unreachedPackages } from './workspace.js'
12
+
13
+ /**
14
+ * Escape an arbitrary string into a single-quoted TS literal. Escapes
15
+ * backslash + quote AND raw newlines/CR — a value with an embedded newline
16
+ * (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
17
+ * otherwise splice into a single-quoted literal as an unterminated / malformed
18
+ * string that fails to load (generated files must round-trip through the
19
+ * loader).
20
+ */
21
+ export function quoteTsLiteral(s: string): string {
22
+ return `'${s
23
+ .replaceAll('\\', '\\\\')
24
+ .replaceAll("'", "\\'")
25
+ .replaceAll('\n', '\\n')
26
+ .replaceAll('\r', '\\r')}'`
27
+ }
28
+
29
+ /**
30
+ * Task names that conventionally never exit. Every mapper guesses the same
31
+ * way from a name — a source that SAYS a task is long-running (turbo's
32
+ * `persistent: true`, an Nx dev-server executor) is believed instead.
33
+ */
34
+ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
35
+ 'dev',
36
+ 'start',
37
+ 'serve',
38
+ 'watch',
39
+ 'preview',
40
+ ])
41
+
42
+ /**
43
+ * A package.json script with the `pre<name>` / `post<name>` hooks npm runs
44
+ * around it, as ONE sh command. Each part runs in its own subshell, so a
45
+ * `;` or an `exit` in one ends that part alone, and the chain stops at the
46
+ * first that fails, as npm stops. The parts sit in a function the
47
+ * forwarded `--` args are appended to, and only the body takes them, as
48
+ * npm appends them to the script and never to its hooks. A plain ` && `
49
+ * join handed them to the post hook, and `test -f x && echo A; echo B` ran
50
+ * `echo B` after a failed pre hook and went green (item 905). Each part
51
+ * ends on its own line, so a trailing `# comment` cannot swallow the
52
+ * paren. A script with no hooks is its body, verbatim.
53
+ */
54
+ export function foldScriptHooks(
55
+ pre: string | undefined,
56
+ body: string,
57
+ post: string | undefined,
58
+ ): string {
59
+ if (pre === undefined && post === undefined) return body
60
+ const parts = [
61
+ ...(pre === undefined ? [] : [`(${pre}\n)`]),
62
+ `(${body} "$@"\n)`,
63
+ ...(post === undefined ? [] : [`(${post}\n)`]),
64
+ ]
65
+ return `vx_script() {\n${parts.join(' && ')}\n}\nvx_script`
66
+ }
67
+
68
+ /** The one wording every mapper emits for a task it made persistent. */
69
+ export const PERSISTENT_TODO =
70
+ 'persistent task — set persistent.readyWhen (regex matched against output) so ' +
71
+ 'dependents unblock on readiness, and consider exec.timeout to bound the wait'
72
+
73
+ /** Verbatim TS expression spliced into a generated array (preset spreads). */
74
+ export interface RawExpr {
75
+ readonly raw: string
76
+ }
77
+
78
+ export interface GeneratedTask {
79
+ name: string
80
+ /** Rendered as `// TODO(vx-migrate): …` above the task + listed in the report. */
81
+ todos: string[]
82
+ /** TaskConfig-shaped object; arrays may contain RawExpr splices.
83
+ * null = target has no vx representation (skipped; todos explain). */
84
+ task: Record<string, unknown> | null
85
+ }
86
+
87
+ export interface GeneratedProject {
88
+ /** package.json name */
89
+ name: string
90
+ /** absolute project dir */
91
+ dir: string
92
+ importLines: string[]
93
+ tasks: GeneratedTask[]
94
+ }
95
+
96
+ export interface MigrationPlan {
97
+ /** report lines printed right under the source line */
98
+ headerNotes: string[]
99
+ projects: GeneratedProject[]
100
+ /** extra root-relative files (e.g. the preset) */
101
+ extraFiles: { relPath: string; contents: string }[]
102
+ /** trailing report lines (e.g. implicit Nx deps) */
103
+ notes: string[]
104
+ }
105
+
106
+ export interface ApplyMigrationArgs {
107
+ root: string
108
+ metas: readonly ProjectMeta[]
109
+ plan: MigrationPlan
110
+ /** Named in the report and the generated header, e.g. `turbo.json`. */
111
+ source: string
112
+ /** The command the user typed, e.g. `vx init` — named in the report. */
113
+ verb: string
114
+ dry: boolean
115
+ force: boolean
116
+ /**
117
+ * `vx init` on a workspace with no scripts still has a job (the workspace
118
+ * file, and a worked example); a migration with nothing to convert is an
119
+ * error. Default false.
120
+ */
121
+ init?: boolean
122
+ /** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
123
+ notes?: readonly string[]
124
+ /**
125
+ * `ts` (default) writes `vx.config.ts` with the type-only import and
126
+ * `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
127
+ * for a package whose own `tsc --build` includes every `.ts` under it
128
+ * and compiled the config into its dist (TanStack/query, 2026-09-11).
129
+ */
130
+ format?: MigrationFormat
131
+ }
132
+
133
+ export type MigrationFormat = 'ts' | 'mjs'
134
+
135
+ /**
136
+ * Render a plan to files, refuse to overwrite without `force`, write (or
137
+ * print, under `dry`), and report. Returns the process exit code.
138
+ */
139
+ export async function applyMigration(args: ApplyMigrationArgs): Promise<number> {
140
+ const { root, metas, plan, source, verb, dry, force } = args
141
+ const init = args.init === true
142
+ const format: MigrationFormat = args.format ?? 'ts'
143
+ const configName = `vx.config.${format}`
144
+ const workspaceName = `vx.workspace.${format}`
145
+ const empty = plan.projects.length === 0
146
+ if (empty && !init) {
147
+ throw new UserError(
148
+ `nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
149
+ )
150
+ }
151
+
152
+ const files: { relPath: string; abs: string; contents: string }[] = []
153
+ for (const p of plan.projects) {
154
+ if (p.tasks.length === 0) continue
155
+ const abs = path.join(p.dir, configName)
156
+ files.push({
157
+ relPath: relPosix(root, abs),
158
+ abs,
159
+ contents: renderConfigFile(source, p, verb, format),
160
+ })
161
+ }
162
+ for (const f of plan.extraFiles) {
163
+ files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
164
+ }
165
+ // A migrated workspace must declare its executor and cache — nothing is
166
+ // applied by default. Emit the workspace file unless the repo already has
167
+ // one in any supported extension.
168
+ const hasWorkspaceFile = (
169
+ await Promise.all(
170
+ ['vx.workspace.ts', 'vx.workspace.mjs', 'vx.workspace.js'].map((n) =>
171
+ Bun.file(path.join(root, n)).exists(),
172
+ ),
173
+ )
174
+ ).some(Boolean)
175
+ if (!hasWorkspaceFile) {
176
+ const abs = path.join(root, workspaceName)
177
+ files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
178
+ }
179
+
180
+ if (!dry && !force) {
181
+ const conflicts = new Set<string>()
182
+ // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
183
+ // so we never shadow a hand-written config with a fresh .ts.
184
+ for (const p of plan.projects) {
185
+ if (p.tasks.length === 0) continue
186
+ const meta = metas.find((m) => m.dir === p.dir)
187
+ if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
188
+ }
189
+ // ALSO stat every actual write target. A SYNTHESIZED project (e.g. the Nx
190
+ // workspace-root node, dir === root) has no discovered meta, so the meta
191
+ // scan alone would miss an existing vx.config.ts at that path and clobber
192
+ // it. This also covers the extraFiles (vx-preset.ts).
193
+ for (const f of files) {
194
+ if (await Bun.file(f.abs).exists()) conflicts.add(f.relPath)
195
+ }
196
+ if (conflicts.size > 0) {
197
+ throw new UserError(
198
+ 'refusing to overwrite existing files (pass --force to overwrite):\n' +
199
+ ` ${[...conflicts].join('\n ')}`,
200
+ )
201
+ }
202
+ }
203
+
204
+ if (dry) {
205
+ for (const f of files) {
206
+ process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
207
+ }
208
+ } else {
209
+ for (const f of files) await Bun.write(f.abs, f.contents)
210
+ }
211
+
212
+ const todoList: string[] = []
213
+ let clean = 0
214
+ for (const p of plan.projects) {
215
+ for (const t of p.tasks) {
216
+ if (t.todos.length === 0 && t.task !== null) clean++
217
+ for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
218
+ }
219
+ }
220
+ const report: string[] = []
221
+ // Single-project mode with packages the root's missing `workspaces` never
222
+ // reaches: the scripts exist, the globs do not (item 248).
223
+ const unreached = empty ? await unreachedPackages(await loadWorkspace(root)) : []
224
+ if (empty && unreached.length > 0) {
225
+ report.push(
226
+ `${verb}: ${unreachedHint(unreached)}`,
227
+ hasWorkspaceFile
228
+ ? `${workspaceName} already exists.`
229
+ : dry
230
+ ? `would write ${workspaceName} (dry run, nothing written).`
231
+ : `wrote ${workspaceName}.`,
232
+ )
233
+ } else if (empty) {
234
+ report.push(
235
+ `${verb}: no package.json scripts to turn into tasks.`,
236
+ hasWorkspaceFile
237
+ ? `${workspaceName} already exists.`
238
+ : dry
239
+ ? `would write ${workspaceName} (dry run, nothing written).`
240
+ : `wrote ${workspaceName}.`,
241
+ `Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
242
+ '',
243
+ ...exampleConfig(format)
244
+ .trimEnd()
245
+ .split('\n')
246
+ .map((l) => ` ${l}`),
247
+ )
248
+ } else {
249
+ report.push(`${verb}: ${source} → ${configName}`)
250
+ for (const n of args.notes ?? []) report.push(`note: ${n}`)
251
+ for (const n of plan.headerNotes) report.push(`note: ${n}`)
252
+ report.push(
253
+ '',
254
+ `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
255
+ `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
256
+ )
257
+ for (const line of todoList) report.push(` ${line}`)
258
+ report.push(...plan.notes)
259
+ report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
260
+ for (const f of files) report.push(` ${f.relPath}`)
261
+ }
262
+ const firstTask =
263
+ plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
264
+ plan.projects[0]?.tasks[0]?.name ??
265
+ 'build'
266
+ report.push('', `next: vx run ${firstTask} --all`)
267
+ process.stdout.write(`${report.join('\n')}\n`)
268
+ return 0
269
+ }
270
+
271
+ const EXAMPLE_BODY = `export default {
272
+ tasks: {
273
+ build: {
274
+ exec: { command: 'tsc -b' },
275
+ dependsOn: ['^build'],
276
+ cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
277
+ },
278
+ },
279
+ }`
280
+
281
+ function exampleConfig(format: MigrationFormat): string {
282
+ return format === 'ts'
283
+ ? `import type { ProjectConfig } from '@vzn/vx'\n\n${EXAMPLE_BODY} satisfies ProjectConfig\n`
284
+ : `${EXAMPLE_BODY}\n`
285
+ }
286
+
287
+ // ─── TS emission ──────────────────────────────────────────────────────
288
+
289
+ // Type-only import + `satisfies`, like the project configs: a runtime
290
+ // `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
291
+ // into every run — measured 2026-09-09 at ~17 ms on a two-package
292
+ // workspace, a fifth of the whole run — for an identity function.
293
+ const WORKSPACE_BODY = `// Plugins are consulted in this order; running here and caching in
294
+ // .vx/cache are the floor under all of them, so an empty list is a
295
+ // complete workspace. Add a remote cache, a remote executor or telemetry
296
+ // as one entry each — those imports are the runtime ones.
297
+ export default { plugins: [] }`
298
+
299
+ function workspaceFile(format: MigrationFormat): string {
300
+ return format === 'ts'
301
+ ? `import type { WorkspaceConfig } from '@vzn/vx'\n\n${WORKSPACE_BODY} satisfies WorkspaceConfig\n`
302
+ : `${WORKSPACE_BODY}\n`
303
+ }
304
+
305
+ const IDENT = /^[A-Za-z_$][\w$]*$/
306
+
307
+ function isRawExpr(v: unknown): v is RawExpr {
308
+ return typeof v === 'object' && v !== null && typeof (v as RawExpr).raw === 'string'
309
+ }
310
+
311
+ function renderValue(v: unknown, indent: string): string {
312
+ if (isRawExpr(v)) return v.raw
313
+ // `Object.entries(null)` throws, which would abort the whole migration
314
+ // with a raw stack mid-write. Mappers no longer produce a null, but a
315
+ // literal is a readable thing to leave behind if one ever does.
316
+ if (v === null) return 'null'
317
+ if (typeof v === 'string') return quoteTsLiteral(v)
318
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
319
+ if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
320
+ const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
321
+ if (entries.length === 0) return '{}'
322
+ const inner = `${indent} `
323
+ const body = entries.map(
324
+ ([k, x]) => `${inner}${IDENT.test(k) ? k : quoteTsLiteral(k)}: ${renderValue(x, inner)},`,
325
+ )
326
+ return `{\n${body.join('\n')}\n${indent}}`
327
+ }
328
+
329
+ function renderConfigFile(
330
+ source: string,
331
+ p: GeneratedProject,
332
+ verb: string,
333
+ format: MigrationFormat,
334
+ ): string {
335
+ // A type-only import: the editor type-checks against the installed
336
+ // package, and Bun erases it, so the config loads in a workspace that
337
+ // runs the vx binary without the package installed. `mjs` is the same
338
+ // object with nothing to erase.
339
+ const lines: string[] = [
340
+ `// Generated by \`${verb}\` from ${source}. Review the TODO(vx-migrate) comments.`,
341
+ ]
342
+ if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx'")
343
+ if (p.importLines.length > 0) lines.push(...p.importLines)
344
+ lines.push('', 'export default {', ' tasks: {')
345
+ for (const t of p.tasks) {
346
+ for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
347
+ if (t.task === null) continue // skipped target — the TODO above explains
348
+ const key = IDENT.test(t.name) ? t.name : quoteTsLiteral(t.name)
349
+ lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
350
+ }
351
+ lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
352
+ return lines.join('\n')
353
+ }