@vzn/vx 0.0.432 → 0.0.434

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.432",
3
+ "version": "0.0.434",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.432",
25
- "@vzn/vx-linux-arm64": "0.0.432",
26
- "@vzn/vx-darwin-x64": "0.0.432",
27
- "@vzn/vx-darwin-arm64": "0.0.432"
24
+ "@vzn/vx-linux-x64": "0.0.434",
25
+ "@vzn/vx-linux-arm64": "0.0.434",
26
+ "@vzn/vx-darwin-x64": "0.0.434",
27
+ "@vzn/vx-darwin-arm64": "0.0.434"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/select.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import type { ProjectConfig } from '../config.js'
25
25
  import type { ProjectEntry } from '../workspace/index.js'
26
26
  import { parseDependencySpec } from '../graph/index.js'
27
- import { maskedLine, nearest, UserError } from '../util/index.js'
27
+ import { listed, maskedLine, nearest, UserError } from '../util/index.js'
28
28
  import {
29
29
  claimedAffected,
30
30
  fingerprintClaims,
@@ -468,5 +468,5 @@ function didYouMeanProject(
468
468
  const scoped = bare === undefined ? undefined : byBare.get(bare)
469
469
  if (scoped?.length === 1) return `. Did you mean ${scoped[0]}?`
470
470
  }
471
- return ''
471
+ return names.length === 0 ? '' : `. Projects: ${listed(names)}`
472
472
  }
@@ -32,6 +32,7 @@ import {
32
32
  lstatSync,
33
33
  existsSync,
34
34
  mkdirSync,
35
+ mkdtempSync,
35
36
  openSync,
36
37
  readFileSync,
37
38
  readlinkSync,
@@ -39,6 +40,7 @@ import {
39
40
  rmSync,
40
41
  statSync,
41
42
  unlinkSync,
43
+ writeFileSync,
42
44
  } from 'node:fs'
43
45
  import { mkdir, unlink } from 'node:fs/promises'
44
46
  import type { SandboxConfig } from '../config.js'
@@ -162,7 +164,12 @@ async function probeUncached(weakerNested: boolean): Promise<SandboxAvailability
162
164
  if (!SandboxManager.isSupportedPlatform()) {
163
165
  return { available: false, reason: `platform ${process.platform} not supported` }
164
166
  }
165
- const deps = SandboxManager.checkDependencies()
167
+ // SRT checks the scan command its live config names, and once that is
168
+ // vx's no-scan file (B-92) a process `exit` hook may have removed it
169
+ // while the process goes on: name one that is there.
170
+ const deps = SandboxManager.checkDependencies(
171
+ noScan === undefined ? undefined : { command: scanCommandAgain() },
172
+ )
166
173
  if (deps.errors.length > 0) return { available: false, reason: dependencyReason(deps.errors) }
167
174
  const long = socketPathRefusal()
168
175
  if (long !== undefined) return { available: false, reason: long }
@@ -503,14 +510,50 @@ let scopedDenyScan = false
503
510
  * 1 the scan finds only the root's entries, which SRT keeps only inside a
504
511
  * write grant, where the scoped walk already reaches (the parity rows in
505
512
  * `sandbox-deny-scan.unsafe.test.ts`). A no-op in rg's place ends the
506
- * spawn's 3.8 ms at 1.0; SRT has no way to skip it.
513
+ * spawn's 3.8 ms at 1.0; SRT has no way to skip it. A file that cannot be
514
+ * exec'd ends it sooner still: `posix_spawn` refuses it (ENOEXEC) in
515
+ * 0.32 ms against `true`'s 1.06, SRT reads the refusal as an empty scan,
516
+ * and its dependency check (`Bun.which`) still finds the file (B-92).
507
517
  */
508
518
  function scopedScanConfig(): { mandatoryDenySearchDepth: number; ripgrep?: { command: string } } {
509
519
  try {
510
- return { mandatoryDenySearchDepth: 1, ripgrep: { command: executablePath('true') } }
520
+ return { mandatoryDenySearchDepth: 1, ripgrep: { command: noScanCommand() } }
511
521
  } catch {
512
- return { mandatoryDenySearchDepth: 1 }
522
+ try {
523
+ return { mandatoryDenySearchDepth: 1, ripgrep: { command: executablePath('true') } }
524
+ } catch {
525
+ return { mandatoryDenySearchDepth: 1 }
526
+ }
527
+ }
528
+ }
529
+
530
+ let noScan: string | undefined
531
+
532
+ /** The no-scan file again (made anew if gone), or `true` where it cannot be. */
533
+ function scanCommandAgain(): string {
534
+ try {
535
+ return noScanCommand()
536
+ } catch {
537
+ return executablePath('true')
538
+ }
539
+ }
540
+ /**
541
+ * An empty executable only this process can write: SRT runs it outside the
542
+ * sandbox on every wrap, so it lives in a fresh 0700 directory, never in the
543
+ * shared `/tmp/claude`. Made once a process, again if a temp cleaner took it.
544
+ */
545
+ function noScanCommand(): string {
546
+ if (noScan !== undefined && existsSync(noScan)) return noScan
547
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'vx-noscan-'))
548
+ const file = path.join(dir, 'rg')
549
+ writeFileSync(file, '', { mode: 0o700 })
550
+ if (noScan === undefined) {
551
+ process.on('exit', () => {
552
+ if (noScan !== undefined) rmSync(path.dirname(noScan), { recursive: true, force: true })
553
+ })
513
554
  }
555
+ noScan = file
556
+ return file
514
557
  }
515
558
 
516
559
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
@@ -38,6 +38,7 @@ import {
38
38
  parseSize,
39
39
  printTimings,
40
40
  ulid,
41
+ listed,
41
42
  nearest,
42
43
  UserError,
43
44
  machineParallelism,
@@ -378,7 +379,7 @@ async function runOnBus(
378
379
  // case too; the message is identical, so that branch stays below.
379
380
  if (prepared.unresolvedTasks.length > 0) {
380
381
  log.status(
381
- `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects)}${await initHint(prepared)}`,
382
+ `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`,
382
383
  )
383
384
  await teardown()
384
385
  prepared.cache.close()
@@ -1427,7 +1428,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1427
1428
  return {
1428
1429
  tasks: [],
1429
1430
  unresolvedTasks: prepared.unresolvedTasks,
1430
- unresolvedHint: `${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects)}${await initHint(prepared)}`,
1431
+ unresolvedHint: `${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`,
1431
1432
  }
1432
1433
  }
1433
1434
  if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
@@ -1520,12 +1521,16 @@ async function initHint(prepared: {
1520
1521
  function didYouMean(
1521
1522
  unresolved: readonly string[],
1522
1523
  projects: ReadonlyMap<string, ProjectEntry>,
1524
+ elsewhere: readonly string[] = [],
1523
1525
  ): string {
1524
1526
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1525
1527
  const allTasks = new Set<string>()
1526
1528
  for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1527
1529
  // A Set: two typos of the same task hint it once, not once per typo.
1528
1530
  const hints = new Set<string>()
1531
+ // A typo past two edits named nothing to pick from (M-56): with no near
1532
+ // name, say what exists instead.
1533
+ const lists = new Set<string>()
1529
1534
  for (const spec of unresolved) {
1530
1535
  const at = spec.indexOf('#')
1531
1536
  const nx = nxProjectTarget(spec, projects)
@@ -1536,18 +1541,31 @@ function didYouMean(
1536
1541
  if (at < 0) {
1537
1542
  const t = nearest(spec, allTasks)
1538
1543
  if (t !== undefined) hints.add(t)
1544
+ // A name outside the selection is already said, with `--all`.
1545
+ else if (allTasks.size > 0 && !elsewhere.includes(spec)) {
1546
+ lists.add(`Tasks: ${listed(allTasks)}.`)
1547
+ }
1539
1548
  continue
1540
1549
  }
1541
1550
  const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
1542
1551
  if (!projects.has(proj)) {
1543
1552
  const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
1544
1553
  if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
1554
+ else if (p === undefined && projects.size > 0) {
1555
+ lists.add(`No project is named ${proj}; projects: ${listed(projects.keys())}.`)
1556
+ }
1545
1557
  continue
1546
1558
  }
1547
1559
  const t = nearest(task, tasksOf(projects.get(proj)))
1548
1560
  if (t !== undefined) hints.add(`${proj}#${t}`)
1561
+ else if (tasksOf(projects.get(proj)).length > 0) {
1562
+ lists.add(`${proj}'s tasks: ${listed(tasksOf(projects.get(proj)))}.`)
1563
+ }
1549
1564
  }
1550
- return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
1565
+ return (
1566
+ (hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`) +
1567
+ [...lists].map((l) => ` ${l}`).join('')
1568
+ )
1551
1569
  }
1552
1570
 
1553
1571
  /**
@@ -72,3 +72,13 @@ export function nearMatches(name: string, candidates: Iterable<string>, limit =
72
72
  }
73
73
  return out.slice(0, limit)
74
74
  }
75
+
76
+ /**
77
+ * The names to offer when no one name is near: sorted, the first `limit`,
78
+ * then a count. A typo past two edits named nothing to pick from (M-56).
79
+ */
80
+ export function listed(names: Iterable<string>, limit = 8): string {
81
+ const all = [...new Set(names)].sort()
82
+ const more = all.length - limit
83
+ return all.slice(0, limit).join(', ') + (more > 0 ? `, and ${more} more` : '')
84
+ }
package/src/util/index.ts CHANGED
@@ -49,7 +49,7 @@ export {
49
49
  export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
50
50
  export { ulid } from './ulid.js'
51
51
  export { splitTaskId } from './task-id.js'
52
- export { editDistance, nearMatches, nearest } from './edit-distance.js'
52
+ export { editDistance, listed, nearMatches, nearest } from './edit-distance.js'
53
53
  export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
54
54
  export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
55
55
  export { executablePath, shellArgv } from './which.js'
@@ -123,9 +123,15 @@ function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
123
123
  } catch {
124
124
  return null
125
125
  }
126
- return hasEsmExport(source, loader) ? source : null
126
+ return !COMMONJS_HINT.test(source) || hasEsmExport(source, loader) ? source : null
127
127
  }
128
128
 
129
+ // What makes Bun run a file as CommonJS is one of these names at the top
130
+ // level (an escaped `\u006dodule` too, so any backslash counts). Source
131
+ // with none of them runs as a module whichever path loads it, so it skips
132
+ // the parse: 16–20 µs a config, 1,000 cold configs (2026-10-03).
133
+ const COMMONJS_HINT = /\b(?:module|exports|require|this|__dirname|__filename)\b|\\/
134
+
129
135
  /** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
130
136
  const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
131
137
 
@@ -303,7 +309,11 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
303
309
  }
304
310
  if (name === 'BuildMessage') {
305
311
  const pos = (err as { position?: BuildPosition | null }).position ?? {}
306
- const file = typeof pos.file === 'string' && pos.file.length > 0 ? pos.file : configPath
312
+ // A served config's position names its specifier, query and all.
313
+ const file =
314
+ typeof pos.file === 'string' && pos.file.length > 0
315
+ ? pos.file.replace(BUST_QUERY, '')
316
+ : configPath
307
317
  const at =
308
318
  typeof pos.line === 'number'
309
319
  ? `:${pos.line}${typeof pos.column === 'number' ? `:${pos.column}` : ''}`
@@ -118,7 +118,8 @@ export async function findWorkspaceRoot(
118
118
  if (nearest !== null) return nearest
119
119
  throw new UserError(
120
120
  `Could not find a workspace root in any parent of ${start} ` +
121
- `(looked for pnpm-workspace.yaml or package.json)`,
121
+ `(looked for pnpm-workspace.yaml or package.json): run vx inside a project, ` +
122
+ `or create a package.json (\`bun init\` or \`npm init -y\`) and run \`vx init\``,
122
123
  )
123
124
  }
124
125