@vzn/vx 0.0.596 → 0.0.598

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 (79) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/bin.ts +9 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +60 -31
  6. package/src/cache/git-inputs.ts +23 -0
  7. package/src/cache/index.ts +2 -0
  8. package/src/cache/inputs.ts +87 -32
  9. package/src/cache/key-fold.ts +1 -1
  10. package/src/cache/layer.ts +2 -1
  11. package/src/cache/layered-cache.ts +14 -9
  12. package/src/cache/output-index.ts +12 -7
  13. package/src/cache/run-history.ts +16 -8
  14. package/src/cli/cache.ts +2 -2
  15. package/src/cli/foreign-flags.ts +56 -2
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +6 -3
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +26 -20
  21. package/src/cli/run.ts +42 -15
  22. package/src/cli/select.ts +19 -5
  23. package/src/cli/show.ts +21 -27
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-cycle.ts +43 -0
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch.ts +50 -11
  28. package/src/cli/workspace-config.ts +3 -1
  29. package/src/config.ts +21 -0
  30. package/src/exec/executor.ts +1 -1
  31. package/src/exec/kill-tree.ts +121 -20
  32. package/src/exec/runner.ts +224 -48
  33. package/src/exec/sandbox-paths.ts +11 -0
  34. package/src/exec/sandbox-runtime.ts +187 -54
  35. package/src/exec/sandbox-violations.ts +59 -6
  36. package/src/graph/priorities.ts +1 -2
  37. package/src/graph/scheduler.ts +2 -2
  38. package/src/graph/task-graph.ts +145 -33
  39. package/src/index.ts +3 -1
  40. package/src/orchestrator/affected-tasks.ts +6 -2
  41. package/src/orchestrator/deferred-outputs.ts +3 -2
  42. package/src/orchestrator/doctor.ts +5 -3
  43. package/src/orchestrator/excluded-keys.ts +2 -13
  44. package/src/orchestrator/execute-task.ts +70 -28
  45. package/src/orchestrator/failure-mode.ts +38 -29
  46. package/src/orchestrator/framed-output.ts +5 -5
  47. package/src/orchestrator/hit-restore.ts +1 -1
  48. package/src/orchestrator/index.ts +0 -1
  49. package/src/orchestrator/keyed-projects.ts +6 -3
  50. package/src/orchestrator/lockfile-claim.ts +14 -7
  51. package/src/orchestrator/logger.ts +17 -7
  52. package/src/orchestrator/miss-save.ts +7 -11
  53. package/src/orchestrator/plan.ts +4 -20
  54. package/src/orchestrator/plugin-host.ts +7 -2
  55. package/src/orchestrator/plugin.ts +5 -2
  56. package/src/orchestrator/prepare.ts +4 -2
  57. package/src/orchestrator/projects.ts +1 -1
  58. package/src/orchestrator/run-artifacts.ts +2 -2
  59. package/src/orchestrator/run-context.ts +1 -1
  60. package/src/orchestrator/run-lock.ts +35 -25
  61. package/src/orchestrator/run.ts +2 -1
  62. package/src/orchestrator/sandbox-request.ts +31 -9
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +5 -9
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/upstream.ts +11 -0
  68. package/src/util/hangup.ts +42 -0
  69. package/src/util/index.ts +2 -0
  70. package/src/util/paths.ts +1 -1
  71. package/src/workspace/affected.ts +79 -42
  72. package/src/workspace/config-cache.ts +15 -1
  73. package/src/workspace/config-eval.ts +47 -7
  74. package/src/workspace/config-imports.ts +8 -55
  75. package/src/workspace/config-schema.ts +30 -6
  76. package/src/workspace/lockfile.ts +2 -1
  77. package/src/workspace/migrate-scripts.ts +1 -1
  78. package/src/workspace/project-loader.ts +33 -6
  79. package/src/workspace/workspace.ts +36 -14
@@ -82,7 +82,8 @@ export interface RemoteCacheLayer {
82
82
  /**
83
83
  * Store an artifact (fire-and-forget from LayeredCache's perspective).
84
84
  * `body` is file-backed (`Bun.file`) when the local store holds the
85
- * artifact, so a plugin that streams it never holds it whole.
85
+ * artifact, so a plugin that streams it never holds it whole, and every
86
+ * read of it sees the same bytes.
86
87
  */
87
88
  put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
88
89
  }
@@ -549,12 +550,12 @@ export class LayeredCache implements CacheLayer {
549
550
  // succeeded; we don't fail it on cache-server issues.
550
551
  //
551
552
  // The job hands the plugin a file-backed Blob over the local artifact,
552
- // opened when the plugin reads it: a queued job holds a path, not a
553
- // buffer, and a plugin that streams the Blob never holds it whole. The
554
- // artifact is content-addressed and immutable, so a deferred read sees
555
- // the same bytes; if a concurrent `vx cache prune` removed it first the
556
- // plugin's read throws and this upload is skipped — the never-fail
557
- // contract.
553
+ // pinned under a private name when the job starts (`pinArtifact`): a
554
+ // queued job holds a path, not a buffer, a plugin that streams the Blob
555
+ // never holds it whole, and every read of it sees the same bytes though
556
+ // a re-save of the key renames others over the live name. If a
557
+ // concurrent `vx cache prune` removed the artifact first the pin throws
558
+ // and this upload is skipped — the never-fail contract.
558
559
  const hash = args.hash
559
560
  const durationMs = args.entry.durationMs
560
561
  // Local writes disabled (`--cache=local:,remote:rw`): there is no
@@ -575,9 +576,11 @@ export class LayeredCache implements CacheLayer {
575
576
  this.enqueueUpload(async () => {
576
577
  const tally = this.local.uploads
577
578
  const start = performance.now()
579
+ let release: (() => Promise<void>) | undefined
578
580
  try {
579
- const body =
580
- packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
581
+ let body: Blob
582
+ if (packed !== undefined) body = new Blob([packed])
583
+ else ({ body, release } = this.local.pinArtifact(hash))
581
584
  await this.wire.put(hash, body, { durationMs })
582
585
  tally.count++
583
586
  tally.bytes += body.size
@@ -585,6 +588,8 @@ export class LayeredCache implements CacheLayer {
585
588
  } catch (err) {
586
589
  tally.failed++
587
590
  this.reportRemoteError('upload', hash, err)
591
+ } finally {
592
+ await release?.()
588
593
  }
589
594
  })
590
595
  }
@@ -336,13 +336,18 @@ export class OutputIndex {
336
336
  if (this.pendingStamps.size > 0) {
337
337
  const stamps = [...this.pendingStamps]
338
338
  this.pendingStamps.clear()
339
- this.db.transaction(() => {
340
- for (const [hash, rows] of stamps) {
341
- // A stamp outlives nothing: an entry pruned since has no rows to vouch for.
342
- if (this.entryExists.get(hash) === null) continue
343
- for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
344
- }
345
- })()
339
+ // IMMEDIATE: the body reads `entries` before it writes, and a
340
+ // deferred transaction that did could not wait for the lock
341
+ // (run-history.ts).
342
+ this.db
343
+ .transaction(() => {
344
+ for (const [hash, rows] of stamps) {
345
+ // A stamp outlives nothing: an entry pruned since has no rows to vouch for.
346
+ if (this.entryExists.get(hash) === null) continue
347
+ for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
348
+ }
349
+ })
350
+ .immediate()
346
351
  }
347
352
  if (this.pendingDirs.size === 0) return
348
353
  const pending = [...this.pendingDirs]
@@ -69,10 +69,9 @@ export class RunHistory {
69
69
  this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
70
70
  return
71
71
  }
72
- // `bun:sqlite`'s `transaction()` returns a callable that wraps the
73
- // body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
74
- // run that's one fsync instead of 200.
75
- this.db.transaction(() => this.insertRuns(runs))()
72
+ // One transaction, one fsync: for a 200-task run, one instead of 200.
73
+ // IMMEDIATE, as `recordRunBundle` explains.
74
+ this.db.transaction(() => this.insertRuns(runs)).immediate()
76
75
  }
77
76
 
78
77
  /** `runs` as few INSERTs; each distinct forward-args list digested once. */
@@ -99,10 +98,19 @@ export class RunHistory {
99
98
  // the entry-save transaction (`save`/`ingest`) so a warm
100
99
  // all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
101
100
  // mismatch — pays nothing for the moat it isn't refreshing.
102
- this.db.transaction(() => {
103
- this.insertRuns(bundle.runs)
104
- this.insertInvocation.run(...bindInvocation(bundle.invocation))
105
- })()
101
+ //
102
+ // IMMEDIATE: the write lock is taken at BEGIN, where the busy timeout
103
+ // waits for it. A run's rows digest their forward args under a salt
104
+ // the body READS first (`[]` from every CLI run), and a deferred
105
+ // transaction that read before it wrote could not wait: 13 of 48 runs
106
+ // on one shared cache dir lost their history to `database is locked`
107
+ // in 2–60 ms (BUSY_SNAPSHOT, a retry passing at once).
108
+ this.db
109
+ .transaction(() => {
110
+ this.insertRuns(bundle.runs)
111
+ this.insertInvocation.run(...bindInvocation(bundle.invocation))
112
+ })
113
+ .immediate()
106
114
  }
107
115
 
108
116
  /**
package/src/cli/cache.ts CHANGED
@@ -140,13 +140,13 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
140
140
  return {
141
141
  error: `${refusedWord(a ?? '')}: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
142
142
  }
143
- if ('error' in cd) return { error: cd.error }
143
+ if ('error' in cd) return { error: `${cd.error}${seeHelp('cache')}` }
144
144
  out.cacheDir = cd.cacheDir
145
145
  i = cd.next
146
146
  }
147
147
  }
148
148
  if (out.olderThanMs === undefined && out.maxBytes === undefined) {
149
- return { error: 'must pass --older-than <duration> or --max-size <bytes>' }
149
+ return { error: `needs --older-than <duration> or --max-size <size>${seeHelp('cache')}` }
150
150
  }
151
151
  return out
152
152
  }
@@ -1,4 +1,4 @@
1
- // What a Turbo or Nx user's hands type, and what `vx run` does with it.
1
+ // What a Turbo, Nx or Vite Task (`vp run`) user's hands type, and what `vx run` does with it.
2
2
  // Every flag here has one outcome: vx takes it as it is (`same`), takes it
3
3
  // as another spelling (`alias`, rewritten before the parse), or refuses it
4
4
  // with the vx way to say it (`refuse`). Nothing is dropped in silence: a
@@ -7,7 +7,7 @@
7
7
  // cli.md's parity section (`renderForeignFlags`), pinned by a test.
8
8
 
9
9
  interface ForeignFlag {
10
- runner: 'turbo' | 'nx'
10
+ runner: 'turbo' | 'nx' | 'vp'
11
11
  /** Every spelling, first the one shown. */
12
12
  names: readonly string[]
13
13
  /** It takes a value: `--flag <v>` or `--flag=<v>`. */
@@ -598,6 +598,60 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
598
598
  outcome: 'refuse',
599
599
  vx: 'vx has no cloud: drop it',
600
600
  },
601
+ {
602
+ runner: 'vp',
603
+ names: ['-r', '--recursive'],
604
+ value: false,
605
+ outcome: 'alias',
606
+ vx: '`--all`',
607
+ to: () => ['--all'],
608
+ },
609
+ {
610
+ runner: 'vp',
611
+ names: ['-w', '--workspace-root'],
612
+ value: false,
613
+ outcome: 'alias',
614
+ vx: '`--filter //` (the root project)',
615
+ to: () => ['--filter', '//'],
616
+ },
617
+ {
618
+ runner: 'vp',
619
+ names: ['--concurrency-limit'],
620
+ value: true,
621
+ outcome: 'alias',
622
+ vx: '`--concurrency <n>`',
623
+ to: (v) => (v === undefined ? ['--concurrency'] : ['--concurrency', v]),
624
+ },
625
+ {
626
+ runner: 'vp',
627
+ names: ['--ignore-depends-on'],
628
+ value: false,
629
+ outcome: 'alias',
630
+ vx: '`--exclude-dependencies`',
631
+ to: () => ['--exclude-dependencies'],
632
+ },
633
+ {
634
+ runner: 'vp',
635
+ names: ['--fail-if-no-match'],
636
+ value: false,
637
+ outcome: 'alias',
638
+ vx: 'nothing: a filter that matches nothing already fails the run',
639
+ to: () => [],
640
+ },
641
+ {
642
+ runner: 'vp',
643
+ names: ['--log'],
644
+ value: true,
645
+ outcome: 'refuse',
646
+ vx: 'vx frames each task’s output: `--output-logs <mode>` sets how much',
647
+ },
648
+ {
649
+ runner: 'vp',
650
+ names: ['--last-details'],
651
+ value: false,
652
+ outcome: 'refuse',
653
+ vx: '`vx last` prints the last run',
654
+ },
601
655
  ]
602
656
 
603
657
  /** Nx's verbs, as its users type them: `vx run` takes their flags. */
package/src/cli/info.ts CHANGED
@@ -29,7 +29,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
29
29
  }
30
30
  const cd = parseCacheDirFlag(args, i)
31
31
  if (cd !== null) {
32
- if ('error' in cd) return { ...out, error: cd.error }
32
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('info')}` }
33
33
  out.cacheDir = cd.cacheDir
34
34
  i = cd.next
35
35
  continue
package/src/cli/init.ts CHANGED
@@ -42,7 +42,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
42
42
  if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
43
43
  return {
44
44
  ...out,
45
- error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
45
+ error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
46
46
  }
47
47
  }
48
48
  out.plugin = seam
@@ -56,7 +56,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
56
56
  if (out.plugin !== undefined && out.mjs) {
57
57
  return {
58
58
  ...out,
59
- error: '--mjs does not combine with --plugin: the plugin templates are TypeScript',
59
+ error: `--mjs does not combine with --plugin: the plugin templates are TypeScript${seeHelp('init')}`,
60
60
  }
61
61
  }
62
62
  return out
package/src/cli/last.ts CHANGED
@@ -80,7 +80,7 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
80
80
  }
81
81
  const cd = parseCacheDirFlag(args, i)
82
82
  if (cd !== null) {
83
- if ('error' in cd) return { ...out, error: cd.error }
83
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('last')}` }
84
84
  out.cacheDir = cd.cacheDir
85
85
  i = cd.next
86
86
  continue
@@ -99,11 +99,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
99
99
  if (out.runId !== undefined && out.list !== undefined) {
100
100
  return {
101
101
  ...out,
102
- error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
102
+ error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
103
103
  }
104
104
  }
105
105
  if (out.runId !== undefined && out.failed === true) {
106
- return { ...out, error: `a run id and --failed do not combine: replay ${out.runId}` }
106
+ return {
107
+ ...out,
108
+ error: `a run id and --failed do not combine: replay ${out.runId}${seeHelp('last')}`,
109
+ }
107
110
  }
108
111
  return out
109
112
  }
package/src/cli/lock.ts CHANGED
@@ -83,9 +83,11 @@ function bareNote(bare: number): string {
83
83
 
84
84
  async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
85
85
  const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
86
- const projects: Record<string, LockfileEntry> = {}
87
- // `listProjects` sorts by name — stable lockfile diffs for free.
88
- for (let i = 0; i < metas.length; i++) projects[metas[i]!.name] = entries[i]!
86
+ // `listProjects` sorts by name — stable lockfile diffs for free. Defined,
87
+ // not assigned: a project named `__proto__` set the map's prototype (D-152).
88
+ const projects: Record<string, LockfileEntry> = Object.fromEntries(
89
+ metas.map((m, i) => [m.name, entries[i]!]),
90
+ )
89
91
  const leaks = secretsIn(projects)
90
92
  if (leaks.length > 0) {
91
93
  process.stderr.write(
@@ -1,7 +1,7 @@
1
1
  // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
2
2
 
3
3
  import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatDuration } from '../orchestrator/index.js'
4
+ import { formatElapsed, maskedCommand } from '../util/index.js'
5
5
 
6
6
  /**
7
7
  * Human-readable preview. One line per real task (groups hidden, same
@@ -37,7 +37,7 @@ export function formatPlanText(plan: RunPlan): string {
37
37
  const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
38
38
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
39
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
40
- const eta = executes && t.p50Ms !== undefined ? ` ~${formatDuration(t.p50Ms)}` : ''
40
+ const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
41
41
  // Placement, by EXECUTOR NAME rather than a local/remote word: the
42
42
  // summary line below already spends "local" and "remote" on the cache
43
43
  // tier, and a task placed on a named executor is what the reader can
@@ -48,7 +48,7 @@ export function formatPlanText(plan: RunPlan): string {
48
48
  )
49
49
  // Optional one-line description from the task config, indented
50
50
  // under the id so the eye picks up the task → blurb mapping.
51
- const taskDesc = t.node.config.description
51
+ const taskDesc = shownDescription(t)
52
52
  if (taskDesc) {
53
53
  lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
54
54
  }
@@ -94,8 +94,8 @@ export function formatPlanText(plan: RunPlan): string {
94
94
  const p = plan.predicted
95
95
  const executes = miss + nocache
96
96
  if (p !== undefined && executes > 0 && executes > p.unknownCount) {
97
- const parts = [`predicted: ~${formatDuration(p.wallMs)} wall`]
98
- parts.push(`~${formatDuration(p.workMs)} total execution`)
97
+ const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
98
+ parts.push(`~${formatElapsed(p.workMs)} total execution`)
99
99
  if (p.unknownCount > 0) {
100
100
  parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
101
101
  }
@@ -104,6 +104,12 @@ export function formatPlanText(plan: RunPlan): string {
104
104
  return lines.join('\n') + '\n'
105
105
  }
106
106
 
107
+ /** A TS config may build the description from `process.env` (L-11). */
108
+ function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
109
+ const { description, exec } = t.node.config
110
+ return description === undefined ? undefined : maskedCommand(description, exec?.env)
111
+ }
112
+
107
113
  /** One task of `vx run --dry=json`: the wire, stated by `schemas/plan.json`. */
108
114
  export interface PlanTaskJson {
109
115
  id: string
@@ -122,21 +128,21 @@ export function formatPlanJson(plan: RunPlan): string {
122
128
  return (
123
129
  JSON.stringify(
124
130
  {
125
- tasks: plan.tasks.map((t): PlanTaskJson => ({
126
- id: t.node.id,
127
- project: t.node.projectName,
128
- task: t.node.taskName,
129
- hash: t.hash,
130
- cacheStatus: t.cacheStatus,
131
- deps: t.deps,
132
- ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
133
- ...(t.executor !== undefined ? { executor: t.executor } : {}),
134
- ...(t.download !== undefined ? { download: t.download } : {}),
135
-
136
- ...(t.node.config.description !== undefined
137
- ? { description: t.node.config.description }
138
- : {}),
139
- })),
131
+ tasks: plan.tasks.map((t): PlanTaskJson => {
132
+ const description = shownDescription(t)
133
+ return {
134
+ id: t.node.id,
135
+ project: t.node.projectName,
136
+ task: t.node.taskName,
137
+ hash: t.hash,
138
+ cacheStatus: t.cacheStatus,
139
+ deps: t.deps,
140
+ ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
141
+ ...(t.executor !== undefined ? { executor: t.executor } : {}),
142
+ ...(t.download !== undefined ? { download: t.download } : {}),
143
+ ...(description !== undefined ? { description } : {}),
144
+ }
145
+ }),
140
146
  ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
141
147
  // The gate's refusals belong on the SCRIPTING surface too: a CI job
142
148
  // asking "did --download=none actually defer anything, and if not
package/src/cli/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
+ import { findWorkspaceRoot } from '../workspace/index.js'
4
5
  import { translateForeign } from './foreign-flags.js'
5
6
  import { flagHint, seeHelp } from './help.js'
6
7
  import {
@@ -13,7 +14,12 @@ import {
13
14
  type RunResult,
14
15
  } from '../orchestrator/index.js'
15
16
  import type { ContinueMode, TaskOutcome } from '../graph/index.js'
16
- import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
17
+ import {
18
+ type CachePolicy,
19
+ FULL_CACHE_POLICY,
20
+ gitRefusal,
21
+ parseCachePolicy,
22
+ } from '../cache/index.js'
17
23
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
18
24
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
19
25
  import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
@@ -175,7 +181,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
175
181
  if (n === null)
176
182
  return {
177
183
  ...out,
178
- error: `invalid concurrency: ${v} (a positive integer, or a share of the cores such as 50%)`,
184
+ error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
179
185
  }
180
186
  out.concurrency = n
181
187
  } else if (a === '--all') {
@@ -261,14 +267,17 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
261
267
  out.download = v
262
268
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
263
269
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
264
- if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
270
+ if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
265
271
  // Unlike every other value flag, a cache dir is an arbitrary
266
272
  // string — nothing about its shape rejects a swallowed flag. An
267
273
  // unquoted empty shell var (`--cache-dir $EMPTY --force`) would
268
274
  // otherwise create a directory literally named `--force` and drop
269
275
  // the flag. A path starting with `-` needs the `=` form.
270
276
  if (a === '--cache-dir' && v.startsWith('-')) {
271
- return { ...out, error: `--cache-dir requires a path, got flag: ${v}` }
277
+ return {
278
+ ...out,
279
+ error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
280
+ }
272
281
  }
273
282
  out.cacheDir = v
274
283
  } else if (a === '--cache' || a?.startsWith('--cache=')) {
@@ -323,14 +332,15 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
323
332
  if (v === undefined)
324
333
  return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
325
334
  const n = parseDecimalInt(v)
326
- if (n === null) return { ...out, error: `invalid verbosity: ${v} (a non-negative integer)` }
335
+ if (n === null)
336
+ return { ...out, error: `--verbosity must be a non-negative integer (got ${v})` }
327
337
  out.verbosity = n
328
338
  } else if (a === '--dry') {
329
339
  out.dry = 'text'
330
340
  } else if (a?.startsWith('--dry=')) {
331
341
  const fmt = a.slice('--dry='.length)
332
342
  if (fmt !== 'text' && fmt !== 'json') {
333
- return { ...out, error: `invalid --dry value: ${fmt} (text or json)` }
343
+ return { ...out, error: `--dry must be text or json (got ${fmt})` }
334
344
  }
335
345
  out.dry = fmt
336
346
  } else if (a === '--graph') {
@@ -358,7 +368,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
358
368
  if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
359
369
  // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
360
370
  const eq = raw.indexOf('=')
361
- if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
371
+ if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
362
372
  out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
363
373
  } else if (a === '--report-file' || a?.startsWith('--report-file=')) {
364
374
  const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
@@ -367,7 +377,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
367
377
  // nothing about its shape rejects a swallowed flag. A path starting
368
378
  // with `-` needs the `=` form.
369
379
  if (a === '--report-file' && v.startsWith('-')) {
370
- return { ...out, error: `--report-file requires a path, got flag: ${v}` }
380
+ return {
381
+ ...out,
382
+ error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
383
+ }
371
384
  }
372
385
  out.reportFile = v
373
386
  } else if (a === '--report') {
@@ -375,7 +388,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
375
388
  } else if (a?.startsWith('--report=')) {
376
389
  const fmt = a.slice('--report='.length)
377
390
  if (fmt !== 'markdown') {
378
- return { ...out, error: `invalid --report value: ${fmt} (only markdown)` }
391
+ return { ...out, error: `--report must be markdown (got ${fmt})` }
379
392
  }
380
393
  out.report = fmt
381
394
  } else if (a !== undefined && a.startsWith('-')) {
@@ -620,6 +633,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
620
633
 
621
634
  const cwd = process.cwd()
622
635
  let tasks = [...parsed.tasks]
636
+ let picked: string | undefined
623
637
 
624
638
  // No positionals → interactive picker (TTY only). Yields a single
625
639
  // anchored pkg#task; the rest of the pipeline treats it like any
@@ -638,6 +652,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
638
652
  )
639
653
  return 1
640
654
  }
655
+ // Every run needs git: refused here, not after the user has chosen.
656
+ const refusal = gitRefusal(await findWorkspaceRoot(cwd))
657
+ if (refusal !== undefined) {
658
+ process.stderr.write(`${refusal.message}\n`)
659
+ return 1
660
+ }
641
661
  const load = {
642
662
  ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
643
663
  ...(parsed.frozen ? { frozen: true } : {}),
@@ -662,10 +682,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
662
682
  }
663
683
  only = new Set(selected.names)
664
684
  }
665
- const picked = await pickTask(cwd, {}, load, only)
666
- if (picked === 'interrupted') return 130
667
- if (!picked) return 1
668
- tasks = [`${picked.project}#${picked.task}`]
685
+ const chosen = await pickTask(cwd, {}, load, only)
686
+ if (chosen === 'interrupted') return 130
687
+ if (!chosen) return 1
688
+ picked = `${chosen.project}#${chosen.task}`
689
+ tasks = [picked]
669
690
  }
670
691
 
671
692
  const resolved = await resolveRunOptions(parsed, cwd, tasks)
@@ -680,8 +701,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
680
701
  const opts = resolved
681
702
  // The raw invocation, recorded on the `invocations` row so dashboards
682
703
  // show what was actually run. `args` is everything after `run`, quoted
683
- // so `vx last` replays one paste-able line.
684
- opts.command = ['vx', 'run', ...args.map(shellQuote)].join(' ')
704
+ // so `vx last` replays one paste-able line; a picked task is named in
705
+ // it, or the line would open the picker again.
706
+ opts.command = [
707
+ 'vx',
708
+ 'run',
709
+ ...(picked !== undefined ? [shellQuote(picked)] : []),
710
+ ...args.map(shellQuote),
711
+ ].join(' ')
685
712
 
686
713
  // Planning paths short-circuit execution. Both build the full task
687
714
  // graph + probe the cache; the difference is just the formatter.
package/src/cli/select.ts CHANGED
@@ -26,8 +26,15 @@ import {
26
26
  import type { ProjectConfig } from '../config.js'
27
27
  import type { ProjectEntry } from '../workspace/index.js'
28
28
  import { parseDependencySpec } from '../graph/index.js'
29
- import { declaresInput } from '../cache/index.js'
30
- import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
29
+ import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
30
+ import {
31
+ isUserError,
32
+ listed,
33
+ maskedCommand,
34
+ maskedLine,
35
+ nearest,
36
+ UserError,
37
+ } from '../util/index.js'
31
38
  import {
32
39
  claimedAffected,
33
40
  fingerprintClaims,
@@ -60,12 +67,15 @@ export async function workspaceGlobOwners(
60
67
  load: CliLoadOptions = {},
61
68
  stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
62
69
  loadCliProjects(root, projects, 'all', load),
70
+ /** The changed nested repositories (`AffectedChanges.nested`). */
71
+ nested: readonly string[] = [],
63
72
  ): Promise<string[]> {
64
73
  const declaresMatch = (config: ProjectConfig): boolean => {
65
74
  for (const task of Object.values(config.tasks ?? {})) {
66
75
  const cache = task.cache
67
76
  if (cache === undefined) continue
68
77
  if (changed.some((rel) => declaresInput(cache, null, rel))) return true
78
+ if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
69
79
  }
70
80
  return false
71
81
  }
@@ -319,8 +329,8 @@ export async function resolveFilters(
319
329
  workspaceRoot: root,
320
330
  since: f.gitSince,
321
331
  projects,
322
- workspaceGlobOwners: (changed: readonly string[]) =>
323
- workspaceGlobOwners(root, projects, changed, load, stagedOnce),
332
+ workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
333
+ workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
324
334
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
325
335
  taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
326
336
  ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
@@ -480,7 +490,11 @@ export async function pickTask(
480
490
  .filter((t) => !isDefaultBuild(config.tasks?.[t]))
481
491
  .sort()
482
492
  for (const t of taskNames) {
483
- const desc = config.tasks?.[t]?.description
493
+ const task = config.tasks?.[t]
494
+ const desc =
495
+ task?.description === undefined
496
+ ? undefined
497
+ : maskedCommand(task.description, task.exec?.env)
484
498
  entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
485
499
  }
486
500
  }
package/src/cli/show.ts CHANGED
@@ -41,7 +41,12 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
41
41
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
42
42
  else if (a === '--filter') {
43
43
  const v = args[++i]
44
- if (v === undefined) return { ...out, error: '--filter requires a value (a pattern)' }
44
+ // `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
45
+ if (v === undefined || v === '' || v.startsWith('--'))
46
+ return {
47
+ ...out,
48
+ error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
49
+ }
45
50
  out.filters.push(v)
46
51
  } else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
47
52
  else if (a === '--affected') out.affected = ''
@@ -55,7 +60,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
55
60
  return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
56
61
  // An empty name is a part of every name: its "did you mean" listed the
57
62
  // whole workspace.
58
- else if (a === '') return { ...out, error: 'empty target (omit it to list every project)' }
63
+ else if (a === '')
64
+ return { ...out, error: `empty target (omit it to list every project)${seeHelp('show')}` }
59
65
  else out.target = a
60
66
 
61
67
  if (format !== undefined) {
@@ -215,33 +221,21 @@ function projectDir(root: string, meta: ProjectMeta): string {
215
221
  }
216
222
 
217
223
  /**
218
- * A task as `vx show` prints it: a secret variable's value masked
219
- * wherever it appears, in `env.define` and in the command a TS config
220
- * built from `process.env` (L-11).
224
+ * A task as `vx show` prints it: a secret variable's value masked in every
225
+ * string it holds, where a TS config that builds one from `process.env`
226
+ * put it (the command, `env.define`, a runtime probe, `readyWhen`, the
227
+ * description) (L-11).
221
228
  */
222
229
  function shownTask(task: TaskConfig): TaskConfig {
223
- const exec = task.exec
224
- if (exec === undefined) return task
225
- const define = exec.env?.define
226
- const secrets = secretMask([process.env, define], exec.env?.secret)
227
- if (secrets === null) return task
228
- return {
229
- ...task,
230
- exec: {
231
- ...exec,
232
- ...(exec.command !== undefined ? { command: secrets.mask(exec.command) } : {}),
233
- ...(define !== undefined
234
- ? {
235
- env: {
236
- ...exec.env,
237
- define: Object.fromEntries(
238
- Object.entries(define).map(([k, v]) => [k, secrets.mask(v)]),
239
- ),
240
- },
241
- }
242
- : {}),
243
- },
244
- }
230
+ const secrets = secretMask([process.env, task.exec?.env?.define], task.exec?.env?.secret)
231
+ return secrets === null ? task : (maskStrings(task, secrets.mask) as TaskConfig)
232
+ }
233
+
234
+ function maskStrings(value: unknown, mask: (text: string) => string): unknown {
235
+ if (typeof value === 'string') return mask(value)
236
+ if (Array.isArray(value)) return value.map((v) => maskStrings(v, mask))
237
+ if (typeof value !== 'object' || value === null) return value
238
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskStrings(v, mask)]))
245
239
  }
246
240
 
247
241
  /** A project config with each task as `shownTask` prints it. */