@vzn/vx 0.0.596 → 0.0.597

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 (64) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +35 -6
  4. package/src/cache/cache.ts +31 -15
  5. package/src/cache/git-inputs.ts +23 -0
  6. package/src/cache/index.ts +2 -0
  7. package/src/cache/inputs.ts +87 -32
  8. package/src/cache/key-fold.ts +1 -1
  9. package/src/cache/layer.ts +2 -1
  10. package/src/cli/cache.ts +2 -2
  11. package/src/cli/foreign-flags.ts +56 -2
  12. package/src/cli/info.ts +1 -1
  13. package/src/cli/init.ts +2 -2
  14. package/src/cli/last.ts +6 -3
  15. package/src/cli/lock.ts +5 -3
  16. package/src/cli/plan-format.ts +4 -4
  17. package/src/cli/run.ts +42 -15
  18. package/src/cli/select.ts +6 -3
  19. package/src/cli/show.ts +8 -2
  20. package/src/cli/upgrade.ts +1 -1
  21. package/src/cli/watch-fs.ts +11 -3
  22. package/src/cli/watch.ts +14 -7
  23. package/src/cli/workspace-config.ts +3 -1
  24. package/src/config.ts +21 -0
  25. package/src/exec/executor.ts +1 -1
  26. package/src/exec/kill-tree.ts +89 -18
  27. package/src/exec/runner.ts +74 -36
  28. package/src/exec/sandbox-paths.ts +11 -0
  29. package/src/exec/sandbox-runtime.ts +168 -49
  30. package/src/exec/sandbox-violations.ts +59 -6
  31. package/src/graph/scheduler.ts +2 -2
  32. package/src/graph/task-graph.ts +145 -33
  33. package/src/orchestrator/affected-tasks.ts +6 -2
  34. package/src/orchestrator/deferred-outputs.ts +3 -2
  35. package/src/orchestrator/doctor.ts +2 -2
  36. package/src/orchestrator/excluded-keys.ts +2 -13
  37. package/src/orchestrator/execute-task.ts +49 -26
  38. package/src/orchestrator/failure-mode.ts +38 -29
  39. package/src/orchestrator/framed-output.ts +5 -5
  40. package/src/orchestrator/index.ts +0 -1
  41. package/src/orchestrator/keyed-projects.ts +6 -3
  42. package/src/orchestrator/logger.ts +10 -3
  43. package/src/orchestrator/miss-save.ts +7 -11
  44. package/src/orchestrator/plan.ts +4 -20
  45. package/src/orchestrator/run-artifacts.ts +2 -2
  46. package/src/orchestrator/run-lock.ts +25 -24
  47. package/src/orchestrator/run.ts +2 -1
  48. package/src/orchestrator/sandbox-request.ts +31 -9
  49. package/src/orchestrator/signals.ts +22 -4
  50. package/src/orchestrator/stable-keys.ts +3 -11
  51. package/src/orchestrator/summary.ts +5 -9
  52. package/src/orchestrator/task-hash.ts +13 -3
  53. package/src/orchestrator/upstream.ts +11 -0
  54. package/src/util/hangup.ts +42 -0
  55. package/src/util/index.ts +2 -0
  56. package/src/util/paths.ts +1 -1
  57. package/src/workspace/affected.ts +79 -42
  58. package/src/workspace/config-eval.ts +27 -6
  59. package/src/workspace/config-imports.ts +8 -55
  60. package/src/workspace/config-schema.ts +30 -6
  61. package/src/workspace/lockfile.ts +2 -1
  62. package/src/workspace/migrate-scripts.ts +1 -1
  63. package/src/workspace/project-loader.ts +15 -2
  64. package/src/workspace/workspace.ts +36 -14
package/launcher.cjs CHANGED
@@ -18,12 +18,16 @@
18
18
  // CommonJS, not ESM: Node's ESM loader cost every `vx` call ~8 ms of a
19
19
  // ~65 ms launch (min of 21, interleaved, Node 22, 2026-10-01).
20
20
  const { execFileSync, spawn, spawnSync } = require('node:child_process')
21
- const { existsSync, readFileSync } = require('node:fs')
21
+ const { existsSync, readFileSync, realpathSync } = require('node:fs')
22
22
  const { constants: osConstants } = require('node:os')
23
23
  const { dirname, join } = require('node:path')
24
24
 
25
- const here = __dirname
26
- const pkg = require('./package.json')
25
+ // The real directory, not `__dirname`: npm's `node_modules/.bin/vx` is a
26
+ // symlink to this file, and under `--preserve-symlinks-main` (NODE_OPTIONS)
27
+ // `__dirname` is `.bin`, where `./package.json` does not resolve and
28
+ // the launcher died MODULE_NOT_FOUND before vx started.
29
+ const here = dirname(realpathSync(__filename))
30
+ const pkg = require(join(here, 'package.json'))
27
31
 
28
32
  // Derive the platform-package prefix + binary basename from this package's
29
33
  // name. `base` is the unscoped name — the command AND the binary filename
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.596",
3
+ "version": "0.0.597",
4
4
  "description": "The fastest 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.596",
25
- "@vzn/vx-linux-arm64": "0.0.596",
26
- "@vzn/vx-darwin-x64": "0.0.596",
27
- "@vzn/vx-darwin-arm64": "0.0.596"
24
+ "@vzn/vx-linux-x64": "0.0.597",
25
+ "@vzn/vx-linux-arm64": "0.0.597",
26
+ "@vzn/vx-darwin-x64": "0.0.597",
27
+ "@vzn/vx-darwin-arm64": "0.0.597"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -233,7 +233,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
233
233
  const few = args.outputs.size <= ON_THREAD_STATS
234
234
  const lstatOf = few ? async (p: string) => lstatSync(p) : lstat
235
235
  const statOf = few ? async (p: string) => statSync(p) : stat
236
- const realpathOf = few ? async (p: string) => realpathSync(p) : realpath
236
+ const realpathOf = (p: string) => realOf(p, few)
237
237
  let withinReal: Promise<string> | undefined
238
238
  // Where this artifact's own non-link outputs really are: a link to one of
239
239
  // them packs bytes the task wrote itself. A root-anchored output tree is
@@ -262,6 +262,16 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
262
262
  : name.startsWith(WORKSPACE_OUTPUT_PREFIX)
263
263
  ? `workspaceFiles output ${name.slice(WORKSPACE_OUTPUT_PREFIX.length)}`
264
264
  : `output ${name}`
265
+ // The index stores a project row as its bare rel and reads one under
266
+ // `workspace-outputs/` as a workspace output, so every hit of this
267
+ // entry was "missing a recorded output", dropped and run again. The
268
+ // loader refuses a glob written under the name; one that reaches it
269
+ // otherwise (`**/*.js`, `w*/**`) is refused here, where names are known.
270
+ if (name.startsWith(`outputs/${WORKSPACE_OUTPUT_PREFIX}`)) {
271
+ throw new UserError(
272
+ `${shown} is under workspace-outputs/, a name vx's artifacts reserve for outputs.workspaceFiles — write the task's files to another directory, or take them back with a '!' entry`,
273
+ )
274
+ }
265
275
  const dangling = (err: NodeJS.ErrnoException): never => {
266
276
  if (err.code === 'ENOENT') {
267
277
  throw new UserError(
@@ -293,12 +303,12 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
293
303
  )
294
304
  }
295
305
  const mode = st.mode & 0o777
296
- meta.files[name] = [mode, Math.floor(st.mtimeMs)]
297
306
  return {
298
307
  name,
299
308
  abs: src,
300
309
  size: st.size,
301
310
  mode,
311
+ mtimeMs: Math.floor(st.mtimeMs),
302
312
  // ustar's octal field holds no sign: an mtime before 1970 made the
303
313
  // header unreadable and every save of it a "corrupt artifact". The
304
314
  // sidecar above carries the real value.
@@ -306,6 +316,8 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
306
316
  }
307
317
  }),
308
318
  )
319
+ // In the outputs' order, not as each stat settled: one tree, one artifact.
320
+ for (const f of files) meta.files[f.name] = [f.mode, f.mtimeMs]
309
321
  const stdout = new TextEncoder().encode(args.stdout)
310
322
  const metaBytes = new TextEncoder().encode(JSON.stringify(meta))
311
323
  const inputs: TarInput[] = [
@@ -624,11 +636,11 @@ class Extractor {
624
636
  // deepest EXISTING ancestor and re-append the rest: the answer is the
625
637
  // same before and after the base is created, which is what makes the
626
638
  // memo safe.
627
- r = await realpath(key).catch(async () => {
639
+ r = await realOf(key).catch(async () => {
628
640
  let probe = path.dirname(key)
629
641
  const tail: string[] = [path.basename(key)]
630
642
  while (probe !== path.dirname(probe)) {
631
- const real = await realpath(probe).then(
643
+ const real = await realOf(probe).then(
632
644
  (v) => v,
633
645
  () => null,
634
646
  )
@@ -674,7 +686,7 @@ class Extractor {
674
686
  // resolve was a round trip spent on a comparison never made (item 627).
675
687
  let probe = path.dirname(targetResolved)
676
688
  while (probe.startsWith(baseResolved + path.sep)) {
677
- const real = await realpath(probe).then(
689
+ const real = await realOf(probe).then(
678
690
  (r) => r,
679
691
  () => null,
680
692
  )
@@ -854,7 +866,7 @@ class Extractor {
854
866
  const top = path.resolve(s.created)
855
867
  // Created at a dangling link's resolved target (`mkdirThroughLink`),
856
868
  // so the chain is pruned from where the link leads.
857
- if (!dir.startsWith(top)) dir = await realpath(dir).catch(() => dir)
869
+ if (!dir.startsWith(top)) dir = await realOf(dir).catch(() => dir)
858
870
  while (dir.startsWith(top)) {
859
871
  if (
860
872
  !(await rmdir(dir).then(
@@ -903,6 +915,23 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
903
915
  )
904
916
  }
905
917
 
918
+ /**
919
+ * `realpath`, for a path holding a backslash too: Bun's answers ENOENT for
920
+ * any such path (1.4.2), so the containment walk took a link named
921
+ * `dist\x` for absent and wrote through it, and a save called a linked
922
+ * output under one dangling. Such a path's links are read one by one.
923
+ */
924
+ async function realOf(p: string, sync = false): Promise<string> {
925
+ try {
926
+ return sync ? realpathSync(p) : await realpath(p)
927
+ } catch (err) {
928
+ if (!p.includes('\\')) throw err
929
+ const real = await resolveThrough(p)
930
+ await stat(real)
931
+ return real
932
+ }
933
+ }
934
+
906
935
  /**
907
936
  * Where `p` leads, every link on the way followed, a dangling one included:
908
937
  * `realpath` for the part that exists, and past the first missing name the
@@ -662,7 +662,7 @@ export class Cache implements CacheLayer {
662
662
 
663
663
  /**
664
664
  * The store this open was asked for and could not use, with why: the
665
- * entries went to a store inside `cacheDir` instead.
665
+ * entries went to a store inside `cacheDir` instead. Never printed.
666
666
  */
667
667
  readonly storeFallback: string | null = null
668
668
 
@@ -849,24 +849,33 @@ export class Cache implements CacheLayer {
849
849
  this.schemaReset = this.db
850
850
  .transaction((): SchemaReset | null => {
851
851
  const found = readVersion()
852
- if (found !== undefined && found !== SCHEMA_VERSION) refuseUnreadable(found)
853
- if (found === undefined) {
854
- this.db
855
- .prepare("INSERT INTO schema_meta(key, value) VALUES ('version', ?)")
856
- .run(SCHEMA_VERSION)
857
- return null
858
- }
859
852
  if (found === SCHEMA_VERSION) return null
853
+ if (found !== undefined) refuseUnreadable(found)
860
854
  // `main.` on every name: unqualified, a table the index lacks
861
855
  // resolves to an attached store's (none is attached yet; this
862
856
  // keeps it so).
863
- this.db.exec(
864
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
865
- )
857
+ if (found !== undefined) {
858
+ this.db.exec(
859
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
860
+ )
861
+ }
862
+ // The tables land under the same write lock as the stamp: made
863
+ // after it, another vx's reset could stamp its own schema in
864
+ // between and keep these tables under it. An index beside a
865
+ // store keeps no entry tables (they would shadow the store's).
866
+ createTables(this.db, 'main')
867
+ const recordedStore = this.db
868
+ .prepare("SELECT 1 FROM schema_meta WHERE key = 'store_dir'")
869
+ .get()
870
+ if (storeRoot === undefined ? recordedStore != null : storeRoot !== null) {
871
+ for (const t of STORE_TABLES) this.db.exec(`DROP TABLE main.${t}`)
872
+ }
866
873
  this.db
867
- .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
874
+ .prepare(
875
+ "INSERT INTO schema_meta(key, value) VALUES ('version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
876
+ )
868
877
  .run(SCHEMA_VERSION)
869
- return { from: found, to: SCHEMA_VERSION }
878
+ return found === undefined ? null : { from: found, to: SCHEMA_VERSION }
870
879
  })
871
880
  .immediate()
872
881
  } catch (err) {
@@ -904,7 +913,8 @@ export class Cache implements CacheLayer {
904
913
  // the entries stay in this workspace rather than fail the run.
905
914
  const fallback = cacheDir
906
915
  if (fallback !== storeDir) {
907
- // Said by the open that falls back; the ones after it find it recorded.
916
+ // Set by the open that falls back, never printed; the ones after it
917
+ // find it recorded.
908
918
  if (recorded !== fallback) this.storeFallback = `${storeDir} (${blocked})`
909
919
  storeDir = fallback
910
920
  openCacheDir(fallback)
@@ -1769,6 +1779,9 @@ export class Cache implements CacheLayer {
1769
1779
  hash,
1770
1780
  `remote body runs past ${cap} bytes (the artifact ceiling's bound)`,
1771
1781
  )
1782
+ // Every refusal releases the remote body: a held response pins its
1783
+ // connection, and a layer that settles on the body's end never hears.
1784
+ let counted: ReadableStream<Uint8Array> | undefined
1772
1785
  try {
1773
1786
  if (body instanceof Blob) {
1774
1787
  if (body.size > cap) throw past()
@@ -1777,7 +1790,7 @@ export class Cache implements CacheLayer {
1777
1790
  throw past()
1778
1791
  } else {
1779
1792
  let n = 0
1780
- const counted = body.body?.pipeThrough(
1793
+ counted = body.body?.pipeThrough(
1781
1794
  new TransformStream<Uint8Array, Uint8Array>({
1782
1795
  transform(chunk, controller) {
1783
1796
  n += chunk.byteLength
@@ -1789,6 +1802,9 @@ export class Cache implements CacheLayer {
1789
1802
  await Bun.write(tmpPath, new Response(counted ?? null))
1790
1803
  }
1791
1804
  } catch (err) {
1805
+ // A failed write leaves the pipe's end unlocked; cancelling it
1806
+ // cancels the source. A body refused by its length is cancelled itself.
1807
+ if (!(body instanceof Blob)) void (counted ?? body.body)?.cancel(err).catch(() => undefined)
1792
1808
  await unlink(tmpPath).catch(() => undefined)
1793
1809
  throw err
1794
1810
  }
@@ -87,6 +87,29 @@ export function repoFacts(dir: string): RepoFacts | null {
87
87
  return facts
88
88
  }
89
89
 
90
+ /**
91
+ * The refusal a run would meet at its enumeration, asked up front by a
92
+ * verb that waits on the user first (the picker); undefined when git
93
+ * tracks `dir`. Free when it does: `repoFacts` is the answer the run
94
+ * reuses.
95
+ */
96
+ export function gitRefusal(dir: string): UserError | undefined {
97
+ if (repoFacts(dir) !== null) return undefined
98
+ let proc
99
+ try {
100
+ proc = Bun.spawnSync({
101
+ cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
102
+ cwd: dir,
103
+ stdout: 'ignore',
104
+ stderr: 'pipe',
105
+ })
106
+ } catch {
107
+ return gitSpawnRefusal(dir)
108
+ }
109
+ if (proc.exitCode === 0) return undefined
110
+ return notAWorkTree(dir, new TextDecoder().decode(proc.stderr).trim())
111
+ }
112
+
90
113
  /** Variables that move where git finds the repository or its index: with any set, git is asked. */
91
114
  const GIT_LOCATION_ENV = [
92
115
  'GIT_INDEX_FILE',
@@ -36,6 +36,7 @@ export {
36
36
  cleanOutputs,
37
37
  cleanWorkspaceOutputs,
38
38
  declaresInput,
39
+ workspaceFilesReachInto,
39
40
  resolveInputs,
40
41
  resolveOutputs,
41
42
  resolveWorkspaceOutputs,
@@ -52,6 +53,7 @@ export {
52
53
  } from './inputs.js'
53
54
  export {
54
55
  GitFilesCache,
56
+ gitRefusal,
55
57
  applyGitEnumeration,
56
58
  gitPathspecs,
57
59
  MAX_SCOPED_PATHSPECS,
@@ -17,7 +17,7 @@
17
17
  // be a git work tree; non-git environments are not supported.
18
18
 
19
19
  import path from 'node:path'
20
- import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs'
20
+ import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
21
21
  import { rm, rmdir } from 'node:fs/promises'
22
22
  import type { CacheConfig, CacheInputs } from '../config.js'
23
23
  import {
@@ -33,6 +33,7 @@ import {
33
33
  slashBraceExpansions,
34
34
  splitNegations,
35
35
  staticPrefix,
36
+ stripTrailingSlash,
36
37
  taskGlob,
37
38
  UserError,
38
39
  } from '../util/index.js'
@@ -643,7 +644,11 @@ function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
643
644
  return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
644
645
  }
645
646
 
646
- /** Resolve declared output globs (project-relative) to actual produced files. */
647
+ /**
648
+ * Resolve declared output globs (project-relative) to actual produced files.
649
+ * An output directory linked out of the project is refused by name (X-88):
650
+ * dropping its files silently saved an empty entry under a green run.
651
+ */
647
652
  export async function resolveOutputs(args: {
648
653
  projectDir: string
649
654
  outputs: readonly string[]
@@ -652,9 +657,11 @@ export async function resolveOutputs(args: {
652
657
  const { positive, negative } = splitNegations(args.outputs)
653
658
  if (positive.length === 0) return []
654
659
  const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
660
+ const trees = asTrees(positive)
661
+ refuseDirLinkedOut(args.projectDir, trees)
655
662
  const scanned = [
656
663
  ...(await scanUnion(
657
- asTrees(positive),
664
+ trees,
658
665
  excludeGlobs,
659
666
  args.projectDir,
660
667
  inNestedProject(args.projectDir, args.nestedProjectDirs),
@@ -670,6 +677,53 @@ export async function resolveOutputs(args: {
670
677
  return containedIn(args.projectDir, scanned).sort()
671
678
  }
672
679
 
680
+ /**
681
+ * The literal head of each output glob, walked: the scan follows a link
682
+ * there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
683
+ * FILE is the archive's to judge (L-23); a dangling one the restore's.
684
+ */
685
+ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
686
+ let realRoot: string | undefined
687
+ for (const tree of trees) {
688
+ let at = projectDir
689
+ for (const part of staticPrefix(tree).split('/')) {
690
+ if (part === '' || part === '.') continue
691
+ at = path.join(at, part)
692
+ if (!isInside(projectDir, at)) break
693
+ let st
694
+ try {
695
+ st = lstatSync(at)
696
+ } catch {
697
+ break
698
+ }
699
+ if (!st.isSymbolicLink()) continue
700
+ let real: string
701
+ try {
702
+ real = realpathSync(at)
703
+ if (!statSync(real).isDirectory()) break
704
+ } catch {
705
+ break
706
+ }
707
+ realRoot ??= realOr(projectDir)
708
+ if (!isInside(realRoot, real)) {
709
+ throw new UserError(
710
+ `${at} is a symbolic link to ${real}, outside ${projectDir} — vx never cleans, saves ` +
711
+ 'or restores declared outputs through a link that leaves the project. Remove the ' +
712
+ 'link and re-run, or stop declaring outputs under it.',
713
+ )
714
+ }
715
+ }
716
+ }
717
+ }
718
+
719
+ function realOr(p: string): string {
720
+ try {
721
+ return realpathSync(p)
722
+ } catch {
723
+ return p
724
+ }
725
+ }
726
+
673
727
  /**
674
728
  * Keep only the paths that are REALLY inside `root` — lexically, and after
675
729
  * resolving symlinks.
@@ -708,16 +762,9 @@ function containedIn(root: string, paths: readonly string[]): string[] {
708
762
  // Sync, as `hashFile`'s lstat: a realpath is microseconds, and the
709
763
  // promise round trip per call was most of this function's cost on every
710
764
  // miss (B, 2026-09-30).
711
- const real = (p: string): string | null => {
712
- try {
713
- return realpathSync(p)
714
- } catch {
715
- return null
716
- }
717
- }
718
- const realRoot = real(root) ?? root
765
+ const realRoot = realOrNull(root) ?? root
719
766
  const uniqueDirs = [...new Set(lexDirs)]
720
- const resolved = uniqueDirs.map(real)
767
+ const resolved = uniqueDirs.map(realOrNull)
721
768
  const contained = new Set<string>()
722
769
  for (const [i, dir] of uniqueDirs.entries()) {
723
770
  const real = resolved[i]
@@ -899,7 +946,7 @@ const SYNC_CLEAN_MAX = 128
899
946
  * sends the reader to file a bug against a permission bit.
900
947
  */
901
948
  async function removeAll(all: readonly string[], root: string): Promise<string[]> {
902
- const files = notThroughLink(all, root)
949
+ const files = insideRoot(all, root)
903
950
  const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
904
951
  const rel = relPosix(root, f)
905
952
  return new UserError(
@@ -929,21 +976,14 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
929
976
  }
930
977
 
931
978
  /**
932
- * The paths whose directory is really where it sits, not reached through a
933
- * symlinked directory. A save follows `dist -> real-out` on purpose
934
- * (turborepo#13042), but a clean through a link deletes the target's
935
- * files: `public -> static` in the same project took the tracked
936
- * `static/logo.svg` before every run (X-5). The link is a declared
937
- * output's own entry; what it leads to is not.
979
+ * The paths whose directory resolves inside `root`. A link inside the
980
+ * project is followed, as the save follows it (turborepo#13042): a clean
981
+ * that skipped `dist -> real-out` left the last run's files for the next
982
+ * entry to save (X-88). One that leaves the project is never deleted
983
+ * through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
984
+ * contained set, so the check is here too.
938
985
  */
939
- function notThroughLink(files: readonly string[], root: string): string[] {
940
- const real = (p: string): string | null => {
941
- try {
942
- return realpathSync(p)
943
- } catch {
944
- return null
945
- }
946
- }
986
+ function insideRoot(files: readonly string[], root: string): string[] {
947
987
  // Resolved only once a directory exists to compare: after a clean pruned
948
988
  // the outputs (the common restore) nothing does, and the call was most
949
989
  // of an empty clean.
@@ -955,9 +995,8 @@ function notThroughLink(files: readonly string[], root: string): string[] {
955
995
  if (ok === undefined) {
956
996
  // A directory already gone has nothing to delete through; its path
957
997
  // stays so the prune still reaches the parents it emptied.
958
- const r = real(dir)
959
- ok =
960
- r === null || r === path.join((realRoot ??= real(root) ?? root), path.relative(root, dir))
998
+ const r = realOrNull(dir)
999
+ ok = r === null || isInside((realRoot ??= realOrNull(root) ?? root), r)
961
1000
  own.set(dir, ok)
962
1001
  }
963
1002
  return ok
@@ -1101,8 +1140,12 @@ export async function cleanWorkspaceOutputs(args: {
1101
1140
  return files.map((f) => relPosix(args.workspaceRoot, f))
1102
1141
  }
1103
1142
 
1104
- function stripTrailingSlash(p: string): string {
1105
- return p.replace(/\/+$/, '')
1143
+ function realOrNull(p: string): string | null {
1144
+ try {
1145
+ return realpathSync(p)
1146
+ } catch {
1147
+ return null
1148
+ }
1106
1149
  }
1107
1150
 
1108
1151
  // The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
@@ -1356,6 +1399,18 @@ export function declaresInput(
1356
1399
  return matcher(workspaceRel)
1357
1400
  }
1358
1401
 
1402
+ /**
1403
+ * Whether a cached task's `workspaceFiles` may name a file under `dir`
1404
+ * (workspace-relative): a nested repository git reports as that one path,
1405
+ * whose files the key folds and a match against the path alone misses.
1406
+ * By the entries' static prefixes, so a glob that may descend counts.
1407
+ */
1408
+ export function workspaceFilesReachInto(cache: CacheConfig, dir: string): boolean {
1409
+ const ws = cache.inputs.workspaceFiles
1410
+ if (ws === undefined || ws.length === 0) return false
1411
+ return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
1412
+ }
1413
+
1359
1414
  function inPlan(plan: FilesPlan, rel: string): boolean {
1360
1415
  let input = plan.verdicts.get(rel)
1361
1416
  if (input === undefined) {
@@ -103,7 +103,7 @@ import type { CacheKeyInput } from './layer.js'
103
103
  // bytes on disk, the filter since removed, holds the CRLF build (A-60).
104
104
  // v39: an entry saved while a gitlink without `.git` held files the key
105
105
  // never listed sits under the key of that directory empty (A-61).
106
- export const CACHE_VERSION = 'vx-cache-v40'
106
+ export const CACHE_VERSION = 'vx-cache-v41'
107
107
 
108
108
  /**
109
109
  * The digest a key folds for an input gone between its enumeration and its
@@ -332,7 +332,8 @@ export interface PruneResult {
332
332
  * discriminator — least-invasive row format, no schema change. A
333
333
  * project output dir literally named `workspace-outputs/` would
334
334
  * collide with the namespace; the name is reserved, and a project
335
- * output glob under it is refused at load (config-schema.ts).
335
+ * output glob under it is refused at load (config-schema.ts), a file
336
+ * another glob reaches there at save (`planArtifact`).
336
337
  */
337
338
  export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
338
339
 
package/src/cli/cache.ts CHANGED
@@ -140,13 +140,13 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
140
140
  return {
141
141
  error: `${refusedWord(a ?? '')}: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
142
142
  }
143
- if ('error' in cd) return { error: cd.error }
143
+ if ('error' in cd) return { error: `${cd.error}${seeHelp('cache')}` }
144
144
  out.cacheDir = cd.cacheDir
145
145
  i = cd.next
146
146
  }
147
147
  }
148
148
  if (out.olderThanMs === undefined && out.maxBytes === undefined) {
149
- return { error: 'must pass --older-than <duration> or --max-size <bytes>' }
149
+ return { error: `needs --older-than <duration> or --max-size <size>${seeHelp('cache')}` }
150
150
  }
151
151
  return out
152
152
  }
@@ -1,4 +1,4 @@
1
- // What a Turbo or Nx user's hands type, and what `vx run` does with it.
1
+ // What a Turbo, Nx or Vite Task (`vp run`) user's hands type, and what `vx run` does with it.
2
2
  // Every flag here has one outcome: vx takes it as it is (`same`), takes it
3
3
  // as another spelling (`alias`, rewritten before the parse), or refuses it
4
4
  // with the vx way to say it (`refuse`). Nothing is dropped in silence: a
@@ -7,7 +7,7 @@
7
7
  // cli.md's parity section (`renderForeignFlags`), pinned by a test.
8
8
 
9
9
  interface ForeignFlag {
10
- runner: 'turbo' | 'nx'
10
+ runner: 'turbo' | 'nx' | 'vp'
11
11
  /** Every spelling, first the one shown. */
12
12
  names: readonly string[]
13
13
  /** It takes a value: `--flag <v>` or `--flag=<v>`. */
@@ -598,6 +598,60 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
598
598
  outcome: 'refuse',
599
599
  vx: 'vx has no cloud: drop it',
600
600
  },
601
+ {
602
+ runner: 'vp',
603
+ names: ['-r', '--recursive'],
604
+ value: false,
605
+ outcome: 'alias',
606
+ vx: '`--all`',
607
+ to: () => ['--all'],
608
+ },
609
+ {
610
+ runner: 'vp',
611
+ names: ['-w', '--workspace-root'],
612
+ value: false,
613
+ outcome: 'alias',
614
+ vx: '`--filter //` (the root project)',
615
+ to: () => ['--filter', '//'],
616
+ },
617
+ {
618
+ runner: 'vp',
619
+ names: ['--concurrency-limit'],
620
+ value: true,
621
+ outcome: 'alias',
622
+ vx: '`--concurrency <n>`',
623
+ to: (v) => (v === undefined ? ['--concurrency'] : ['--concurrency', v]),
624
+ },
625
+ {
626
+ runner: 'vp',
627
+ names: ['--ignore-depends-on'],
628
+ value: false,
629
+ outcome: 'alias',
630
+ vx: '`--exclude-dependencies`',
631
+ to: () => ['--exclude-dependencies'],
632
+ },
633
+ {
634
+ runner: 'vp',
635
+ names: ['--fail-if-no-match'],
636
+ value: false,
637
+ outcome: 'alias',
638
+ vx: 'nothing: a filter that matches nothing already fails the run',
639
+ to: () => [],
640
+ },
641
+ {
642
+ runner: 'vp',
643
+ names: ['--log'],
644
+ value: true,
645
+ outcome: 'refuse',
646
+ vx: 'vx frames each task’s output: `--output-logs <mode>` sets how much',
647
+ },
648
+ {
649
+ runner: 'vp',
650
+ names: ['--last-details'],
651
+ value: false,
652
+ outcome: 'refuse',
653
+ vx: '`vx last` prints the last run',
654
+ },
601
655
  ]
602
656
 
603
657
  /** Nx's verbs, as its users type them: `vx run` takes their flags. */
package/src/cli/info.ts CHANGED
@@ -29,7 +29,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
29
29
  }
30
30
  const cd = parseCacheDirFlag(args, i)
31
31
  if (cd !== null) {
32
- if ('error' in cd) return { ...out, error: cd.error }
32
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('info')}` }
33
33
  out.cacheDir = cd.cacheDir
34
34
  i = cd.next
35
35
  continue
package/src/cli/init.ts CHANGED
@@ -42,7 +42,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
42
42
  if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
43
43
  return {
44
44
  ...out,
45
- error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
45
+ error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
46
46
  }
47
47
  }
48
48
  out.plugin = seam
@@ -56,7 +56,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
56
56
  if (out.plugin !== undefined && out.mjs) {
57
57
  return {
58
58
  ...out,
59
- error: '--mjs does not combine with --plugin: the plugin templates are TypeScript',
59
+ error: `--mjs does not combine with --plugin: the plugin templates are TypeScript${seeHelp('init')}`,
60
60
  }
61
61
  }
62
62
  return out
package/src/cli/last.ts CHANGED
@@ -80,7 +80,7 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
80
80
  }
81
81
  const cd = parseCacheDirFlag(args, i)
82
82
  if (cd !== null) {
83
- if ('error' in cd) return { ...out, error: cd.error }
83
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('last')}` }
84
84
  out.cacheDir = cd.cacheDir
85
85
  i = cd.next
86
86
  continue
@@ -99,11 +99,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
99
99
  if (out.runId !== undefined && out.list !== undefined) {
100
100
  return {
101
101
  ...out,
102
- error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
102
+ error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
103
103
  }
104
104
  }
105
105
  if (out.runId !== undefined && out.failed === true) {
106
- return { ...out, error: `a run id and --failed do not combine: replay ${out.runId}` }
106
+ return {
107
+ ...out,
108
+ error: `a run id and --failed do not combine: replay ${out.runId}${seeHelp('last')}`,
109
+ }
107
110
  }
108
111
  return out
109
112
  }