@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.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- package/src/workspace/workspace.ts +176 -0
package/src/cli/show.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/watch.ts
ADDED
|
@@ -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
|
+
}
|