@vzn/vx 0.0.594 → 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 (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
@@ -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
  }
@@ -446,20 +459,9 @@ async function scopeFilters(
446
459
  const filterStrings = [...parsed.filters]
447
460
  let affectedFilter: string | undefined
448
461
  if (parsed.affected !== undefined) {
449
- const root = await findWorkspaceRoot(cwd)
450
- let base = parsed.affected
451
- // The workspace's `affectedBase` — or a plugin's `config` stage, from
452
- // nx.json's `defaultBase` or `TURBO_SCM_BASE` — comes before the guess.
453
- if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
454
- if (base === '') {
455
- try {
456
- base = await defaultAffectedBase(root)
457
- } catch (err) {
458
- if (!isUserError(err)) throw err
459
- return { error: err.message }
460
- }
461
- }
462
- affectedFilter = `...[${base}]`
462
+ const f = await affectedFilterFor(cwd, parsed.affected)
463
+ if (typeof f === 'object') return f
464
+ affectedFilter = f
463
465
  filterStrings.unshift(affectedFilter)
464
466
  }
465
467
  return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
@@ -612,7 +614,7 @@ const TASKS_SHOWN = 12
612
614
  export async function runCmd(args: readonly string[]): Promise<number> {
613
615
  const parsed = parseRunArgs(args)
614
616
  if (parsed.error) {
615
- process.stderr.write(`vx run: ${parsed.error}\n`)
617
+ process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
616
618
  return 1
617
619
  }
618
620
 
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[],
@@ -471,7 +496,9 @@ export async function pickTask(
471
496
  )
472
497
  return null
473
498
  }
474
- 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
475
502
  const numW = String(entries.length).length
476
503
  const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
477
504
  out.write('Tasks:\n')
@@ -486,7 +513,7 @@ export async function pickTask(
486
513
  const readline = await import('node:readline/promises')
487
514
  const rl = readline.createInterface({
488
515
  input: io.input ?? process.stdin,
489
- output: io.output ?? process.stdout,
516
+ output: out,
490
517
  })
491
518
  // On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
492
519
  // rejects the pending question with an AbortError — which reached the
package/src/cli/show.ts CHANGED
@@ -12,6 +12,7 @@ import { declaredTask } from '../graph/index.js'
12
12
  import { isDefaultBuild } from '../orchestrator/index.js'
13
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,
@@ -24,16 +25,27 @@ import {
24
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')
@@ -103,10 +115,35 @@ export async function showCmd(args: readonly string[]): Promise<number> {
103
115
  const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
104
116
  const scope = projectName === undefined || bareTask ? 'all' : [projectName]
105
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
+
106
142
  const projects = await loadCliProjects(root, metas, scope, { noCreate: true, closure: true })
107
143
 
108
144
  if (parsed.target === undefined) {
109
- 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))
110
147
  return 0
111
148
  }
112
149
 
@@ -124,7 +161,8 @@ export async function showCmd(args: readonly string[]): Promise<number> {
124
161
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
125
162
  )
126
163
  }
127
- 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))
128
166
  return 0
129
167
  }
130
168
 
@@ -239,6 +277,7 @@ function renderList(
239
277
  2,
240
278
  )}\n`
241
279
  }
280
+ if (rows.length === 0) return ''
242
281
  const nameW = Math.max(...rows.map((r) => r.name.length))
243
282
  const dirW = Math.max(...rows.map((r) => r.dir.length))
244
283
  const lines = rows.map((r) => {
@@ -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
@@ -77,10 +77,10 @@ export function pollWatcher(
77
77
  */
78
78
  skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
79
79
  ): WatchHandle {
80
- let previous = new Map<string, number>()
80
+ let previous = new Map<string, string>()
81
81
  let first = true
82
82
  const scan = (): void => {
83
- const current = new Map<string, number>()
83
+ const current = new Map<string, string>()
84
84
  const walk = (abs: string, rel: string): void => {
85
85
  let entries: fs.Dirent[]
86
86
  try {
@@ -95,15 +95,23 @@ export function pollWatcher(
95
95
  if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
96
96
  continue
97
97
  }
98
- if (!e.isFile()) continue
98
+ // A link is an input as its target string (watch-judge.ts), so that
99
+ // string is sampled, never the target's times: a retarget inside
100
+ // one clock tick carries the same times.
101
+ const link = e.isSymbolicLink()
102
+ if (!e.isFile() && !link) continue
99
103
  try {
100
104
  // The later of the two clocks, as `modifiedBefore` reads them: a
101
105
  // replacement that carries the old file's mtime (`cp -p`, `rsync
102
106
  // -a`, `mv` of a file stamped the same) moved nothing under mtime
103
107
  // alone, and the poller never ran it where the native watcher did.
104
108
  // A rename or a write moves ctime, and no process can set it.
105
- const st = fs.statSync(path.join(abs, e.name))
106
- current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
109
+ const p = path.join(abs, e.name)
110
+ if (link) current.set(childRel, `link:${fs.readlinkSync(p)}`)
111
+ else {
112
+ const st = fs.statSync(p)
113
+ current.set(childRel, String(Math.max(st.mtimeMs, st.ctimeMs)))
114
+ }
107
115
  } catch {
108
116
  // raced with a delete; the next scan settles it
109
117
  }
@@ -111,8 +119,8 @@ export function pollWatcher(
111
119
  }
112
120
  walk(dir, '')
113
121
  if (!first) {
114
- for (const [rel, mtime] of current) {
115
- if (previous.get(rel) !== mtime) onEvent(rel)
122
+ for (const [rel, stamp] of current) {
123
+ if (previous.get(rel) !== stamp) onEvent(rel)
116
124
  }
117
125
  for (const rel of previous.keys()) {
118
126
  if (!current.has(rel)) onEvent(rel)
@@ -77,7 +77,7 @@ export class ChangeJudge {
77
77
  // are judged one window after the run ends, all together — an edit made
78
78
  // meanwhile still differs from what the loop last saw and re-runs.
79
79
  readonly pending = new Map<string, string>()
80
- /** The last cycle's wall window; a write inside it is the run's own. */
80
+ /** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
81
81
  lastCycle: { start: number; end: number } | undefined
82
82
  // Declared outputs are ignored by PATH above. A task with no `cache`
83
83
  // block declares none and still writes into its project, and the
package/src/cli/watch.ts CHANGED
@@ -86,7 +86,7 @@ export function watchRefusal(parsed: RunArgs): string | null {
86
86
  export async function watchCmd(args: readonly string[]): Promise<number> {
87
87
  const parsed = parseRunArgs(args, 'watch')
88
88
  if (parsed.error) {
89
- process.stderr.write(`vx watch: ${parsed.error}\n`)
89
+ process.stderr.write(`vx watch: ${parsed.error}${seeHelp('watch')}\n`)
90
90
  return 1
91
91
  }
92
92
 
@@ -137,10 +137,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
137
137
  // worth; every cycle after an edit must load and list live.
138
138
  delete opts.staged
139
139
  delete opts.discovered
140
- process.once('SIGINT', () => {
141
- process.stdout.write('\nvx watch: stopped\n')
142
- stop.abort('SIGINT')
143
- })
140
+ process.once('SIGINT', () => stop.abort('SIGINT'))
141
+ // Said once the loop is down, the last line: written as the signal
142
+ // landed, the aborted cycle's summary printed below it.
143
+ const stopped = (): number => {
144
+ if (stop.signal.reason === 'SIGINT') process.stdout.write('\nvx watch: stopped\n')
145
+ return 0
146
+ }
144
147
  process.once('SIGTERM', () => stop.abort('SIGTERM'))
145
148
  // A task runs in its own session (exec/kill-tree.ts): the terminal
146
149
  // closing reaches the loop alone, and the loop passes it on.
@@ -206,7 +209,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
206
209
  }
207
210
  if (stop.signal.aborted) {
208
211
  await held?.stop(forwardedSignal(stop.signal.reason))
209
- return 0
212
+ return stopped()
210
213
  }
211
214
  if (refusedToStart) return 1
212
215
  // After the initial run, so a `pkg#task` naming no project gets the
@@ -234,7 +237,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
234
237
  ...fingerprintClaims(plugins).keys(),
235
238
  ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
236
239
  ])
237
- return await runWatchLoop({
240
+ await runWatchLoop({
238
241
  opts,
239
242
  held,
240
243
  stop: stop.signal,
@@ -285,6 +288,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
285
288
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
286
289
  cacheDir: opts.cacheDir ?? ws.cacheDir,
287
290
  })
291
+ return stopped()
288
292
  }
289
293
 
290
294
  interface WatchLoopArgs {
@@ -345,7 +349,7 @@ interface Rediscovered {
345
349
  fenceDirs: readonly string[]
346
350
  }
347
351
 
348
- async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
352
+ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
349
353
  const { opts, stop, workspaceRoot, projects, cacheDir } = args
350
354
  // The watched set as of the last cycle: `rearm` replaces these after an
351
355
  // event that can change it, and every filter below reads the current one.
@@ -403,7 +407,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
403
407
  await held?.stop()
404
408
  held = undefined
405
409
  restartTimings()
406
- const start = Date.now()
410
+ // On the mtime clock, as the arm is: from `Date.now()` a write the
411
+ // run made within a tick of it carried an earlier mtime and read as
412
+ // an edit (WD-20). The end needs no stamp: an mtime never leads it.
413
+ const start = fsClockNow(cacheDir)
407
414
  const cycle = await runOrchestrator(opts)
408
415
  held = cycle.persistent
409
416
  if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
@@ -838,7 +845,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
838
845
 
839
846
  process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
840
847
 
841
- return await new Promise<number>((resolve) => {
848
+ return await new Promise<void>((resolve) => {
842
849
  const cleanup = async (): Promise<void> => {
843
850
  pool.closeAll()
844
851
  if (debounceTimer) clearTimeout(debounceTimer)
@@ -846,7 +853,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
846
853
  // it has returned, so the process never exits over a live child.
847
854
  await inFlight
848
855
  await held?.stop(forwardedSignal(stop.reason))
849
- resolve(0)
856
+ resolve()
850
857
  }
851
858
  if (stop.aborted) {
852
859
  void cleanup()
package/src/config.ts CHANGED
@@ -510,9 +510,9 @@ export interface CacheInputs {
510
510
  * project files" (gitignore-aware), or narrow as needed. An empty
511
511
  * array means no file inputs at all.
512
512
  *
513
- * Declared outputs and any nested-project files are excluded
514
- * automatically — a task cannot invalidate itself, and cannot read
515
- * across project boundaries.
513
+ * Declared outputs (both `outputs` fields) and any nested-project
514
+ * files are excluded automatically — a task cannot invalidate itself,
515
+ * and cannot read across project boundaries.
516
516
  */
517
517
  files: string[]
518
518
  /**
@@ -525,8 +525,8 @@ export interface CacheInputs {
525
525
  * NO project-boundary rule applies: these globs may match files
526
526
  * inside other projects' directories. Deliberate escape hatch —
527
527
  * prefer project-relative `files` declarations where possible.
528
- * Declared `outputs.workspaceFiles` are excluded automatically (a
529
- * task cannot invalidate itself).
528
+ * Declared outputs (both `outputs` fields) are excluded
529
+ * automatically (a task cannot invalidate itself).
530
530
  */
531
531
  workspaceFiles?: string[]
532
532
  /**
package/src/exec/env.ts CHANGED
@@ -33,6 +33,11 @@ export const ESSENTIAL_ENV: readonly string[] = [
33
33
  'NO_COLOR',
34
34
  'CI',
35
35
  'NODE_OPTIONS',
36
+ // Where corepack and pnpm keep their own binaries, as HOME is: a machine
37
+ // path, never task output. Without them a corepack shim re-downloads its
38
+ // manager (offline or sandboxed: fails) and pnpm loses its global dir.
39
+ 'COREPACK_HOME',
40
+ 'PNPM_HOME',
36
41
  ]
37
42
 
38
43
  /**
package/src/exec/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  exitSignal,
19
19
  type CaptureConfig,
20
20
  execWord,
21
+ maskCaptured,
21
22
  } from './runner.js'
22
23
  export {
23
24
  initSandbox,
@@ -134,12 +134,16 @@ async function psTable(): Promise<Map<number, Proc> | undefined> {
134
134
  return table
135
135
  }
136
136
 
137
- /** `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths. */
137
+ /**
138
+ * `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths after
139
+ * the locale's decimal point — `0:01,50` under a German or French locale.
140
+ */
138
141
  export function psTimeMs(s: string): number | undefined {
139
142
  const dash = s.indexOf('-')
140
143
  const days = dash === -1 ? 0 : Number(s.slice(0, dash))
141
144
  const parts = s
142
145
  .slice(dash + 1)
146
+ .replace(',', '.')
143
147
  .split(':')
144
148
  .map(Number)
145
149
  if (parts.length < 2 || parts.some((n) => !Number.isFinite(n)) || !Number.isFinite(days)) {