@vzn/vx 0.0.420 → 0.0.422

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.420",
3
+ "version": "0.0.422",
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.420",
25
- "@vzn/vx-linux-arm64": "0.0.420",
26
- "@vzn/vx-darwin-x64": "0.0.420",
27
- "@vzn/vx-darwin-arm64": "0.0.420"
24
+ "@vzn/vx-linux-x64": "0.0.422",
25
+ "@vzn/vx-linux-arm64": "0.0.422",
26
+ "@vzn/vx-darwin-x64": "0.0.422",
27
+ "@vzn/vx-darwin-arm64": "0.0.422"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/init.ts CHANGED
@@ -75,7 +75,8 @@ export async function initCmd(args: readonly string[]): Promise<number> {
75
75
  throw err
76
76
  })
77
77
  const nameless: string[] = []
78
- const metas = await discoverProjects(await loadWorkspace(root, reads), nameless)
78
+ const workspace = await loadWorkspace(root, reads)
79
+ const metas = await discoverProjects(workspace, nameless)
79
80
  // `init` reads scripts only; a runner's own config beside them is the
80
81
  // richer source (dependsOn, inputs, outputs) and was ignored without a
81
82
  // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
@@ -107,7 +108,10 @@ export async function initCmd(args: readonly string[]): Promise<number> {
107
108
  ),
108
109
  source: 'package.json scripts',
109
110
  verb: 'vx init',
110
- notes: namelessNotes(root, nameless),
111
+ notes: [
112
+ ...(await unreadWorkspaces(root, workspace.packageGlobs)),
113
+ ...namelessNotes(root, nameless),
114
+ ],
111
115
  dry: parsed.dry,
112
116
  force: parsed.force,
113
117
  init: true,
@@ -379,6 +383,29 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
379
383
  ]
380
384
  }
381
385
 
386
+ /**
387
+ * `pnpm-workspace.yaml`'s `packages` decides the members, as pnpm reads it,
388
+ * and a root `workspaces` that lists other globs was dropped without a word:
389
+ * a bun or npm repo with a stale yaml lost those members (M-51).
390
+ */
391
+ async function unreadWorkspaces(root: string, globs: readonly string[]): Promise<string[]> {
392
+ if (!existsSync(path.join(root, 'pnpm-workspace.yaml'))) return []
393
+ const ws = (await readRootManifest(root))['workspaces']
394
+ const list = Array.isArray(ws)
395
+ ? ws
396
+ : typeof ws === 'object' &&
397
+ ws !== null &&
398
+ Array.isArray((ws as { packages?: unknown }).packages)
399
+ ? (ws as { packages: unknown[] }).packages
400
+ : undefined
401
+ if (list === undefined) return []
402
+ const pkg = list.filter((g): g is string => typeof g === 'string')
403
+ if ([...pkg].sort().join('\0') === [...globs].sort().join('\0')) return []
404
+ return [
405
+ `pnpm-workspace.yaml's \`packages\` lists the members, as pnpm reads them; package.json's \`workspaces\` (${pkg.map((g) => `"${g}"`).join(', ')}) is not read: if bun, npm or yarn installs this repo, copy those globs into pnpm-workspace.yaml's \`packages\`, or delete that key so package.json decides`,
406
+ ]
407
+ }
408
+
382
409
  /** The root package.json as an object, `{}` when unreadable or not one. */
383
410
  async function readRootManifest(root: string): Promise<Record<string, unknown>> {
384
411
  const pj: unknown = await Bun.file(path.join(root, 'package.json'))
@@ -160,26 +160,51 @@ function outputContainer(raw: string): string {
160
160
  */
161
161
  export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
162
162
  const ignored = new Set<string>()
163
- if (paths.length === 0) return ignored
164
- let proc: ReturnType<typeof Bun.spawnSync>
165
- try {
166
- proc = Bun.spawnSync({
167
- cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
168
- cwd: workspaceRoot,
169
- stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
170
- stdout: 'pipe',
171
- stderr: 'ignore',
172
- })
173
- } catch {
174
- // No git on PATH: nothing is ignored, and the loop keeps judging —
175
- // the initial run already said what vx requires.
176
- return ignored
163
+ // `-v -n` answers every path with a record, so on a refusal the count of
164
+ // records says which path git refused: a path inside a submodule ends
165
+ // the batch (exit 128, "is in submodule"), and reading that as nothing
166
+ // ignored let a pid file in the same window start cycles again. The
167
+ // refused path is skipped and the rest asked again; a refusal before
168
+ // any record outside a work tree is git refusing them all.
169
+ let rest = paths
170
+ let inWorkTree: boolean | undefined
171
+ while (rest.length > 0) {
172
+ let proc: ReturnType<typeof Bun.spawnSync>
173
+ try {
174
+ proc = Bun.spawnSync({
175
+ cmd: [executablePath('git'), 'check-ignore', '-v', '-n', '-z', '--stdin'],
176
+ cwd: workspaceRoot,
177
+ stdin: Buffer.from(rest.map((p) => `${p}\0`).join('')),
178
+ stdout: 'pipe',
179
+ stderr: 'ignore',
180
+ })
181
+ } catch {
182
+ // No git on PATH: nothing is ignored, and the loop keeps judging —
183
+ // the initial run already said what vx requires.
184
+ return ignored
185
+ }
186
+ // Each record: source, line, pattern, path. No source: no pattern
187
+ // matched; a `!` pattern: re-included. Either way not ignored.
188
+ const fields = new TextDecoder().decode(proc.stdout).split('\0')
189
+ const records = Math.floor(fields.length / 4)
190
+ for (let i = 0; i < records; i++) {
191
+ const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
192
+ if (source !== '' && !pattern!.startsWith('!')) ignored.add(p!)
193
+ }
194
+ // 0: some ignored; 1: none. Anything else is git refusing.
195
+ if (proc.exitCode === 0 || proc.exitCode === 1) return ignored
196
+ if (records === 0) {
197
+ inWorkTree ??=
198
+ Bun.spawnSync({
199
+ cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
200
+ cwd: workspaceRoot,
201
+ stdout: 'ignore',
202
+ stderr: 'ignore',
203
+ }).exitCode === 0
204
+ if (!inWorkTree) return ignored
205
+ }
206
+ rest = rest.slice(records + 1)
177
207
  }
178
- // 0: some ignored; 1: none. Anything else is git refusing (not a
179
- // repository, a path inside a nested one): no path is ignored.
180
- if (proc.exitCode !== 0) return ignored
181
- for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
182
- if (p.length > 0) ignored.add(p)
183
208
  return ignored
184
209
  }
185
210
 
@@ -217,7 +217,7 @@ interface Pending {
217
217
 
218
218
  /**
219
219
  * How long a single config evaluation may take, in the worker or in process
220
- * (D-66). Real evaluations are ~10 ms; this exists only so a hung config or a
220
+ * (D-66); in process a synchronous loop holds the thread the deadline needs. Real evaluations are ~10 ms; this exists only so a hung config or a
221
221
  * killed worker cannot stall a run or a long-lived process indefinitely. Read
222
222
  * per call so a test can drive the deadline instead of waiting it out.
223
223
  */
@@ -5,6 +5,8 @@
5
5
  // it. The map lives exactly as long as the load: a `vx watch` cycle is a
6
6
  // new load and reads fresh bytes.
7
7
 
8
+ import { isPermissionError, UserError } from '../util/index.js'
9
+
8
10
  /** The files one load has read, by absolute path: the bytes, or null when no file is there. */
9
11
  export type LoadReads = Map<string, Promise<Uint8Array | null>>
10
12
 
@@ -28,5 +30,17 @@ export function readOnce(reads: LoadReads | undefined, file: string): Promise<Ui
28
30
 
29
31
  async function readIfFile(file: string): Promise<Uint8Array | null> {
30
32
  const f = Bun.file(file)
31
- return (await f.exists()) ? await f.bytes() : null
33
+ return (await f.exists()) ? await f.bytes().catch((err: unknown) => unreadable(err, file)) : null
34
+ }
35
+
36
+ /**
37
+ * A read the file system refused is the user's to fix, named as a read: a
38
+ * config at mode 000 reached the user as "a path vx must write is not
39
+ * writable" (D-132). Anything else is rethrown as it came.
40
+ */
41
+ export function unreadable(err: unknown, file: string): never {
42
+ if (isPermissionError(err)) {
43
+ throw new UserError(`${file}: not readable by this user (${err.code})`)
44
+ }
45
+ throw err
32
46
  }
@@ -20,7 +20,7 @@ import {
20
20
  configImports,
21
21
  type ConfigEvalStore,
22
22
  } from './config-cache.js'
23
- import { readOnce } from './load-reads.js'
23
+ import { readOnce, unreadable } from './load-reads.js'
24
24
 
25
25
  // The validator lives in config-schema.ts; re-exported so a reader that
26
26
  // reaches the loader for it (the tests do) keeps working.
@@ -401,7 +401,9 @@ export async function loadProjectConfigs(
401
401
  })
402
402
  if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
403
403
  }
404
- const bytes = await Bun.file(configPath).bytes()
404
+ const bytes = await Bun.file(configPath)
405
+ .bytes()
406
+ .catch((err: unknown) => unreadable(err, configPath))
405
407
  const keyed =
406
408
  evalCache === undefined
407
409
  ? null
@@ -465,7 +467,9 @@ export async function loadProjectConfigs(
465
467
  let closure = entry.closure
466
468
  let key = cacheKey
467
469
  if (entry.indexed) {
468
- bytes = await Bun.file(configPath).bytes()
470
+ bytes = await Bun.file(configPath)
471
+ .bytes()
472
+ .catch((err: unknown) => unreadable(err, configPath))
469
473
  const keyed = await configEvalKey({
470
474
  configPath,
471
475
  bytes,
@@ -670,7 +674,52 @@ interface BuiltinSnapshot {
670
674
  readonly props: readonly (OwnProperties | undefined)[]
671
675
  }
672
676
 
673
- function builtinSnapshot(leaveOut?: string): BuiltinSnapshot {
677
+ /**
678
+ * The members of `Bun` vx reads, which the workspace config's guard checks;
679
+ * of the rest it keeps the keys and their order, not their descriptors.
680
+ * Some members are built on their first read (`postgres` loaded `bun:sql`,
681
+ * 3.4 ms), and the guard runs on every warm run, where its full read of
682
+ * `Bun` was 4.4–5 ms of a 45 ms no-op (compiled, 2026-10-03). A project
683
+ * config's guard reads them all. Held to every `Bun.<name>` in `src/` by
684
+ * tests/workspace-guard-bun.test.ts.
685
+ */
686
+ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
687
+ 'Archive',
688
+ 'BunFile',
689
+ 'CryptoHasher',
690
+ 'Glob',
691
+ 'JSONC',
692
+ 'Subprocess',
693
+ 'Transpiler',
694
+ 'YAML',
695
+ 'color',
696
+ 'deepEquals',
697
+ 'env',
698
+ 'file',
699
+ 'hash',
700
+ 'main',
701
+ 'nanoseconds',
702
+ 'plugin',
703
+ 'randomUUIDv7',
704
+ 'resolveSync',
705
+ 'semver',
706
+ 'serve',
707
+ 'sleep',
708
+ 'spawn',
709
+ 'spawnSync',
710
+ 'stdout',
711
+ 'stringWidth',
712
+ 'version',
713
+ 'which',
714
+ 'write',
715
+ 'zstdCompress',
716
+ 'zstdDecompress',
717
+ ]
718
+
719
+ /** A descriptor the snapshot did not read. */
720
+ const UNREAD: PropertyDescriptor = Object.freeze({})
721
+
722
+ function builtinSnapshot(leaveOut?: string, onlyBunMembersVxReads = false): BuiltinSnapshot {
674
723
  const prims: Primitives = {
675
724
  ownKeys: Reflect.ownKeys,
676
725
  descriptorOf: Object.getOwnPropertyDescriptor,
@@ -689,7 +738,12 @@ function builtinSnapshot(leaveOut?: string): BuiltinSnapshot {
689
738
  const proto = WATCHED_BUILTINS[i]![1]
690
739
  const keys = prims.ownKeys(proto)
691
740
  const descriptors: PropertyDescriptor[] = []
692
- for (let j = 0; j < keys.length; j++) descriptors[j] = prims.descriptorOf(proto, keys[j]!)!
741
+ const narrow = onlyBunMembersVxReads && proto === Bun
742
+ for (let j = 0; j < keys.length; j++) {
743
+ const key = keys[j]!
744
+ descriptors[j] =
745
+ narrow && !BUN_MEMBERS_VX_READS.includes(key) ? UNREAD : prims.descriptorOf(proto, key)!
746
+ }
693
747
  props[i] = { keys, descriptors }
694
748
  }
695
749
  return { prims, props }
@@ -712,7 +766,9 @@ function unchanged(
712
766
  for (let j = 0; j < keys.length; j++) {
713
767
  const key = keys[j]!
714
768
  if (key !== was.keys[j]) return false
715
- if (!sameDescriptor(p, was.descriptors[j]!, p.descriptorOf(proto, key)!)) return false
769
+ const prior = was.descriptors[j]!
770
+ if (prior === UNREAD) continue
771
+ if (!sameDescriptor(p, prior, p.descriptorOf(proto, key)!)) return false
716
772
  }
717
773
  return true
718
774
  }
@@ -785,6 +841,7 @@ function restoreBuiltins(before: BuiltinSnapshot | undefined, everyLoadOnly = fa
785
841
  break
786
842
  }
787
843
  }
844
+ if (prior === UNREAD) continue
788
845
  if (prior !== undefined && sameDescriptor(p, prior, p.descriptorOf(proto, key)!)) continue
789
846
  changed[changed.length] = name + '.' + p.keyName(key)
790
847
  if (prior === undefined) p.deleteOwn(proto, key)
@@ -794,7 +851,8 @@ function restoreBuiltins(before: BuiltinSnapshot | undefined, everyLoadOnly = fa
794
851
  const key = was.keys[k]!
795
852
  if (p.hasOwn(proto, key)) continue
796
853
  changed[changed.length] = name + '.' + p.keyName(key)
797
- p.defineOwn(proto, key, was.descriptors[k]!)
854
+ // An unread member cannot be put back as it was; it is refused all the same.
855
+ if (was.descriptors[k] !== UNREAD) p.defineOwn(proto, key, was.descriptors[k]!)
798
856
  }
799
857
  }
800
858
  return changed
@@ -933,7 +991,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
933
991
  // left out: it loads first in every run, filtered or not, so none
934
992
  // depends on what else loaded (D-122's case), and plugins' tests and
935
993
  // tools hand state through them.
936
- const builtins = builtinSnapshot('globalThis')
994
+ const builtins = builtinSnapshot('globalThis', true)
937
995
  const env = { ...process.env }
938
996
  const cwd = process.cwd()
939
997
  const umask = currentUmask()
@@ -1,16 +1,17 @@
1
- import type { Dirent } from 'node:fs'
2
- import { readdir, stat } from 'node:fs/promises'
1
+ import { constants, type Dirent } from 'node:fs'
2
+ import { access, readdir, stat } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
5
5
  import {
6
6
  BUN_GLOB_WILDCARDS,
7
7
  isOutOfFds,
8
+ isPermissionError,
8
9
  relPosix,
9
10
  slashBraceExpansions,
10
11
  UserError,
11
12
  normalizeBunGlob,
12
13
  } from '../util/index.js'
13
- import { type LoadReads, readOnce } from './load-reads.js'
14
+ import { type LoadReads, readOnce, unreadable } from './load-reads.js'
14
15
 
15
16
  export interface PackageJson {
16
17
  name: string
@@ -548,6 +549,30 @@ function absent<T>(err: unknown, value: T): T {
548
549
  return value
549
550
  }
550
551
 
552
+ /**
553
+ * A member's manifest text, or null when there is none. A manifest this
554
+ * user may not read is refused, not skipped: its project dropped out of
555
+ * `--all` and the run went green without it (D-132). A directory it may
556
+ * not search hides whether a manifest is there at all, so that one is
557
+ * named and skipped (a service's data directory under `packages/*`).
558
+ */
559
+ async function readManifest(root: string, dir: string, file: string): Promise<string | null> {
560
+ try {
561
+ return await Bun.file(file).text()
562
+ } catch (err) {
563
+ if (!isPermissionError(err)) return absent(err, null)
564
+ const searchable = await access(dir, constants.X_OK).then(
565
+ () => true,
566
+ () => false,
567
+ )
568
+ if (searchable) unreadable(err, file)
569
+ process.stderr.write(
570
+ `vx: ${relPosix(root, dir)} is not readable by this user — skipped, with any project in it\n`,
571
+ )
572
+ return null
573
+ }
574
+ }
575
+
551
576
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
552
577
  return discoverProjects(workspace)
553
578
  }
@@ -591,9 +616,7 @@ export async function discoverProjects(
591
616
  const pkgJsonPath = dir + path.sep + 'package.json'
592
617
  const [configPath, text] = await Promise.all([
593
618
  findConfigFile(dir),
594
- Bun.file(pkgJsonPath)
595
- .text()
596
- .catch((err: unknown) => absent(err, null)),
619
+ readManifest(workspace.root, dir, pkgJsonPath),
597
620
  ])
598
621
  if (text === null) {
599
622
  // A member dir with a vx config and no manifest was skipped without
@@ -712,9 +735,7 @@ export async function namedProject(
712
735
  const pkgJsonPath = dir + path.sep + 'package.json'
713
736
  const [configPath, text] = await Promise.all([
714
737
  findConfigFile(dir),
715
- Bun.file(pkgJsonPath)
716
- .text()
717
- .catch((err: unknown) => absent(err, null)),
738
+ readManifest(workspace.root, dir, pkgJsonPath),
718
739
  ])
719
740
  if (
720
741
  text === null &&