@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 +5 -5
- package/src/cli/foreign-flags.ts +2 -1
- package/src/cli/init.ts +33 -2
- package/src/orchestrator/prepare.ts +39 -32
- package/src/orchestrator/run.ts +9 -1
- package/src/util/secret-mask.ts +17 -3
- package/src/workspace/affected.ts +10 -1
- package/src/workspace/index.ts +1 -0
- package/src/workspace/migration.ts +4 -1
- package/src/workspace/workspace.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
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,
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/workspace/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|