@vzn/vx 0.0.81 → 0.0.82

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/launcher.mjs CHANGED
@@ -15,8 +15,8 @@
15
15
  // with no prebuilt binary if the user happens to have Bun).
16
16
  // Anything else is a clear, actionable error.
17
17
 
18
- import { spawnSync } from 'node:child_process'
19
- import { existsSync } from 'node:fs'
18
+ import { execFileSync, spawn, spawnSync } from 'node:child_process'
19
+ import { existsSync, readFileSync } from 'node:fs'
20
20
  import { createRequire } from 'node:module'
21
21
  import { constants as osConstants } from 'node:os'
22
22
  import { dirname, join } from 'node:path'
@@ -57,35 +57,63 @@ function hasBun() {
57
57
  return probe.status === 0
58
58
  }
59
59
 
60
+ // Is this launcher in its terminal's foreground process group? Then a
61
+ // keyboard Ctrl-C reached the binary too, and a forward would be its second
62
+ // signal, which vx reads as "stop now".
63
+ function inForeground() {
64
+ try {
65
+ const stat = readFileSync('/proc/self/stat', 'utf8')
66
+ const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
67
+ return f[2] === f[5] // pgrp === tpgid
68
+ } catch {
69
+ try {
70
+ const out = execFileSync('ps', ['-o', 'pgid=,tpgid=', '-p', String(process.pid)], {
71
+ encoding: 'utf8',
72
+ })
73
+ const [pgid, tpgid] = out.trim().split(/\s+/)
74
+ return pgid === tpgid
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+ }
80
+
60
81
  function run(cmd, cmdArgs) {
61
- const res = spawnSync(cmd, cmdArgs, { stdio: 'inherit' })
62
- if (res.error) {
63
- process.stderr.write(`${base}: failed to launch (${res.error.message})\n`)
64
- process.exit(1)
82
+ const child = spawn(cmd, cmdArgs, { stdio: 'inherit' })
83
+ // A signal sent to this launcher alone (`kill`, a process manager, a CI
84
+ // cancel) reaches vx only through here; unhandled, it killed the launcher
85
+ // and left vx running under init. Either way the launcher waits for vx.
86
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
87
+ process.on(sig, () => {
88
+ if (!inForeground()) child.kill(sig)
89
+ })
65
90
  }
91
+ child.on('error', (err) => {
92
+ process.stderr.write(`${base}: failed to launch (${err.message})\n`)
93
+ process.exit(1)
94
+ })
66
95
  // Mirror the child's exit; a signal death maps to the POSIX 128+signo code.
67
- if (res.signal) process.exit(128 + (osConstants.signals[res.signal] ?? 1))
68
- process.exit(res.status ?? 0)
96
+ child.on('exit', (code, signal) => {
97
+ process.exit(signal ? 128 + (osConstants.signals[signal] ?? 1) : (code ?? 0))
98
+ })
69
99
  }
70
100
 
71
101
  const bin = platformBinary()
72
- if (bin !== undefined) {
73
- run(bin, args)
74
- }
75
-
76
102
  // No prebuilt binary for this platform — fall back to the shipped source if Bun
77
103
  // is available (a source checkout, or an unsupported platform + Bun installed).
78
104
  const source = join(here, sourceEntry)
79
- if (existsSync(source) && hasBun()) {
105
+ if (bin !== undefined) {
106
+ run(bin, args)
107
+ } else if (existsSync(source) && hasBun()) {
80
108
  run('bun', [source, ...args])
109
+ } else {
110
+ const supported = SUPPORTED.join(', ')
111
+ process.stderr.write(
112
+ `${base}: no prebuilt binary for ${key}.\n` +
113
+ ` Supported platforms: ${supported}.\n` +
114
+ ` If your platform should be supported, reinstall so npm fetches the\n` +
115
+ ` matching ${name}-${key} optionalDependency, or install Bun (>=1.4) to\n` +
116
+ ` run ${base} from source.\n`,
117
+ )
118
+ process.exit(1)
81
119
  }
82
-
83
- const supported = SUPPORTED.join(', ')
84
- process.stderr.write(
85
- `${base}: no prebuilt binary for ${key}.\n` +
86
- ` Supported platforms: ${supported}.\n` +
87
- ` If your platform should be supported, reinstall so npm fetches the\n` +
88
- ` matching ${name}-${key} optionalDependency, or install Bun (>=1.4) to\n` +
89
- ` run ${base} from source.\n`,
90
- )
91
- process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.81",
3
+ "version": "0.0.82",
4
4
  "description": "An open, extensible monorepo task runner.",
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.81",
21
- "@vzn/vx-linux-arm64": "0.0.81",
22
- "@vzn/vx-darwin-x64": "0.0.81",
23
- "@vzn/vx-darwin-arm64": "0.0.81"
20
+ "@vzn/vx-linux-x64": "0.0.82",
21
+ "@vzn/vx-linux-arm64": "0.0.82",
22
+ "@vzn/vx-darwin-x64": "0.0.82",
23
+ "@vzn/vx-darwin-arm64": "0.0.82"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -483,6 +483,8 @@ async function dropFilteredOids(
483
483
  args: {
484
484
  /** Every path the enumeration listed: tracked (dirty ones too) and untracked. */
485
485
  listed: readonly string[]
486
+ /** Whether `git status` named an ignored `.gitattributes`, which git applies too. */
487
+ ignoredAttributes: boolean
486
488
  workspaceRoot: string
487
489
  gitDir: string
488
490
  gitPrefix: string
@@ -501,6 +503,7 @@ async function dropFilteredOids(
501
503
  // a line scan of `git var -l`, three stats, and a key walk that exits on the
502
504
  // first `.gitattributes` it does not find.
503
505
  let attributesPossible =
506
+ args.ignoredAttributes ||
504
507
  attributeFilesOutsideTree(args.gitVars, process.env).some((f) =>
505
508
  existsSync(path.resolve(args.workspaceRoot, f)),
506
509
  ) ||
@@ -715,14 +718,25 @@ export function attributeFilesOutsideTree(
715
718
  function parseStatusOutput(
716
719
  out: string,
717
720
  undecodableRecords: ReadonlySet<string>,
718
- ): { dirty: Set<string>; untracked: string[]; undecodable: Set<string> } {
721
+ ): {
722
+ dirty: Set<string>
723
+ untracked: string[]
724
+ undecodable: Set<string>
725
+ ignoredAttributes: boolean
726
+ } {
719
727
  const tokens = out.split('\0')
720
728
  const dirty = new Set<string>()
721
729
  const untracked: string[] = []
722
730
  const undecodable = new Set<string>()
731
+ let ignoredAttributes = false
723
732
  for (let i = 0; i < tokens.length; i++) {
724
733
  const token = tokens[i]!
725
734
  if (token.length < 4) continue
735
+ if (token[0] === '!') {
736
+ if (token === '!! .gitattributes' || token.endsWith('/.gitattributes'))
737
+ ignoredAttributes = true
738
+ continue
739
+ }
726
740
  if (undecodableRecords.size > 0 && undecodableRecords.has(token))
727
741
  undecodable.add(token.slice(3))
728
742
  if (token[0] === '?') {
@@ -743,7 +757,7 @@ function parseStatusOutput(
743
757
  dirty.add(tokens[i]!)
744
758
  }
745
759
  }
746
- return { dirty, untracked, undecodable }
760
+ return { dirty, untracked, undecodable, ignoredAttributes }
747
761
  }
748
762
 
749
763
  /**
@@ -885,7 +899,10 @@ export async function startGitEnumeration(
885
899
  // time (~50 ms of CPU, concurrent with status but contending with it).
886
900
  const running = Promise.all([
887
901
  spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
888
- spawnGit(['status', '--porcelain', '-z', '-uall', '--', ...pathspecs]),
902
+ // `--ignored=matching` names an ignored path without descending into an
903
+ // ignored directory: git applies an ignored `.gitattributes` as it does
904
+ // any other, and the filter gate below must see it (A-19).
905
+ spawnGit(['status', '--porcelain', '-z', '-uall', '--ignored=matching', '--', ...pathspecs]),
889
906
  // The gate for whether a clean filter can rewrite bytes between the
890
907
  // index and the worktree: git's merged config (`core.autocrlf`) and,
891
908
  // from git 2.42, the attributes files it reads outside the tree
@@ -983,6 +1000,7 @@ export async function startGitEnumeration(
983
1000
  // ONLY where a filter can actually apply.
984
1001
  await dropFilteredOids(trusted, {
985
1002
  listed: all,
1003
+ ignoredAttributes: parsedStatus?.ignoredAttributes ?? false,
986
1004
  workspaceRoot,
987
1005
  gitDir,
988
1006
  gitPrefix,
@@ -470,11 +470,17 @@ async function findConfigFile(dir: string): Promise<string | null> {
470
470
  } catch {
471
471
  return null
472
472
  }
473
- const byName = new Map(entries.map((e) => [e.name, e]))
474
- for (const name of PROJECT_CONFIG_FILENAMES) {
475
- const entry = byName.get(name)
473
+ // By precedence slot, not a Map per directory, and no `path.join`: at
474
+ // 5,000 projects that cut `listProjects` from 53.5 to 47.7 ms (min of
475
+ // 15, six interleaved rounds, A/A 54.7).
476
+ const found: Dirent[] = []
477
+ for (const e of entries) {
478
+ const rank = CONFIG_RANK.get(e.name)
479
+ if (rank !== undefined) found[rank] = e
480
+ }
481
+ for (const entry of found) {
476
482
  if (entry === undefined) continue
477
- const candidate = path.join(dir, name)
483
+ const candidate = dir + path.sep + entry.name
478
484
  if (entry.isFile()) return candidate
479
485
  if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
480
486
  }
@@ -487,6 +493,8 @@ async function findConfigFile(dir: string): Promise<string | null> {
487
493
  return null
488
494
  }
489
495
 
496
+ const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
497
+
490
498
  async function isFile(p: string): Promise<boolean> {
491
499
  try {
492
500
  return (await stat(p)).isFile()
@@ -518,7 +526,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
518
526
  // a 1000-member workspace.
519
527
  const loaded = await Promise.all(
520
528
  [...matches].map(async (dir) => {
521
- const pkgJsonPath = path.join(dir, 'package.json')
529
+ const pkgJsonPath = dir + path.sep + 'package.json'
522
530
  const [configPath, text] = await Promise.all([
523
531
  findConfigFile(dir),
524
532
  Bun.file(pkgJsonPath)