@vzn/vx 0.0.596 → 0.0.598
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.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
|
@@ -82,7 +82,8 @@ export interface RemoteCacheLayer {
|
|
|
82
82
|
/**
|
|
83
83
|
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
84
84
|
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
85
|
-
* artifact, so a plugin that streams it never holds it whole
|
|
85
|
+
* artifact, so a plugin that streams it never holds it whole, and every
|
|
86
|
+
* read of it sees the same bytes.
|
|
86
87
|
*/
|
|
87
88
|
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
|
|
88
89
|
}
|
|
@@ -549,12 +550,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
549
550
|
// succeeded; we don't fail it on cache-server issues.
|
|
550
551
|
//
|
|
551
552
|
// The job hands the plugin a file-backed Blob over the local artifact,
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// contract.
|
|
553
|
+
// pinned under a private name when the job starts (`pinArtifact`): a
|
|
554
|
+
// queued job holds a path, not a buffer, a plugin that streams the Blob
|
|
555
|
+
// never holds it whole, and every read of it sees the same bytes though
|
|
556
|
+
// a re-save of the key renames others over the live name. If a
|
|
557
|
+
// concurrent `vx cache prune` removed the artifact first the pin throws
|
|
558
|
+
// and this upload is skipped — the never-fail contract.
|
|
558
559
|
const hash = args.hash
|
|
559
560
|
const durationMs = args.entry.durationMs
|
|
560
561
|
// Local writes disabled (`--cache=local:,remote:rw`): there is no
|
|
@@ -575,9 +576,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
575
576
|
this.enqueueUpload(async () => {
|
|
576
577
|
const tally = this.local.uploads
|
|
577
578
|
const start = performance.now()
|
|
579
|
+
let release: (() => Promise<void>) | undefined
|
|
578
580
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
+
let body: Blob
|
|
582
|
+
if (packed !== undefined) body = new Blob([packed])
|
|
583
|
+
else ({ body, release } = this.local.pinArtifact(hash))
|
|
581
584
|
await this.wire.put(hash, body, { durationMs })
|
|
582
585
|
tally.count++
|
|
583
586
|
tally.bytes += body.size
|
|
@@ -585,6 +588,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
585
588
|
} catch (err) {
|
|
586
589
|
tally.failed++
|
|
587
590
|
this.reportRemoteError('upload', hash, err)
|
|
591
|
+
} finally {
|
|
592
|
+
await release?.()
|
|
588
593
|
}
|
|
589
594
|
})
|
|
590
595
|
}
|
|
@@ -336,13 +336,18 @@ export class OutputIndex {
|
|
|
336
336
|
if (this.pendingStamps.size > 0) {
|
|
337
337
|
const stamps = [...this.pendingStamps]
|
|
338
338
|
this.pendingStamps.clear()
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
339
|
+
// IMMEDIATE: the body reads `entries` before it writes, and a
|
|
340
|
+
// deferred transaction that did could not wait for the lock
|
|
341
|
+
// (run-history.ts).
|
|
342
|
+
this.db
|
|
343
|
+
.transaction(() => {
|
|
344
|
+
for (const [hash, rows] of stamps) {
|
|
345
|
+
// A stamp outlives nothing: an entry pruned since has no rows to vouch for.
|
|
346
|
+
if (this.entryExists.get(hash) === null) continue
|
|
347
|
+
for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
.immediate()
|
|
346
351
|
}
|
|
347
352
|
if (this.pendingDirs.size === 0) return
|
|
348
353
|
const pending = [...this.pendingDirs]
|
package/src/cache/run-history.ts
CHANGED
|
@@ -69,10 +69,9 @@ export class RunHistory {
|
|
|
69
69
|
this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
|
|
70
70
|
return
|
|
71
71
|
}
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
this.db.transaction(() => this.insertRuns(runs))()
|
|
72
|
+
// One transaction, one fsync: for a 200-task run, one instead of 200.
|
|
73
|
+
// IMMEDIATE, as `recordRunBundle` explains.
|
|
74
|
+
this.db.transaction(() => this.insertRuns(runs)).immediate()
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/** `runs` as few INSERTs; each distinct forward-args list digested once. */
|
|
@@ -99,10 +98,19 @@ export class RunHistory {
|
|
|
99
98
|
// the entry-save transaction (`save`/`ingest`) so a warm
|
|
100
99
|
// all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
|
|
101
100
|
// mismatch — pays nothing for the moat it isn't refreshing.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
//
|
|
102
|
+
// IMMEDIATE: the write lock is taken at BEGIN, where the busy timeout
|
|
103
|
+
// waits for it. A run's rows digest their forward args under a salt
|
|
104
|
+
// the body READS first (`[]` from every CLI run), and a deferred
|
|
105
|
+
// transaction that read before it wrote could not wait: 13 of 48 runs
|
|
106
|
+
// on one shared cache dir lost their history to `database is locked`
|
|
107
|
+
// in 2–60 ms (BUSY_SNAPSHOT, a retry passing at once).
|
|
108
|
+
this.db
|
|
109
|
+
.transaction(() => {
|
|
110
|
+
this.insertRuns(bundle.runs)
|
|
111
|
+
this.insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
112
|
+
})
|
|
113
|
+
.immediate()
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/**
|
package/src/cli/cache.ts
CHANGED
|
@@ -140,13 +140,13 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
140
140
|
return {
|
|
141
141
|
error: `${refusedWord(a ?? '')}: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
|
|
142
142
|
}
|
|
143
|
-
if ('error' in cd) return { error: cd.error }
|
|
143
|
+
if ('error' in cd) return { error: `${cd.error}${seeHelp('cache')}` }
|
|
144
144
|
out.cacheDir = cd.cacheDir
|
|
145
145
|
i = cd.next
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
if (out.olderThanMs === undefined && out.maxBytes === undefined) {
|
|
149
|
-
return { error:
|
|
149
|
+
return { error: `needs --older-than <duration> or --max-size <size>${seeHelp('cache')}` }
|
|
150
150
|
}
|
|
151
151
|
return out
|
|
152
152
|
}
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What a Turbo or
|
|
1
|
+
// What a Turbo, Nx or Vite Task (`vp run`) user's hands type, and what `vx run` does with it.
|
|
2
2
|
// Every flag here has one outcome: vx takes it as it is (`same`), takes it
|
|
3
3
|
// as another spelling (`alias`, rewritten before the parse), or refuses it
|
|
4
4
|
// with the vx way to say it (`refuse`). Nothing is dropped in silence: a
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// cli.md's parity section (`renderForeignFlags`), pinned by a test.
|
|
8
8
|
|
|
9
9
|
interface ForeignFlag {
|
|
10
|
-
runner: 'turbo' | 'nx'
|
|
10
|
+
runner: 'turbo' | 'nx' | 'vp'
|
|
11
11
|
/** Every spelling, first the one shown. */
|
|
12
12
|
names: readonly string[]
|
|
13
13
|
/** It takes a value: `--flag <v>` or `--flag=<v>`. */
|
|
@@ -598,6 +598,60 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
598
598
|
outcome: 'refuse',
|
|
599
599
|
vx: 'vx has no cloud: drop it',
|
|
600
600
|
},
|
|
601
|
+
{
|
|
602
|
+
runner: 'vp',
|
|
603
|
+
names: ['-r', '--recursive'],
|
|
604
|
+
value: false,
|
|
605
|
+
outcome: 'alias',
|
|
606
|
+
vx: '`--all`',
|
|
607
|
+
to: () => ['--all'],
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
runner: 'vp',
|
|
611
|
+
names: ['-w', '--workspace-root'],
|
|
612
|
+
value: false,
|
|
613
|
+
outcome: 'alias',
|
|
614
|
+
vx: '`--filter //` (the root project)',
|
|
615
|
+
to: () => ['--filter', '//'],
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
runner: 'vp',
|
|
619
|
+
names: ['--concurrency-limit'],
|
|
620
|
+
value: true,
|
|
621
|
+
outcome: 'alias',
|
|
622
|
+
vx: '`--concurrency <n>`',
|
|
623
|
+
to: (v) => (v === undefined ? ['--concurrency'] : ['--concurrency', v]),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
runner: 'vp',
|
|
627
|
+
names: ['--ignore-depends-on'],
|
|
628
|
+
value: false,
|
|
629
|
+
outcome: 'alias',
|
|
630
|
+
vx: '`--exclude-dependencies`',
|
|
631
|
+
to: () => ['--exclude-dependencies'],
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
runner: 'vp',
|
|
635
|
+
names: ['--fail-if-no-match'],
|
|
636
|
+
value: false,
|
|
637
|
+
outcome: 'alias',
|
|
638
|
+
vx: 'nothing: a filter that matches nothing already fails the run',
|
|
639
|
+
to: () => [],
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
runner: 'vp',
|
|
643
|
+
names: ['--log'],
|
|
644
|
+
value: true,
|
|
645
|
+
outcome: 'refuse',
|
|
646
|
+
vx: 'vx frames each task’s output: `--output-logs <mode>` sets how much',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
runner: 'vp',
|
|
650
|
+
names: ['--last-details'],
|
|
651
|
+
value: false,
|
|
652
|
+
outcome: 'refuse',
|
|
653
|
+
vx: '`vx last` prints the last run',
|
|
654
|
+
},
|
|
601
655
|
]
|
|
602
656
|
|
|
603
657
|
/** Nx's verbs, as its users type them: `vx run` takes their flags. */
|
package/src/cli/info.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
|
29
29
|
}
|
|
30
30
|
const cd = parseCacheDirFlag(args, i)
|
|
31
31
|
if (cd !== null) {
|
|
32
|
-
if ('error' in cd) return { ...out, error: cd.error }
|
|
32
|
+
if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('info')}` }
|
|
33
33
|
out.cacheDir = cd.cacheDir
|
|
34
34
|
i = cd.next
|
|
35
35
|
continue
|
package/src/cli/init.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
42
42
|
if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
|
|
43
43
|
return {
|
|
44
44
|
...out,
|
|
45
|
-
error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
|
|
45
|
+
error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
out.plugin = seam
|
|
@@ -56,7 +56,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
56
56
|
if (out.plugin !== undefined && out.mjs) {
|
|
57
57
|
return {
|
|
58
58
|
...out,
|
|
59
|
-
error:
|
|
59
|
+
error: `--mjs does not combine with --plugin: the plugin templates are TypeScript${seeHelp('init')}`,
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
return out
|
package/src/cli/last.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
80
80
|
}
|
|
81
81
|
const cd = parseCacheDirFlag(args, i)
|
|
82
82
|
if (cd !== null) {
|
|
83
|
-
if ('error' in cd) return { ...out, error: cd.error }
|
|
83
|
+
if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('last')}` }
|
|
84
84
|
out.cacheDir = cd.cacheDir
|
|
85
85
|
i = cd.next
|
|
86
86
|
continue
|
|
@@ -99,11 +99,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
99
99
|
if (out.runId !== undefined && out.list !== undefined) {
|
|
100
100
|
return {
|
|
101
101
|
...out,
|
|
102
|
-
error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
|
|
102
|
+
error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
if (out.runId !== undefined && out.failed === true) {
|
|
106
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
...out,
|
|
108
|
+
error: `a run id and --failed do not combine: replay ${out.runId}${seeHelp('last')}`,
|
|
109
|
+
}
|
|
107
110
|
}
|
|
108
111
|
return out
|
|
109
112
|
}
|
package/src/cli/lock.ts
CHANGED
|
@@ -83,9 +83,11 @@ function bareNote(bare: number): string {
|
|
|
83
83
|
|
|
84
84
|
async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
85
85
|
const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
86
|
+
// `listProjects` sorts by name — stable lockfile diffs for free. Defined,
|
|
87
|
+
// not assigned: a project named `__proto__` set the map's prototype (D-152).
|
|
88
|
+
const projects: Record<string, LockfileEntry> = Object.fromEntries(
|
|
89
|
+
metas.map((m, i) => [m.name, entries[i]!]),
|
|
90
|
+
)
|
|
89
91
|
const leaks = secretsIn(projects)
|
|
90
92
|
if (leaks.length > 0) {
|
|
91
93
|
process.stderr.write(
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
|
|
2
2
|
|
|
3
3
|
import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import {
|
|
4
|
+
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -37,7 +37,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
37
37
|
const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
|
|
38
38
|
const shortHash = t.hash ? t.hash.slice(0, 8) : ''
|
|
39
39
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
40
|
-
const eta = executes && t.p50Ms !== undefined ? ` ~${
|
|
40
|
+
const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
|
|
41
41
|
// Placement, by EXECUTOR NAME rather than a local/remote word: the
|
|
42
42
|
// summary line below already spends "local" and "remote" on the cache
|
|
43
43
|
// tier, and a task placed on a named executor is what the reader can
|
|
@@ -48,7 +48,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
48
48
|
)
|
|
49
49
|
// Optional one-line description from the task config, indented
|
|
50
50
|
// under the id so the eye picks up the task → blurb mapping.
|
|
51
|
-
const taskDesc = t
|
|
51
|
+
const taskDesc = shownDescription(t)
|
|
52
52
|
if (taskDesc) {
|
|
53
53
|
lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
|
|
54
54
|
}
|
|
@@ -94,8 +94,8 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
94
94
|
const p = plan.predicted
|
|
95
95
|
const executes = miss + nocache
|
|
96
96
|
if (p !== undefined && executes > 0 && executes > p.unknownCount) {
|
|
97
|
-
const parts = [`predicted: ~${
|
|
98
|
-
parts.push(`~${
|
|
97
|
+
const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
|
|
98
|
+
parts.push(`~${formatElapsed(p.workMs)} total execution`)
|
|
99
99
|
if (p.unknownCount > 0) {
|
|
100
100
|
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
|
|
101
101
|
}
|
|
@@ -104,6 +104,12 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
104
104
|
return lines.join('\n') + '\n'
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** A TS config may build the description from `process.env` (L-11). */
|
|
108
|
+
function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
|
|
109
|
+
const { description, exec } = t.node.config
|
|
110
|
+
return description === undefined ? undefined : maskedCommand(description, exec?.env)
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
/** One task of `vx run --dry=json`: the wire, stated by `schemas/plan.json`. */
|
|
108
114
|
export interface PlanTaskJson {
|
|
109
115
|
id: string
|
|
@@ -122,21 +128,21 @@ export function formatPlanJson(plan: RunPlan): string {
|
|
|
122
128
|
return (
|
|
123
129
|
JSON.stringify(
|
|
124
130
|
{
|
|
125
|
-
tasks: plan.tasks.map((t): PlanTaskJson =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
? { description:
|
|
138
|
-
|
|
139
|
-
})
|
|
131
|
+
tasks: plan.tasks.map((t): PlanTaskJson => {
|
|
132
|
+
const description = shownDescription(t)
|
|
133
|
+
return {
|
|
134
|
+
id: t.node.id,
|
|
135
|
+
project: t.node.projectName,
|
|
136
|
+
task: t.node.taskName,
|
|
137
|
+
hash: t.hash,
|
|
138
|
+
cacheStatus: t.cacheStatus,
|
|
139
|
+
deps: t.deps,
|
|
140
|
+
...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
|
|
141
|
+
...(t.executor !== undefined ? { executor: t.executor } : {}),
|
|
142
|
+
...(t.download !== undefined ? { download: t.download } : {}),
|
|
143
|
+
...(description !== undefined ? { description } : {}),
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
140
146
|
...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
|
|
141
147
|
// The gate's refusals belong on the SCRIPTING surface too: a CI job
|
|
142
148
|
// asking "did --download=none actually defer anything, and if not
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
4
5
|
import { translateForeign } from './foreign-flags.js'
|
|
5
6
|
import { flagHint, seeHelp } from './help.js'
|
|
6
7
|
import {
|
|
@@ -13,7 +14,12 @@ import {
|
|
|
13
14
|
type RunResult,
|
|
14
15
|
} from '../orchestrator/index.js'
|
|
15
16
|
import type { ContinueMode, TaskOutcome } from '../graph/index.js'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type CachePolicy,
|
|
19
|
+
FULL_CACHE_POLICY,
|
|
20
|
+
gitRefusal,
|
|
21
|
+
parseCachePolicy,
|
|
22
|
+
} from '../cache/index.js'
|
|
17
23
|
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
18
24
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
19
25
|
import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
@@ -175,7 +181,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
175
181
|
if (n === null)
|
|
176
182
|
return {
|
|
177
183
|
...out,
|
|
178
|
-
error:
|
|
184
|
+
error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
|
|
179
185
|
}
|
|
180
186
|
out.concurrency = n
|
|
181
187
|
} else if (a === '--all') {
|
|
@@ -261,14 +267,17 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
261
267
|
out.download = v
|
|
262
268
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
263
269
|
const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
|
|
264
|
-
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a
|
|
270
|
+
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
|
|
265
271
|
// Unlike every other value flag, a cache dir is an arbitrary
|
|
266
272
|
// string — nothing about its shape rejects a swallowed flag. An
|
|
267
273
|
// unquoted empty shell var (`--cache-dir $EMPTY --force`) would
|
|
268
274
|
// otherwise create a directory literally named `--force` and drop
|
|
269
275
|
// the flag. A path starting with `-` needs the `=` form.
|
|
270
276
|
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
271
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
...out,
|
|
279
|
+
error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
|
|
280
|
+
}
|
|
272
281
|
}
|
|
273
282
|
out.cacheDir = v
|
|
274
283
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
@@ -323,14 +332,15 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
323
332
|
if (v === undefined)
|
|
324
333
|
return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
|
|
325
334
|
const n = parseDecimalInt(v)
|
|
326
|
-
if (n === null)
|
|
335
|
+
if (n === null)
|
|
336
|
+
return { ...out, error: `--verbosity must be a non-negative integer (got ${v})` }
|
|
327
337
|
out.verbosity = n
|
|
328
338
|
} else if (a === '--dry') {
|
|
329
339
|
out.dry = 'text'
|
|
330
340
|
} else if (a?.startsWith('--dry=')) {
|
|
331
341
|
const fmt = a.slice('--dry='.length)
|
|
332
342
|
if (fmt !== 'text' && fmt !== 'json') {
|
|
333
|
-
return { ...out, error:
|
|
343
|
+
return { ...out, error: `--dry must be text or json (got ${fmt})` }
|
|
334
344
|
}
|
|
335
345
|
out.dry = fmt
|
|
336
346
|
} else if (a === '--graph') {
|
|
@@ -358,7 +368,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
358
368
|
if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
|
|
359
369
|
// Split on the FIRST `=` so values may contain `=` (e.g. a URL).
|
|
360
370
|
const eq = raw.indexOf('=')
|
|
361
|
-
if (eq <= 0) return { ...out, error:
|
|
371
|
+
if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
|
|
362
372
|
out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
|
|
363
373
|
} else if (a === '--report-file' || a?.startsWith('--report-file=')) {
|
|
364
374
|
const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
|
|
@@ -367,7 +377,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
367
377
|
// nothing about its shape rejects a swallowed flag. A path starting
|
|
368
378
|
// with `-` needs the `=` form.
|
|
369
379
|
if (a === '--report-file' && v.startsWith('-')) {
|
|
370
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
...out,
|
|
382
|
+
error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
|
|
383
|
+
}
|
|
371
384
|
}
|
|
372
385
|
out.reportFile = v
|
|
373
386
|
} else if (a === '--report') {
|
|
@@ -375,7 +388,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
375
388
|
} else if (a?.startsWith('--report=')) {
|
|
376
389
|
const fmt = a.slice('--report='.length)
|
|
377
390
|
if (fmt !== 'markdown') {
|
|
378
|
-
return { ...out, error:
|
|
391
|
+
return { ...out, error: `--report must be markdown (got ${fmt})` }
|
|
379
392
|
}
|
|
380
393
|
out.report = fmt
|
|
381
394
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
@@ -620,6 +633,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
620
633
|
|
|
621
634
|
const cwd = process.cwd()
|
|
622
635
|
let tasks = [...parsed.tasks]
|
|
636
|
+
let picked: string | undefined
|
|
623
637
|
|
|
624
638
|
// No positionals → interactive picker (TTY only). Yields a single
|
|
625
639
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
@@ -638,6 +652,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
638
652
|
)
|
|
639
653
|
return 1
|
|
640
654
|
}
|
|
655
|
+
// Every run needs git: refused here, not after the user has chosen.
|
|
656
|
+
const refusal = gitRefusal(await findWorkspaceRoot(cwd))
|
|
657
|
+
if (refusal !== undefined) {
|
|
658
|
+
process.stderr.write(`${refusal.message}\n`)
|
|
659
|
+
return 1
|
|
660
|
+
}
|
|
641
661
|
const load = {
|
|
642
662
|
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
643
663
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
@@ -662,10 +682,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
662
682
|
}
|
|
663
683
|
only = new Set(selected.names)
|
|
664
684
|
}
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
if (!
|
|
668
|
-
|
|
685
|
+
const chosen = await pickTask(cwd, {}, load, only)
|
|
686
|
+
if (chosen === 'interrupted') return 130
|
|
687
|
+
if (!chosen) return 1
|
|
688
|
+
picked = `${chosen.project}#${chosen.task}`
|
|
689
|
+
tasks = [picked]
|
|
669
690
|
}
|
|
670
691
|
|
|
671
692
|
const resolved = await resolveRunOptions(parsed, cwd, tasks)
|
|
@@ -680,8 +701,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
680
701
|
const opts = resolved
|
|
681
702
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
682
703
|
// show what was actually run. `args` is everything after `run`, quoted
|
|
683
|
-
// so `vx last` replays one paste-able line
|
|
684
|
-
|
|
704
|
+
// so `vx last` replays one paste-able line; a picked task is named in
|
|
705
|
+
// it, or the line would open the picker again.
|
|
706
|
+
opts.command = [
|
|
707
|
+
'vx',
|
|
708
|
+
'run',
|
|
709
|
+
...(picked !== undefined ? [shellQuote(picked)] : []),
|
|
710
|
+
...args.map(shellQuote),
|
|
711
|
+
].join(' ')
|
|
685
712
|
|
|
686
713
|
// Planning paths short-circuit execution. Both build the full task
|
|
687
714
|
// graph + probe the cache; the difference is just the formatter.
|
package/src/cli/select.ts
CHANGED
|
@@ -26,8 +26,15 @@ import {
|
|
|
26
26
|
import type { ProjectConfig } from '../config.js'
|
|
27
27
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
28
28
|
import { parseDependencySpec } from '../graph/index.js'
|
|
29
|
-
import { declaresInput } from '../cache/index.js'
|
|
30
|
-
import {
|
|
29
|
+
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
30
|
+
import {
|
|
31
|
+
isUserError,
|
|
32
|
+
listed,
|
|
33
|
+
maskedCommand,
|
|
34
|
+
maskedLine,
|
|
35
|
+
nearest,
|
|
36
|
+
UserError,
|
|
37
|
+
} from '../util/index.js'
|
|
31
38
|
import {
|
|
32
39
|
claimedAffected,
|
|
33
40
|
fingerprintClaims,
|
|
@@ -60,12 +67,15 @@ export async function workspaceGlobOwners(
|
|
|
60
67
|
load: CliLoadOptions = {},
|
|
61
68
|
stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
|
|
62
69
|
loadCliProjects(root, projects, 'all', load),
|
|
70
|
+
/** The changed nested repositories (`AffectedChanges.nested`). */
|
|
71
|
+
nested: readonly string[] = [],
|
|
63
72
|
): Promise<string[]> {
|
|
64
73
|
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
65
74
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
66
75
|
const cache = task.cache
|
|
67
76
|
if (cache === undefined) continue
|
|
68
77
|
if (changed.some((rel) => declaresInput(cache, null, rel))) return true
|
|
78
|
+
if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
|
|
69
79
|
}
|
|
70
80
|
return false
|
|
71
81
|
}
|
|
@@ -319,8 +329,8 @@ export async function resolveFilters(
|
|
|
319
329
|
workspaceRoot: root,
|
|
320
330
|
since: f.gitSince,
|
|
321
331
|
projects,
|
|
322
|
-
workspaceGlobOwners: (changed: readonly string[]) =>
|
|
323
|
-
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
332
|
+
workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
|
|
333
|
+
workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
|
|
324
334
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
325
335
|
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
326
336
|
...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
|
|
@@ -480,7 +490,11 @@ export async function pickTask(
|
|
|
480
490
|
.filter((t) => !isDefaultBuild(config.tasks?.[t]))
|
|
481
491
|
.sort()
|
|
482
492
|
for (const t of taskNames) {
|
|
483
|
-
const
|
|
493
|
+
const task = config.tasks?.[t]
|
|
494
|
+
const desc =
|
|
495
|
+
task?.description === undefined
|
|
496
|
+
? undefined
|
|
497
|
+
: maskedCommand(task.description, task.exec?.env)
|
|
484
498
|
entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
|
|
485
499
|
}
|
|
486
500
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
41
41
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
42
42
|
else if (a === '--filter') {
|
|
43
43
|
const v = args[++i]
|
|
44
|
-
|
|
44
|
+
// `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
|
|
45
|
+
if (v === undefined || v === '' || v.startsWith('--'))
|
|
46
|
+
return {
|
|
47
|
+
...out,
|
|
48
|
+
error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
|
|
49
|
+
}
|
|
45
50
|
out.filters.push(v)
|
|
46
51
|
} else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
|
|
47
52
|
else if (a === '--affected') out.affected = ''
|
|
@@ -55,7 +60,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
55
60
|
return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
|
|
56
61
|
// An empty name is a part of every name: its "did you mean" listed the
|
|
57
62
|
// whole workspace.
|
|
58
|
-
else if (a === '')
|
|
63
|
+
else if (a === '')
|
|
64
|
+
return { ...out, error: `empty target (omit it to list every project)${seeHelp('show')}` }
|
|
59
65
|
else out.target = a
|
|
60
66
|
|
|
61
67
|
if (format !== undefined) {
|
|
@@ -215,33 +221,21 @@ function projectDir(root: string, meta: ProjectMeta): string {
|
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
/**
|
|
218
|
-
* A task as `vx show` prints it: a secret variable's value masked
|
|
219
|
-
*
|
|
220
|
-
*
|
|
224
|
+
* A task as `vx show` prints it: a secret variable's value masked in every
|
|
225
|
+
* string it holds, where a TS config that builds one from `process.env`
|
|
226
|
+
* put it (the command, `env.define`, a runtime probe, `readyWhen`, the
|
|
227
|
+
* description) (L-11).
|
|
221
228
|
*/
|
|
222
229
|
function shownTask(task: TaskConfig): TaskConfig {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
...(exec.command !== undefined ? { command: secrets.mask(exec.command) } : {}),
|
|
233
|
-
...(define !== undefined
|
|
234
|
-
? {
|
|
235
|
-
env: {
|
|
236
|
-
...exec.env,
|
|
237
|
-
define: Object.fromEntries(
|
|
238
|
-
Object.entries(define).map(([k, v]) => [k, secrets.mask(v)]),
|
|
239
|
-
),
|
|
240
|
-
},
|
|
241
|
-
}
|
|
242
|
-
: {}),
|
|
243
|
-
},
|
|
244
|
-
}
|
|
230
|
+
const secrets = secretMask([process.env, task.exec?.env?.define], task.exec?.env?.secret)
|
|
231
|
+
return secrets === null ? task : (maskStrings(task, secrets.mask) as TaskConfig)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function maskStrings(value: unknown, mask: (text: string) => string): unknown {
|
|
235
|
+
if (typeof value === 'string') return mask(value)
|
|
236
|
+
if (Array.isArray(value)) return value.map((v) => maskStrings(v, mask))
|
|
237
|
+
if (typeof value !== 'object' || value === null) return value
|
|
238
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskStrings(v, mask)]))
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
/** A project config with each task as `shownTask` prints it. */
|