@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.111",
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.111",
21
- "@vzn/vx-linux-arm64": "0.0.111",
22
- "@vzn/vx-darwin-x64": "0.0.111",
23
- "@vzn/vx-darwin-arm64": "0.0.111"
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"
@@ -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 helpText().split('\n')) {
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-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 {
@@ -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
- const comment = trailingCommentStart(command)
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
 
@@ -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
  /**
@@ -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
- for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
176
- if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
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',