@vzn/vx 0.0.80 → 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.80",
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.80",
21
- "@vzn/vx-linux-arm64": "0.0.80",
22
- "@vzn/vx-darwin-x64": "0.0.80",
23
- "@vzn/vx-darwin-arm64": "0.0.80"
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,
@@ -97,10 +97,11 @@ export interface DefaultLogger extends Logger {
97
97
  * broad — news only: one `success` line per executed task,
98
98
  * full frames for failures, silence for cache hits.
99
99
  *
100
- * `gha` (full mode only): wrap each task's block in `::group::` /
101
- * `::endgroup::` workflow commands so tasks collapse in the GitHub
102
- * Actions log viewer — except failed tasks, which stay pre-expanded
103
- * and emit an `::error` annotation instead.
100
+ * `gha` (on GitHub Actions, any mode): task output is fenced from
101
+ * workflow commands. In full mode each task's block is also wrapped in
102
+ * `::group::` / `::endgroup::` so tasks collapse in the log viewer —
103
+ * except failed tasks, which stay pre-expanded and emit an `::error`
104
+ * annotation instead.
104
105
  *
105
106
  * `ci`: a truthy CI env was detected. Suppresses the dynamic status
106
107
  * line even if stdout happens to be a TTY.
@@ -163,7 +164,7 @@ export function resolveOutputView(
163
164
  const gha = truthyEnv(env['GITHUB_ACTIONS'])
164
165
  const mk = (mode: OutputView['mode']): OutputView => ({
165
166
  mode,
166
- ...(mode === 'full' && gha ? { gha: true } : {}),
167
+ ...(gha ? { gha: true } : {}),
167
168
  ...(ci ? { ci: true } : {}),
168
169
  })
169
170
  if (options.outputLogs !== undefined) return mk(options.outputLogs)
@@ -232,6 +233,10 @@ export function defaultLogger(
232
233
  // One per run, and only in GHA mode: the fence token must be something the
233
234
  // fenced task output cannot print.
234
235
  const ghaToken = view.gha === true ? crypto.randomUUID() : ''
236
+ // A task's own text, fenced on GitHub Actions: the frames `errors-only`
237
+ // and `broad` defer and a server's tail printed a task's `::error::` raw.
238
+ const fenced = (block: string): string =>
239
+ view.gha === true && block.length > 0 ? ghaFence(block, ghaToken) : block
235
240
 
236
241
  // All stdout flows through the writer so the status line can never
237
242
  // interleave with content. Inert (pure passthrough) on non-TTY
@@ -487,12 +492,14 @@ export function defaultLogger(
487
492
  // its buffered output belongs to the frame it never got.
488
493
  if (t.outcome === undefined) continue
489
494
  emitBlock(
490
- formatPersistentTailBlock(
491
- t.node,
492
- t.outcome,
493
- { stdout: tailText(t.out), stderr: tailText(t.err) },
494
- { stdout: t.out.dropped, stderr: t.err.dropped },
495
- colors,
495
+ fenced(
496
+ formatPersistentTailBlock(
497
+ t.node,
498
+ t.outcome,
499
+ { stdout: tailText(t.out), stderr: tailText(t.err) },
500
+ { stdout: t.out.dropped, stderr: t.err.dropped },
501
+ colors,
502
+ ),
496
503
  ),
497
504
  )
498
505
  }
@@ -508,7 +515,7 @@ export function defaultLogger(
508
515
  // Guarded for repeat runEnd calls.
509
516
  if (!flushedFailures && deferredFailures.length > 0) {
510
517
  flushedFailures = true
511
- for (const block of deferredFailures) emitBlock(block)
518
+ for (const block of deferredFailures) emitBlock(fenced(block))
512
519
  }
513
520
  // The summary and the CLI's own lines follow on the stream directly:
514
521
  // nothing may still be held, and nothing after this is.
@@ -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)