@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/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 === '') return { ...out, error: `--filter requires a value` }
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) return { ...out, error: `--concurrency requires a value` }
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) return { ...out, error: `--retry requires a value` }
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) return { ...out, error: `--timeout requires a value` }
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) return { ...out, error: `--cache requires a value` }
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) return { ...out, error: `--verbosity requires a value` }
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) return { ...out, error: `unexpected argument: ${a}` }
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
- return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config })), null, 2)}\n`
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, task: TaskConfig): 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 { ProjectConfig } from '../config.js'
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
- const armed = armWatcher(dir, recursive, onEvent)
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 === '') return { ...out, error: 'invalid --run: empty' }
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: `invalid --format: ${fv ?? ''} (expected pretty | json)`,
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) return { ...out, error: `unexpected argument: ${a}` }
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
- for (const k of kinds) lines.push(` ${k.padEnd(kindW)} ${WHAT_TO_DO[k]}`)
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}`)
@@ -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',