@vzn/vx 0.0.434 → 0.0.436

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.434",
3
+ "version": "0.0.436",
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.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"
24
+ "@vzn/vx-linux-x64": "0.0.436",
25
+ "@vzn/vx-linux-arm64": "0.0.436",
26
+ "@vzn/vx-darwin-x64": "0.0.436",
27
+ "@vzn/vx-darwin-arm64": "0.0.436"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/show.ts CHANGED
@@ -10,7 +10,7 @@ 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 { flagHint, seeHelp } from './help.js'
13
- import { nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
13
+ import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
14
14
  import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
15
15
  import {
16
16
  findWorkspaceRoot,
@@ -94,7 +94,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
94
94
  if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
95
95
  if (!byName.has(projectName)) {
96
96
  throw new UserError(
97
- `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
97
+ `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
98
98
  )
99
99
  }
100
100
  }
@@ -121,7 +121,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
121
121
  // `nx show projects` lists them; here a bare `vx show` does.
122
122
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
123
123
  throw new UserError(
124
- `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names])}`,
124
+ `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
125
125
  )
126
126
  }
127
127
  process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
@@ -143,7 +143,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
143
143
  const task = declaredTask(config, taskName)
144
144
  if (task === undefined) {
145
145
  throw new UserError(
146
- `vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
146
+ `vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`, 'its tasks')}`,
147
147
  )
148
148
  }
149
149
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
@@ -154,14 +154,21 @@ export async function showCmd(args: readonly string[]): Promise<number> {
154
154
  * Near misses by edit distance, plus partial names in either direction.
155
155
  * `prefix` makes each a spec the user can paste (`app#build`, not `build`).
156
156
  */
157
- function suggest(query: string, candidates: readonly string[], prefix = ''): string {
157
+ function suggest(
158
+ query: string,
159
+ candidates: readonly string[],
160
+ prefix = '',
161
+ label = 'names',
162
+ ): string {
158
163
  const q = query.toLowerCase()
159
164
  const near = new Set(nearMatches(query, candidates))
160
165
  for (const c of candidates) {
161
166
  const n = c.toLowerCase()
162
167
  if (n.includes(q) || q.includes(n)) near.add(c)
163
168
  }
164
- return near.size > 0 ? ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?` : ''
169
+ if (near.size > 0) return ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?`
170
+ // Nothing near named nothing to pick from (M-57): say what exists.
171
+ return candidates.length === 0 ? '' : `; ${label}: ${listed(candidates.map((c) => prefix + c))}`
165
172
  }
166
173
 
167
174
  function projectDir(root: string, meta: ProjectMeta): string {
@@ -26,7 +26,7 @@ export interface WatchHandle {
26
26
  }
27
27
 
28
28
  export interface ArmedWatcher {
29
- watcher: fs.FSWatcher
29
+ watcher: WatchHandle
30
30
  /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
31
31
  ready: Promise<boolean>
32
32
  }
@@ -157,7 +157,7 @@ export function armWatcher(
157
157
  const seen = new Promise<boolean>((resolve) => {
158
158
  markReady = resolve
159
159
  })
160
- const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
160
+ const listener = (filename: string | null): void => {
161
161
  if (filename == null || typeof filename !== 'string') return
162
162
  // An event naming the watched directory itself (macOS reports the
163
163
  // directory a write landed in as its own item) carries nothing a key
@@ -175,7 +175,11 @@ export function armWatcher(
175
175
  // proves this stream live.
176
176
  if (path.basename(filename) === WATCH_PROBE) return
177
177
  onEvent(filename)
178
- })
178
+ }
179
+ const watcher: WatchHandle =
180
+ recursive && process.platform === 'linux'
181
+ ? treeWatcher(dir, listener)
182
+ : fs.watch(dir, { recursive, persistent: true }, (_event, filename) => listener(filename))
179
183
  const probe = path.join(dir, WATCH_PROBE)
180
184
  const ready = (async (): Promise<boolean> => {
181
185
  // The probe is subject to the very race it detects: a write that lands
@@ -217,6 +221,99 @@ export function armWatcher(
217
221
  return { watcher, ready }
218
222
  }
219
223
 
224
+ /**
225
+ * A recursive watch on Linux, built from one non-recursive watch per
226
+ * directory, that never enters `IGNORED_SEGMENTS`. Bun's recursive form is
227
+ * the same inotify watch per directory and descended into all of them:
228
+ * this repo's root arm held 4,657 watches where 438 directories can
229
+ * matter, 40–55 ms of the arm, and a monorepo's `node_modules` met the OS
230
+ * watch limit (8,192 on many distros) and fell back to polling — for
231
+ * events the loop drops by name. A directory that appears is watched, and
232
+ * what it already holds is reported (it may have landed before its watch);
233
+ * one that goes, or moves, is dropped with everything under it, since an
234
+ * inotify watch follows the inode and would report the old name. The
235
+ * arming walk throws at the watch limit, so the pool's fallback still
236
+ * applies; a subdirectory that vanished mid-walk is simply not watched.
237
+ */
238
+ function treeWatcher(root: string, listener: (filename: string) => void): WatchHandle {
239
+ const watchers = new Map<string, fs.FSWatcher>()
240
+ let closed = false
241
+ let armed = false
242
+ let warned = false
243
+ const drop = (rel: string): void => {
244
+ for (const [key, w] of watchers) {
245
+ if (key !== rel && !key.startsWith(rel + '/')) continue
246
+ w.close()
247
+ watchers.delete(key)
248
+ }
249
+ }
250
+ const watchDir = (rel: string, report: boolean): void => {
251
+ if (closed || watchers.has(rel)) return
252
+ const abs = rel === '' ? root : path.join(root, rel)
253
+ let w: fs.FSWatcher
254
+ try {
255
+ w = fs.watch(abs, { persistent: true }, (_event, name) => {
256
+ if (name == null || typeof name !== 'string' || name === '' || name === '.') return
257
+ const child = rel === '' ? name : `${rel}/${name}`
258
+ listener(child)
259
+ if (IGNORED_SEGMENTS.includes(name)) return
260
+ let st: fs.Stats
261
+ try {
262
+ st = fs.statSync(path.join(abs, name))
263
+ } catch {
264
+ drop(child)
265
+ return
266
+ }
267
+ if (!st.isDirectory()) return
268
+ if (watchers.has(child)) return
269
+ watchDir(child, true)
270
+ })
271
+ } catch (err) {
272
+ const code = (err as NodeJS.ErrnoException).code
273
+ const limit = code === 'ENOSPC' || code === 'EMFILE'
274
+ // The walk that arms is all or nothing at the watch limit, as the
275
+ // recursive form was, so the pool's fallback polls instead of a
276
+ // tree half watched without a word.
277
+ if (rel === '' || (limit && !armed)) throw err
278
+ if (limit && !warned) {
279
+ warned = true
280
+ process.stderr.write(
281
+ `vx watch: ${abs}: the OS watch limit is reached (${code}); directories made from here on are not watched — raise it (Linux: sysctl fs.inotify.max_user_watches)\n`,
282
+ )
283
+ }
284
+ return
285
+ }
286
+ w.on('error', () => drop(rel))
287
+ watchers.set(rel, w)
288
+ let entries: fs.Dirent[]
289
+ try {
290
+ entries = fs.readdirSync(abs, { withFileTypes: true })
291
+ } catch {
292
+ return
293
+ }
294
+ for (const e of entries) {
295
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
296
+ if (e.isDirectory()) {
297
+ if (!IGNORED_SEGMENTS.includes(e.name)) watchDir(child, report)
298
+ } else if (report) listener(child)
299
+ }
300
+ }
301
+ try {
302
+ watchDir('', false)
303
+ } catch (err) {
304
+ for (const w of watchers.values()) w.close()
305
+ throw err
306
+ }
307
+ armed = true
308
+ return {
309
+ close(): void {
310
+ closed = true
311
+ for (const w of watchers.values()) w.close()
312
+ watchers.clear()
313
+ },
314
+ }
315
+ }
316
+
220
317
  /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
221
318
  export function modifiedBefore(abs: string, t: number): boolean {
222
319
  try {
@@ -32,7 +32,6 @@ import {
32
32
  lstatSync,
33
33
  existsSync,
34
34
  mkdirSync,
35
- mkdtempSync,
36
35
  openSync,
37
36
  readFileSync,
38
37
  readlinkSync,
@@ -538,19 +537,42 @@ function scanCommandAgain(): string {
538
537
  }
539
538
  }
540
539
  /**
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.
540
+ * An empty executable only this user can write: SRT runs it outside the
541
+ * sandbox on every wrap, so it lives in this user's own 0700 directory under
542
+ * the OS temp dir, never the shared `/tmp/claude`. One directory per user,
543
+ * not per process: a per-process one outlived every SIGKILLed run, and the
544
+ * exit hook that removed it fired under a test's emitted `exit` with the
545
+ * process going on (B-93). Anything else at the name (another owner, group
546
+ * or other bits, a link, a non-empty file) is refused, and the caller falls
547
+ * back to `true`.
544
548
  */
545
549
  function noScanCommand(): string {
546
550
  if (noScan !== undefined && existsSync(noScan)) return noScan
547
- const dir = mkdtempSync(path.join(os.tmpdir(), 'vx-noscan-'))
551
+ const uid = process.getuid!()
552
+ const dir = path.join(os.tmpdir(), `vx-noscan-${uid}`)
553
+ try {
554
+ mkdirSync(dir, { mode: 0o700 })
555
+ } catch (err) {
556
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
557
+ }
558
+ const d = lstatSync(dir)
559
+ // A link reads mode 0777 here, and a file at the name fails the write
560
+ // below (ENOTDIR).
561
+ if (d.uid !== uid || (d.mode & 0o077) !== 0) {
562
+ throw new Error(`${dir} is not this user's own directory`)
563
+ }
548
564
  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
- })
565
+ try {
566
+ writeFileSync(file, '', { mode: 0o700, flag: 'wx' })
567
+ } catch (err) {
568
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
569
+ }
570
+ const f = lstatSync(file)
571
+ // SRT's dependency check (`Bun.which`) refuses what is not an executable
572
+ // file, which would make the sandbox unavailable rather than fall back. A
573
+ // directory's size is never 0.
574
+ if (f.size !== 0 || (f.mode & 0o100) === 0) {
575
+ throw new Error(`${file} is not an empty executable of this user's`)
554
576
  }
555
577
  noScan = file
556
578
  return file