@vzn/vx 0.0.100 → 0.0.102
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/README.md +60 -29
- package/package.json +5 -5
- package/src/bin.ts +9 -1
- package/src/cache/inputs.ts +5 -3
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +17 -0
- package/src/cli/init.ts +50 -3
- package/src/cli/last.ts +20 -4
- package/src/cli/plugin-templates.ts +51 -0
- package/src/cli/run-id.ts +23 -2
- package/src/cli/run.ts +22 -7
- package/src/cli/show.ts +48 -6
- package/src/cli/watch-set.ts +182 -0
- package/src/cli/watch.ts +16 -181
- package/src/cli/why.ts +21 -4
- package/src/exec/kill-tree.ts +2 -0
- package/src/exec/runner.ts +24 -25
- package/src/exec/sandbox-binds.ts +6 -6
- package/src/exec/sandbox-runtime.ts +131 -6
- package/src/orchestrator/admission.ts +4 -3
- package/src/orchestrator/events.ts +3 -1
- package/src/orchestrator/execute-task.ts +38 -11
- package/src/orchestrator/framed-output.ts +7 -2
- package/src/orchestrator/hit-restore.ts +7 -2
- package/src/orchestrator/sandbox-request.ts +10 -2
- package/src/orchestrator/telemetry.ts +3 -2
- package/src/util/errors.ts +15 -0
- package/src/util/index.ts +4 -1
- package/src/util/secret-mask.ts +145 -0
- package/src/util/which.ts +12 -0
- package/src/workspace/affected.ts +10 -2
- package/src/workspace/config-cache.ts +3 -1
- package/src/workspace/config-imports.ts +151 -25
package/src/cli/run.ts
CHANGED
|
@@ -150,11 +150,19 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
150
150
|
const a = before[i]
|
|
151
151
|
if (a === '--filter' || a?.startsWith('--filter=')) {
|
|
152
152
|
const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
|
|
153
|
-
if (v === undefined || v === '')
|
|
153
|
+
if (v === undefined || v === '')
|
|
154
|
+
return {
|
|
155
|
+
...out,
|
|
156
|
+
error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
|
|
157
|
+
}
|
|
154
158
|
out.filters.push(v)
|
|
155
159
|
} else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
|
|
156
160
|
const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
|
|
157
|
-
if (v === undefined)
|
|
161
|
+
if (v === undefined)
|
|
162
|
+
return {
|
|
163
|
+
...out,
|
|
164
|
+
error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
|
|
165
|
+
}
|
|
158
166
|
const n = parseConcurrency(v)
|
|
159
167
|
if (n === null)
|
|
160
168
|
return {
|
|
@@ -193,7 +201,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
193
201
|
out.frozen = true
|
|
194
202
|
} else if (a === '--retry' || a?.startsWith('--retry=')) {
|
|
195
203
|
const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
|
|
196
|
-
if (v === undefined)
|
|
204
|
+
if (v === undefined)
|
|
205
|
+
return { ...out, error: `--retry requires a value (a non-negative integer)` }
|
|
197
206
|
const n = parseDecimalInt(v)
|
|
198
207
|
if (n === null) {
|
|
199
208
|
return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
|
|
@@ -201,7 +210,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
201
210
|
out.retries = n
|
|
202
211
|
} else if (a === '--timeout' || a?.startsWith('--timeout=')) {
|
|
203
212
|
const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
|
|
204
|
-
if (v === undefined)
|
|
213
|
+
if (v === undefined)
|
|
214
|
+
return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
|
|
205
215
|
const n = parseDecimalInt(v)
|
|
206
216
|
if (n !== null && n > MAX_TIMEOUT_MS) {
|
|
207
217
|
// Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
|
|
@@ -243,7 +253,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
243
253
|
out.cacheDir = v
|
|
244
254
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
245
255
|
const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
|
|
246
|
-
if (v === undefined)
|
|
256
|
+
if (v === undefined)
|
|
257
|
+
return {
|
|
258
|
+
...out,
|
|
259
|
+
error: `--cache requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
|
|
260
|
+
}
|
|
247
261
|
// A spec with no segment at all applies NOTHING, so `--cache=`
|
|
248
262
|
// (an empty shell var, or someone reaching for "no cache") left
|
|
249
263
|
// all four axes ON — the opposite of the intent. Reject it; an
|
|
@@ -283,7 +297,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
283
297
|
out.continueMode = v
|
|
284
298
|
} else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
|
|
285
299
|
const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
|
|
286
|
-
if (v === undefined)
|
|
300
|
+
if (v === undefined)
|
|
301
|
+
return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
|
|
287
302
|
const n = parseDecimalInt(v)
|
|
288
303
|
if (n === null) return { ...out, error: `invalid verbosity: ${v} (a non-negative integer)` }
|
|
289
304
|
out.verbosity = n
|
|
@@ -317,7 +332,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
317
332
|
out.affected = a.slice('--affected='.length)
|
|
318
333
|
} else if (a === '--tag' || a?.startsWith('--tag=')) {
|
|
319
334
|
const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
|
|
320
|
-
if (raw === undefined) return { ...out, error: `${a} requires a value` }
|
|
335
|
+
if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
|
|
321
336
|
// Split on the FIRST `=` so values may contain `=` (e.g. a URL).
|
|
322
337
|
const eq = raw.indexOf('=')
|
|
323
338
|
if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
|
package/src/cli/show.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
10
10
|
import { declaredTask } from '../graph/index.js'
|
|
11
11
|
import { flagHint, seeHelp } from './help.js'
|
|
12
|
-
import { nearMatches, relPosix, UserError } from '../util/index.js'
|
|
12
|
+
import { nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
|
|
13
13
|
import { loadCliProjects } from './workspace-config.js'
|
|
14
14
|
import {
|
|
15
15
|
findWorkspaceRoot,
|
|
@@ -35,7 +35,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
35
35
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
36
36
|
else if (a.startsWith('-'))
|
|
37
37
|
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
38
|
-
else if (out.target !== undefined)
|
|
38
|
+
else if (out.target !== undefined)
|
|
39
|
+
return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
|
|
39
40
|
// An empty name is a part of every name: its "did you mean" listed the
|
|
40
41
|
// whole workspace.
|
|
41
42
|
else if (a === '') return { ...out, error: 'empty target (omit it to list every project)' }
|
|
@@ -152,6 +153,45 @@ function projectDir(root: string, meta: ProjectMeta): string {
|
|
|
152
153
|
return rel === '' ? '.' : rel
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A task as `vx show` prints it: a secret-named variable's value masked
|
|
158
|
+
* wherever it appears, in `env.define` and in the command a TS config
|
|
159
|
+
* built from `process.env` (L-11).
|
|
160
|
+
*/
|
|
161
|
+
function shownTask(task: TaskConfig): TaskConfig {
|
|
162
|
+
const exec = task.exec
|
|
163
|
+
if (exec === undefined) return task
|
|
164
|
+
const define = exec.env?.define
|
|
165
|
+
const secrets = secretMask(process.env, define)
|
|
166
|
+
if (secrets === null) return task
|
|
167
|
+
return {
|
|
168
|
+
...task,
|
|
169
|
+
exec: {
|
|
170
|
+
...exec,
|
|
171
|
+
...(exec.command !== undefined ? { command: secrets.mask(exec.command) } : {}),
|
|
172
|
+
...(define !== undefined
|
|
173
|
+
? {
|
|
174
|
+
env: {
|
|
175
|
+
...exec.env,
|
|
176
|
+
define: Object.fromEntries(
|
|
177
|
+
Object.entries(define).map(([k, v]) => [k, secrets.mask(v)]),
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A project config with each task as `shownTask` prints it. */
|
|
187
|
+
function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
|
|
188
|
+
if (config?.tasks === undefined) return config
|
|
189
|
+
return {
|
|
190
|
+
...config,
|
|
191
|
+
tasks: Object.fromEntries(Object.entries(config.tasks).map(([n, t]) => [n, shownTask(t)])),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
155
195
|
function renderList(
|
|
156
196
|
root: string,
|
|
157
197
|
metas: readonly ProjectMeta[],
|
|
@@ -197,7 +237,8 @@ function renderProject(
|
|
|
197
237
|
if (format === 'json') {
|
|
198
238
|
// Round-trip so the printed object is exactly the JSON form of the
|
|
199
239
|
// resolved config (drops `undefined` fields).
|
|
200
|
-
|
|
240
|
+
const shown = shownConfig(config)
|
|
241
|
+
return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
|
|
201
242
|
}
|
|
202
243
|
const head = `${name} — ${dir}`
|
|
203
244
|
const tasks = Object.entries(config?.tasks ?? {})
|
|
@@ -215,7 +256,7 @@ function renderTask(
|
|
|
215
256
|
format: 'pretty' | 'json',
|
|
216
257
|
): string {
|
|
217
258
|
if (format === 'json') {
|
|
218
|
-
const obj = { name, dir, task: taskName, config: task }
|
|
259
|
+
const obj = { name, dir, task: taskName, config: shownTask(task) }
|
|
219
260
|
return `${JSON.stringify(JSON.parse(JSON.stringify(obj)), null, 2)}\n`
|
|
220
261
|
}
|
|
221
262
|
return `${name} — ${dir}\n\n${taskBlock(taskName, task)}`
|
|
@@ -232,7 +273,7 @@ function renderTaskAcross(
|
|
|
232
273
|
name: p.name,
|
|
233
274
|
dir: relPosix(root, p.dir) || '.',
|
|
234
275
|
task: taskName,
|
|
235
|
-
config: p.config.tasks![taskName],
|
|
276
|
+
config: shownTask(p.config.tasks![taskName]!),
|
|
236
277
|
}))
|
|
237
278
|
return `${JSON.stringify(JSON.parse(JSON.stringify(list)), null, 2)}\n`
|
|
238
279
|
}
|
|
@@ -245,7 +286,8 @@ function renderTaskAcross(
|
|
|
245
286
|
}
|
|
246
287
|
|
|
247
288
|
/** Every field the run reads, in the order the schema declares them. */
|
|
248
|
-
function taskBlock(taskName: string,
|
|
289
|
+
function taskBlock(taskName: string, raw: TaskConfig): string {
|
|
290
|
+
const task = shownTask(raw)
|
|
249
291
|
const rows: [string, string][] = []
|
|
250
292
|
const list = (xs: readonly string[] | undefined): string | undefined =>
|
|
251
293
|
xs === undefined ? undefined : xs.join(', ')
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What `vx watch` watches: the projects a cycle can run, what their configs
|
|
2
|
+
// declare (outputs to ignore, inputs, workspace-wide globs) and the member
|
|
3
|
+
// directories a package glob can grow. Read at start and again whenever an
|
|
4
|
+
// edit reshapes the set; the loop in watch.ts decides when.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import type { ProjectConfig } from '../config.js'
|
|
9
|
+
import {
|
|
10
|
+
buildPackageGraph,
|
|
11
|
+
configImports,
|
|
12
|
+
loadProjectConfig,
|
|
13
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
14
|
+
type ProjectEntry,
|
|
15
|
+
type ProjectMeta,
|
|
16
|
+
} from '../workspace/index.js'
|
|
17
|
+
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
18
|
+
import { type CliLoadOptions, loadCliProjects } from './workspace-config.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
22
|
+
* the scope plus its transitive dependencies through the package graph
|
|
23
|
+
* and the cross-project `dependsOn` edges the task graph adds
|
|
24
|
+
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
25
|
+
* per-project arm watched the filter's answer only, so
|
|
26
|
+
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
27
|
+
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
28
|
+
* scope is every project already and walks nothing. A config the loader
|
|
29
|
+
* rejects contributes no edges here; the run that just happened said so.
|
|
30
|
+
*/
|
|
31
|
+
export async function watchedProjects(
|
|
32
|
+
workspaceRoot: string,
|
|
33
|
+
allProjects: readonly ProjectMeta[],
|
|
34
|
+
scope: readonly ProjectMeta[],
|
|
35
|
+
load: CliLoadOptions = {},
|
|
36
|
+
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
37
|
+
): Promise<ProjectMeta[]> {
|
|
38
|
+
if (scope.length === allProjects.length) return [...allProjects]
|
|
39
|
+
let edges: Map<string, string[]> | undefined
|
|
40
|
+
try {
|
|
41
|
+
edges =
|
|
42
|
+
staged !== null
|
|
43
|
+
? taskEdgesFrom(staged)
|
|
44
|
+
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
45
|
+
} catch {
|
|
46
|
+
edges = undefined
|
|
47
|
+
}
|
|
48
|
+
const graph = buildPackageGraph([...allProjects], edges)
|
|
49
|
+
const names = new Set(scope.map((p) => p.name))
|
|
50
|
+
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
51
|
+
return allProjects.filter((p) => names.has(p.name))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What one sweep over every config tells the loop. */
|
|
55
|
+
export interface ConfigSweep {
|
|
56
|
+
workspaceWide: boolean
|
|
57
|
+
workspaceInputs: string[]
|
|
58
|
+
outputs: Map<string, string[]>
|
|
59
|
+
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
60
|
+
inputs: Map<string, string[]>
|
|
61
|
+
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
62
|
+
uncached: Set<string>
|
|
63
|
+
/** Files the project configs import by relative path (item 949). */
|
|
64
|
+
configImports: string[]
|
|
65
|
+
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
66
|
+
workspaceConfigImports: string[]
|
|
67
|
+
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
68
|
+
staged: Map<string, ProjectEntry> | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One sweep over every config for the two things the loop needs: whether
|
|
73
|
+
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
74
|
+
* prefix, so the whole tree has to be watched — and every project's
|
|
75
|
+
* declared outputs, so their writes are not taken for edits. The run
|
|
76
|
+
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
77
|
+
* output a plugin gave a config-less package is ignored like a declared
|
|
78
|
+
* one, and a pure config is served from its cached evaluation rather than
|
|
79
|
+
* re-evaluated in a worker. A config that fails to load is out of this
|
|
80
|
+
* concern's scope — the run that just happened already said so, or will —
|
|
81
|
+
* and the sweep falls back to the files that do load, one by one.
|
|
82
|
+
*/
|
|
83
|
+
export async function sweepConfigs(
|
|
84
|
+
projects: readonly ProjectMeta[],
|
|
85
|
+
workspaceRoot: string,
|
|
86
|
+
load: CliLoadOptions = {},
|
|
87
|
+
): Promise<ConfigSweep> {
|
|
88
|
+
const outputs = new Map<string, string[]>()
|
|
89
|
+
const inputs = new Map<string, string[]>()
|
|
90
|
+
const addTo = (
|
|
91
|
+
into: Map<string, string[]>,
|
|
92
|
+
dir: string,
|
|
93
|
+
globs: readonly string[] | undefined,
|
|
94
|
+
): void => {
|
|
95
|
+
if (globs === undefined || globs.length === 0) return
|
|
96
|
+
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
97
|
+
}
|
|
98
|
+
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
99
|
+
addTo(outputs, dir, globs)
|
|
100
|
+
const workspaceInputs = new Set<string>()
|
|
101
|
+
const uncached = new Set<string>()
|
|
102
|
+
const fold = (dir: string, config: ProjectConfig): void => {
|
|
103
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
104
|
+
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
105
|
+
uncached.add(dir)
|
|
106
|
+
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
107
|
+
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
108
|
+
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
109
|
+
add(dir, task.cache?.outputs?.files)
|
|
110
|
+
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|
|
114
|
+
workspaceWide: workspaceInputs.size > 0,
|
|
115
|
+
workspaceInputs: [...workspaceInputs],
|
|
116
|
+
outputs,
|
|
117
|
+
inputs,
|
|
118
|
+
uncached,
|
|
119
|
+
configImports: [...projectImports],
|
|
120
|
+
workspaceConfigImports: [...wsImports],
|
|
121
|
+
staged,
|
|
122
|
+
})
|
|
123
|
+
const projectImports = new Set<string>()
|
|
124
|
+
const wsImports = new Set<string>()
|
|
125
|
+
await Promise.all([
|
|
126
|
+
...projects.map(async (p) => {
|
|
127
|
+
if (p.configPath === null) return
|
|
128
|
+
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
129
|
+
}),
|
|
130
|
+
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
131
|
+
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
132
|
+
}),
|
|
133
|
+
])
|
|
134
|
+
let staged: Map<string, ProjectEntry> | null = null
|
|
135
|
+
try {
|
|
136
|
+
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
137
|
+
} catch {
|
|
138
|
+
staged = null
|
|
139
|
+
}
|
|
140
|
+
if (staged !== null) {
|
|
141
|
+
for (const p of staged.values()) fold(p.dir, p.config)
|
|
142
|
+
return result(staged)
|
|
143
|
+
}
|
|
144
|
+
await Promise.all(
|
|
145
|
+
projects.map(async (p) => {
|
|
146
|
+
if (p.configPath === null) return
|
|
147
|
+
try {
|
|
148
|
+
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
149
|
+
} catch {
|
|
150
|
+
// broken config — out of this concern's scope
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
return result(null)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The directory entries under a package glob's directory that can be
|
|
159
|
+
* members: directories (or links) whose name is not dotted and not
|
|
160
|
+
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
161
|
+
* directory reacts only when this set changes.
|
|
162
|
+
*/
|
|
163
|
+
export function memberEntries(base: string): ReadonlySet<string> {
|
|
164
|
+
const out = new Set<string>()
|
|
165
|
+
let entries: fs.Dirent[]
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
168
|
+
} catch {
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
173
|
+
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
179
|
+
if (a.size !== b.size) return false
|
|
180
|
+
for (const name of a) if (!b.has(name)) return false
|
|
181
|
+
return true
|
|
182
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -25,22 +25,15 @@ import {
|
|
|
25
25
|
type RunOptions,
|
|
26
26
|
} from '../orchestrator/index.js'
|
|
27
27
|
import {
|
|
28
|
-
buildPackageGraph,
|
|
29
28
|
findWorkspaceRoot,
|
|
30
29
|
type LoadReads,
|
|
31
30
|
listProjects,
|
|
32
|
-
loadProjectConfig,
|
|
33
31
|
loadWorkspace,
|
|
34
32
|
LOCKFILE_NAME,
|
|
35
33
|
memberBaseDirs,
|
|
36
|
-
WORKSPACE_CONFIG_FILENAMES,
|
|
37
|
-
configImports,
|
|
38
|
-
type ProjectEntry,
|
|
39
34
|
type ProjectMeta,
|
|
40
35
|
} from '../workspace/index.js'
|
|
41
|
-
import type
|
|
42
|
-
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
43
|
-
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
36
|
+
import { type CliLoadOptions, loadCliWorkspace } from './workspace-config.js'
|
|
44
37
|
import {
|
|
45
38
|
gitIgnored,
|
|
46
39
|
isIgnoredWatchPath,
|
|
@@ -59,6 +52,7 @@ import {
|
|
|
59
52
|
WATCH_PROBE_TIMEOUT_MS,
|
|
60
53
|
type WatchHandle,
|
|
61
54
|
} from './watch-fs.js'
|
|
55
|
+
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
62
56
|
|
|
63
57
|
/** Wait this long after the last filesystem event before re-running. */
|
|
64
58
|
const DEBOUNCE_MS = 150
|
|
@@ -264,151 +258,6 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
264
258
|
})
|
|
265
259
|
}
|
|
266
260
|
|
|
267
|
-
/**
|
|
268
|
-
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
269
|
-
* the scope plus its transitive dependencies through the package graph
|
|
270
|
-
* and the cross-project `dependsOn` edges the task graph adds
|
|
271
|
-
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
272
|
-
* per-project arm watched the filter's answer only, so
|
|
273
|
-
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
274
|
-
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
275
|
-
* scope is every project already and walks nothing. A config the loader
|
|
276
|
-
* rejects contributes no edges here; the run that just happened said so.
|
|
277
|
-
*/
|
|
278
|
-
export async function watchedProjects(
|
|
279
|
-
workspaceRoot: string,
|
|
280
|
-
allProjects: readonly ProjectMeta[],
|
|
281
|
-
scope: readonly ProjectMeta[],
|
|
282
|
-
load: CliLoadOptions = {},
|
|
283
|
-
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
284
|
-
): Promise<ProjectMeta[]> {
|
|
285
|
-
if (scope.length === allProjects.length) return [...allProjects]
|
|
286
|
-
let edges: Map<string, string[]> | undefined
|
|
287
|
-
try {
|
|
288
|
-
edges =
|
|
289
|
-
staged !== null
|
|
290
|
-
? taskEdgesFrom(staged)
|
|
291
|
-
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
292
|
-
} catch {
|
|
293
|
-
edges = undefined
|
|
294
|
-
}
|
|
295
|
-
const graph = buildPackageGraph([...allProjects], edges)
|
|
296
|
-
const names = new Set(scope.map((p) => p.name))
|
|
297
|
-
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
298
|
-
return allProjects.filter((p) => names.has(p.name))
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* One sweep over every config for the two things the loop needs: whether
|
|
303
|
-
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
304
|
-
* prefix, so the whole tree has to be watched — and every project's
|
|
305
|
-
* declared outputs, so their writes are not taken for edits. The run
|
|
306
|
-
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
307
|
-
* output a plugin gave a config-less package is ignored like a declared
|
|
308
|
-
* one, and a pure config is served from its cached evaluation rather than
|
|
309
|
-
* re-evaluated in a worker. A config that fails to load is out of this
|
|
310
|
-
* concern's scope — the run that just happened already said so, or will —
|
|
311
|
-
* and the sweep falls back to the files that do load, one by one.
|
|
312
|
-
*/
|
|
313
|
-
export async function sweepConfigs(
|
|
314
|
-
projects: readonly ProjectMeta[],
|
|
315
|
-
workspaceRoot: string,
|
|
316
|
-
load: CliLoadOptions = {},
|
|
317
|
-
): Promise<{
|
|
318
|
-
workspaceWide: boolean
|
|
319
|
-
workspaceInputs: string[]
|
|
320
|
-
outputs: Map<string, string[]>
|
|
321
|
-
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
322
|
-
inputs: Map<string, string[]>
|
|
323
|
-
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
324
|
-
uncached: Set<string>
|
|
325
|
-
/** Files the project configs import by relative path (item 949). */
|
|
326
|
-
configImports: string[]
|
|
327
|
-
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
328
|
-
workspaceConfigImports: string[]
|
|
329
|
-
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
330
|
-
staged: Map<string, ProjectEntry> | null
|
|
331
|
-
}> {
|
|
332
|
-
const outputs = new Map<string, string[]>()
|
|
333
|
-
const inputs = new Map<string, string[]>()
|
|
334
|
-
const addTo = (
|
|
335
|
-
into: Map<string, string[]>,
|
|
336
|
-
dir: string,
|
|
337
|
-
globs: readonly string[] | undefined,
|
|
338
|
-
): void => {
|
|
339
|
-
if (globs === undefined || globs.length === 0) return
|
|
340
|
-
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
341
|
-
}
|
|
342
|
-
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
343
|
-
addTo(outputs, dir, globs)
|
|
344
|
-
const workspaceInputs = new Set<string>()
|
|
345
|
-
const uncached = new Set<string>()
|
|
346
|
-
const fold = (dir: string, config: ProjectConfig): void => {
|
|
347
|
-
for (const task of Object.values(config.tasks ?? {})) {
|
|
348
|
-
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
349
|
-
uncached.add(dir)
|
|
350
|
-
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
351
|
-
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
352
|
-
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
353
|
-
add(dir, task.cache?.outputs?.files)
|
|
354
|
-
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
const result = (
|
|
358
|
-
staged: Map<string, ProjectEntry> | null,
|
|
359
|
-
): {
|
|
360
|
-
workspaceWide: boolean
|
|
361
|
-
workspaceInputs: string[]
|
|
362
|
-
outputs: Map<string, string[]>
|
|
363
|
-
inputs: Map<string, string[]>
|
|
364
|
-
uncached: Set<string>
|
|
365
|
-
configImports: string[]
|
|
366
|
-
workspaceConfigImports: string[]
|
|
367
|
-
staged: Map<string, ProjectEntry> | null
|
|
368
|
-
} => ({
|
|
369
|
-
workspaceWide: workspaceInputs.size > 0,
|
|
370
|
-
workspaceInputs: [...workspaceInputs],
|
|
371
|
-
outputs,
|
|
372
|
-
inputs,
|
|
373
|
-
uncached,
|
|
374
|
-
configImports: [...projectImports],
|
|
375
|
-
workspaceConfigImports: [...wsImports],
|
|
376
|
-
staged,
|
|
377
|
-
})
|
|
378
|
-
const projectImports = new Set<string>()
|
|
379
|
-
const wsImports = new Set<string>()
|
|
380
|
-
await Promise.all([
|
|
381
|
-
...projects.map(async (p) => {
|
|
382
|
-
if (p.configPath === null) return
|
|
383
|
-
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
384
|
-
}),
|
|
385
|
-
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
386
|
-
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
387
|
-
}),
|
|
388
|
-
])
|
|
389
|
-
let staged: Map<string, ProjectEntry> | null = null
|
|
390
|
-
try {
|
|
391
|
-
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
392
|
-
} catch {
|
|
393
|
-
staged = null
|
|
394
|
-
}
|
|
395
|
-
if (staged !== null) {
|
|
396
|
-
for (const p of staged.values()) fold(p.dir, p.config)
|
|
397
|
-
return result(staged)
|
|
398
|
-
}
|
|
399
|
-
await Promise.all(
|
|
400
|
-
projects.map(async (p) => {
|
|
401
|
-
if (p.configPath === null) return
|
|
402
|
-
try {
|
|
403
|
-
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
404
|
-
} catch {
|
|
405
|
-
// broken config — out of this concern's scope
|
|
406
|
-
}
|
|
407
|
-
}),
|
|
408
|
-
)
|
|
409
|
-
return result(null)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
261
|
interface WatchLoopArgs {
|
|
413
262
|
opts: RunOptions
|
|
414
263
|
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
@@ -746,7 +595,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
746
595
|
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
747
596
|
return handle
|
|
748
597
|
}
|
|
749
|
-
|
|
598
|
+
let armed: ReturnType<typeof armWatcher>
|
|
599
|
+
try {
|
|
600
|
+
armed = armWatcher(dir, recursive, onEvent)
|
|
601
|
+
} catch (err) {
|
|
602
|
+
// The OS's watch limit, not the directory: the loop said "watching"
|
|
603
|
+
// and never fired. The poller needs no watch slot.
|
|
604
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
605
|
+
if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
|
|
606
|
+
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
607
|
+
process.stderr.write(
|
|
608
|
+
`vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
|
|
609
|
+
)
|
|
610
|
+
return handle
|
|
611
|
+
}
|
|
750
612
|
watchers.push(armed.watcher)
|
|
751
613
|
proofs.push(
|
|
752
614
|
armed.ready.then((ok) => {
|
|
@@ -1099,30 +961,3 @@ export function pendingAfterCycle(
|
|
|
1099
961
|
const [abs, label] = [...pending][0]!
|
|
1100
962
|
return [abs, label]
|
|
1101
963
|
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* The directory entries under a package glob's directory that can be
|
|
1105
|
-
* members: directories (or links) whose name is not dotted and not
|
|
1106
|
-
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
1107
|
-
* directory reacts only when this set changes.
|
|
1108
|
-
*/
|
|
1109
|
-
export function memberEntries(base: string): ReadonlySet<string> {
|
|
1110
|
-
const out = new Set<string>()
|
|
1111
|
-
let entries: fs.Dirent[]
|
|
1112
|
-
try {
|
|
1113
|
-
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
1114
|
-
} catch {
|
|
1115
|
-
return out
|
|
1116
|
-
}
|
|
1117
|
-
for (const e of entries) {
|
|
1118
|
-
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
1119
|
-
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
1120
|
-
}
|
|
1121
|
-
return out
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
1125
|
-
if (a.size !== b.size) return false
|
|
1126
|
-
for (const name of a) if (!b.has(name)) return false
|
|
1127
|
-
return true
|
|
1128
|
-
}
|
package/src/cli/why.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
61
61
|
// got two different diagnoses depending on how it was typed.
|
|
62
62
|
if (a === '--run' || a.startsWith('--run=')) {
|
|
63
63
|
const rv = a === '--run' ? args[++i] : a.slice(6)
|
|
64
|
-
if (rv === undefined || rv === '')
|
|
64
|
+
if (rv === undefined || rv === '')
|
|
65
|
+
return { ...out, error: '--run requires a run id (`vx last --list` shows them)' }
|
|
65
66
|
out.runId = rv
|
|
66
67
|
continue
|
|
67
68
|
}
|
|
@@ -71,7 +72,10 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
71
72
|
// An omitted value reads as empty, not as the literal word "undefined".
|
|
72
73
|
return {
|
|
73
74
|
...out,
|
|
74
|
-
error:
|
|
75
|
+
error:
|
|
76
|
+
fv === undefined || fv === ''
|
|
77
|
+
? '--format requires a value (pretty | json)'
|
|
78
|
+
: `invalid --format: ${fv} (expected pretty | json)`,
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
81
|
out.format = fv
|
|
@@ -86,7 +90,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
86
90
|
}
|
|
87
91
|
if (a.startsWith('-'))
|
|
88
92
|
return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
|
|
89
|
-
if (out.target !== undefined)
|
|
93
|
+
if (out.target !== undefined)
|
|
94
|
+
return { ...out, error: `unexpected argument: ${a}${seeHelp('why')}` }
|
|
90
95
|
// Empty is no task: read as a query it matched every recorded one.
|
|
91
96
|
if (a !== '') out.target = a
|
|
92
97
|
}
|
|
@@ -239,7 +244,19 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
239
244
|
const kinds = [...new Set(diff.entries.map((e) => e.kind))].filter((k) => k in WHAT_TO_DO)
|
|
240
245
|
if (kinds.length > 0) {
|
|
241
246
|
lines.push('', ' what to do:')
|
|
242
|
-
|
|
247
|
+
// An upstream row names the task to ask next: the command, not a
|
|
248
|
+
// placeholder to fill in from the row above.
|
|
249
|
+
const upstream = diff.entries.filter((e) => e.kind === 'upstream').map((e) => e.name)
|
|
250
|
+
for (const k of kinds) {
|
|
251
|
+
const todo =
|
|
252
|
+
k === 'upstream'
|
|
253
|
+
? WHAT_TO_DO[k]!.replace(
|
|
254
|
+
'`vx why <that task>`',
|
|
255
|
+
upstream.map((n) => `\`vx why ${n}\``).join(', '),
|
|
256
|
+
)
|
|
257
|
+
: WHAT_TO_DO[k]
|
|
258
|
+
lines.push(` ${k.padEnd(kindW)} ${todo}`)
|
|
259
|
+
}
|
|
243
260
|
}
|
|
244
261
|
} else if (why.hashChanged === true) {
|
|
245
262
|
lines.push(` detail ${diff.note}`)
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -78,6 +78,8 @@ const GUARD_SCRIPT = [
|
|
|
78
78
|
function startGuard(): void {
|
|
79
79
|
if (guardFd !== undefined) return
|
|
80
80
|
guardFd = null
|
|
81
|
+
// The guard is an `sh` script over process groups; Windows has neither.
|
|
82
|
+
if (process.platform === 'win32') return
|
|
81
83
|
try {
|
|
82
84
|
const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
|
|
83
85
|
argv0: 'vx-group-guard',
|