@vzn/vx 0.0.631 → 0.0.632

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.
Files changed (62) hide show
  1. package/README.md +7 -8
  2. package/package.json +7 -5
  3. package/schemas/cache.json +24 -0
  4. package/schemas/error.json +23 -0
  5. package/schemas/info.json +147 -0
  6. package/schemas/last.json +188 -0
  7. package/schemas/plan.json +88 -0
  8. package/schemas/show.json +124 -0
  9. package/schemas/summary.json +133 -0
  10. package/schemas/why.json +168 -0
  11. package/skills/vx/SKILL.md +85 -0
  12. package/src/bin.ts +9 -0
  13. package/src/cache/archive.ts +153 -51
  14. package/src/cache/cache.ts +45 -19
  15. package/src/cache/layer.ts +2 -1
  16. package/src/cache/schema.ts +2 -1
  17. package/src/cache/tar-stream.ts +204 -66
  18. package/src/cache/zstd.ts +1 -1
  19. package/src/cli/cache.ts +14 -14
  20. package/src/cli/foreign-flags.ts +1 -1
  21. package/src/cli/help.ts +30 -5
  22. package/src/cli/index.ts +23 -11
  23. package/src/cli/info.ts +38 -9
  24. package/src/cli/init.ts +97 -19
  25. package/src/cli/last.ts +54 -3
  26. package/src/cli/plan-format.ts +23 -1
  27. package/src/cli/plugin-commands.ts +3 -0
  28. package/src/cli/plugin-templates.ts +1 -1
  29. package/src/cli/run.ts +40 -21
  30. package/src/cli/show.ts +10 -7
  31. package/src/cli/upgrade.ts +1 -1
  32. package/src/cli/watch.ts +7 -2
  33. package/src/cli/why.ts +14 -40
  34. package/src/exec/runner.ts +27 -10
  35. package/src/graph/scheduler.ts +36 -0
  36. package/src/graph/task-graph.ts +1 -1
  37. package/src/index.ts +22 -5
  38. package/src/orchestrator/admission.ts +29 -1
  39. package/src/orchestrator/affected-tasks.ts +87 -18
  40. package/src/orchestrator/doctor.ts +14 -1
  41. package/src/orchestrator/execute-task.ts +149 -64
  42. package/src/orchestrator/failure-mode.ts +5 -3
  43. package/src/orchestrator/framed-output.ts +17 -3
  44. package/src/orchestrator/index.ts +11 -0
  45. package/src/orchestrator/logger.ts +57 -6
  46. package/src/orchestrator/metrics.ts +65 -0
  47. package/src/orchestrator/options.ts +12 -0
  48. package/src/orchestrator/output-log.ts +14 -1
  49. package/src/orchestrator/path-links.ts +79 -0
  50. package/src/orchestrator/plan.ts +20 -3
  51. package/src/orchestrator/plugin.ts +7 -0
  52. package/src/orchestrator/prepare.ts +14 -4
  53. package/src/orchestrator/run-artifacts.ts +18 -2
  54. package/src/orchestrator/run-failures.ts +192 -0
  55. package/src/orchestrator/run-records.ts +10 -8
  56. package/src/orchestrator/run.ts +87 -22
  57. package/src/orchestrator/terminal-signals.ts +16 -0
  58. package/src/orchestrator/upstream.ts +65 -10
  59. package/src/util/errors.ts +55 -1
  60. package/src/util/index.ts +3 -0
  61. package/src/util/secret-mask.ts +13 -1
  62. package/src/workspace/workspace.ts +1 -0
@@ -48,13 +48,14 @@ import {
48
48
  machineParallelism,
49
49
  teardownTimeoutMs,
50
50
  secretMask,
51
+ isFsRefusal,
51
52
  } from '../util/index.js'
52
53
  import { keyedProjects } from './keyed-projects.js'
53
54
  import { isDefaultBuild } from './projects.js'
54
55
  import { prepareSandbox } from './sandbox-request.js'
55
56
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
56
57
  import { admitTasks, taintTracker } from './admission.js'
57
- import type { ExecuteArgs } from './execute-task.js'
58
+ import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
58
59
  import { excludedTaint } from './excluded-keys.js'
59
60
  import { keyUpstream } from './upstream.js'
60
61
  import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
@@ -70,6 +71,7 @@ import { formatPersistentList } from './framed-output.js'
70
71
  import { createForecast } from './forecast.js'
71
72
  import { LocalHistoryProvider } from './history.js'
72
73
  import { plan, type RunPlan } from './plan.js'
74
+ import type { AffectedReason } from './affected-tasks.js'
73
75
  import { prepareRun, type PreparedRun } from './prepare.js'
74
76
  import { acquireRunLock } from './run-lock.js'
75
77
  import {
@@ -103,6 +105,7 @@ import {
103
105
  } from './local-shortcircuit.js'
104
106
  import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
105
107
 
108
+ import { writeRunFailures } from './run-failures.js'
106
109
  import { assembleRunRecords } from './run-records.js'
107
110
  import {
108
111
  hasEnded,
@@ -111,7 +114,12 @@ import {
111
114
  shutdownPersistent,
112
115
  takeHeldServers,
113
116
  } from './persistent.js'
114
- import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
117
+ import {
118
+ runSummaryJson,
119
+ writeRunProfile,
120
+ writeRunSummary,
121
+ type RunSummaryJson,
122
+ } from './run-artifacts.js'
115
123
  import { createSaveLane } from './save-lane.js'
116
124
  import { formatOutcomeTable, formatRunSummary } from './summary.js'
117
125
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
@@ -316,11 +324,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
316
324
  // See docs/design/event-stream-2026-06.md.
317
325
  const terminal =
318
326
  options.log === undefined
319
- ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
320
- coalesce: true,
321
- ...(options.tty === true ? { tty: true } : {}),
322
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
323
- })
327
+ ? defaultLogger(
328
+ colors,
329
+ resolveOutputView(options),
330
+ options.json === true ? process.stderr : process.stdout,
331
+ {
332
+ coalesce: true,
333
+ ...(options.tty === true ? { tty: true } : {}),
334
+ ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
335
+ },
336
+ )
324
337
  : null
325
338
  const sink = options.log ?? terminal!
326
339
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -970,7 +983,8 @@ async function runOnBus(
970
983
  // dispatch, and the tracker carries it to what is built on it.
971
984
  const taintSeeds = new Set(excluded.seeds)
972
985
  const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
973
- const taintedRan = new Set<string>()
986
+ // Each task that ran tainted, and the task at the root of it.
987
+ const taintedRan = new Map<string, string>()
974
988
  const dependedOn = new Set<string>()
975
989
  for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
976
990
 
@@ -995,13 +1009,16 @@ async function runOnBus(
995
1009
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
996
1010
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
997
1011
  const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
998
- if (
999
- options.continueMode === 'always' &&
1000
- node.deps.some((d) => deadServerBehind(nodes, serverDied, d) !== undefined)
1001
- )
1002
- taintSeeds.add(node.id)
1012
+ let deadServer: string | undefined
1013
+ if (options.continueMode === 'always') {
1014
+ for (const d of node.deps) {
1015
+ deadServer = deadServerBehind(nodes, serverDied, d)
1016
+ if (deadServer !== undefined) break
1017
+ }
1018
+ if (deadServer !== undefined) taintSeeds.add(node.id)
1019
+ }
1003
1020
  const tainted = taint.judge(node, upstream)
1004
- if (tainted) taintedRan.add(node.id)
1021
+ if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
1005
1022
  const a: ExecuteArgs = {
1006
1023
  node,
1007
1024
  upstream,
@@ -1130,6 +1147,21 @@ async function runOnBus(
1130
1147
  ? Promise.resolve(kept)
1131
1148
  : executeWithDedup(node, keyUpstream(node, upstream))
1132
1149
  },
1150
+ // An unkeyed group runs nothing: settled in place, unless a taint
1151
+ // may ride it, which `buildExecuteArgs` records on dispatch.
1152
+ ...(options.continueMode !== 'always' && taintSeeds.size === 0
1153
+ ? {
1154
+ settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1155
+ isGroupTask(node) && node.config.cache === undefined
1156
+ ? unkeyedGroupOutcome(
1157
+ node,
1158
+ keyUpstream(node, upstream),
1159
+ shortCircuit.groupKeys.get(node.id),
1160
+ runStartHrTimeNs,
1161
+ )
1162
+ : undefined,
1163
+ }
1164
+ : {}),
1133
1165
  // A `schedule` plugin's weights; the scheduler keeps its structural
1134
1166
  // baseline as the tie-break. Empty map → baseline only.
1135
1167
  ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
@@ -1232,7 +1264,20 @@ async function runOnBus(
1232
1264
  // change the run's exit code — the run already happened.
1233
1265
  // Written again after the keep-alive wait: a kept server's crash or a
1234
1266
  // Ctrl-C there is the process's exit, and the first write said ok.
1267
+ let json: RunSummaryJson | undefined
1235
1268
  const summarize = async (runOk: boolean, final = list): Promise<void> => {
1269
+ if (options.json === true) {
1270
+ json = runSummaryJson({
1271
+ runId,
1272
+ startedAtMs: endedAtMsAtStart,
1273
+ endedAtMs,
1274
+ totalMs,
1275
+ ok: runOk,
1276
+ ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1277
+ outcomes: final,
1278
+ flaky,
1279
+ })
1280
+ }
1236
1281
  if (options.summarize === undefined) return
1237
1282
  // The rewrite after the keep-alive wait says nothing: the footer is
1238
1283
  // the run's last word.
@@ -1319,8 +1364,21 @@ async function runOnBus(
1319
1364
  // cache closes: its history is written once the wait has decided, or it
1320
1365
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1321
1366
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
1322
- if (stoppedBy === undefined && !historyAfterWait)
1367
+ // A failed task's output, for `vx last` and an agent: files beside the
1368
+ // index, so a refused write (a full disk, a read-only cache dir) costs
1369
+ // only that output.
1370
+ const recordFailures = (final: readonly TaskOutcome[]): void => {
1371
+ try {
1372
+ writeRunFailures(prepared.cacheDir, runId, final)
1373
+ } catch (err) {
1374
+ if (!isFsRefusal(err)) throw err
1375
+ log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
1376
+ }
1377
+ }
1378
+ if (stoppedBy === undefined && !historyAfterWait) {
1323
1379
  recordHistory(() => cache.recordRunBundle(records))
1380
+ recordFailures(list)
1381
+ }
1324
1382
  mark('record history')
1325
1383
  // Drain any still-in-flight background prefetches before closing the
1326
1384
  // cache handle — a prefetch ingesting into a closed SQLite DB would
@@ -1533,11 +1591,12 @@ async function runOnBus(
1533
1591
  local.close()
1534
1592
  }
1535
1593
  })
1594
+ recordFailures(final)
1536
1595
  }
1537
- return { ok: ok && first.code === 0, outcomes: final }
1596
+ return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
1538
1597
  }
1539
1598
 
1540
- return { ok, outcomes: list }
1599
+ return { ok, outcomes: list, ...(json && { json }) }
1541
1600
  } finally {
1542
1601
  // Idempotent. The status-line ticker starts in runStart, and every
1543
1602
  // call between it and the success path's runEnd is crash-isolated
@@ -1627,7 +1686,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1627
1686
  // The plan is the product and goes to stdout (`--dry=json` is parsed):
1628
1687
  // what a stage says on the way goes to stderr (C-6).
1629
1688
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1630
- const prepared = await prepareRun(options, log)
1689
+ const reasons = new Map<string, AffectedReason>()
1690
+ const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1631
1691
  try {
1632
1692
  if (prepared.unresolvedTasks.length > 0) {
1633
1693
  return {
@@ -1675,6 +1735,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1675
1735
  ...(await planRestorable(prepared, policy, options.forwardArgs)),
1676
1736
  })
1677
1737
  mark('plan')
1738
+ for (const t of planned.tasks) {
1739
+ const why = reasons.get(t.node.id)
1740
+ if (why !== undefined) t.affected = why
1741
+ }
1678
1742
  return planned
1679
1743
  } finally {
1680
1744
  // The plan called the cache and executor factories; teardown releases
@@ -1861,7 +1925,7 @@ export function projectNamed(
1861
1925
  /** The executed, keyed outcomes of a run — what flakiness is judged on. */
1862
1926
  function flakyCandidates(
1863
1927
  outcomes: readonly TaskOutcome[],
1864
- tainted: ReadonlySet<string>,
1928
+ tainted: ReadonlyMap<string, string>,
1865
1929
  ): FlakyCandidate[] {
1866
1930
  const out: FlakyCandidate[] = []
1867
1931
  for (const o of outcomes) {
@@ -1871,9 +1935,10 @@ function flakyCandidates(
1871
1935
  // and runs every time, so one bad network day would read as a flake for
1872
1936
  // thirty days. Groups do no work.
1873
1937
  if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
1874
- // Behind a failed dependency the failure is the dependency's: the key it
1875
- // had passed on read as a flake for thirty days.
1876
- if (o.status === 'failed' && tainted.has(o.node.id)) continue
1938
+ // Behind a failed dependency the outcome is not the key's: a failure is
1939
+ // the dependency's, and a pass built on bytes the key never named, so a
1940
+ // later failure relapsed from it and read as a flake for thirty days.
1941
+ if (tainted.has(o.node.id)) continue
1877
1942
  out.push({
1878
1943
  project: o.node.projectName,
1879
1944
  task: o.node.taskName,
@@ -43,3 +43,19 @@ export function notifyEscape(text: string): string {
43
43
  // eslint-disable-next-line no-control-regex -- the control range is the point
44
44
  return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
45
45
  }
46
+
47
+ /**
48
+ * Whether the terminal `env` names opens OSC 8 links. A terminal that does
49
+ * not know the sequence may print it, so only ones known to read it get it;
50
+ * tmux and screen pass it through only when configured to.
51
+ */
52
+ export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
53
+ if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
54
+ const program = env['TERM_PROGRAM']
55
+ if (program === 'tmux' || program === 'screen') return false
56
+ if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
57
+ if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
58
+ if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
59
+ if (env['KONSOLE_VERSION'] !== undefined) return true
60
+ return Number(env['VTE_VERSION'] ?? 0) >= 5000
61
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DependencySpecError,
3
3
  compileTaskPattern,
4
+ isGroupTask,
4
5
  isTaskPattern,
5
6
  parseDependencySpec,
6
7
  type DependencySpec,
@@ -76,6 +77,69 @@ export function filterUpstreamHashes(
76
77
  ])
77
78
  }
78
79
 
80
+ /**
81
+ * A filter selects among the task's own dependencies, never through a
82
+ * group: `dependsOn: ['build']` over a group `build` of `build.a` and
83
+ * `build.b` offers the filter `build`, not its members. A pattern that
84
+ * matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
85
+ * with that member's inputs: a stale hit under a green run. Such a spec
86
+ * is refused, once, over the graph every key site reads. One that matches
87
+ * nothing anywhere stays legal (a preset's pattern needn't match in every
88
+ * project). An exact name is refused earlier, at load, unless `dependsOn`
89
+ * names it (`assertFilterNamesDeclaredDeps`).
90
+ */
91
+ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
92
+ for (const node of nodes.values()) {
93
+ const filter = node.config.cache?.inputs?.tasks
94
+ if (filter === undefined || filter.length === 0) continue
95
+ const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
96
+ const groups = deps.filter(isGroupTask)
97
+ if (groups.length === 0) continue
98
+ for (const raw of filter) {
99
+ const spec = parseSpec(raw, node.id)
100
+ if (spec.negated) continue
101
+ const matches = specMatcher(spec)
102
+ const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
103
+ if (deps.some(hit)) continue
104
+ for (const group of groups) {
105
+ const member = groupMembers(group, nodes).find(hit)
106
+ if (member === undefined) continue
107
+ throw new UserError(
108
+ `${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
109
+ )
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ function parseSpec(raw: string, selfTaskId: string): DependencySpec {
116
+ try {
117
+ return parseDependencySpec(raw)
118
+ } catch (err) {
119
+ if (err instanceof DependencySpecError) {
120
+ throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
121
+ }
122
+ throw err
123
+ }
124
+ }
125
+
126
+ /** Every task a group stands for, through nested groups, each once. */
127
+ function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
128
+ const out: TaskNode[] = []
129
+ const seen = new Set<string>([group.id])
130
+ const stack = group.deps.toReversed()
131
+ while (stack.length > 0) {
132
+ const id = stack.pop()!
133
+ if (seen.has(id)) continue
134
+ seen.add(id)
135
+ const n = nodes.get(id)
136
+ if (n === undefined) continue
137
+ out.push(n)
138
+ if (isGroupTask(n)) stack.push(...n.deps.toReversed())
139
+ }
140
+ return out
141
+ }
142
+
79
143
  /**
80
144
  * One dependency as the key fold sees it: the task, and the value the
81
145
  * fold dedups by — its hash on the hash path, a structural stand-in for
@@ -123,16 +187,7 @@ export function selectFoldedDeps(
123
187
  ): FoldCandidate[] {
124
188
  if (filter === undefined) return [...deps]
125
189
 
126
- const specs: DependencySpec[] = filter.map((raw) => {
127
- try {
128
- return parseDependencySpec(raw)
129
- } catch (err) {
130
- if (err instanceof DependencySpecError) {
131
- throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
132
- }
133
- throw err
134
- }
135
- })
190
+ const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
136
191
 
137
192
  // Per-spec predicate, compiled once (exact compares + patterns).
138
193
  const matchers = specs.map((spec) => specMatcher(spec))
@@ -7,12 +7,66 @@ import { realpathSync } from 'node:fs'
7
7
  import os from 'node:os'
8
8
 
9
9
  export class UserError extends Error {
10
- constructor(message: string) {
10
+ /**
11
+ * What an agent branches on: `VX_E_…`, printed by a verb that answers
12
+ * JSON (`errorDocument`). The message is prose and may change; a code
13
+ * does not. `VX_E_REFUSED` is any refusal without a code of its own.
14
+ */
15
+ readonly code: string
16
+ // Two signatures, not an optional parameter: the package-api record
17
+ // keeps the one-argument form every plugin already calls.
18
+ constructor(message: string)
19
+ constructor(message: string, code: string)
20
+ constructor(message: string, code = 'VX_E_REFUSED') {
11
21
  super(message)
12
22
  this.name = 'UserError'
23
+ this.code = code
13
24
  }
14
25
  }
15
26
 
27
+ // A refusal that opens with the config that caused it: the schema's
28
+ // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
29
+ const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
30
+
31
+ /**
32
+ * The stable code of anything `bin.ts` catches. A UserError's own, but
33
+ * `VX_E_CONFIG` for a codeless one that names its config file; then the
34
+ * environment's refusals; anything else is a defect, `VX_E_INTERNAL`. A
35
+ * UserError from another copy of core has no `code` when that copy
36
+ * predates it.
37
+ */
38
+ export function errorCode(err: unknown): string {
39
+ if (isUserError(err)) {
40
+ const code = (err as { code?: unknown }).code
41
+ if (typeof code === 'string' && code !== 'VX_E_REFUSED') return code
42
+ return CONFIG_REFUSAL.test(err.message) ? 'VX_E_CONFIG' : 'VX_E_REFUSED'
43
+ }
44
+ if (isOutOfFds(err)) return 'VX_E_FDS'
45
+ if (isFsRefusal(err)) return 'VX_E_FS'
46
+ return 'VX_E_INTERNAL'
47
+ }
48
+
49
+ /**
50
+ * Does this argv ask for JSON (`--format json`, `--format=json`,
51
+ * `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
52
+ * reader of stdout gets an answer, not nothing. Args after `--` are the
53
+ * task's, not vx's.
54
+ */
55
+ export function wantsJson(argv: readonly string[]): boolean {
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]!
58
+ if (a === '--') return false
59
+ if (a === '--format=json' || a === '--dry=json') return true
60
+ if (a === '--format' && argv[i + 1] === 'json') return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ /** The JSON a verb that answers JSON prints for a refusal: one line. */
66
+ export function errorDocument(code: string, message: string): string {
67
+ return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
68
+ }
69
+
16
70
  /**
17
71
  * `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
18
72
  * core. A compiled `vx` binary carries core inside it while a plugin in the
package/src/util/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  export {
5
5
  DISK_FULL_HINT,
6
+ errorCode,
7
+ errorDocument,
6
8
  fsRefusalHint,
7
9
  isDiskFull,
8
10
  isFsRefusal,
@@ -17,6 +19,7 @@ export {
17
19
  isOutOfFds,
18
20
  notAWorkTree,
19
21
  OUT_OF_FDS_HINT,
22
+ wantsJson,
20
23
  } from './errors.js'
21
24
  export { configErrorFrame } from './config-frame.js'
22
25
  export { xxh3, xxh3hex } from './hash.js'
@@ -32,6 +32,12 @@ export function secretNamed(name: string): boolean {
32
32
  return secret
33
33
  }
34
34
 
35
+ /**
36
+ * A URL's password (`postgres://app:<pw>@db/app`): a credential whatever
37
+ * the variable is named, so `DATABASE_URL` and `REDIS_URL` printed it.
38
+ */
39
+ const URL_PASSWORD = /[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:([^\s/@]+)@/gi
40
+
35
41
  /**
36
42
  * Values shorter than this are not masked: `KEY=1` would mask every `1`
37
43
  * in every line, and a value that short is no secret worth the noise.
@@ -86,7 +92,13 @@ export function secretMask(
86
92
  }
87
93
  for (const source of sources) {
88
94
  if (source === undefined) continue
89
- for (const name in source) if (secretNamed(name)) add(source[name])
95
+ for (const name in source) {
96
+ const value = source[name]
97
+ if (secretNamed(name)) add(value)
98
+ if (value?.includes('://') === true && value.includes('@')) {
99
+ for (const m of value.matchAll(URL_PASSWORD)) add(m[1])
100
+ }
101
+ }
90
102
  for (const name of named) add(source[name])
91
103
  }
92
104
  if (values.size === 0) return null
@@ -165,6 +165,7 @@ export async function findWorkspaceRoot(
165
165
  `Could not find a workspace root in any parent of ${start} ` +
166
166
  `(looked for pnpm-workspace.yaml or package.json): run vx inside a project, ` +
167
167
  `or create a package.json (\`bun init\` or \`npm init -y\`) and run \`vx init\``,
168
+ 'VX_E_NO_WORKSPACE',
168
169
  )
169
170
  }
170
171