@vzn/vx 0.0.361 → 0.0.363

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.361",
3
+ "version": "0.0.363",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.361",
25
- "@vzn/vx-linux-arm64": "0.0.361",
26
- "@vzn/vx-darwin-x64": "0.0.361",
27
- "@vzn/vx-darwin-arm64": "0.0.361"
24
+ "@vzn/vx-linux-x64": "0.0.363",
25
+ "@vzn/vx-linux-arm64": "0.0.363",
26
+ "@vzn/vx-darwin-x64": "0.0.363",
27
+ "@vzn/vx-darwin-arm64": "0.0.363"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -1590,8 +1590,24 @@ export class Cache implements CacheLayer {
1590
1590
  // whole. A commit that fails after the rename takes the artifact back
1591
1591
  // out: the old rows then name no artifact, which a probe reads as a
1592
1592
  // miss.
1593
+ //
1594
+ // A previous artifact is moved aside first, never renamed over: ext4
1595
+ // flushes the incoming file's delayed blocks when a rename replaces a
1596
+ // file, 0.55 ms against 0.04 on the main thread with the write lock
1597
+ // held (a `--force` run, 2026-10-02). A reader probing between the two
1598
+ // renames finds no artifact, a miss (`ArtifactVanishedError`); one
1599
+ // holding the old file still reads it whole. The aside name is a temp
1600
+ // name, so a crash before its unlink leaves an orphan the sweep takes.
1601
+ const aside = this.tempPath(hash)
1602
+ let displaced = false
1593
1603
  let renamed = false
1594
1604
  const tx = this.db.transaction(() => {
1605
+ try {
1606
+ renameSync(finalPath, aside)
1607
+ displaced = true
1608
+ } catch (err) {
1609
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
1610
+ }
1595
1611
  renameSync(tmpPath, finalPath)
1596
1612
  renamed = true
1597
1613
  insertEntry.run(
@@ -1628,11 +1644,13 @@ export class Cache implements CacheLayer {
1628
1644
  try {
1629
1645
  tx.immediate()
1630
1646
  } catch (err) {
1647
+ endTx()
1631
1648
  await unlink(renamed ? finalPath : tmpPath).catch(() => undefined)
1649
+ if (displaced) await unlink(aside).catch(() => undefined)
1632
1650
  throw err
1633
- } finally {
1634
- endTx()
1635
1651
  }
1652
+ endTx()
1653
+ if (displaced) await unlink(aside).catch(() => undefined)
1636
1654
  }
1637
1655
 
1638
1656
  /** Apply the deferred accessed_at bumps in one statement. */
package/src/cli/lock.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
6
  import { flagHint, refusedWord, seeHelp } from './help.js'
7
- import { relPosix, xxh3hex } from '../util/index.js'
7
+ import { relPosix, secretMask, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
10
10
  type LoadReads,
@@ -86,6 +86,15 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
86
86
  const projects: Record<string, LockfileEntry> = {}
87
87
  // `listProjects` sorts by name — stable lockfile diffs for free.
88
88
  for (let i = 0; i < metas.length; i++) projects[metas[i]!.name] = entries[i]!
89
+ const leaks = secretsIn(projects)
90
+ if (leaks.length > 0) {
91
+ process.stderr.write(
92
+ `vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
93
+ leaks.map((l) => ` ${l}\n`).join('') +
94
+ `let the shell expand it ($API_TOKEN in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
95
+ )
96
+ return 1
97
+ }
89
98
  const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
90
99
  await writeLockfile(root, lock)
91
100
  const n = metas.length
@@ -95,6 +104,40 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
95
104
  return 0
96
105
  }
97
106
 
107
+ /**
108
+ * Each place an evaluated config holds the value of a secret in this
109
+ * environment, by the rule output masking uses (a secret-named variable,
110
+ * or one a task lists in `exec.env.secret`). The lock is committed, so
111
+ * such a value would be published, and masking it would freeze a `***`
112
+ * that `--frozen` runs (L-42).
113
+ */
114
+ function secretsIn(projects: Record<string, LockfileEntry>): string[] {
115
+ const named = new Set<string>()
116
+ for (const entry of Object.values(projects)) {
117
+ for (const task of Object.values(entry.config.tasks ?? {}))
118
+ for (const n of task?.exec?.env?.secret ?? []) named.add(n)
119
+ }
120
+ const secrets: [value: string, name: string][] = []
121
+ for (const [name, value] of Object.entries(process.env)) {
122
+ if (value !== undefined && secretMask([{ [name]: value }], named.has(name) ? [name] : []))
123
+ secrets.push([value, name])
124
+ }
125
+ if (secrets.length === 0) return []
126
+ const found: string[] = []
127
+ const walk = (project: string, at: string, v: unknown): void => {
128
+ if (typeof v === 'string') {
129
+ for (const [value, name] of secrets)
130
+ if (v.includes(value)) found.push(`${project}: ${at} holds $${name}`)
131
+ } else if (Array.isArray(v)) {
132
+ v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
133
+ } else if (v !== null && typeof v === 'object') {
134
+ for (const [k, x] of Object.entries(v)) walk(project, at === '' ? k : `${at}.${k}`, x)
135
+ }
136
+ }
137
+ for (const [project, entry] of Object.entries(projects)) walk(project, '', entry.config)
138
+ return found
139
+ }
140
+
98
141
  /**
99
142
  * Audit the lock. Two layers, strictly stronger than what runs do:
100
143
  * 1. The same hash check runs perform (file bytes vs lock hash).
@@ -10,7 +10,7 @@
10
10
  import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
11
11
  import { findWorkspaceRoot } from '../workspace/index.js'
12
12
  import { loadCliWorkspace } from './workspace-config.js'
13
- import { machineParallelism } from '../util/index.js'
13
+ import { machineParallelism, maskedLine } from '../util/index.js'
14
14
 
15
15
  export interface ResolvedPluginCommand {
16
16
  plugin: VxPlugin
@@ -92,7 +92,7 @@ export async function resolvePluginCommand(
92
92
  workspaceRoot: ws.workspaceRoot,
93
93
  cacheDir: ws.cacheDir,
94
94
  concurrency: ws.concurrency,
95
- warn: (m) => process.stderr.write(`${m}\n`),
95
+ warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
96
96
  },
97
97
  }
98
98
  }
package/src/cli/select.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  import type { ProjectConfig } from '../config.js'
26
26
  import type { ProjectEntry } from '../workspace/index.js'
27
27
  import { parseDependencySpec } from '../graph/index.js'
28
- import { nearest, UserError } from '../util/index.js'
28
+ import { maskedLine, nearest, UserError } from '../util/index.js'
29
29
  import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
30
30
  import {
31
31
  type CliLoadOptions,
@@ -104,7 +104,7 @@ async function workspaceFingerprintClaims(
104
104
  const ctx = {
105
105
  workspaceRoot: root,
106
106
  cacheDir: load.cacheDir ?? ws.cacheDir,
107
- warn: (m: string) => process.stderr.write(`${m}\n`),
107
+ warn: (m: string) => process.stderr.write(`${maskedLine(m)}\n`),
108
108
  projects: projects.map((p) => ({ name: p.name, dir: p.dir })),
109
109
  }
110
110
  return {
@@ -333,3 +333,40 @@ export function buildCustomConfig(
333
333
  }
334
334
  return custom
335
335
  }
336
+
337
+ /**
338
+ * Linux: SRT's bwrap line with each `--tmpfs` it lays over a read-denied
339
+ * directory (the workspace root, a sibling's tree, the cwd with no read
340
+ * grant) remounted read-only once the mounts beneath it are made. bwrap's
341
+ * tmpfs is writable, so a write there succeeded and vanished with the
342
+ * sandbox: a task that wrote `../../dist` went green with its output gone,
343
+ * where seatbelt refuses the same write. Now it is `EROFS` on both, and a
344
+ * failed task's hint names the path. The mask a `scratch` glob's writes
345
+ * land in stays writable: that is the grant (`scratchWrites`). Placed
346
+ * before the `--` that ends bwrap's options; the words are SRT's own
347
+ * quoting, copied as they are.
348
+ */
349
+ export function readOnlyMasks(wrapped: string, scratch: readonly string[] = []): string {
350
+ const masks: string[] = []
351
+ let prev = ''
352
+ for (const m of wrapped.matchAll(/(?:'[^']*'|"[^"]*"|[^\s'"])+/g)) {
353
+ if (m[0] === '--') {
354
+ const held = scratch.map((g) => {
355
+ const dir = toRealPath(path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS) + 1)))
356
+ const holding = masks.filter((w) => atOrUnder(dir, unquoted(w)))
357
+ return holding.sort((a, b) => unquoted(b).length - unquoted(a).length)[0]
358
+ })
359
+ const ro = masks.filter((w) => !held.includes(w))
360
+ if (ro.length === 0) return wrapped
361
+ const words = ro.map((w) => `--remount-ro ${w} `).join('')
362
+ return `${wrapped.slice(0, m.index)}${words}${wrapped.slice(m.index)}`
363
+ }
364
+ if (prev === '--tmpfs') masks.push(m[0])
365
+ prev = m[0]
366
+ }
367
+ return wrapped
368
+ }
369
+
370
+ /** A word as SRT's `quote` spells it, read back: single-quoted runs and `"'"`. */
371
+ const unquoted = (word: string): string =>
372
+ word.replace(/'([^']*)'|"([^"]*)"/g, (_, a: string | undefined, b: string | undefined) => a ?? b!)
@@ -70,6 +70,7 @@ import {
70
70
  bindableReads,
71
71
  bindableWrites,
72
72
  buildCustomConfig,
73
+ readOnlyMasks,
73
74
  scratchWrites,
74
75
  widenedEntries,
75
76
  } from './sandbox-binds.js'
@@ -1065,7 +1066,8 @@ export async function wrapSandboxedCommand(
1065
1066
  // sandboxed server and all it forked outlived vx (turborepo#9666). Now
1066
1067
  // the namespace goes with vx, a `setsid` daemon inside included, a
1067
1068
  // traced one-shot task too: its strace runs inside (B-11).
1068
- if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped)) wrapped = `exec ${wrapped}`
1069
+ if (process.platform === 'linux' && /^\S*bwrap /.test(wrapped))
1070
+ wrapped = `exec ${readOnlyMasks(wrapped, scratch)}`
1069
1071
  if (process.platform === 'linux' && !hostHasIpv6())
1070
1072
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
1071
1073
  if (args.server === true) liveServers.add(tag)
@@ -6,7 +6,7 @@ import {
6
6
  OUT_OF_FDS_HINT,
7
7
  } from '../util/index.js'
8
8
  import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
9
- import type { TaskNode } from './task-graph.js'
9
+ import { isGroupTask, type TaskNode } from './task-graph.js'
10
10
 
11
11
  /**
12
12
  * Thrown by `execute` for a restore-tier task that turned out to have
@@ -181,6 +181,14 @@ export interface ScheduleOptions {
181
181
  * be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
182
182
  */
183
183
  continueMode?: ContinueMode
184
+ /**
185
+ * A failure the graph's outcomes do not hold yet: a persistent task
186
+ * that died after it became ready. Its outcome said `success` when it
187
+ * was ready, so this is how a dependant not yet started learns of it:
188
+ * under 'deps-ok' it is skipped as below a failed task, and under
189
+ * 'never' it stops dispatch.
190
+ */
191
+ serverDied?: (id: string) => boolean
184
192
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
185
193
  onStart?: (node: TaskNode) => void
186
194
  onFinish?: (outcome: TaskOutcome) => void
@@ -563,8 +571,32 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
563
571
  // they're dep-independent, so they typically restore before a dep
564
572
  // could fail anyway.
565
573
  const aborted = (): boolean => options.signal?.aborted === true
574
+ const serverDied = options.serverDied ?? ((): boolean => false)
575
+ // The dead server a dependency stands for: itself, or one a group
576
+ // reaches. A group is a name for its deps, and it finished the moment
577
+ // the server was ready, so a task behind it ran against the dead one.
578
+ const deadServerVia = (id: string): string | undefined => {
579
+ if (serverDied(id)) return id
580
+ const n = nodes.get(id)
581
+ if (n === undefined || !isGroupTask(n)) return undefined
582
+ for (const d of n.deps) {
583
+ const dead = deadServerVia(d)
584
+ if (dead !== undefined) return dead
585
+ }
586
+ return undefined
587
+ }
588
+ const servers =
589
+ options.serverDied === undefined
590
+ ? []
591
+ : [...nodes.values()]
592
+ .filter((n) => n.config.exec?.persistent !== undefined)
593
+ .map((n) => n.id)
566
594
  const willSkip = (id: string): boolean => {
567
595
  if (failFastTripped || aborted()) return true
596
+ if (continueMode === 'never' && servers.some(serverDied)) {
597
+ failFastTripped = true
598
+ return true
599
+ }
568
600
  if (inRestoreTier(id)) return false
569
601
  if (continueMode === 'always') return false
570
602
  const node = nodes.get(id) as TaskNode
@@ -580,7 +612,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
580
612
  u?.status === 'failed' ||
581
613
  u?.status === 'skipped' ||
582
614
  u?.status === 'aborted' ||
583
- blockedRestores.has(d)
615
+ blockedRestores.has(d) ||
616
+ deadServerVia(d) !== undefined
584
617
  )
585
618
  })
586
619
  }
@@ -651,6 +684,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
651
684
  const viaRestore = node.deps.find((d) => blockedRestores.has(d))
652
685
  const blockedBy =
653
686
  blocker?.node.id ??
687
+ node.deps.map(deadServerVia).find((d) => d !== undefined) ??
654
688
  viaSkip?.blockedBy ??
655
689
  (viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
656
690
  finishOne(id, {
@@ -9,7 +9,7 @@
9
9
  import { exitSignal } from '../exec/index.js'
10
10
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
11
11
  import { isGroupTask } from '../graph/index.js'
12
- import { maskedCommand } from '../util/index.js'
12
+ import { maskedCommand, secretMask } from '../util/index.js'
13
13
  import type { Logger } from './logger.js'
14
14
  import type { RunContext } from './summary.js'
15
15
 
@@ -103,8 +103,11 @@ export function busLogger(bus: EventBus): Logger {
103
103
  // run() reaches runEnd on its success path, in its finally and from a
104
104
  // signal; a plugin's onRunEnd hears the first only.
105
105
  let ended = false
106
+ // A status line carries plugin warnings, and a remote layer's warning
107
+ // quotes the server's reply, which may echo what it was sent (L-40).
108
+ const secrets = secretMask([process.env])
106
109
  return {
107
- status: (line) => bus.emit({ kind: 'run:status', line }),
110
+ status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
108
111
  taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
109
112
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
110
113
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
@@ -821,7 +821,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
821
821
  })
822
822
  .catch(async (raw: unknown) => {
823
823
  const err = nameExecutorFailure(args.executor, raw)
824
- const message = err instanceof Error ? err.message : String(err)
824
+ // A remote executor's message carries the server's own text, which
825
+ // may echo the env it was sent: masked here, where it is printed,
826
+ // and on the error the scheduler prints with its cause (L-39).
827
+ if (secrets !== null) {
828
+ for (const e of [err, err instanceof Error ? err.cause : undefined])
829
+ if (e instanceof Error) e.message = secrets.mask(e.message)
830
+ }
831
+ const message =
832
+ err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
825
833
  log.taskStderr(node, `${message}\n`)
826
834
  await sweepPlaceholders(placeholders)
827
835
  throw err
@@ -358,6 +358,8 @@ export interface PluginContext {
358
358
  readonly workspaceRoot: string
359
359
  /** Where vx's cache lives — read-only as far as the plugin is concerned. */
360
360
  readonly cacheDir: string
361
+ /** Funnel warnings into the run:status channel, as every hook's context does. */
362
+ warn(message: string): void
361
363
  /**
362
364
  * The run event bus. A plugin can subscribe directly if its needs exceed
363
365
  * the hooks; the subscription ends with the run, as a hook's does.
@@ -539,6 +541,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
539
541
  const ctx: PluginContext = {
540
542
  workspaceRoot,
541
543
  cacheDir,
544
+ warn,
542
545
  // A subscription made past the hooks leaves with the run like a
543
546
  // hook's does: the bus can outlive the run (`RunOptions.bus`), and
544
547
  // one left behind hears every later run on it (item 635).
@@ -693,7 +693,11 @@ async function runOnBus(
693
693
  vxVersion: VERSION,
694
694
  // An embedder's `command` is redacted as the argv is: it passed a
695
695
  // token after `--` to every sink verbatim (C-67).
696
- command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
696
+ // Masked as the stored line is: `--tag key=$DEPLOY_KEY` sits before
697
+ // the `--` the count covers (L-44).
698
+ command: maskInvocation(
699
+ invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
700
+ ),
697
701
  requestedTasks: [...options.tasks],
698
702
  cachePolicy: compactCachePolicy(policy),
699
703
  concurrency,
@@ -922,6 +926,12 @@ async function runOnBus(
922
926
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
923
927
  ...(admit !== undefined ? { admit } : {}),
924
928
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
929
+ // A server that ended on its own is failed (the end of the run says
930
+ // so); what has not yet started hears it before it dispatches (C-88).
931
+ serverDied: (id) => {
932
+ const child = persistentRegistry.get(id)
933
+ return child !== undefined && hasEnded(child) && child.exitCode !== 0
934
+ },
925
935
  signal: stopRun.signal,
926
936
  onStart: (node) => {
927
937
  log.taskStart?.(node)
@@ -3,6 +3,7 @@
3
3
  // (bwrap cannot bind a path that does not exist). Shared by the cached path
4
4
  // (through the executor) and the persistent path (spawned in execute-task).
5
5
 
6
+ import { existsSync } from 'node:fs'
6
7
  import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
7
8
  import { homedir } from 'node:os'
8
9
  import path from 'node:path'
@@ -254,7 +255,13 @@ export async function sandboxRequestFor(
254
255
  // 0.0.76). A grant naming a wall is not strictly outside it and wins.
255
256
  // Without it a root task's `read: ['.']` read its nested projects under
256
257
  // seatbelt (B-4).
257
- baseDenyRead: process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot],
258
+ // The host's credential stores are denied too: the rest of home stays
259
+ // readable (tools need `~/.cache`), but a dependency the task runs
260
+ // could copy a key into an output the cache shares (L-41).
261
+ baseDenyRead: [
262
+ ...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
263
+ ...credentialStores(workspaceRoot),
264
+ ],
258
265
  // …but only denials INSIDE the project are worth reporting. A task
259
266
  // bumping into the wall is the sandbox working, not a finding: the
260
267
  // walk `bun build --compile` makes from `/` down to its cwd lists
@@ -555,6 +562,44 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
555
562
  )
556
563
  }
557
564
 
565
+ /**
566
+ * Where tools keep credentials under the user's home. A sandboxed task
567
+ * reads none of them unless its `allow.read` names one (a publish task's
568
+ * `~/.npmrc`).
569
+ */
570
+ const CREDENTIAL_STORES = [
571
+ '.ssh',
572
+ '.gnupg',
573
+ '.aws',
574
+ '.azure',
575
+ '.kube',
576
+ '.config/gcloud',
577
+ '.config/gh',
578
+ '.docker/config.json',
579
+ '.netrc',
580
+ '.git-credentials',
581
+ '.npmrc',
582
+ '.yarnrc.yml',
583
+ '.pypirc',
584
+ ]
585
+
586
+ /** The stores present on this host, learned once per home. */
587
+ let presentStores: { home: string; paths: string[] } | undefined
588
+
589
+ /** The credential stores to deny, less any that holds the workspace. */
590
+ function credentialStores(workspaceRoot: string): string[] {
591
+ const home = homedir()
592
+ if (presentStores?.home !== home) {
593
+ presentStores = {
594
+ home,
595
+ paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
596
+ }
597
+ }
598
+ return presentStores.paths.filter(
599
+ (p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
600
+ )
601
+ }
602
+
558
603
  /** `~/x` against the user's home; anything else unchanged. */
559
604
  function expandHome(p: string): string {
560
605
  return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
package/src/util/index.ts CHANGED
@@ -55,4 +55,11 @@ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.
55
55
  export { executablePath, shellArgv } from './which.js'
56
56
  export { procfsIsOwn } from './procfs.js'
57
57
  export { realPath } from './real-path.js'
58
- export { maskedCommand, maskedEmitter, MASKED, secretMask, type SecretMask } from './secret-mask.js'
58
+ export {
59
+ maskedCommand,
60
+ maskedEmitter,
61
+ maskedLine,
62
+ MASKED,
63
+ secretMask,
64
+ type SecretMask,
65
+ } from './secret-mask.js'
@@ -124,6 +124,11 @@ export function maskedCommand(command: string, env?: TaskEnvSecrets): string {
124
124
  return secretMask([process.env, env?.define], env?.secret)?.mask(command) ?? command
125
125
  }
126
126
 
127
+ /** A line vx prints for no one task (a plugin's warning): this process's secrets masked. */
128
+ export function maskedLine(line: string): string {
129
+ return secretMask([process.env])?.mask(line) ?? line
130
+ }
131
+
127
132
  /** What of a task's `exec.env` names its secrets. */
128
133
  export interface TaskEnvSecrets {
129
134
  readonly define?: Readonly<Record<string, string>>
@@ -123,28 +123,32 @@ export interface FingerprintClaims {
123
123
  * or `<since>` doesn't resolve to a commit.
124
124
  */
125
125
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
126
+ // Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
127
+ // vx already diffs from the merge base (three dots' meaning), and the
128
+ // working tree it diffs to holds HEAD (D-117). Any other range is refused.
129
+ const since = /^(.+)\.\.\.HEAD$/.exec(args.since)?.[1] ?? args.since
126
130
  // The base reaches git as an argument, never through a shell, so `$(…)`
127
131
  // is opaque — but an option-like value is not: `--output=<path>` is a
128
132
  // real `git diff` option and an arbitrary file write. This is a security
129
133
  // boundary, so it is a check that knows it is one, before any spawn, and
130
134
  // every git call below also ends its options (`--end-of-options`) so a
131
135
  // second caller cannot lose the guard by accident.
132
- if (args.since.length === 0 || args.since.startsWith('-')) {
136
+ if (since.length === 0 || since.startsWith('-')) {
133
137
  throw new UserError(
134
- `git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
138
+ `git ref "${since}" is not a ref: a base cannot be empty or start with "-".`,
135
139
  )
136
140
  }
137
141
  // `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
138
142
  // the user read "did not resolve" about refs that both exist. `..` is
139
143
  // illegal in a ref name (git-check-ref-format), so this refuses no ref.
140
- const range = args.since.indexOf('..')
144
+ const range = since.indexOf('..')
141
145
  if (range >= 0) {
142
146
  throw new UserError(
143
- `git ref "${args.since}" is a range: ranges are not supported — pass the base alone ` +
144
- `("${args.since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
147
+ `git ref "${since}" is a range: ranges are not supported — pass the base alone ` +
148
+ `("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
145
149
  )
146
150
  }
147
- await verifyRef(args.workspaceRoot, args.since)
151
+ await verifyRef(args.workspaceRoot, since)
148
152
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
149
153
  // on a branch whose base has moved on, `git diff <base>` reports every
150
154
  // file OTHER people changed on the base (over-selection that defeats a
@@ -152,7 +156,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
152
156
  // byte-identical content. Turbo and Nx both diff from the merge base;
153
157
  // when there is none (unrelated histories, a detached probe) the ref
154
158
  // itself is the base, as before.
155
- const base = await mergeBase(args.workspaceRoot, args.since)
159
+ const base = await mergeBase(args.workspaceRoot, since)
156
160
 
157
161
  const [diffed, untracked] = await Promise.all([
158
162
  // `--no-renames` is crucial for project-affected detection: with
@@ -296,7 +296,7 @@ function servesCommand(command: string): boolean {
296
296
  /**
297
297
  * Whether script `name` never exits: by name, as a watcher, as a server, or
298
298
  * running such a script of its own package by name (docusaurus's
299
- * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`).
299
+ * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`) or through a runner.
300
300
  */
301
301
  function isPersistent(
302
302
  name: string,
@@ -307,8 +307,13 @@ function isPersistent(
307
307
  if (typeof own !== 'string' || seen.has(name)) return false
308
308
  seen.add(name)
309
309
  if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
310
+ // Through a runner too: `concurrently "npm:web" "npm:api"` and `run-p web
311
+ // api` over two servers mapped as one-shot tasks (D-113).
312
+ const text = Object.entries(scripts).filter(
313
+ (e): e is [string, string] => typeof e[1] === 'string',
314
+ )
310
315
  return foreground(own).some((segment) =>
311
- [...segment.matchAll(RUNS_SCRIPT)].some((m) => isPersistent(m[1]!, scripts, seen)),
316
+ scriptRefs(segment, text).some((ref) => isPersistent(ref, scripts, seen)),
312
317
  )
313
318
  }
314
319