@vzn/vx 0.0.102 → 0.0.104

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.102",
3
+ "version": "0.0.104",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.102",
21
- "@vzn/vx-linux-arm64": "0.0.102",
22
- "@vzn/vx-darwin-x64": "0.0.102",
23
- "@vzn/vx-darwin-arm64": "0.0.102"
20
+ "@vzn/vx-linux-x64": "0.0.104",
21
+ "@vzn/vx-linux-arm64": "0.0.104",
22
+ "@vzn/vx-darwin-x64": "0.0.104",
23
+ "@vzn/vx-darwin-arm64": "0.0.104"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/help.ts CHANGED
@@ -49,7 +49,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
49
49
  ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
50
50
  ' vx info [--format pretty|json] [--cache-dir <path>]',
51
51
  ' vx why [TASK | PKG#TASK] [--run <runId>] [--format pretty|json] [--cache-dir <path>]',
52
- ' vx last [RUNID] [--list[=N]] [--format pretty|json] [--cache-dir <path>]',
52
+ ' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
53
53
  ' vx completions bash|zsh|fish',
54
54
  ' vx help [VERB]',
55
55
  ' vx version',
@@ -150,6 +150,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
150
150
  ' header (command, when, branch, counts) plus the per-task',
151
151
  ' table, failures first. No re-execution, no cache probe.',
152
152
  ' --list[=N] List the N most recent runs (default 10) with run ids.',
153
+ ' --failed Only failed runs: the latest one, or with --list the list.',
153
154
  ' --format <fmt> pretty (default) | json.',
154
155
  '',
155
156
  'Migration:',
package/src/cli/last.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // history in cache.db is the only replay surface, and this verb reads it.
5
5
  // Read-only — no config evaluation, no re-hash, no cache probe.
6
6
 
7
+ import type { Database } from 'bun:sqlite'
7
8
  import { Cache, noteSchemaReset } from '../cache/index.js'
8
9
  import { formatBytes } from './format.js'
9
10
  import { flagHint, seeHelp } from './help.js'
@@ -11,6 +12,7 @@ import {
11
12
  exitSignal,
12
13
  getInvocation,
13
14
  getRun,
15
+ type InvocationDetail,
14
16
  listInvocations,
15
17
  type RunSummaryRow,
16
18
  } from '../orchestrator/index.js'
@@ -22,6 +24,8 @@ import { resolveRunId, shortRunId } from './run-id.js'
22
24
  interface LastArgs {
23
25
  runId?: string
24
26
  list?: number
27
+ /** Only runs that failed: the latest one replayed, or the list narrowed. */
28
+ failed?: boolean
25
29
  format: 'pretty' | 'json'
26
30
  /** `--cache-dir`: read the history a run with the same flag wrote. */
27
31
  cacheDir?: string
@@ -48,6 +52,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
48
52
  out.list = n
49
53
  continue
50
54
  }
55
+ if (a === '--failed') {
56
+ out.failed = true
57
+ continue
58
+ }
51
59
  if (a === '--format' || a.startsWith('--format=')) {
52
60
  const fv = a === '--format' ? args[++i] : a.slice(9)
53
61
  if (fv !== 'pretty' && fv !== 'json') {
@@ -82,9 +90,20 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
82
90
  error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
83
91
  }
84
92
  }
93
+ if (out.runId !== undefined && out.failed === true) {
94
+ return { ...out, error: `a run id and --failed do not combine: replay ${out.runId}` }
95
+ }
85
96
  return out
86
97
  }
87
98
 
99
+ /** The `limit` most recent runs that failed, newest first. */
100
+ function failedInvocations(db: Database, limit: number): InvocationDetail[] {
101
+ const ids = db
102
+ .query('SELECT run_id FROM invocations WHERE exit_ok = 0 ORDER BY rowid DESC LIMIT ?')
103
+ .all(limit) as Array<{ run_id: string }>
104
+ return ids.map((r) => getInvocation(db, r.run_id)).filter((i) => i !== null)
105
+ }
106
+
88
107
  const fmtWhen = (ms: number): string => new Date(ms).toISOString()
89
108
 
90
109
  const fmtMs = (ms: number): string => formatElapsed(ms, true)
@@ -190,7 +209,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
190
209
  const db = cache.dbHandle()
191
210
 
192
211
  if (parsed.list !== undefined) {
193
- const invocations = listInvocations(db, { limit: parsed.list })
212
+ const invocations =
213
+ parsed.failed === true
214
+ ? failedInvocations(db, parsed.list)
215
+ : listInvocations(db, { limit: parsed.list })
194
216
  if (parsed.format === 'json') {
195
217
  process.stdout.write(`${JSON.stringify(invocations)}\n`)
196
218
  return 0
@@ -213,12 +235,16 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
213
235
  const inv =
214
236
  parsed.runId !== undefined
215
237
  ? getInvocation(db, resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
216
- : (listInvocations(db, { limit: 1 })[0] ?? null)
238
+ : parsed.failed === true
239
+ ? (failedInvocations(db, 1)[0] ?? null)
240
+ : (listInvocations(db, { limit: 1 })[0] ?? null)
217
241
  if (inv === null || inv === undefined) {
218
242
  throw new UserError(
219
243
  parsed.runId !== undefined
220
244
  ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
221
- : 'vx last: no recorded runs yet — run something first',
245
+ : parsed.failed === true
246
+ ? 'vx last: no recorded run failed'
247
+ : 'vx last: no recorded runs yet — run something first',
222
248
  )
223
249
  }
224
250
  const detail = getRun(db, inv.runId)
package/src/exec/env.ts CHANGED
@@ -40,6 +40,14 @@ export const ESSENTIAL_ENV: readonly string[] = [
40
40
  'PROGRAMFILES(X86)',
41
41
  'COMSPEC',
42
42
  'PATHEXT',
43
+ // Read on Windows by os.homedir(), npm and git, which break without them.
44
+ 'SYSTEMDRIVE',
45
+ 'WINDIR',
46
+ 'USERPROFILE',
47
+ 'HOMEDRIVE',
48
+ 'HOMEPATH',
49
+ 'NUMBER_OF_PROCESSORS',
50
+ 'PROCESSOR_ARCHITECTURE',
43
51
  ]
44
52
 
45
53
  /**
@@ -29,6 +29,7 @@ import {
29
29
  chmodSync,
30
30
  closeSync,
31
31
  lstatSync,
32
+ existsSync,
32
33
  mkdirSync,
33
34
  openSync,
34
35
  readlinkSync,
@@ -461,6 +462,21 @@ function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
461
462
  return paths
462
463
  }
463
464
 
465
+ /**
466
+ * Whether this Linux host speaks IPv6. SRT's in-sandbox network bridge is
467
+ * `socat TCP-LISTEN:3128`, and socat 1.8 opens that as an IPv6 socket: on
468
+ * a host without IPv6 it failed ("Address family not supported by
469
+ * protocol"), its error went to /dev/null, and every networked task met
470
+ * only "connection refused" on the proxy (J-33's lead, B-22). Without
471
+ * IPv6 the wrapped command sets `SOCAT_DEFAULT_LISTEN_IP=4`, socat's own
472
+ * switch for the listen family; with it nothing changes. Asked once.
473
+ */
474
+ let hasIpv6: boolean | undefined
475
+ function hostHasIpv6(): boolean {
476
+ hasIpv6 ??= existsSync('/proc/net/if_inet6')
477
+ return hasIpv6
478
+ }
479
+
464
480
  /**
465
481
  * One-time SRT initialization per orchestrator run. Starts the proxy
466
482
  * servers + (on macOS) the violation log monitor. Safe to call repeatedly
@@ -922,6 +938,8 @@ export async function wrapSandboxedCommand(
922
938
  // the namespace goes with vx, a `setsid` daemon inside included, a
923
939
  // traced one-shot task too: its strace runs inside (B-11).
924
940
  if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped)) wrapped = `exec ${wrapped}`
941
+ if (process.platform === 'linux' && !hostHasIpv6())
942
+ wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
925
943
  if (args.server === true) liveServers.add(tag)
926
944
  if (ports.length > 0) spawnHostBridges(ports, tag)
927
945
  return {
@@ -18,6 +18,8 @@
18
18
  //
19
19
  // - RELATIVE specifiers only. A bare specifier is a package; it moves when
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
+ // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
+ // from disk (D-27).
21
23
  // - Descend only through files owned by NO project. A config reaching into
22
24
  // another project (say a site's `vx.config.ts` importing
23
25
  // `../core/src/index.ts`) records that edge and STOPS there — following
@@ -25,7 +27,7 @@
25
27
  // closure, and the containment channel already selects the project that
26
28
  // owns it.
27
29
 
28
- import { existsSync, readFileSync, statSync } from 'node:fs'
30
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
29
31
  import { realpath } from 'node:fs/promises'
30
32
  import { builtinModules } from 'node:module'
31
33
  import path from 'node:path'
@@ -137,18 +139,6 @@ export function unprovidedBareImports(
137
139
  return out
138
140
  }
139
141
 
140
- /** The nearest `tsconfig.json`, else `jsconfig.json`, at or above `dir`: Bun reads that one alone. */
141
- function nearestTsconfig(dir: string): string | undefined {
142
- for (let d = path.resolve(dir); ;) {
143
- for (const name of ['tsconfig.json', 'jsconfig.json']) {
144
- if (existsSync(path.join(d, name))) return path.join(d, name)
145
- }
146
- const up = path.dirname(d)
147
- if (up === d) return undefined
148
- d = up
149
- }
150
- }
151
-
152
142
  interface CompilerPaths {
153
143
  paths?: Record<string, unknown>
154
144
  /** What `paths` targets resolve against: `baseUrl`, else the config's own dir. */
@@ -161,7 +151,9 @@ interface CompilerPaths {
161
151
  * against the config that set it. A package `extends` is not followed: its
162
152
  * paths are unseen, so a specifier only they map stays refused.
163
153
  */
164
- function compilerPaths(file: string, depth = 0): CompilerPaths {
154
+ function compilerPaths(file: string, memo?: TsconfigMemo, depth = 0): CompilerPaths {
155
+ const known = memo?.get(file)
156
+ if (known !== undefined) return known
165
157
  let json: unknown
166
158
  try {
167
159
  json = Bun.JSONC.parse(readFileSync(file, 'utf8'))
@@ -175,7 +167,7 @@ function compilerPaths(file: string, depth = 0): CompilerPaths {
175
167
  for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
176
168
  if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
177
169
  const f = path.resolve(dir, base)
178
- Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', depth + 1))
170
+ Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', memo, depth + 1))
179
171
  }
180
172
  const opts = (json as { compilerOptions?: unknown }).compilerOptions
181
173
  if (typeof opts !== 'object' || opts === null) return out
@@ -187,6 +179,7 @@ function compilerPaths(file: string, depth = 0): CompilerPaths {
187
179
  } else if (out.paths !== undefined && out.baseUrl !== undefined) {
188
180
  out.pathsBase = out.baseUrl
189
181
  }
182
+ memo?.set(file, out)
190
183
  return out
191
184
  }
192
185
 
@@ -204,15 +197,45 @@ function fileAt(target: string): string | undefined {
204
197
  return undefined
205
198
  }
206
199
 
200
+ /** Paths per directory and per tsconfig file, memoised across one walk. */
201
+ type TsconfigMemo = Map<string, CompilerPaths>
202
+
203
+ /**
204
+ * The paths of the nearest `tsconfig.json`, else `jsconfig.json`, at or
205
+ * above `dir`: Bun reads that one alone.
206
+ */
207
+ function pathsAt(dir: string, memo?: TsconfigMemo): CompilerPaths {
208
+ const known = memo?.get(dir)
209
+ if (known !== undefined) return known
210
+ const file = ['tsconfig.json', 'jsconfig.json'].map((n) => path.join(dir, n)).find(existsSync)
211
+ const up = path.dirname(dir)
212
+ const cp = file !== undefined ? compilerPaths(file, memo) : up === dir ? {} : pathsAt(up, memo)
213
+ memo?.set(dir, cp)
214
+ return cp
215
+ }
216
+
207
217
  /**
208
- * The local file a bare specifier names through the nearest tsconfig's
209
- * `paths` or `baseUrl`, as Bun resolves it (D-26): an alias whose target
210
- * exists loads from disk and never reaches the registry.
218
+ * Whether `source` quotes a bare specifier the nearest tsconfig maps: the
219
+ * textual pass that keeps a config importing only packages unscanned, as
220
+ * D-23's keeps one importing nothing relative (D-27).
211
221
  */
212
- function tsconfigTarget(spec: string, fromDir: string): string | undefined {
213
- const cfg = nearestTsconfig(fromDir)
214
- if (cfg === undefined) return undefined
215
- const { paths, pathsBase, baseUrl } = compilerPaths(cfg)
222
+ function hasAliasCandidate(source: string, fromDir: string, memo: TsconfigMemo): boolean {
223
+ SPECIFIER.lastIndex = 0
224
+ for (let m = SPECIFIER.exec(source); m !== null; m = SPECIFIER.exec(source)) {
225
+ const spec = m[1] ?? m[2] ?? ''
226
+ if (needsLookup(spec) && tsconfigBases(spec, fromDir, memo).length > 0) return true
227
+ }
228
+ return false
229
+ }
230
+
231
+ /**
232
+ * The paths a bare specifier names through the nearest tsconfig's `paths`
233
+ * (each matching target, in order) or `baseUrl`, before extensions: Bun
234
+ * tries them ahead of `node_modules`.
235
+ */
236
+ function tsconfigBases(spec: string, fromDir: string, memo?: TsconfigMemo): string[] {
237
+ const { paths, pathsBase, baseUrl } = pathsAt(path.resolve(fromDir), memo)
238
+ const out: string[] = []
216
239
  for (const [key, targets] of Object.entries(paths ?? {})) {
217
240
  const star = key.indexOf('*')
218
241
  let hole: string
@@ -226,12 +249,39 @@ function tsconfigTarget(spec: string, fromDir: string): string | undefined {
226
249
  hole = spec.slice(head.length, spec.length - tail.length)
227
250
  }
228
251
  for (const t of Array.isArray(targets) ? targets : []) {
229
- if (typeof t !== 'string') continue
230
- const hit = fileAt(path.resolve(pathsBase!, t.replace('*', hole)))
231
- if (hit !== undefined) return hit
252
+ if (typeof t === 'string') out.push(path.resolve(pathsBase!, t.replace('*', hole)))
232
253
  }
233
254
  }
234
- return baseUrl === undefined ? undefined : fileAt(path.resolve(baseUrl, spec))
255
+ if (baseUrl !== undefined) out.push(path.resolve(baseUrl, spec))
256
+ return out
257
+ }
258
+
259
+ /**
260
+ * The local file a bare specifier names through the nearest tsconfig, as
261
+ * Bun resolves it (D-26): an alias whose target exists loads from disk and
262
+ * never reaches the registry.
263
+ */
264
+ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): string | undefined {
265
+ for (const base of tsconfigBases(spec, fromDir, memo)) {
266
+ const hit = fileAt(base)
267
+ if (hit !== undefined) return hit
268
+ }
269
+ return undefined
270
+ }
271
+
272
+ /** A path as Bun names it: realpath'd when it exists. */
273
+ function realpathOr(file: string): string {
274
+ try {
275
+ return realpathSync(file)
276
+ } catch {
277
+ return file
278
+ }
279
+ }
280
+
281
+ /** A path and, bare of an extension, the files Bun would try for it. */
282
+ function expansions(named: string): string[] {
283
+ if (path.extname(named) !== '') return [named]
284
+ return [named, ...RESOLVED_EXTENSIONS.flatMap((e) => [named + e, path.join(named, 'index' + e)])]
235
285
  }
236
286
 
237
287
  /**
@@ -251,8 +301,14 @@ const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
251
301
  * reason. `import type` is erased by `scanImports` and so contributes no edge,
252
302
  * which is correct: an erased import cannot move a resolved value.
253
303
  */
254
- function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
255
- if (!RELATIVE_CANDIDATE.test(source)) return []
304
+ function scanLocalImports(
305
+ source: string,
306
+ fromDir: string,
307
+ loader: 'ts' | 'js',
308
+ memo: TsconfigMemo,
309
+ ): string[] {
310
+ const aliased = hasAliasCandidate(source, fromDir, memo)
311
+ if (!aliased && !RELATIVE_CANDIDATE.test(source)) return []
256
312
  let specifiers: string[]
257
313
  try {
258
314
  specifiers = scanner(loader)
@@ -263,7 +319,16 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
263
319
  }
264
320
  const out: string[] = []
265
321
  for (const spec of specifiers) {
266
- if (!spec.startsWith('./') && !spec.startsWith('../')) continue
322
+ if (!spec.startsWith('./') && !spec.startsWith('../')) {
323
+ // A tsconfig alias is a local import Bun resolves by path (D-27); a
324
+ // target the change deleted still names its paths, as below.
325
+ if (!aliased || spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
326
+ const bases = tsconfigBases(spec, fromDir, memo)
327
+ const hit = bases.map(fileAt).find((f) => f !== undefined)
328
+ if (hit !== undefined) out.push(realpathOr(hit))
329
+ else out.push(...bases.flatMap(expansions))
330
+ continue
331
+ }
267
332
  try {
268
333
  out.push(Bun.resolveSync(spec, fromDir))
269
334
  } catch {
@@ -273,12 +338,7 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
273
338
  // of an extension, the files Bun would have tried), so the deleted
274
339
  // path in the diff still reaches its importer (item 958). Selection
275
340
  // may widen; it is never hashed.
276
- const named = path.resolve(fromDir, spec)
277
- out.push(named)
278
- if (path.extname(spec) === '') {
279
- for (const ext of RESOLVED_EXTENSIONS)
280
- out.push(named + ext, path.join(named, 'index' + ext))
281
- }
341
+ out.push(...expansions(path.resolve(fromDir, spec)))
282
342
  }
283
343
  }
284
344
  return out
@@ -361,6 +421,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
361
421
 
362
422
  // target → the files that import it. Reversed up front so one BFS from the
363
423
  // changed set answers every root at once, instead of a walk per root.
424
+ const tsconfigs: TsconfigMemo = new Map()
364
425
  const importedBy = new Map<string, string[]>()
365
426
  const visited = new Set<string>()
366
427
  // A level at a time, its files read together: one awaited read per
@@ -383,7 +444,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
383
444
  if (entry === null) continue
384
445
  const { file, source } = entry
385
446
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
386
- for (const target of scanLocalImports(source, path.dirname(file), loader)) {
447
+ for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
387
448
  if (!target.startsWith(workspaceRoot + path.sep)) continue
388
449
  if (target.split(path.sep).includes('node_modules')) continue
389
450
  const list = importedBy.get(target)