@vzn/vx 0.0.591 → 0.0.595

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -20,8 +20,8 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
20
20
  },
21
21
  executor: {
22
22
  plugin:
23
- "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n const child = Bun.spawn([sh, '-c', [req.command, ...req.forwardArgs].join(' ')], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
- test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n",
23
+ "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
+ test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
25
25
  },
26
26
  graph: {
27
27
  plugin:
package/src/cli/run.ts CHANGED
@@ -3,7 +3,6 @@ import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
4
  import { translateForeign } from './foreign-flags.js'
5
5
  import { flagHint, seeHelp } from './help.js'
6
- import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
7
6
  import {
8
7
  planRun,
9
8
  formatRunReportMarkdown,
@@ -15,10 +14,9 @@ import {
15
14
  } from '../orchestrator/index.js'
16
15
  import type { ContinueMode } from '../graph/index.js'
17
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
18
- import { findCwdSelection, pickTask, resolveFilters } from './select.js'
17
+ import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
19
18
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
20
- import { loadCliWorkspace } from './workspace-config.js'
21
- import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
19
+ import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
22
20
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
23
21
 
24
22
  export interface RunArgs {
@@ -113,7 +111,7 @@ export function parseConcurrency(v: string, cpus = machineParallelism()): number
113
111
  }
114
112
 
115
113
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
116
- * its refusals pointed at `vx run --help` (M-58). */
114
+ * its flag hints named `vx run` (M-58). The caller appends the help pointer. */
117
115
  export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
118
116
  const out: RunArgs = {
119
117
  tasks: [],
@@ -185,7 +183,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
185
183
  } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
186
184
  return {
187
185
  ...out,
188
- error: `unknown flag: ${a} (the flag is --exclude-dependencies)${seeHelp(verb)}`,
186
+ error: `unknown flag: ${a} (the flag is --exclude-dependencies)`,
189
187
  }
190
188
  } else if (a === '--exclude-dependencies') {
191
189
  out.excludeDependencies = 'all'
@@ -215,7 +213,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
215
213
  return { ...out, error: `--retry requires a value (a non-negative integer)` }
216
214
  const n = parseDecimalInt(v)
217
215
  if (n === null) {
218
- return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
216
+ return { ...out, error: `--retry must be a non-negative integer (got ${v})` }
219
217
  }
220
218
  out.retries = n
221
219
  } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
@@ -234,19 +232,31 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
234
232
  }
235
233
  }
236
234
  if (n === null || n <= 0) {
237
- return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
235
+ return { ...out, error: `--timeout must be a positive integer in ms (got ${v})` }
238
236
  }
239
237
  out.timeout = n
240
238
  } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
241
239
  const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
240
+ if (v === undefined || v === '') {
241
+ return {
242
+ ...out,
243
+ error: `--output-logs requires a value (full, errors-only, hash-only, or none)`,
244
+ }
245
+ }
242
246
  if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
243
- return { ...out, error: `--output-logs must be full, errors-only, hash-only, or none` }
247
+ return {
248
+ ...out,
249
+ error: `--output-logs must be full, errors-only, hash-only, or none (got ${v})`,
250
+ }
244
251
  }
245
252
  out.outputLogs = v
246
253
  } else if (a === '--download' || a?.startsWith('--download=')) {
247
254
  const v = a === '--download' ? before[++i] : a.slice('--download='.length)
255
+ if (v === undefined || v === '') {
256
+ return { ...out, error: `--download requires a value (all, toplevel, or none)` }
257
+ }
248
258
  if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
249
- return { ...out, error: `--download must be all, toplevel, or none` }
259
+ return { ...out, error: `--download must be all, toplevel, or none (got ${v})` }
250
260
  }
251
261
  out.download = v
252
262
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
@@ -301,8 +311,11 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
301
311
  out.continueMode = 'always'
302
312
  } else if (a?.startsWith('--continue=')) {
303
313
  const v = a.slice('--continue='.length)
314
+ if (v === '') {
315
+ return { ...out, error: `--continue= requires a mode (never, deps-ok, or always)` }
316
+ }
304
317
  if (v !== 'never' && v !== 'deps-ok' && v !== 'always') {
305
- return { ...out, error: `--continue must be never, deps-ok, or always` }
318
+ return { ...out, error: `--continue must be never, deps-ok, or always (got ${v})` }
306
319
  }
307
320
  out.continueMode = v
308
321
  } else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
@@ -366,7 +379,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
366
379
  }
367
380
  out.report = fmt
368
381
  } else if (a !== undefined && a.startsWith('-')) {
369
- return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}${seeHelp(verb)}` }
382
+ return { ...out, error: `unknown flag: ${a}${flagHint(verb, a)}` }
370
383
  } else if (a !== undefined) {
371
384
  out.tasks.push(a)
372
385
  }
@@ -428,6 +441,32 @@ function onCI(v: string | undefined): boolean {
428
441
  return v !== undefined && v !== '' && v !== '0' && v !== 'false'
429
442
  }
430
443
 
444
+ /** The filters a run's scope comes from: `--filter`s, with `--affected` as its `...[<base>]`. */
445
+ async function scopeFilters(
446
+ parsed: RunArgs,
447
+ cwd: string,
448
+ ): Promise<{ filterStrings: string[]; affectedFilter?: string } | { error: string }> {
449
+ // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
450
+ // changed projects AND their dependents — what a CI gate must run, what
451
+ // the flag's name says, what the guides promised while the sugar was the
452
+ // changed-only `[<base>]` (item 287). `--filter '[<base>]'` stays the
453
+ // "only what I touched" form. Merging it into the filter list means the
454
+ // same code path handles plain filter use, --affected alone, and the combo.
455
+ // Its place does not change the selection: `applyFilters` takes every
456
+ // include before any exclude (item 979), so `--affected --filter '!app'`
457
+ // drops app from either side (item 955 had put it first for that). First
458
+ // it stays, so a message naming the filters names it first.
459
+ const filterStrings = [...parsed.filters]
460
+ let affectedFilter: string | undefined
461
+ if (parsed.affected !== undefined) {
462
+ const f = await affectedFilterFor(cwd, parsed.affected)
463
+ if (typeof f === 'object') return f
464
+ affectedFilter = f
465
+ filterStrings.unshift(affectedFilter)
466
+ }
467
+ return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
468
+ }
469
+
431
470
  /**
432
471
  * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
433
472
  * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
@@ -459,35 +498,9 @@ export async function resolveRunOptions(
459
498
  }
460
499
  }
461
500
 
462
- // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
463
- // changed projects AND their dependents — what a CI gate must run, what
464
- // the flag's name says, what the guides promised while the sugar was the
465
- // changed-only `[<base>]` (item 287). `--filter '[<base>]'` stays the
466
- // "only what I touched" form. Merging it into the filter list means the
467
- // same code path handles plain filter use, --affected alone, and the combo.
468
- // Its place does not change the selection: `applyFilters` takes every
469
- // include before any exclude (item 979), so `--affected --filter '!app'`
470
- // drops app from either side (item 955 had put it first for that). First
471
- // it stays, so a message naming the filters names it first.
472
- const filterStrings = [...parsed.filters]
473
- let affectedFilter: string | undefined
474
- if (parsed.affected !== undefined) {
475
- const root = await findWorkspaceRoot(cwd)
476
- let base = parsed.affected
477
- // The workspace's `affectedBase` — or a plugin's `config` stage, from
478
- // nx.json's `defaultBase` or `TURBO_SCM_BASE` — comes before the guess.
479
- if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
480
- if (base === '') {
481
- try {
482
- base = await defaultAffectedBase(root)
483
- } catch (err) {
484
- if (!isUserError(err)) throw err
485
- return { error: err.message }
486
- }
487
- }
488
- affectedFilter = `...[${base}]`
489
- filterStrings.unshift(affectedFilter)
490
- }
501
+ const scope = await scopeFilters(parsed, cwd)
502
+ if ('error' in scope) return scope
503
+ const { filterStrings, affectedFilter } = scope
491
504
 
492
505
  // Project scope applies to bare task names only. Anchored entries
493
506
  // (pkg#task) resolve directly to their own project regardless.
@@ -601,7 +614,7 @@ const TASKS_SHOWN = 12
601
614
  export async function runCmd(args: readonly string[]): Promise<number> {
602
615
  const parsed = parseRunArgs(args)
603
616
  if (parsed.error) {
604
- process.stderr.write(`vx run: ${parsed.error}\n`)
617
+ process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
605
618
  return 1
606
619
  }
607
620
 
@@ -625,14 +638,31 @@ export async function runCmd(args: readonly string[]): Promise<number> {
625
638
  )
626
639
  return 1
627
640
  }
628
- const picked = await pickTask(
629
- cwd,
630
- {},
631
- {
632
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
633
- ...(parsed.frozen ? { frozen: true } : {}),
634
- },
635
- )
641
+ const load = {
642
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
643
+ ...(parsed.frozen ? { frozen: true } : {}),
644
+ }
645
+ // `--filter` / `--affected` scope the menu, as they scope a bare task:
646
+ // the menu listed every project and the anchored pick ran outside it.
647
+ const scope = await scopeFilters(parsed, cwd)
648
+ if ('error' in scope) {
649
+ process.stderr.write(`vx run: ${scope.error}\n`)
650
+ return 1
651
+ }
652
+ let only: ReadonlySet<string> | undefined
653
+ if (scope.filterStrings.length > 0) {
654
+ const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
655
+ if ('error' in selected) {
656
+ process.stderr.write(`vx run: ${selected.error}\n`)
657
+ return 1
658
+ }
659
+ if ('empty' in selected) {
660
+ process.stderr.write(`vx run: ${selected.empty}\n`)
661
+ return 0
662
+ }
663
+ only = new Set(selected.names)
664
+ }
665
+ const picked = await pickTask(cwd, {}, load, only)
636
666
  if (picked === 'interrupted') return 130
637
667
  if (!picked) return 1
638
668
  tasks = [`${picked.project}#${picked.task}`]
package/src/cli/select.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  refIsHead,
13
13
  applyFilters,
14
14
  buildPackageGraph,
15
+ defaultAffectedBase,
15
16
  findWorkspaceRoot,
16
17
  type LoadReads,
17
18
  type FingerprintClaims,
@@ -26,7 +27,7 @@ import type { ProjectConfig } from '../config.js'
26
27
  import type { ProjectEntry } from '../workspace/index.js'
27
28
  import { parseDependencySpec } from '../graph/index.js'
28
29
  import { declaresInput } from '../cache/index.js'
29
- import { listed, maskedLine, nearest, UserError } from '../util/index.js'
30
+ import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
30
31
  import {
31
32
  claimedAffected,
32
33
  fingerprintClaims,
@@ -228,6 +229,30 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
228
229
  return out
229
230
  }
230
231
 
232
+ /**
233
+ * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
234
+ * projects and their dependents. An empty base is the workspace's
235
+ * `affectedBase` (or a plugin's `config` stage, from nx.json's
236
+ * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
237
+ */
238
+ export async function affectedFilterFor(
239
+ cwd: string,
240
+ affected: string,
241
+ ): Promise<string | { error: string }> {
242
+ const root = await findWorkspaceRoot(cwd)
243
+ let base = affected
244
+ if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
245
+ if (base === '') {
246
+ try {
247
+ base = await defaultAffectedBase(root)
248
+ } catch (err) {
249
+ if (!isUserError(err)) throw err
250
+ return { error: err.message }
251
+ }
252
+ }
253
+ return `...[${base}]`
254
+ }
255
+
231
256
  export async function resolveFilters(
232
257
  cwd: string,
233
258
  raw: string[],
@@ -243,7 +268,10 @@ export async function resolveFilters(
243
268
  } catch (err) {
244
269
  return { error: err instanceof Error ? err.message : String(err) }
245
270
  }
246
- const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
271
+ // `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
272
+ const walksGraph = parsed.some(
273
+ (f) => f.withDeps || f.withDependents || f.onlyDeps || f.sinceViaDeps === true,
274
+ )
247
275
  // A filter that diffs against git starts the run's whole-tree walk now:
248
276
  // the diff reads its untracked files from it (one walk where it spawned
249
277
  // its own), and the run reuses it with the discovery (I-26).
@@ -422,7 +450,7 @@ export async function resolveFilters(
422
450
  }
423
451
  }
424
452
 
425
- export interface PickedTask {
453
+ interface PickedTask {
426
454
  project: string
427
455
  task: string
428
456
  description?: string
@@ -436,6 +464,8 @@ export async function pickTask(
436
464
  cwd: string,
437
465
  io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
438
466
  load: CliLoadOptions = {},
467
+ /** The projects `--filter` / `--affected` selected; every project when absent. */
468
+ only?: ReadonlySet<string>,
439
469
  ): Promise<PickedTask | null | 'interrupted'> {
440
470
  const projects = await loadWorkspaceProjects(cwd)
441
471
  // The staged load: a task a `project` plugin gave a config-less package
@@ -443,6 +473,7 @@ export async function pickTask(
443
473
  const staged = await loadCliProjects(await findWorkspaceRoot(cwd), projects, 'all', load)
444
474
  const entries: PickedTask[] = []
445
475
  for (const meta of projects) {
476
+ if (only !== undefined && !only.has(meta.name)) continue
446
477
  const config = staged.get(meta.name)?.config
447
478
  if (config === undefined) continue
448
479
  const taskNames = Object.keys(config.tasks ?? {})
@@ -453,13 +484,21 @@ export async function pickTask(
453
484
  entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
454
485
  }
455
486
  }
487
+ if (entries.length === 0 && only !== undefined) {
488
+ process.stderr.write(
489
+ `vx run: no tasks declared in the selected projects (${[...only].sort().join(', ')})\n`,
490
+ )
491
+ return null
492
+ }
456
493
  if (entries.length === 0) {
457
494
  process.stderr.write(
458
495
  'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts\n',
459
496
  )
460
497
  return null
461
498
  }
462
- const out = io.output ?? process.stdout
499
+ // The menu is a conversation with the terminal, not the run's output: on
500
+ // stdout, `vx run > out.txt` put it in the file and asked a blank screen.
501
+ const out = io.output ?? process.stderr
463
502
  const numW = String(entries.length).length
464
503
  const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
465
504
  out.write('Tasks:\n')
@@ -474,7 +513,7 @@ export async function pickTask(
474
513
  const readline = await import('node:readline/promises')
475
514
  const rl = readline.createInterface({
476
515
  input: io.input ?? process.stdin,
477
- output: io.output ?? process.stdout,
516
+ output: out,
478
517
  })
479
518
  // On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
480
519
  // rejects the pending question with an AbortError — which reached the
package/src/cli/show.ts CHANGED
@@ -10,8 +10,9 @@ import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { isDefaultBuild } from '../orchestrator/index.js'
13
- import { flagHint, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
+ import { affectedFilterFor, resolveFilters } from './select.js'
15
16
  import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
16
17
  import {
17
18
  findWorkspaceRoot,
@@ -21,19 +22,30 @@ import {
21
22
  type ProjectMeta,
22
23
  } from '../workspace/index.js'
23
24
 
24
- export interface ShowArgs {
25
+ interface ShowArgs {
25
26
  target?: string
26
27
  format: 'pretty' | 'json'
28
+ /** `--filter`, as `vx run` reads it: which projects the list shows. */
29
+ filters: string[]
30
+ /** `--affected[=<base>]`: `''` is the default base. */
31
+ affected?: string
27
32
  error?: string
28
33
  }
29
34
 
30
35
  export function parseShowArgs(args: readonly string[]): ShowArgs {
31
- const out: ShowArgs = { format: 'pretty' }
36
+ const out: ShowArgs = { format: 'pretty', filters: [] }
32
37
  for (let i = 0; i < args.length; i++) {
33
38
  const a = args[i]!
34
39
  let format: string | undefined
35
40
  if (a === '--format') format = args[++i] ?? ''
36
41
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
42
+ else if (a === '--filter') {
43
+ const v = args[++i]
44
+ if (v === undefined) return { ...out, error: '--filter requires a value (a pattern)' }
45
+ out.filters.push(v)
46
+ } else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
47
+ else if (a === '--affected') out.affected = ''
48
+ else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
37
49
  else if (a.startsWith('-'))
38
50
  return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
39
51
  else if (out.target === 'project')
@@ -47,10 +59,9 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
47
59
  else out.target = a
48
60
 
49
61
  if (format !== undefined) {
50
- if (format !== 'pretty' && format !== 'json') {
51
- return { ...out, error: '--format must be pretty or json' }
52
- }
53
- out.format = format
62
+ const fv = formatValue(format, 'show')
63
+ if (typeof fv === 'object') return { ...out, ...fv }
64
+ out.format = fv
54
65
  }
55
66
  }
56
67
  return out
@@ -104,10 +115,35 @@ export async function showCmd(args: readonly string[]): Promise<number> {
104
115
  const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
105
116
  const scope = projectName === undefined || bareTask ? 'all' : [projectName]
106
117
 
118
+ // `turbo ls --filter` / `nx show projects --affected`: the list, and a
119
+ // task's projects, narrowed by the selection `vx run` would make.
120
+ let selected: Set<string> | undefined
121
+ if (parsed.filters.length > 0 || parsed.affected !== undefined) {
122
+ if (scope !== 'all') {
123
+ throw new UserError(
124
+ 'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
125
+ )
126
+ }
127
+ const raw = [...parsed.filters]
128
+ let affected: string | undefined
129
+ if (parsed.affected !== undefined) {
130
+ const f = await affectedFilterFor(process.cwd(), parsed.affected)
131
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`)
132
+ affected = f
133
+ raw.unshift(f)
134
+ }
135
+ const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
136
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`)
137
+ if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
138
+ selected = new Set('empty' in r ? [] : r.names)
139
+ }
140
+ const inSelection = (name: string): boolean => selected === undefined || selected.has(name)
141
+
107
142
  const projects = await loadCliProjects(root, metas, scope, { noCreate: true, closure: true })
108
143
 
109
144
  if (parsed.target === undefined) {
110
- process.stdout.write(renderList(root, metas, projects, parsed.format))
145
+ const shown = metas.filter((m) => inSelection(m.name))
146
+ process.stdout.write(renderList(root, shown, projects, parsed.format))
111
147
  return 0
112
148
  }
113
149
 
@@ -125,7 +161,8 @@ export async function showCmd(args: readonly string[]): Promise<number> {
125
161
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
126
162
  )
127
163
  }
128
- process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
164
+ const shown = declaring.filter((p) => inSelection(p.name))
165
+ process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
129
166
  return 0
130
167
  }
131
168
 
@@ -240,6 +277,7 @@ function renderList(
240
277
  2,
241
278
  )}\n`
242
279
  }
280
+ if (rows.length === 0) return ''
243
281
  const nameW = Math.max(...rows.map((r) => r.name.length))
244
282
  const dirW = Math.max(...rows.map((r) => r.dir.length))
245
283
  const lines = rows.map((r) => {
@@ -1,4 +1,4 @@
1
- // `vx upgrade [tag]` — self-update the compiled binary in place.
1
+ // `vx upgrade [TAG]` — self-update the compiled binary in place.
2
2
  // Asks the GitHub release API for the asset of this os/arch and the
3
3
  // SHA-256 digest it publishes, downloads the asset, verifies the digest,
4
4
  // writes next to the current executable, atomic rename over it. The
@@ -299,8 +299,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
299
299
  }
300
300
  if (!isCompiledBinary()) {
301
301
  throw new UserError(
302
- 'vx upgrade only works for the compiled binary. ' +
303
- 'You are running from source — use git pull instead. ' +
302
+ 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull ' +
304
303
  '(npm installs update with: npm install -g @vzn/vx@latest)',
305
304
  )
306
305
  }
@@ -8,6 +8,7 @@ import {
8
8
  isLiteralPattern,
9
9
  normalizeGlob,
10
10
  outputMatcher,
11
+ realPath,
11
12
  staticPrefix,
12
13
  taskGlob,
13
14
  } from '../util/index.js'
@@ -54,7 +55,9 @@ export function makeWatchIgnore(
54
55
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
55
56
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
56
57
  ): (base: string, filename: string) => boolean {
57
- const cacheAbs = path.resolve(cacheDir)
58
+ // Watchers report under the real root; a cache dir named through a link
59
+ // (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
60
+ const cacheAbs = realThrough(path.resolve(cacheDir))
58
61
  // A task's own outputs are not edits: without this every cycle that
59
62
  // writes `dist/` (or `out.txt`) re-runs once more, reporting
60
63
  // "up-to-date" for the trouble. Matched under the directory the globs
@@ -132,6 +135,16 @@ export function makeWatchIgnore(
132
135
  }
133
136
  }
134
137
 
138
+ /** `p` with its longest existing prefix realpath'd: the cache dir may not exist yet. */
139
+ function realThrough(p: string): string {
140
+ try {
141
+ return realPath(p)
142
+ } catch {
143
+ const up = path.dirname(p)
144
+ return up === p ? p : path.join(realThrough(up), path.basename(p))
145
+ }
146
+ }
147
+
135
148
  /**
136
149
  * The literal directory a glob's matches live under (`''` when the glob
137
150
  * starts with a pattern, or negates). A literal entry is a file or its
@@ -185,7 +198,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
185
198
  }
186
199
  // Each record: source, line, pattern, path. No source: no pattern
187
200
  // matched; a `!` pattern: re-included. Either way not ignored.
188
- const fields = new TextDecoder().decode(proc.stdout).split('\0')
201
+ const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
189
202
  const records = Math.floor(fields.length / 4)
190
203
  for (let i = 0; i < records; i++) {
191
204
  const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
@@ -232,10 +245,11 @@ export function makeRootEventFilter(
232
245
  fenced: (ownDir: string, abs: string) => boolean = () => false,
233
246
  ): (filename: string) => boolean {
234
247
  const dirs = projectDirs.map((d) => path.resolve(d))
235
- const globs = workspaceInputs
236
- .map(normalizeGlob)
237
- .filter((g) => !g.startsWith('!'))
238
- .map((g) => taskGlob(g))
248
+ // A literal is its tree, as the key resolves it: `shared` matched only
249
+ // the directory's own event and no edit under it ran a cycle (WD-2).
250
+ const globs = asTrees(workspaceInputs.filter((g) => !normalizeGlob(g).startsWith('!'))).map((g) =>
251
+ taskGlob(g),
252
+ )
239
253
  return (filename: string): boolean => {
240
254
  const rel = filename.split(path.sep).join('/')
241
255
  // The depth test is a READING AID, not a guard: both predicates below
@@ -359,7 +373,7 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
359
373
  }
360
374
  if (proc.exitCode !== 0) return undefined
361
375
  const files = new Set<string>()
362
- for (const p of new TextDecoder().decode(proc.stdout).split('\0')) {
376
+ for (const p of new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')) {
363
377
  if (p.length === 0) continue
364
378
  // An untracked nested repository is listed as `dir/`.
365
379
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)