@vzn/vx 0.0.356 → 0.0.358

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.356",
3
+ "version": "0.0.358",
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.356",
25
- "@vzn/vx-linux-arm64": "0.0.356",
26
- "@vzn/vx-darwin-x64": "0.0.356",
27
- "@vzn/vx-darwin-arm64": "0.0.356"
24
+ "@vzn/vx-linux-x64": "0.0.358",
25
+ "@vzn/vx-linux-arm64": "0.0.358",
26
+ "@vzn/vx-darwin-x64": "0.0.358",
27
+ "@vzn/vx-darwin-arm64": "0.0.358"
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'))
@@ -235,9 +235,23 @@ export function execWrap(command: string): string {
235
235
  */
236
236
  export function execWord(command: string): string | undefined {
237
237
  if (SHELL_CONTROL.test(command)) return undefined
238
- const first = command.trimStart().split(/\s+/)[0] ?? ''
239
- if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return undefined
240
- return first
238
+ // The word as the shell reads it: quotes group and are removed, so
239
+ // `"./my tool.sh"` names one file (the verdict looked for `"./my`).
240
+ const raw = command.trimStart()
241
+ let first = ''
242
+ let quote = ''
243
+ let i = 0
244
+ for (; i < raw.length; i++) {
245
+ const c = raw[i]!
246
+ if (quote !== '') {
247
+ if (c === quote) quote = ''
248
+ else first += c
249
+ } else if (c === "'" || c === '"') quote = c
250
+ else if (/\s/.test(c)) break
251
+ else first += c
252
+ }
253
+ if (quote !== '' || first === '' || raw.slice(0, i).includes('=')) return undefined
254
+ return SHELL_BUILTINS.has(first) ? undefined : first
241
255
  }
242
256
 
243
257
  /**
@@ -37,7 +37,7 @@ import {
37
37
  } from '../exec/index.js'
38
38
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
39
39
  import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
40
- import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
40
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
41
41
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
42
42
  import {
43
43
  mayWriteFingerprint,
@@ -354,6 +354,22 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
354
354
  persistentOpts.timeoutMs = effectiveTimeout
355
355
  }
356
356
 
357
+ // A stop that landed during the awaits above (the key, the sandbox's
358
+ // arming, request and wrap) leaves nothing to kill yet: spawned now, the
359
+ // server came up after the teardown and held the run's exit 7 s.
360
+ if (args.stopSignal?.aborted === true) {
361
+ if (bridgeTag !== undefined) releaseBridges(bridgeTag)
362
+ await placeholderSweeper(placeholders)()
363
+ return {
364
+ node,
365
+ status: 'aborted',
366
+ // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
367
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
368
+ durationMs: 0,
369
+ wallclockStartNs,
370
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
371
+ }
372
+ }
357
373
  const spawn = runPersistent(persistentOpts)
358
374
  // The host side of a port bridge lives exactly as long as the server:
359
375
  // released on the child's exit, whether the run tore it down or it died.
@@ -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