@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
+
}
|