@vzn/vx 0.0.111 → 0.0.113
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/cache/cache.ts +29 -1
- package/src/cli/help.ts +2 -2
- package/src/cli/show.ts +2 -2
- package/src/config.ts +7 -0
- package/src/exec/runner.ts +4 -1
- package/src/graph/scheduler.ts +13 -3
- package/src/orchestrator/events.ts +1 -1
- package/src/orchestrator/execute-task.ts +2 -2
- package/src/orchestrator/framed-output.ts +2 -2
- package/src/orchestrator/hit-restore.ts +4 -1
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/util/secret-mask.ts +18 -14
- package/src/workspace/config-imports.ts +4 -3
- package/src/workspace/config-schema.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.113",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.113",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.113",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.113",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.113"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
formatBytes,
|
|
37
37
|
isDiskFull,
|
|
38
38
|
isFsRefusal,
|
|
39
|
+
isOutOfFds,
|
|
40
|
+
OUT_OF_FDS_HINT,
|
|
39
41
|
relPosix,
|
|
40
42
|
span,
|
|
41
43
|
splitTaskId,
|
|
@@ -1044,6 +1046,14 @@ export class Cache implements CacheLayer {
|
|
|
1044
1046
|
// Same distinction for a tree the process cannot write into — a
|
|
1045
1047
|
// `dist/` another user owns, a read-only checkout, a full disk: the
|
|
1046
1048
|
// artifact is intact, the tree is not the process's to change.
|
|
1049
|
+
// A refusal to READ the artifact is the cache directory's, not the
|
|
1050
|
+
// outputs': the old line sent the reader to the wrong tree (A-40).
|
|
1051
|
+
if (isFsRefusal(err) && err.path === src) {
|
|
1052
|
+
throw new UserError(
|
|
1053
|
+
`restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
|
|
1054
|
+
`Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1047
1057
|
if (isFsRefusal(err)) {
|
|
1048
1058
|
const remedy = isDiskFull(err)
|
|
1049
1059
|
? 'Free space on that disk and re-run.'
|
|
@@ -1052,6 +1062,13 @@ export class Cache implements CacheLayer {
|
|
|
1052
1062
|
`restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${err.message}). ${remedy}`,
|
|
1053
1063
|
)
|
|
1054
1064
|
}
|
|
1065
|
+
// The process is out of descriptors: the artifact is fine, and
|
|
1066
|
+
// calling it corrupt sent the reader after the cache (A-39).
|
|
1067
|
+
if (isOutOfFds(err)) {
|
|
1068
|
+
throw new UserError(
|
|
1069
|
+
`restore of ${hash} into ${projectDir} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
|
|
1070
|
+
)
|
|
1071
|
+
}
|
|
1055
1072
|
// The artifact itself is gone: the read names its path (`bytes()`
|
|
1056
1073
|
// opens it; a staged file's ENOENT names the temp below).
|
|
1057
1074
|
if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
|
|
@@ -1127,7 +1144,18 @@ export class Cache implements CacheLayer {
|
|
|
1127
1144
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
1128
1145
|
// round trips for an EEXIST and a stat (item 630).
|
|
1129
1146
|
const endPack = span('save: pack')
|
|
1130
|
-
const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
1147
|
+
const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
|
|
1148
|
+
(err: unknown) => {
|
|
1149
|
+
// A save that could not open an output names the limit, not only
|
|
1150
|
+
// the file, which is fine (A-39).
|
|
1151
|
+
if (isOutOfFds(err)) {
|
|
1152
|
+
throw new UserError(
|
|
1153
|
+
`save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
|
|
1154
|
+
)
|
|
1155
|
+
}
|
|
1156
|
+
throw err
|
|
1157
|
+
},
|
|
1158
|
+
)
|
|
1131
1159
|
endPack()
|
|
1132
1160
|
await this.guard(() =>
|
|
1133
1161
|
this.writeArtifactAndIndex(args.hash, compressed, {
|
package/src/cli/help.ts
CHANGED
|
@@ -221,10 +221,10 @@ export function flagHint(verb: string, arg: string): string {
|
|
|
221
221
|
* reading every flag on it put `--check` in the list, so `vx run --chek`
|
|
222
222
|
* was told "did you mean --check?" and `--check` was then refused.
|
|
223
223
|
*/
|
|
224
|
-
export function documentedFlags(verb: string): string[] {
|
|
224
|
+
export function documentedFlags(verb: string, text = helpText()): string[] {
|
|
225
225
|
const flags = new Set<string>()
|
|
226
226
|
let inVerb = false
|
|
227
|
-
for (const line of
|
|
227
|
+
for (const line of text.split('\n')) {
|
|
228
228
|
if (/^[A-Z][A-Za-z ]*(?: \(for [a-z]+\))?:$/.test(line))
|
|
229
229
|
inVerb = line.endsWith(`(for ${verb}):`)
|
|
230
230
|
if (!inVerb) continue
|
package/src/cli/show.ts
CHANGED
|
@@ -154,7 +154,7 @@ function projectDir(root: string, meta: ProjectMeta): string {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
* A task as `vx show` prints it: a secret
|
|
157
|
+
* A task as `vx show` prints it: a secret variable's value masked
|
|
158
158
|
* wherever it appears, in `env.define` and in the command a TS config
|
|
159
159
|
* built from `process.env` (L-11).
|
|
160
160
|
*/
|
|
@@ -162,7 +162,7 @@ function shownTask(task: TaskConfig): TaskConfig {
|
|
|
162
162
|
const exec = task.exec
|
|
163
163
|
if (exec === undefined) return task
|
|
164
164
|
const define = exec.env?.define
|
|
165
|
-
const secrets = secretMask(process.env, define)
|
|
165
|
+
const secrets = secretMask([process.env, define], exec.env?.secret)
|
|
166
166
|
if (secrets === null) return task
|
|
167
167
|
return {
|
|
168
168
|
...task,
|
package/src/config.ts
CHANGED
|
@@ -399,6 +399,13 @@ export interface ExecEnv {
|
|
|
399
399
|
* via the task config hash).
|
|
400
400
|
*/
|
|
401
401
|
define?: Record<string, string>
|
|
402
|
+
/**
|
|
403
|
+
* Names whose values are masked (`***`) wherever vx prints, stores or
|
|
404
|
+
* exports the task's output or command, whatever the name. Names that
|
|
405
|
+
* hold `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL`
|
|
406
|
+
* are masked without it; this reaches `GH_PAT`, `NPM_AUTH`.
|
|
407
|
+
*/
|
|
408
|
+
secret?: string[]
|
|
402
409
|
}
|
|
403
410
|
|
|
404
411
|
export interface CacheConfig {
|
package/src/exec/runner.ts
CHANGED
|
@@ -140,8 +140,11 @@ function trailingCommentStart(command: string): number {
|
|
|
140
140
|
export function withForwardArgs(command: string, args: readonly string[] | undefined): string {
|
|
141
141
|
if (!args || args.length === 0) return command
|
|
142
142
|
const quoted = args.map(shellQuote).join(' ')
|
|
143
|
-
|
|
143
|
+
let comment = trailingCommentStart(command)
|
|
144
144
|
if (comment < 0) return `${command} ${quoted}`
|
|
145
|
+
// A comment-only last line after a commented line: the args go before
|
|
146
|
+
// the earliest comment, or they land inside it (`echo one # c\n# two`).
|
|
147
|
+
for (let c = comment; c >= 0; c = trailingCommentStart(command.slice(0, c).trimEnd())) comment = c
|
|
145
148
|
return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
|
|
146
149
|
}
|
|
147
150
|
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
fsRefusalHint,
|
|
3
|
+
isFsRefusal,
|
|
4
|
+
isOutOfFds,
|
|
5
|
+
isUserError,
|
|
6
|
+
OUT_OF_FDS_HINT,
|
|
7
|
+
} from '../util/index.js'
|
|
2
8
|
import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
|
|
3
9
|
import type { TaskNode } from './task-graph.js'
|
|
4
10
|
|
|
@@ -732,8 +738,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
732
738
|
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
733
739
|
// plainly, never as an "internal error".
|
|
734
740
|
let line: string
|
|
735
|
-
|
|
736
|
-
|
|
741
|
+
// Out of descriptors is the environment's limit too: a stack
|
|
742
|
+
// naming a file that was fine sent the user after the file.
|
|
743
|
+
if (isUserError(err) || isFsRefusal(err) || isOutOfFds(err)) {
|
|
744
|
+
const text = isUserError(err)
|
|
745
|
+
? message
|
|
746
|
+
: `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
|
|
737
747
|
const first = refusedBy.get(text)
|
|
738
748
|
if (first === undefined) refusedBy.set(text, id)
|
|
739
749
|
line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
|
|
@@ -342,7 +342,7 @@ export function projectNode(node: TaskNode): TaskView {
|
|
|
342
342
|
persistent: node.config.exec?.persistent !== undefined,
|
|
343
343
|
}
|
|
344
344
|
if (node.config.exec?.command !== undefined)
|
|
345
|
-
view.command = maskedCommand(node.config.exec.command, node.config.exec.env
|
|
345
|
+
view.command = maskedCommand(node.config.exec.command, node.config.exec.env)
|
|
346
346
|
return view
|
|
347
347
|
}
|
|
348
348
|
|
|
@@ -326,7 +326,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
326
326
|
// A server's output masked as a one-shot task's is (L-11); `readyWhen`
|
|
327
327
|
// is matched on the raw chunks, before this. Its held tail is flushed by
|
|
328
328
|
// the idle timer: a server never ends on the way to a flush.
|
|
329
|
-
const serverSecrets = secretMask(process.env, env, step.env?.define)
|
|
329
|
+
const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
330
330
|
const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
|
|
331
331
|
const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
|
|
332
332
|
const persistentOpts: Parameters<typeof runPersistent>[0] = {
|
|
@@ -892,7 +892,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
892
892
|
|
|
893
893
|
// The values of secret-named variables, masked in what the task prints
|
|
894
894
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
895
|
-
const secrets = secretMask(process.env, env, step.env?.define)
|
|
895
|
+
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
896
896
|
let flushMasked = (): void => {}
|
|
897
897
|
// The entry's command is shown by `vx why` and sent with the entry to a
|
|
898
898
|
// remote cache: a value a config interpolated stays out of both.
|
|
@@ -107,7 +107,7 @@ export function formatTaskBlock(
|
|
|
107
107
|
// line under the header reads as the command on its own.
|
|
108
108
|
lines.push(
|
|
109
109
|
'',
|
|
110
|
-
corner(`$ ${maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env
|
|
110
|
+
corner(`$ ${maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env)}`),
|
|
111
111
|
'',
|
|
112
112
|
)
|
|
113
113
|
}
|
|
@@ -391,7 +391,7 @@ export function formatTaskSkippedLine(
|
|
|
391
391
|
*/
|
|
392
392
|
export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR): string {
|
|
393
393
|
const corner = (t: string) => paint('', t, colors, { dim: true })
|
|
394
|
-
const cmd = maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env
|
|
394
|
+
const cmd = maskedCommand(node.config.exec?.command ?? '', node.config.exec?.env)
|
|
395
395
|
const mark = isPersistentNode(node) ? `${paint(ACCENT, '▸', colors)} ` : ''
|
|
396
396
|
return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
|
|
397
397
|
}
|
|
@@ -266,7 +266,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
266
266
|
if (hit.stdout) {
|
|
267
267
|
// An entry saved before masking, or by another machine, can hold a
|
|
268
268
|
// secret-named variable's value (L-11). Asked only of a hit that prints.
|
|
269
|
-
const secrets = secretMask(
|
|
269
|
+
const secrets = secretMask(
|
|
270
|
+
[process.env, node.config.exec?.env?.define],
|
|
271
|
+
node.config.exec?.env?.secret,
|
|
272
|
+
)
|
|
270
273
|
log.taskStdout(node, secrets === null ? hit.stdout : secrets.mask(hit.stdout))
|
|
271
274
|
}
|
|
272
275
|
const status =
|
|
@@ -439,7 +439,7 @@ export function createTelemetrySource(args: {
|
|
|
439
439
|
ts,
|
|
440
440
|
}
|
|
441
441
|
if (node.config.exec.command !== undefined)
|
|
442
|
-
rec.command = maskedCommand(node.config.exec.command, node.config.exec.env
|
|
442
|
+
rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
|
|
443
443
|
deliver(rec)
|
|
444
444
|
return
|
|
445
445
|
}
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -38,25 +38,23 @@ export interface SecretMask {
|
|
|
38
38
|
/**
|
|
39
39
|
* The mask for these variables (a process's env, a task's `define`), or
|
|
40
40
|
* null when none is secret-named with a value worth masking: the common
|
|
41
|
-
* run pays one name test per variable and nothing per byte.
|
|
41
|
+
* run pays one name test per variable and nothing per byte. `named` are
|
|
42
|
+
* the task's `env.secret`: masked whatever the name.
|
|
42
43
|
*/
|
|
43
44
|
export function secretMask(
|
|
44
|
-
|
|
45
|
+
sources: ReadonlyArray<Readonly<Record<string, string | undefined>> | undefined>,
|
|
46
|
+
named: readonly string[] = [],
|
|
45
47
|
): SecretMask | null {
|
|
46
48
|
const values = new Set<string>()
|
|
49
|
+
const add = (value: string | undefined): void => {
|
|
50
|
+
if (value !== undefined && value.length >= MIN_SECRET_CHARS) values.add(value)
|
|
51
|
+
}
|
|
47
52
|
for (const source of sources) {
|
|
48
53
|
if (source === undefined) continue
|
|
49
54
|
for (const name in source) {
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
value !== undefined &&
|
|
53
|
-
value.length >= MIN_SECRET_CHARS &&
|
|
54
|
-
SECRET_NAME.test(name) &&
|
|
55
|
-
!NOT_SECRET.test(name)
|
|
56
|
-
) {
|
|
57
|
-
values.add(value)
|
|
58
|
-
}
|
|
55
|
+
if (SECRET_NAME.test(name) && !NOT_SECRET.test(name)) add(source[name])
|
|
59
56
|
}
|
|
57
|
+
for (const name of named) add(source[name])
|
|
60
58
|
}
|
|
61
59
|
if (values.size === 0) return null
|
|
62
60
|
// Longest first, so a value holding another is replaced whole.
|
|
@@ -107,9 +105,15 @@ export function secretMask(
|
|
|
107
105
|
}
|
|
108
106
|
}
|
|
109
107
|
|
|
110
|
-
/** A task's command as vx shows it: its secret
|
|
111
|
-
export function maskedCommand(command: string,
|
|
112
|
-
return secretMask(process.env, define)?.mask(command) ?? command
|
|
108
|
+
/** A task's command as vx shows it: its secret values masked. */
|
|
109
|
+
export function maskedCommand(command: string, env?: TaskEnvSecrets): string {
|
|
110
|
+
return secretMask([process.env, env?.define], env?.secret)?.mask(command) ?? command
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What of a task's `exec.env` names its secrets. */
|
|
114
|
+
export interface TaskEnvSecrets {
|
|
115
|
+
readonly define?: Readonly<Record<string, string>>
|
|
116
|
+
readonly secret?: readonly string[]
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/**
|
|
@@ -171,10 +171,11 @@ function compilerPaths(file: string, memo?: TsconfigMemo, depth = 0): CompilerPa
|
|
|
171
171
|
if (typeof json !== 'object' || json === null) return {}
|
|
172
172
|
const dir = path.dirname(file)
|
|
173
173
|
const out: CompilerPaths = {}
|
|
174
|
+
// A string only: Bun follows no array `extends` (TS 5's form), and an
|
|
175
|
+
// alias only an array maps went to the registry (D-30).
|
|
174
176
|
const ext = (json as { extends?: unknown }).extends
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const f = path.resolve(dir, base)
|
|
177
|
+
if (typeof ext === 'string' && ext.startsWith('.') && depth <= 8) {
|
|
178
|
+
const f = path.resolve(dir, ext)
|
|
178
179
|
Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', memo, depth + 1))
|
|
179
180
|
}
|
|
180
181
|
const opts = (json as { compilerOptions?: unknown }).compilerOptions
|
|
@@ -347,6 +347,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
347
347
|
)
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
|
+
const secret = (env as { secret?: unknown }).secret
|
|
351
|
+
if (
|
|
352
|
+
secret !== undefined &&
|
|
353
|
+
(!Array.isArray(secret) ||
|
|
354
|
+
secret.some((n) => !isEnvName(n) || BUN_GLOB_WILDCARDS.test(n)))
|
|
355
|
+
) {
|
|
356
|
+
throw new UserError(
|
|
357
|
+
`${where}.exec.env.secret must be an array of env var names (non-empty, no '=', NUL or wildcard)`,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
350
360
|
const define = (env as { define?: unknown }).define
|
|
351
361
|
if (define !== undefined) {
|
|
352
362
|
if (typeof define !== 'object' || define === null || Array.isArray(define)) {
|
|
@@ -641,7 +651,7 @@ const EXEC_FIELDS = new Set([
|
|
|
641
651
|
'sandbox',
|
|
642
652
|
])
|
|
643
653
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
644
|
-
const ENV_FIELDS = new Set(['passThrough', 'define'])
|
|
654
|
+
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
645
655
|
const CACHE_FIELDS = new Set(['inputs', 'outputs'])
|
|
646
656
|
const CACHE_INPUT_FIELDS = new Set([
|
|
647
657
|
'files',
|