@vzn/vx 0.0.110 → 0.0.112

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.110",
3
+ "version": "0.0.112",
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.110",
21
- "@vzn/vx-linux-arm64": "0.0.110",
22
- "@vzn/vx-darwin-x64": "0.0.110",
23
- "@vzn/vx-darwin-arm64": "0.0.110"
20
+ "@vzn/vx-linux-x64": "0.0.112",
21
+ "@vzn/vx-linux-arm64": "0.0.112",
22
+ "@vzn/vx-darwin-x64": "0.0.112",
23
+ "@vzn/vx-darwin-arm64": "0.0.112"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -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,
@@ -1052,6 +1054,13 @@ export class Cache implements CacheLayer {
1052
1054
  `restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${err.message}). ${remedy}`,
1053
1055
  )
1054
1056
  }
1057
+ // The process is out of descriptors: the artifact is fine, and
1058
+ // calling it corrupt sent the reader after the cache (A-39).
1059
+ if (isOutOfFds(err)) {
1060
+ throw new UserError(
1061
+ `restore of ${hash} into ${projectDir} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
1062
+ )
1063
+ }
1055
1064
  // The artifact itself is gone: the read names its path (`bytes()`
1056
1065
  // opens it; a staged file's ENOENT names the temp below).
1057
1066
  if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
@@ -1127,7 +1136,18 @@ export class Cache implements CacheLayer {
1127
1136
  // again in `writeArtifactAndIndex` cost every save two thread-pool
1128
1137
  // round trips for an EEXIST and a stat (item 630).
1129
1138
  const endPack = span('save: pack')
1130
- const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
1139
+ const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
1140
+ (err: unknown) => {
1141
+ // A save that could not open an output names the limit, not only
1142
+ // the file, which is fine (A-39).
1143
+ if (isOutOfFds(err)) {
1144
+ throw new UserError(
1145
+ `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
1146
+ )
1147
+ }
1148
+ throw err
1149
+ },
1150
+ )
1131
1151
  endPack()
1132
1152
  await this.guard(() =>
1133
1153
  this.writeArtifactAndIndex(args.hash, compressed, {
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-named variable's value masked
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 {
@@ -1,4 +1,10 @@
1
- import { fsRefusalHint, isFsRefusal, isUserError } from '../util/index.js'
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
- if (isUserError(err) || isFsRefusal(err)) {
736
- const text = isUserError(err) ? message : `${message} — ${fsRefusalHint(err)}`
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?.define)
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?.define)}`),
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?.define)
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(process.env, node.config.exec?.env?.define)
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?.define)
442
+ rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
443
443
  deliver(rec)
444
444
  return
445
445
  }
@@ -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
- ...sources: ReadonlyArray<Readonly<Record<string, string | undefined>> | undefined>
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
- const value = source[name]
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-named values masked. */
111
- export function maskedCommand(command: string, define?: Readonly<Record<string, string>>): 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
  /**
@@ -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',