@vzn/vx 0.0.0 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,201 @@
1
+ // `vx show [target]` — introspect the workspace's LIVE resolved configs.
2
+ // No target: one line per project. `<project>`: every task's resolved
3
+ // config, evaluated with the same loader the run path uses (scoped to
4
+ // that single project). `<pkg>#<task>`: one task. This is deliberately
5
+ // NOT the lock — vx-lock.json is already the frozen JSON; `show` answers
6
+ // "what would a live run see here, now".
7
+
8
+ import type { ProjectConfig, TaskConfig } from '../config.js'
9
+ import { relPosix, UserError } from '../util/index.js'
10
+ import {
11
+ findWorkspaceRoot,
12
+ listProjects,
13
+ loadProjectConfig,
14
+ loadWorkspace,
15
+ type ProjectMeta,
16
+ } from '../workspace/index.js'
17
+
18
+ export interface ShowArgs {
19
+ target?: string
20
+ format: 'pretty' | 'json'
21
+ error?: string
22
+ }
23
+
24
+ export function parseShowArgs(args: readonly string[]): ShowArgs {
25
+ const out: ShowArgs = { format: 'pretty' }
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i]!
28
+ let format: string | undefined
29
+ if (a === '--format') format = args[++i] ?? ''
30
+ else if (a.startsWith('--format=')) format = a.slice('--format='.length)
31
+ else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
32
+ else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
33
+ else out.target = a
34
+
35
+ if (format !== undefined) {
36
+ if (format !== 'pretty' && format !== 'json') {
37
+ return { ...out, error: '--format must be pretty or json' }
38
+ }
39
+ out.format = format
40
+ }
41
+ }
42
+ return out
43
+ }
44
+
45
+ export async function showCmd(args: readonly string[]): Promise<number> {
46
+ const parsed = parseShowArgs(args)
47
+ if (parsed.error) {
48
+ process.stderr.write(`vx show: ${parsed.error}\n`)
49
+ return 1
50
+ }
51
+ const root = await findWorkspaceRoot(process.cwd())
52
+ const metas = await listProjects(await loadWorkspace(root))
53
+
54
+ if (parsed.target === undefined) {
55
+ process.stdout.write(await renderList(root, metas, parsed.format))
56
+ return 0
57
+ }
58
+
59
+ const hashAt = parsed.target.indexOf('#')
60
+ const projectName = hashAt === -1 ? parsed.target : parsed.target.slice(0, hashAt)
61
+ const taskName = hashAt === -1 ? undefined : parsed.target.slice(hashAt + 1)
62
+ if (taskName === '') throw new UserError(`missing task name after '#' in "${parsed.target}"`)
63
+
64
+ const meta = metas.find((m) => m.name === projectName)
65
+ if (!meta) {
66
+ throw new UserError(
67
+ `unknown project: "${projectName}"${suggest(
68
+ projectName,
69
+ metas.map((m) => m.name),
70
+ )}`,
71
+ )
72
+ }
73
+ const config = meta.configPath === null ? null : await loadProjectConfig(meta.configPath)
74
+ const dir = projectDir(root, meta)
75
+
76
+ if (taskName === undefined) {
77
+ process.stdout.write(renderProject(meta.name, dir, config, parsed.format))
78
+ return 0
79
+ }
80
+
81
+ const task = config?.tasks?.[taskName]
82
+ if (task === undefined) {
83
+ throw new UserError(
84
+ `unknown task: "${meta.name}#${taskName}"${suggest(
85
+ taskName,
86
+ Object.keys(config?.tasks ?? {}),
87
+ )}`,
88
+ )
89
+ }
90
+ process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
91
+ return 0
92
+ }
93
+
94
+ /** Simple includes-match in both directions, case-insensitive. */
95
+ function suggest(query: string, candidates: readonly string[]): string {
96
+ const q = query.toLowerCase()
97
+ const near = candidates.filter((c) => {
98
+ const n = c.toLowerCase()
99
+ return n.includes(q) || q.includes(n)
100
+ })
101
+ return near.length > 0 ? ` — did you mean ${near.join(', ')}?` : ''
102
+ }
103
+
104
+ function projectDir(root: string, meta: ProjectMeta): string {
105
+ const rel = relPosix(root, meta.dir)
106
+ return rel === '' ? '.' : rel
107
+ }
108
+
109
+ async function renderList(
110
+ root: string,
111
+ metas: readonly ProjectMeta[],
112
+ format: 'pretty' | 'json',
113
+ ): Promise<string> {
114
+ const rows = await Promise.all(
115
+ metas.map(async (meta) => {
116
+ const config = meta.configPath === null ? null : await loadProjectConfig(meta.configPath)
117
+ return {
118
+ name: meta.name,
119
+ dir: projectDir(root, meta),
120
+ tasks: Object.keys(config?.tasks ?? {}),
121
+ configured: meta.configPath !== null,
122
+ }
123
+ }),
124
+ )
125
+ if (format === 'json') {
126
+ return `${JSON.stringify(
127
+ rows.map(({ name, dir, tasks }) => ({ name, dir, tasks })),
128
+ null,
129
+ 2,
130
+ )}\n`
131
+ }
132
+ const nameW = Math.max(...rows.map((r) => r.name.length))
133
+ const dirW = Math.max(...rows.map((r) => r.dir.length))
134
+ const lines = rows.map((r) => {
135
+ const tasks = r.configured
136
+ ? `${r.tasks.length} task${r.tasks.length === 1 ? '' : 's'}`
137
+ : '(no vx config)'
138
+ return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}`
139
+ })
140
+ return `${lines.join('\n')}\n`
141
+ }
142
+
143
+ function renderProject(
144
+ name: string,
145
+ dir: string,
146
+ config: ProjectConfig | null,
147
+ format: 'pretty' | 'json',
148
+ ): string {
149
+ if (format === 'json') {
150
+ // Round-trip so the printed object is exactly the JSON form of the
151
+ // resolved config (drops `undefined` fields).
152
+ return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config })), null, 2)}\n`
153
+ }
154
+ const head = `${name} — ${dir}`
155
+ if (config === null) return `${head}\n (no vx config)\n`
156
+ const tasks = Object.entries(config.tasks ?? {})
157
+ if (tasks.length === 0) return `${head}\n (no tasks declared)\n`
158
+ const blocks = tasks.map(([taskName, task]) => taskBlock(taskName, task))
159
+ return `${head}\n\n${blocks.join('\n')}`
160
+ }
161
+
162
+ function renderTask(
163
+ name: string,
164
+ dir: string,
165
+ taskName: string,
166
+ task: TaskConfig,
167
+ format: 'pretty' | 'json',
168
+ ): string {
169
+ if (format === 'json') {
170
+ const obj = { name, dir, task: taskName, config: task }
171
+ return `${JSON.stringify(JSON.parse(JSON.stringify(obj)), null, 2)}\n`
172
+ }
173
+ return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
174
+ }
175
+
176
+ function taskBlock(taskName: string, task: TaskConfig): string {
177
+ const rows: [string, string][] = []
178
+ if (task.description !== undefined) rows.push(['description', task.description])
179
+ rows.push(['command', task.exec?.command ?? '(group)'])
180
+ if (task.dependsOn !== undefined) rows.push(['dependsOn', task.dependsOn.join(', ')])
181
+ if (task.cache !== undefined) {
182
+ rows.push(['inputs.files', task.cache.inputs.files.join(', ')])
183
+ if (task.cache.inputs.env !== undefined) {
184
+ rows.push(['inputs.env', task.cache.inputs.env.join(', ')])
185
+ }
186
+ if (task.cache.inputs.tasks !== undefined) {
187
+ rows.push(['inputs.tasks', task.cache.inputs.tasks.join(', ')])
188
+ }
189
+ rows.push(['outputs.files', task.cache.outputs.files.join(', ')])
190
+ }
191
+ if (task.exec?.timeout !== undefined) rows.push(['timeout', `${task.exec.timeout}ms`])
192
+ const persistent = task.exec?.persistent
193
+ if (persistent !== undefined) {
194
+ const fields: string[] = []
195
+ if (persistent.readyWhen !== undefined) fields.push(`readyWhen: ${persistent.readyWhen}`)
196
+ rows.push(['persistent', fields.length > 0 ? fields.join(', ') : 'yes'])
197
+ }
198
+ const labelW = Math.max(...rows.map(([label]) => label.length))
199
+ const body = rows.map(([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value}`)
200
+ return `${taskName}\n${body.join('\n')}\n`
201
+ }
@@ -0,0 +1,103 @@
1
+ // `vx upgrade [tag]` — self-update the compiled binary in place.
2
+ // Mirrors install.sh: download the release asset for this os/arch,
3
+ // write next to the current executable, atomic rename over it.
4
+ // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
5
+ // deno upgrade — "update" is what package managers do to indexes.
6
+
7
+ import { chmod, rename, rm } from 'node:fs/promises'
8
+ import { UserError } from '../util/index.js'
9
+ import { VERSION } from '../version.js'
10
+
11
+ const REPO = 'vznjs/vx'
12
+
13
+ /**
14
+ * A Bun standalone-binary path lives under the bunfs virtual root.
15
+ * Marker differs by platform / Bun version: `/$bunfs/...` (posix) and
16
+ * `B:\~BUN\...` or `B:/~BUN/...` (windows).
17
+ */
18
+ export function isBunfsPath(p: string): boolean {
19
+ return p.startsWith('/$bunfs') || p.startsWith('B:\\~BUN') || p.startsWith('B:/~BUN')
20
+ }
21
+
22
+ /**
23
+ * True when running as a `bun build --compile` binary. Keys off
24
+ * `Bun.main` (and argv[1]) rather than `import.meta.path`: with
25
+ * `--minify --bytecode` — vx's release build flags — `import.meta.path`
26
+ * reports the ORIGINAL SOURCE path, not the bunfs path, so the old
27
+ * check silently failed for every curl-installed binary and `vx
28
+ * upgrade` refused with "running from source". `Bun.main` stays the
29
+ * bunfs path under every compile-flag combination.
30
+ */
31
+ function isCompiledBinary(): boolean {
32
+ return (
33
+ isBunfsPath(Bun.main) || isBunfsPath(process.argv[1] ?? '') || isBunfsPath(import.meta.path)
34
+ )
35
+ }
36
+
37
+ function assetName(): string {
38
+ const os = process.platform === 'darwin' ? 'darwin' : process.platform
39
+ const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null
40
+ if ((os !== 'darwin' && os !== 'linux') || arch === null) {
41
+ throw new UserError(`vx upgrade: unsupported platform ${process.platform}/${process.arch}`)
42
+ }
43
+ return `vx-${os}-${arch}`
44
+ }
45
+
46
+ /**
47
+ * Download `url` and atomically replace `dest` with it. Exported for
48
+ * tests (which point `url` at a local server and `dest` at a tmp
49
+ * file); the CLI wires it to the GitHub release asset and
50
+ * process.execPath.
51
+ */
52
+ export async function replaceBinary(dest: string, url: string): Promise<void> {
53
+ const res = await fetch(url, { redirect: 'follow' })
54
+ if (!res.ok) {
55
+ throw new UserError(`vx upgrade: download failed (${res.status}) — ${url}`)
56
+ }
57
+ const bytes = new Uint8Array(await res.arrayBuffer())
58
+ if (bytes.byteLength === 0) {
59
+ throw new UserError(`vx upgrade: empty download — ${url}`)
60
+ }
61
+ const tmp = `${dest}.upgrade-${process.pid}`
62
+ try {
63
+ await Bun.write(tmp, bytes)
64
+ await chmod(tmp, 0o755)
65
+ await rename(tmp, dest)
66
+ } catch (err) {
67
+ await rm(tmp, { force: true })
68
+ const msg = err instanceof Error ? err.message : String(err)
69
+ throw new UserError(
70
+ `vx upgrade: could not replace ${dest} (${msg}) — ` +
71
+ `check permissions, or reinstall via install.sh with VX_INSTALL_DIR set`,
72
+ )
73
+ }
74
+ }
75
+
76
+ export async function upgradeCmd(args: readonly string[]): Promise<number> {
77
+ const tag = args.find((a) => !a.startsWith('-'))
78
+ const unknown = args.find((a) => a.startsWith('-'))
79
+ if (unknown !== undefined) {
80
+ process.stderr.write(`vx upgrade: unknown flag: ${unknown}\n`)
81
+ return 1
82
+ }
83
+ if (!isCompiledBinary()) {
84
+ throw new UserError(
85
+ 'vx upgrade only works for the compiled binary (install.sh). ' +
86
+ 'You are running from source — use git pull instead.',
87
+ )
88
+ }
89
+ const asset = assetName()
90
+ const url =
91
+ tag === undefined
92
+ ? `https://github.com/${REPO}/releases/latest/download/${asset}`
93
+ : `https://github.com/${REPO}/releases/download/${tag}/${asset}`
94
+ const dest = process.execPath
95
+ process.stdout.write(`vx upgrade: ${VERSION} → ${tag ?? 'latest'} (${dest})\n`)
96
+ await replaceBinary(dest, url)
97
+ // Report the replaced binary's own version — the new build speaks
98
+ // for itself rather than this process guessing.
99
+ const proc = Bun.spawnSync({ cmd: [dest, '--version'], stdout: 'pipe', stderr: 'pipe' })
100
+ const v = new TextDecoder().decode(proc.stdout).trim()
101
+ process.stdout.write(`vx upgrade: installed ${v || '(version check failed)'}\n`)
102
+ return 0
103
+ }
@@ -0,0 +1,287 @@
1
+ // `vx watch <task>` — re-run on file changes.
2
+ //
3
+ // Initial run uses the same orchestrator path as `vx run`. After it
4
+ // finishes, set up recursive filesystem watchers on every project's
5
+ // directory in the resolved scope. Filesystem changes trigger a
6
+ // debounced re-invocation of the orchestrator with the same options.
7
+ //
8
+ // The cache does the heavy lifting: re-runs are typically cache hits
9
+ // (input hash matches), so spurious events from edits outside any
10
+ // task's `cache.inputs.files` cost ~tens of ms of orchestrator
11
+ // overhead. We deliberately don't try to filter events through the
12
+ // per-task input globs — the cache key is the source of truth, and
13
+ // filtering would mean re-doing the glob/boundary work on every event.
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { parseRunArgs, resolveRunOptions } from './run.js'
18
+ import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
19
+ import {
20
+ findWorkspaceRoot,
21
+ listProjects,
22
+ loadProjectConfig,
23
+ loadWorkspace,
24
+ type ProjectMeta,
25
+ } from '../workspace/index.js'
26
+
27
+ /** Wait this long after the last filesystem event before re-running. */
28
+ const DEBOUNCE_MS = 150
29
+
30
+ /** Paths whose changes never trigger a re-run. */
31
+ const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
32
+ const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
33
+
34
+ export async function watchCmd(args: readonly string[]): Promise<number> {
35
+ const parsed = parseRunArgs(args)
36
+ if (parsed.error) {
37
+ process.stderr.write(`vx watch: ${parsed.error}\n`)
38
+ return 1
39
+ }
40
+
41
+ if (parsed.dry !== undefined || parsed.graph !== undefined) {
42
+ process.stderr.write(`vx watch: --dry / --graph are not supported in watch mode\n`)
43
+ return 1
44
+ }
45
+ if (parsed.summarize !== undefined || parsed.profile !== undefined) {
46
+ process.stderr.write(
47
+ `vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)\n`,
48
+ )
49
+ return 1
50
+ }
51
+ if (parsed.tasks.length === 0) {
52
+ process.stderr.write(`vx watch: missing task name\n`)
53
+ return 1
54
+ }
55
+
56
+ const cwd = process.cwd()
57
+ const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks)
58
+ if ('error' in resolved) {
59
+ process.stderr.write(`vx watch: ${resolved.error}\n`)
60
+ return 1
61
+ }
62
+ // The watch loop owns SIGINT/SIGTERM for its whole lifetime (the
63
+ // process.once handlers below close watchers and resolve 0). A
64
+ // cycle's run() must not install its exit-the-process handlers —
65
+ // Ctrl-C mid-cycle would kill the loop with 130 instead of the
66
+ // loop's own clean shutdown.
67
+ const opts: RunOptions = { ...resolved, handleSignals: false }
68
+
69
+ // Enumerate projects-in-scope so we know what dirs to watch.
70
+ // `opts.projects` is the resolved scope; undefined means "every
71
+ // project". We watch only those (plus the workspace root, for
72
+ // lockfile changes).
73
+ const workspaceRoot = await findWorkspaceRoot(cwd)
74
+ const allProjects = await listProjects(await loadWorkspace(workspaceRoot))
75
+ const scope =
76
+ opts.projects === undefined
77
+ ? allProjects
78
+ : allProjects.filter((p) => opts.projects!.includes(p.name))
79
+
80
+ if (scope.length === 0) {
81
+ process.stderr.write(`vx watch: no projects in scope\n`)
82
+ return 1
83
+ }
84
+
85
+ // Initial run — same code path as `vx run`.
86
+ process.stdout.write('vx watch: initial run...\n\n')
87
+ await runOrchestrator(opts)
88
+
89
+ return await runWatchLoop({
90
+ opts,
91
+ workspaceRoot,
92
+ projects: scope,
93
+ workspaceWide: await anyTaskUsesWorkspaceFiles(allProjects),
94
+ })
95
+ }
96
+
97
+ /**
98
+ * `inputs.workspaceFiles` globs have no project boundary — any file in
99
+ * the workspace can be an input. When any config declares them, the
100
+ * per-project watchers can't see all triggering paths, so the loop
101
+ * switches to one recursive root watcher. Checked across ALL projects
102
+ * (not just the scope) because dependsOn can pull tasks from anywhere;
103
+ * a broken out-of-scope config is skipped, matching scoped-run
104
+ * semantics (it surfaces when that project enters scope).
105
+ */
106
+ async function anyTaskUsesWorkspaceFiles(projects: readonly ProjectMeta[]): Promise<boolean> {
107
+ for (const p of projects) {
108
+ if (p.configPath === null) continue
109
+ try {
110
+ const config = await loadProjectConfig(p.configPath)
111
+ for (const task of Object.values(config.tasks ?? {})) {
112
+ if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) return true
113
+ }
114
+ } catch {
115
+ // broken config — out of this concern's scope
116
+ }
117
+ }
118
+ return false
119
+ }
120
+
121
+ interface WatchLoopArgs {
122
+ opts: RunOptions
123
+ workspaceRoot: string
124
+ projects: readonly ProjectMeta[]
125
+ workspaceWide: boolean
126
+ }
127
+
128
+ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
129
+ const { opts, workspaceRoot, projects, workspaceWide } = args
130
+
131
+ // Reentrancy guard — never two orchestrator runs in flight. While
132
+ // one is running, any further events set `pending = true` and the
133
+ // loop drains it after the current run finishes.
134
+ let running = false
135
+ let pending = false
136
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
137
+
138
+ const trigger = (label: string): void => {
139
+ if (debounceTimer) clearTimeout(debounceTimer)
140
+ debounceTimer = setTimeout(() => {
141
+ debounceTimer = null
142
+ void cycle(label)
143
+ }, DEBOUNCE_MS)
144
+ }
145
+
146
+ const cycle = async (label: string): Promise<void> => {
147
+ if (running) {
148
+ pending = true
149
+ return
150
+ }
151
+ running = true
152
+ try {
153
+ do {
154
+ pending = false
155
+ process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
156
+ try {
157
+ await runOrchestrator(opts)
158
+ } catch (err) {
159
+ // A re-run can fail catastrophically when the workspace
160
+ // itself moved out from under us — e.g. the user deleted
161
+ // the project dir, or git lost its repo (the watch loop
162
+ // outlives its own cwd in test teardown). Surface the
163
+ // message but DON'T let it crash the watch loop; the next
164
+ // FS event (if any) will retry. The dispose() on SIGINT
165
+ // is the canonical exit; we don't unilaterally abort here.
166
+ const message = err instanceof Error ? err.message : String(err)
167
+ process.stderr.write(`vx watch: cycle failed: ${message}\n`)
168
+ }
169
+ // If a change arrived mid-run, loop again immediately.
170
+ } while (pending)
171
+ } finally {
172
+ running = false
173
+ }
174
+ }
175
+
176
+ // Filter out events for paths we don't care about. We watch each
177
+ // project's dir recursively, so a `node_modules` write under a
178
+ // project would otherwise trigger every save during `bun install`.
179
+ const isIgnoredPath = (rel: string): boolean => {
180
+ const segments = rel.split(path.sep)
181
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
182
+ if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
183
+ return false
184
+ }
185
+
186
+ const watchers: fs.FSWatcher[] = []
187
+
188
+ if (workspaceWide) {
189
+ // workspaceFiles inputs in play: any file in the workspace can be
190
+ // an input, so one recursive root watcher replaces the per-project
191
+ // ones (it also covers lockfile / pnpm-workspace.yaml edits). The
192
+ // ignore filter keeps node_modules / .git / .vx churn out; edits
193
+ // outside any task's inputs still cost only a cache-hit cycle.
194
+ try {
195
+ const w = fs.watch(
196
+ workspaceRoot,
197
+ { recursive: true, persistent: true },
198
+ (_event, filename) => {
199
+ if (filename == null || typeof filename !== 'string') return
200
+ if (isIgnoredPath(filename)) return
201
+ trigger(`root ${filename}`)
202
+ },
203
+ )
204
+ watchers.push(w)
205
+ } catch (err) {
206
+ const msg = err instanceof Error ? err.message : String(err)
207
+ process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
208
+ }
209
+ } else {
210
+ // One recursive watcher per project. Each project owns its own
211
+ // subtree; we don't watch the workspace root recursively (would
212
+ // cover every project + node_modules + caches).
213
+ for (const proj of projects) {
214
+ try {
215
+ const w = fs.watch(proj.dir, { recursive: true, persistent: true }, (_event, filename) => {
216
+ if (filename == null) return
217
+ if (typeof filename !== 'string') return
218
+ if (isIgnoredPath(filename)) return
219
+ trigger(`${proj.name} ${filename}`)
220
+ })
221
+ watchers.push(w)
222
+ } catch (err) {
223
+ const msg = err instanceof Error ? err.message : String(err)
224
+ process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
225
+ }
226
+ }
227
+
228
+ // Plus the workspace root itself (non-recursive) so lockfile +
229
+ // pnpm-workspace.yaml edits trigger re-runs even when no project
230
+ // dir saw the change.
231
+ try {
232
+ const w = fs.watch(
233
+ workspaceRoot,
234
+ { recursive: false, persistent: true },
235
+ (_event, filename) => {
236
+ if (filename == null || typeof filename !== 'string') return
237
+ if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
238
+ },
239
+ )
240
+ watchers.push(w)
241
+ } catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err)
243
+ process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
244
+ }
245
+ }
246
+
247
+ // The orchestrator's writes into `.vx/cache/` don't trigger
248
+ // re-runs because IGNORED_SEGMENTS includes `.vx`. Users who
249
+ // relocate the cache dir outside `.vx/` need their own filtering.
250
+
251
+ process.stdout.write(
252
+ workspaceWide
253
+ ? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
254
+ : `\nvx watch: watching ${projects.length} project(s); press Ctrl+C to stop\n`,
255
+ )
256
+
257
+ return await new Promise<number>((resolve) => {
258
+ const cleanup = (): void => {
259
+ for (const w of watchers) {
260
+ try {
261
+ w.close()
262
+ } catch {
263
+ // ignore
264
+ }
265
+ }
266
+ if (debounceTimer) clearTimeout(debounceTimer)
267
+ resolve(0)
268
+ }
269
+ process.once('SIGINT', () => {
270
+ process.stdout.write('\nvx watch: stopped\n')
271
+ cleanup()
272
+ })
273
+ process.once('SIGTERM', cleanup)
274
+ })
275
+ }
276
+
277
+ function isWorkspaceFingerprintFile(name: string): boolean {
278
+ return (
279
+ name === 'pnpm-lock.yaml' ||
280
+ name === 'package-lock.json' ||
281
+ name === 'npm-shrinkwrap.json' ||
282
+ name === 'yarn.lock' ||
283
+ name === 'bun.lock' ||
284
+ name === 'bun.lockb' ||
285
+ name === 'pnpm-workspace.yaml'
286
+ )
287
+ }