@vzn/vx 0.0.357 → 0.0.359

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.357",
3
+ "version": "0.0.359",
4
4
  "description": "A fast, correct 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.357",
25
- "@vzn/vx-linux-arm64": "0.0.357",
26
- "@vzn/vx-darwin-x64": "0.0.357",
27
- "@vzn/vx-darwin-arm64": "0.0.357"
24
+ "@vzn/vx-linux-x64": "0.0.359",
25
+ "@vzn/vx-linux-arm64": "0.0.359",
26
+ "@vzn/vx-darwin-x64": "0.0.359",
27
+ "@vzn/vx-darwin-arm64": "0.0.359"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -572,7 +572,8 @@ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
572
572
  ls: '`turbo ls` is `vx show` here: every project, its directory and its task count',
573
573
  query:
574
574
  '`turbo query` has no vx form: `vx run <task> --dry=json` prints the planned graph as JSON',
575
- reset: '`nx reset` is `vx cache prune` here (or remove the cache directory `vx info` names)',
575
+ reset:
576
+ '`nx reset` is `vx cache prune --older-than <age>` here, or remove the cache directory `vx info` names to drop it all',
576
577
  daemon: 'vx has no daemon: there is nothing to start or stop',
577
578
  login: TURBO_LOGIN,
578
579
  logout: TURBO_LOGIN,
package/src/cli/init.ts CHANGED
@@ -14,9 +14,9 @@ import { PLUGIN_TEMPLATES } from './plugin-templates.js'
14
14
  import { isUserError, UserError } from '../util/index.js'
15
15
  import {
16
16
  applyMigration,
17
+ discoverProjects,
17
18
  findWorkspaceRoot,
18
19
  type LoadReads,
19
- listProjects,
20
20
  loadWorkspace,
21
21
  migrateScripts,
22
22
  WORKSPACE_CONFIG_FILENAMES,
@@ -74,7 +74,8 @@ export async function initCmd(args: readonly string[]): Promise<number> {
74
74
  }
75
75
  throw err
76
76
  })
77
- const metas = await listProjects(await loadWorkspace(root, reads))
77
+ const nameless: string[] = []
78
+ const metas = await discoverProjects(await loadWorkspace(root, reads), nameless)
78
79
  // `init` reads scripts only; a runner's own config beside them is the
79
80
  // richer source (dependsOn, inputs, outputs) and was ignored without a
80
81
  // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
@@ -106,6 +107,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
106
107
  ),
107
108
  source: 'package.json scripts',
108
109
  verb: 'vx init',
110
+ notes: namelessNotes(root, nameless),
109
111
  dry: parsed.dry,
110
112
  force: parsed.force,
111
113
  init: true,
@@ -348,6 +350,35 @@ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
348
350
  return first ?? 'build'
349
351
  }
350
352
 
353
+ /**
354
+ * A member whose package.json has no `name` is no project, so its scripts
355
+ * map to nothing, and init said nothing of them (D-106).
356
+ */
357
+ function namelessNotes(root: string, dirs: readonly string[]): string[] {
358
+ const withScripts = dirs
359
+ .filter((dir) => {
360
+ const scripts = (
361
+ JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as {
362
+ scripts?: unknown
363
+ }
364
+ ).scripts
365
+ return (
366
+ typeof scripts === 'object' &&
367
+ scripts !== null &&
368
+ Object.values(scripts).some((v) => typeof v === 'string' && v !== '')
369
+ )
370
+ })
371
+ .map((dir) => path.relative(root, dir).split(path.sep).join('/'))
372
+ .sort()
373
+ if (withScripts.length === 0) return []
374
+ const shown =
375
+ withScripts.slice(0, 3).join(', ') +
376
+ (withScripts.length > 3 ? `, and ${withScripts.length - 3} more` : '')
377
+ return [
378
+ `not mapped: ${shown} — ${withScripts.length === 1 ? 'its package.json has' : 'their package.json files have'} no "name", and vx names a project by it; give ${withScripts.length === 1 ? 'it one' : 'each one'} and run \`vx init\` again`,
379
+ ]
380
+ }
381
+
351
382
  /** The root package.json as an object, `{}` when unreadable or not one. */
352
383
  async function readRootManifest(root: string): Promise<Record<string, unknown>> {
353
384
  const pj: unknown = await Bun.file(path.join(root, 'package.json'))
@@ -438,33 +438,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
438
438
  // cycle under `vx watch` (item 1029). run() owns both only once this
439
439
  // returns.
440
440
  try {
441
- const gitFilesCache = new GitFilesCache()
442
- // Bulk-populate via a single `git ls-files` at the workspace root —
443
- // partitions the output by project. Avoids one fork+exec per project
444
- // (~5-10ms each on Linux; the dominant cold-start cost on big
445
- // monorepos). When any loaded task declares inputs.workspaceFiles,
446
- // the enumeration must see every file from the root (no pathspec
447
- // scoping) and additionally stores a workspace-wide partition.
448
- const usesWorkspaceInputs = [...projects.values()].some((p) =>
449
- Object.values(p.config.tasks ?? {}).some(
450
- (t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
451
- ),
452
- )
453
- const projectDirs = [...projects.values()].map((p) => p.dir)
454
- const enumeration = await (git.started ??
455
- startGitEnumeration(
456
- workspaceRoot,
457
- gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
458
- ))
459
- await applyGitEnumeration(
460
- enumeration,
461
- workspaceRoot,
462
- projectDirs,
463
- gitFilesCache,
464
- usesWorkspaceInputs,
465
- localCache,
466
- )
467
- mark('git enumeration')
468
441
  const hashCache = createHashCache()
469
442
  const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
470
443
 
@@ -490,7 +463,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
490
463
  workspaceFingerprint,
491
464
  fingerprintWatch,
492
465
  nestedDirsByProject,
493
- gitFilesCache,
466
+ gitFilesCache: new GitFilesCache(),
494
467
  hashCache,
495
468
  workspaceProjectCount: projectMetas.length,
496
469
  empty:
@@ -545,11 +518,45 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
545
518
  )
546
519
  }
547
520
  }
548
- // The graph is built; what follows is the plugins' (graph, key,
549
- // schedule). Two rows, so a plugin's key stage reads as its own cost
550
- // and not as graph building — a lockfile plugin's 1000 stats per run
551
- // hid inside one `prepare (graph)` row until 2026-09-10.
521
+ // The graph is built; what follows is git's enumeration over its
522
+ // projects, then the plugins' stages (graph, key, schedule). Separate
523
+ // rows, so a plugin's key stage reads as its own cost and not as graph
524
+ // building — a lockfile plugin's 1000 stats per run hid inside one
525
+ // `prepare (graph)` row until 2026-09-10.
552
526
  mark('build graph')
527
+ const gitFilesCache = new GitFilesCache()
528
+ // Bulk-populate via a single `git ls-files` at the workspace root —
529
+ // partitions the output by project. Avoids one fork+exec per project
530
+ // (~5-10ms each on Linux; the dominant cold-start cost on big
531
+ // monorepos). When any task in the graph declares inputs.workspaceFiles,
532
+ // the enumeration must see every file from the root (no pathspec
533
+ // scoping) and additionally stores a workspace-wide partition.
534
+ // Over the projects that own a task, not every project loaded: a scoped
535
+ // run loads its dependency closure for the `^` walk, and a `lint` of one
536
+ // package walked the whole tree for it (~60 ms of git where one
537
+ // project's pathspec takes ~7, 1,000 projects). A node a `graph` hook
538
+ // adds in another project keys through `resolveFiles`' own spawn.
539
+ const graphDirs = new Set<string>()
540
+ let usesWorkspaceInputs = false
541
+ for (const n of nodes.values()) {
542
+ graphDirs.add(n.projectDir)
543
+ if ((n.config.cache?.inputs.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
544
+ }
545
+ const projectDirs = [...graphDirs]
546
+ const enumeration = await (git.started ??
547
+ startGitEnumeration(
548
+ workspaceRoot,
549
+ gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
550
+ ))
551
+ await applyGitEnumeration(
552
+ enumeration,
553
+ workspaceRoot,
554
+ projectDirs,
555
+ gitFilesCache,
556
+ usesWorkspaceInputs,
557
+ localCache,
558
+ )
559
+ mark('git enumeration')
553
560
  if (hasHook(plugins, 'graph')) {
554
561
  await applyGraphHooks(plugins, nodes, {
555
562
  workspaceRoot,
@@ -41,6 +41,7 @@ import {
41
41
  UserError,
42
42
  machineParallelism,
43
43
  teardownTimeoutMs,
44
+ secretMask,
44
45
  } from '../util/index.js'
45
46
  import { keyedProjects } from './keyed-projects.js'
46
47
  import { prepareSandbox } from './sandbox-request.js'
@@ -191,6 +192,10 @@ export function shouldShortCircuit(
191
192
  * GitHub job summary and a check-run posted over the API verbatim (item
192
193
  * 1057). Local history (`vx last`) keeps the whole line, on this machine.
193
194
  */
195
+ function maskInvocation(command: string): string {
196
+ return secretMask([process.env])?.mask(command) ?? command
197
+ }
198
+
194
199
  export function invocationCommand(argv: readonly string[]): string {
195
200
  const sep = argv.indexOf('--')
196
201
  if (sep === -1) return argv.join(' ')
@@ -1046,7 +1051,10 @@ async function runOnBus(
1046
1051
  endedAtMs,
1047
1052
  totalMs,
1048
1053
  ok,
1049
- command: options.command ?? process.argv.slice(1).join(' '),
1054
+ // The invocation is stored and `vx last` prints it: a secret passed
1055
+ // after `--` (`-- --token=$NPM_TOKEN`) is masked here as the task's
1056
+ // own output masks it, so cache.db holds no plaintext value.
1057
+ command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
1050
1058
  requestedTasks: options.tasks,
1051
1059
  cachePolicy: compactCachePolicy(policy),
1052
1060
  concurrency,
@@ -16,6 +16,22 @@ const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL/i
16
16
  */
17
17
  const NOT_SECRET = /_(FILE|PATH|DIR)$|^GIT_CONFIG_KEY_\d+$/i
18
18
 
19
+ /**
20
+ * Each name's verdict, decided once: every task asks it of the whole
21
+ * process env (two regex tests a variable, ~10 µs a call at 150 names).
22
+ * The values are read fresh each time; only the name decides this.
23
+ */
24
+ const secretNames = new Map<string, boolean>()
25
+
26
+ function secretNamed(name: string): boolean {
27
+ let secret = secretNames.get(name)
28
+ if (secret === undefined) {
29
+ secret = SECRET_NAME.test(name) && !NOT_SECRET.test(name)
30
+ secretNames.set(name, secret)
31
+ }
32
+ return secret
33
+ }
34
+
19
35
  /**
20
36
  * Values shorter than this are not masked: `KEY=1` would mask every `1`
21
37
  * in every line, and a value that short is no secret worth the noise.
@@ -51,9 +67,7 @@ export function secretMask(
51
67
  }
52
68
  for (const source of sources) {
53
69
  if (source === undefined) continue
54
- for (const name in source) {
55
- if (SECRET_NAME.test(name) && !NOT_SECRET.test(name)) add(source[name])
56
- }
70
+ for (const name in source) if (secretNamed(name)) add(source[name])
57
71
  for (const name of named) add(source[name])
58
72
  }
59
73
  if (values.size === 0) return null
@@ -700,7 +700,16 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
700
700
  (stderr.length > 0 ? `: ${stderr}` : ''),
701
701
  )
702
702
  }
703
- throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
703
+ // A shallow clone (CI's checkout fetches one commit by default) has no
704
+ // HEAD~1 and no base branch: the ref exists, the history does not.
705
+ const shallow = spawnGitSync(['rev-parse', '--is-shallow-repository'], workspaceRoot, 'pipe')
706
+ const isShallow = new TextDecoder().decode(shallow.stdout).trim() === 'true'
707
+ throw new UserError(
708
+ `git ref "${ref}" did not resolve. Pass a branch or commit you have locally.` +
709
+ (isShallow
710
+ ? ' This clone is shallow: fetch the history the base needs (`git fetch --unshallow`, or `fetch-depth: 0` on actions/checkout).'
711
+ : ''),
712
+ )
704
713
  }
705
714
 
706
715
  function isDirectory(abs: string): boolean {
@@ -45,6 +45,7 @@ export {
45
45
  } from './config-cache.js'
46
46
  export {
47
47
  findWorkspaceRoot,
48
+ discoverProjects,
48
49
  listProjects,
49
50
  namedProject,
50
51
  loadWorkspace,
@@ -365,6 +365,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
365
365
  } else if (empty) {
366
366
  report.push(
367
367
  `${verb}: no package.json scripts to turn into tasks.`,
368
+ ...(args.notes ?? []).map((n) => `note: ${n}`),
368
369
  hasWorkspaceFile
369
370
  ? `${workspaceName} already exists.`
370
371
  : dry
@@ -477,7 +478,9 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
477
478
  case 'pnpm':
478
479
  return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
479
480
  case 'yarn':
480
- return installed ? 'yarn vx' : 'yarn dlx @vzn/vx'
481
+ // `dlx` is Yarn 2+; Yarn 1 answers `Command "dlx" not found`.
482
+ if (installed) return 'yarn vx'
483
+ return userAgent!.startsWith('yarn/1.') ? 'npx @vzn/vx' : 'yarn dlx @vzn/vx'
481
484
  default:
482
485
  return 'vx'
483
486
  }
@@ -522,6 +522,18 @@ function absent<T>(err: unknown, value: T): T {
522
522
  }
523
523
 
524
524
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
525
+ return discoverProjects(workspace)
526
+ }
527
+
528
+ /**
529
+ * `listProjects`, and `nameless` collects each member directory whose
530
+ * manifest has no `name`: `vx init` names them, since their scripts map to
531
+ * nothing.
532
+ */
533
+ export async function discoverProjects(
534
+ workspace: Workspace,
535
+ nameless?: string[],
536
+ ): Promise<ProjectMeta[]> {
525
537
  // Run all package globs concurrently. Disk-bound walks parallelize
526
538
  // well; serializing them just stretches the discovery phase by N×.
527
539
  const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
@@ -567,6 +579,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
567
579
  if (entry === null) continue
568
580
  const { dir, pkg, configPath } = entry
569
581
  if (!pkg.name) {
582
+ nameless?.push(dir)
570
583
  // A nameless manifest can't be addressed, filtered, or made affected —
571
584
  // and vx identifies projects by name, so it simply vanishes. Silent is
572
585
  // fine for a dir that declares no tasks; a dir with a vx config was