@vzn/vx 0.0.591 → 0.0.595

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 (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -77,10 +77,10 @@ export function pollWatcher(
77
77
  */
78
78
  skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
79
79
  ): WatchHandle {
80
- let previous = new Map<string, number>()
80
+ let previous = new Map<string, string>()
81
81
  let first = true
82
82
  const scan = (): void => {
83
- const current = new Map<string, number>()
83
+ const current = new Map<string, string>()
84
84
  const walk = (abs: string, rel: string): void => {
85
85
  let entries: fs.Dirent[]
86
86
  try {
@@ -95,15 +95,23 @@ export function pollWatcher(
95
95
  if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
96
96
  continue
97
97
  }
98
- if (!e.isFile()) continue
98
+ // A link is an input as its target string (watch-judge.ts), so that
99
+ // string is sampled, never the target's times: a retarget inside
100
+ // one clock tick carries the same times.
101
+ const link = e.isSymbolicLink()
102
+ if (!e.isFile() && !link) continue
99
103
  try {
100
104
  // The later of the two clocks, as `modifiedBefore` reads them: a
101
105
  // replacement that carries the old file's mtime (`cp -p`, `rsync
102
106
  // -a`, `mv` of a file stamped the same) moved nothing under mtime
103
107
  // alone, and the poller never ran it where the native watcher did.
104
108
  // A rename or a write moves ctime, and no process can set it.
105
- const st = fs.statSync(path.join(abs, e.name))
106
- current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
109
+ const p = path.join(abs, e.name)
110
+ if (link) current.set(childRel, `link:${fs.readlinkSync(p)}`)
111
+ else {
112
+ const st = fs.statSync(p)
113
+ current.set(childRel, String(Math.max(st.mtimeMs, st.ctimeMs)))
114
+ }
107
115
  } catch {
108
116
  // raced with a delete; the next scan settles it
109
117
  }
@@ -111,8 +119,8 @@ export function pollWatcher(
111
119
  }
112
120
  walk(dir, '')
113
121
  if (!first) {
114
- for (const [rel, mtime] of current) {
115
- if (previous.get(rel) !== mtime) onEvent(rel)
122
+ for (const [rel, stamp] of current) {
123
+ if (previous.get(rel) !== stamp) onEvent(rel)
116
124
  }
117
125
  for (const rel of previous.keys()) {
118
126
  if (!current.has(rel)) onEvent(rel)
@@ -237,6 +245,12 @@ export function armWatcher(
237
245
  */
238
246
  function treeWatcher(root: string, listener: (filename: string) => void): WatchHandle {
239
247
  const watchers = new Map<string, fs.FSWatcher>()
248
+ // Which directory each watch holds: inotify follows the inode, so a name
249
+ // removed and made again before its event is handled stats as a
250
+ // directory still, and the watch on the deleted one heard nothing more.
251
+ // The birth time too: a freed inode number goes to the next directory.
252
+ const held = new Map<string, string>()
253
+ const idOf = (st: fs.Stats): string => `${st.dev}:${st.ino}:${st.birthtimeMs}`
240
254
  let closed = false
241
255
  let armed = false
242
256
  let warned = false
@@ -249,6 +263,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
249
263
  if (key !== rel && !key.startsWith(rel + '/')) continue
250
264
  w.close()
251
265
  watchers.delete(key)
266
+ held.delete(key)
252
267
  }
253
268
  }
254
269
  const watchDir = (rel: string, report: boolean): void => {
@@ -269,7 +284,10 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
269
284
  return
270
285
  }
271
286
  if (!st.isDirectory()) return
272
- if (watchers.has(child)) return
287
+ if (watchers.has(child)) {
288
+ if (held.get(child) === idOf(st)) return
289
+ drop(child)
290
+ }
273
291
  watchDir(child, true)
274
292
  })
275
293
  } catch (err) {
@@ -289,6 +307,11 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
289
307
  }
290
308
  w.on('error', () => drop(rel))
291
309
  watchers.set(rel, w)
310
+ try {
311
+ held.set(rel, idOf(fs.statSync(abs)))
312
+ } catch {
313
+ held.set(rel, '')
314
+ }
292
315
  let entries: fs.Dirent[]
293
316
  try {
294
317
  entries = fs.readdirSync(abs, { withFileTypes: true })
@@ -314,6 +337,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
314
337
  closed = true
315
338
  for (const w of watchers.values()) w.close()
316
339
  watchers.clear()
340
+ held.clear()
317
341
  },
318
342
  }
319
343
  }
@@ -77,7 +77,7 @@ export class ChangeJudge {
77
77
  // are judged one window after the run ends, all together — an edit made
78
78
  // meanwhile still differs from what the loop last saw and re-runs.
79
79
  readonly pending = new Map<string, string>()
80
- /** The last cycle's wall window; a write inside it is the run's own. */
80
+ /** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
81
81
  lastCycle: { start: number; end: number } | undefined
82
82
  // Declared outputs are ignored by PATH above. A task with no `cache`
83
83
  // block declares none and still writes into its project, and the
package/src/cli/watch.ts CHANGED
@@ -55,6 +55,12 @@ function sayCannot(what: string, err: unknown): void {
55
55
 
56
56
  /** Wait this long after the last filesystem event before re-running. */
57
57
  const DEBOUNCE_MS = 150
58
+ /**
59
+ * ...but no longer than this after the first: a writer that never pauses
60
+ * for a window (a dev server logging into its project every 50 ms) reset
61
+ * the timer forever, and an edit made meanwhile never ran.
62
+ */
63
+ const DEBOUNCE_MAX_MS = 1_000
58
64
 
59
65
  /**
60
66
  * The run flags a watch loop cannot honour, as the refusal line it prints; null
@@ -80,7 +86,7 @@ export function watchRefusal(parsed: RunArgs): string | null {
80
86
  export async function watchCmd(args: readonly string[]): Promise<number> {
81
87
  const parsed = parseRunArgs(args, 'watch')
82
88
  if (parsed.error) {
83
- process.stderr.write(`vx watch: ${parsed.error}\n`)
89
+ process.stderr.write(`vx watch: ${parsed.error}${seeHelp('watch')}\n`)
84
90
  return 1
85
91
  }
86
92
 
@@ -131,10 +137,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
131
137
  // worth; every cycle after an edit must load and list live.
132
138
  delete opts.staged
133
139
  delete opts.discovered
134
- process.once('SIGINT', () => {
135
- process.stdout.write('\nvx watch: stopped\n')
136
- stop.abort('SIGINT')
137
- })
140
+ process.once('SIGINT', () => stop.abort('SIGINT'))
141
+ // Said once the loop is down, the last line: written as the signal
142
+ // landed, the aborted cycle's summary printed below it.
143
+ const stopped = (): number => {
144
+ if (stop.signal.reason === 'SIGINT') process.stdout.write('\nvx watch: stopped\n')
145
+ return 0
146
+ }
138
147
  process.once('SIGTERM', () => stop.abort('SIGTERM'))
139
148
  // A task runs in its own session (exec/kill-tree.ts): the terminal
140
149
  // closing reaches the loop alone, and the loop passes it on.
@@ -200,7 +209,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
200
209
  }
201
210
  if (stop.signal.aborted) {
202
211
  await held?.stop(forwardedSignal(stop.signal.reason))
203
- return 0
212
+ return stopped()
204
213
  }
205
214
  if (refusedToStart) return 1
206
215
  // After the initial run, so a `pkg#task` naming no project gets the
@@ -228,7 +237,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
228
237
  ...fingerprintClaims(plugins).keys(),
229
238
  ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
230
239
  ])
231
- return await runWatchLoop({
240
+ await runWatchLoop({
232
241
  opts,
233
242
  held,
234
243
  stop: stop.signal,
@@ -279,6 +288,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
279
288
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
280
289
  cacheDir: opts.cacheDir ?? ws.cacheDir,
281
290
  })
291
+ return stopped()
282
292
  }
283
293
 
284
294
  interface WatchLoopArgs {
@@ -339,7 +349,7 @@ interface Rediscovered {
339
349
  fenceDirs: readonly string[]
340
350
  }
341
351
 
342
- async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
352
+ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
343
353
  const { opts, stop, workspaceRoot, projects, cacheDir } = args
344
354
  // The watched set as of the last cycle: `rearm` replaces these after an
345
355
  // event that can change it, and every filter below reads the current one.
@@ -365,11 +375,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
365
375
  /** The cycle in flight, so the stop path can wait for its teardown before resolving. */
366
376
  let inFlight: Promise<void> = Promise.resolve()
367
377
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
378
+ let windowOpened = 0
368
379
 
369
380
  const trigger = (label: string, abs: string): void => {
370
381
  if (!changes.pending.has(abs)) changes.pending.set(abs, label)
371
382
  if (running) return
372
- if (debounceTimer) clearTimeout(debounceTimer)
383
+ if (debounceTimer) {
384
+ if (Date.now() - windowOpened >= DEBOUNCE_MAX_MS) return
385
+ clearTimeout(debounceTimer)
386
+ } else windowOpened = Date.now()
373
387
  debounceTimer = setTimeout(() => {
374
388
  debounceTimer = null
375
389
  if (running) return
@@ -393,7 +407,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
393
407
  await held?.stop()
394
408
  held = undefined
395
409
  restartTimings()
396
- const start = Date.now()
410
+ // On the mtime clock, as the arm is: from `Date.now()` a write the
411
+ // run made within a tick of it carried an earlier mtime and read as
412
+ // an edit (WD-20). The end needs no stamp: an mtime never leads it.
413
+ const start = fsClockNow(cacheDir)
397
414
  const cycle = await runOrchestrator(opts)
398
415
  held = cycle.persistent
399
416
  if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
@@ -491,7 +508,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
491
508
  const handle = arm(proj.dir, true, (filename) => {
492
509
  const abs = path.join(proj.dir, filename)
493
510
  if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
494
- if (shapesWatchedSet(filename)) reread = true
511
+ // A module the config imports from inside the project is the config
512
+ // too: its edit can add an output or a `workspaceFiles` input.
513
+ if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
495
514
  trigger(`${proj.name} ${filename}`, abs)
496
515
  })
497
516
  perProject.set(proj.dir, handle)
@@ -512,8 +531,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
512
531
  // / .git / .vx and declared outputs out of what remains.
513
532
  arm(workspaceRoot, true, (filename) => {
514
533
  if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
515
- if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
516
- trigger(`root ${filename}`, path.join(workspaceRoot, filename))
534
+ const abs = path.join(workspaceRoot, filename)
535
+ if (
536
+ shapesWatchedSet(filename) ||
537
+ filename === LOCKFILE_NAME ||
538
+ configImportFiles.includes(abs)
539
+ )
540
+ reread = true
541
+ trigger(`root ${filename}`, abs)
517
542
  })
518
543
  : // The root itself, non-recursive, beside the per-project arms, so
519
544
  // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
@@ -820,7 +845,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
820
845
 
821
846
  process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
822
847
 
823
- return await new Promise<number>((resolve) => {
848
+ return await new Promise<void>((resolve) => {
824
849
  const cleanup = async (): Promise<void> => {
825
850
  pool.closeAll()
826
851
  if (debounceTimer) clearTimeout(debounceTimer)
@@ -828,7 +853,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
828
853
  // it has returned, so the process never exits over a live child.
829
854
  await inFlight
830
855
  await held?.stop(forwardedSignal(stop.reason))
831
- resolve(0)
856
+ resolve()
832
857
  }
833
858
  if (stop.aborted) {
834
859
  void cleanup()
package/src/cli/why.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import path from 'node:path'
9
9
  import { Cache } from '../cache/index.js'
10
- import { flagHint, seeHelp } from './help.js'
10
+ import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import { splitTaskId } from '../graph/index.js'
12
12
  import {
13
13
  cacheKeyDiff,
@@ -70,17 +70,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
70
70
  continue
71
71
  }
72
72
  if (a === '--format' || a.startsWith('--format=')) {
73
- const fv = a === '--format' ? args[++i] : a.slice(9)
74
- if (fv !== 'pretty' && fv !== 'json') {
75
- // An omitted value reads as empty, not as the literal word "undefined".
76
- return {
77
- ...out,
78
- error:
79
- fv === undefined || fv === ''
80
- ? '--format requires a value (pretty | json)'
81
- : `invalid --format: ${fv} (expected pretty | json)`,
82
- }
83
- }
73
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'why')
74
+ if (typeof fv === 'object') return { ...out, ...fv }
84
75
  out.format = fv
85
76
  continue
86
77
  }
@@ -235,7 +226,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
235
226
  const why = whyDidThisRerun(db, runId, taskId)
236
227
  if (!why.found) {
237
228
  throw new UserError(
238
- `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last <runId>\` what one ran`,
229
+ `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
239
230
  )
240
231
  }
241
232
  const diff = cacheKeyDiff(db, runId, taskId)
@@ -23,7 +23,7 @@ import {
23
23
  resolveStoreRoot,
24
24
  } from '../workspace/index.js'
25
25
 
26
- export interface CliWorkspace {
26
+ interface CliWorkspace {
27
27
  workspaceConfig: WorkspaceConfig | null
28
28
  plugins: readonly VxPlugin[]
29
29
  cacheDir: string
package/src/config.ts CHANGED
@@ -510,9 +510,9 @@ export interface CacheInputs {
510
510
  * project files" (gitignore-aware), or narrow as needed. An empty
511
511
  * array means no file inputs at all.
512
512
  *
513
- * Declared outputs and any nested-project files are excluded
514
- * automatically — a task cannot invalidate itself, and cannot read
515
- * across project boundaries.
513
+ * Declared outputs (both `outputs` fields) and any nested-project
514
+ * files are excluded automatically — a task cannot invalidate itself,
515
+ * and cannot read across project boundaries.
516
516
  */
517
517
  files: string[]
518
518
  /**
@@ -525,8 +525,8 @@ export interface CacheInputs {
525
525
  * NO project-boundary rule applies: these globs may match files
526
526
  * inside other projects' directories. Deliberate escape hatch —
527
527
  * prefer project-relative `files` declarations where possible.
528
- * Declared `outputs.workspaceFiles` are excluded automatically (a
529
- * task cannot invalidate itself).
528
+ * Declared outputs (both `outputs` fields) are excluded
529
+ * automatically (a task cannot invalidate itself).
530
530
  */
531
531
  workspaceFiles?: string[]
532
532
  /**
package/src/exec/env.ts CHANGED
@@ -33,6 +33,11 @@ export const ESSENTIAL_ENV: readonly string[] = [
33
33
  'NO_COLOR',
34
34
  'CI',
35
35
  'NODE_OPTIONS',
36
+ // Where corepack and pnpm keep their own binaries, as HOME is: a machine
37
+ // path, never task output. Without them a corepack shim re-downloads its
38
+ // manager (offline or sandboxed: fails) and pnpm loses its global dir.
39
+ 'COREPACK_HOME',
40
+ 'PNPM_HOME',
36
41
  ]
37
42
 
38
43
  /**
@@ -125,11 +130,9 @@ export function packageManagerPath(root: string): string | null {
125
130
  if (known !== undefined) return known
126
131
  let name: string | undefined
127
132
  try {
128
- const field = (
129
- JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as {
130
- packageManager?: unknown
131
- }
132
- ).packageManager
133
+ // A byte-order mark is stripped, as npm and discovery strip it.
134
+ const manifest = readFileSync(path.join(root, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
135
+ const field = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
133
136
  if (typeof field === 'string') name = MANAGERS.find((m) => field.startsWith(`${m}@`))
134
137
  } catch {}
135
138
  name ??= LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1]
package/src/exec/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  exitSignal,
19
19
  type CaptureConfig,
20
20
  execWord,
21
+ maskCaptured,
21
22
  } from './runner.js'
22
23
  export {
23
24
  initSandbox,
@@ -134,12 +134,16 @@ async function psTable(): Promise<Map<number, Proc> | undefined> {
134
134
  return table
135
135
  }
136
136
 
137
- /** `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths. */
137
+ /**
138
+ * `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths after
139
+ * the locale's decimal point — `0:01,50` under a German or French locale.
140
+ */
138
141
  export function psTimeMs(s: string): number | undefined {
139
142
  const dash = s.indexOf('-')
140
143
  const days = dash === -1 ? 0 : Number(s.slice(0, dash))
141
144
  const parts = s
142
145
  .slice(dash + 1)
146
+ .replace(',', '.')
143
147
  .split(':')
144
148
  .map(Number)
145
149
  if (parts.length < 2 || parts.some((n) => !Number.isFinite(n)) || !Number.isFinite(days)) {