@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 +5 -5
- package/src/cli/show.ts +13 -6
- package/src/cli/watch-fs.ts +100 -3
- package/src/exec/sandbox-runtime.ts +32 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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(
|
|
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
|
-
|
|
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 {
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface WatchHandle {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface ArmedWatcher {
|
|
29
|
-
watcher:
|
|
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
|
|
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
|
|
542
|
-
* sandbox on every wrap, so it lives in
|
|
543
|
-
* shared `/tmp/claude`.
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|