@vzn/vx 0.0.645 → 0.0.646

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 (71) hide show
  1. package/README.md +10 -28
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/skills/vx/SKILL.md +1 -1
  5. package/src/bin.ts +1 -1
  6. package/src/cache/cache.ts +66 -16
  7. package/src/cache/git-inputs.ts +55 -10
  8. package/src/cache/index.ts +4 -0
  9. package/src/cache/inputs.ts +61 -11
  10. package/src/cache/layer.ts +25 -2
  11. package/src/cache/layered-cache.ts +32 -2
  12. package/src/cache/output-index.ts +25 -11
  13. package/src/cache/remote-lane.ts +13 -4
  14. package/src/cache/run-history.ts +13 -6
  15. package/src/cache/schema.ts +19 -2
  16. package/src/cli/core-alias.ts +5 -2
  17. package/src/cli/help.ts +4 -6
  18. package/src/cli/index.ts +0 -12
  19. package/src/cli/init.ts +43 -10
  20. package/src/cli/last.ts +4 -2
  21. package/src/cli/plugin-commands.ts +96 -3
  22. package/src/cli/plugin-templates.ts +1 -1
  23. package/src/cli/run.ts +2 -7
  24. package/src/cli/select.ts +5 -3
  25. package/src/cli/upgrade.ts +4 -5
  26. package/src/exec/executor.ts +112 -9
  27. package/src/exec/index.ts +2 -0
  28. package/src/exec/proc-sample.ts +67 -5
  29. package/src/exec/runner.ts +4 -0
  30. package/src/exec/sandbox-runtime.ts +11 -4
  31. package/src/graph/scheduler.ts +59 -8
  32. package/src/history/history.ts +119 -7
  33. package/src/history/metrics.ts +53 -16
  34. package/src/index.ts +15 -0
  35. package/src/orchestrator/execute-args.ts +3 -0
  36. package/src/orchestrator/execute-task.ts +28 -7
  37. package/src/orchestrator/held-servers.ts +86 -0
  38. package/src/orchestrator/hit-restore.ts +11 -4
  39. package/src/orchestrator/index.ts +17 -1
  40. package/src/orchestrator/local-floor.ts +104 -0
  41. package/src/orchestrator/local-shortcircuit.ts +26 -7
  42. package/src/orchestrator/miss-save.ts +10 -4
  43. package/src/orchestrator/persistent-task.ts +14 -0
  44. package/src/orchestrator/placement.ts +84 -9
  45. package/src/orchestrator/plugin-host.ts +21 -3
  46. package/src/orchestrator/plugin.ts +37 -0
  47. package/src/orchestrator/prepare.ts +11 -3
  48. package/src/orchestrator/run-keys.ts +164 -0
  49. package/src/orchestrator/run-records.ts +6 -0
  50. package/src/orchestrator/run.ts +93 -6
  51. package/src/orchestrator/sandbox-request.ts +9 -3
  52. package/src/orchestrator/task-artifacts.ts +142 -0
  53. package/src/orchestrator/task-hash.ts +11 -1
  54. package/src/orchestrator/task-process.ts +16 -0
  55. package/src/orchestrator/telemetry-host.ts +2 -1
  56. package/src/orchestrator/telemetry.ts +60 -17
  57. package/src/report/forecast.ts +6 -27
  58. package/src/report/framed-output.ts +41 -5
  59. package/src/report/logger.ts +60 -20
  60. package/src/report/status-line.ts +38 -18
  61. package/src/report/summary.ts +61 -7
  62. package/src/settings.ts +7 -0
  63. package/src/util/colors.ts +16 -1
  64. package/src/util/index.ts +1 -0
  65. package/src/util/paths.ts +30 -0
  66. package/src/util/ui.ts +4 -1
  67. package/src/workspace/config-bytecode.ts +276 -0
  68. package/src/workspace/index.ts +0 -1
  69. package/src/workspace/migration.ts +4 -32
  70. package/src/workspace/project-loader.ts +92 -14
  71. package/src/cli/foreign-flags.ts +0 -727
@@ -15,6 +15,7 @@ import {
15
15
  gradientRule,
16
16
  paint,
17
17
  SUCCESS,
18
+ ui,
18
19
  WARN,
19
20
  type ColorSupport,
20
21
  } from '../util/index.js'
@@ -27,6 +28,7 @@ const LOCAL = '#38bdf8' // sky-400
27
28
  const REMOTE = '#2563eb' // blue-600
28
29
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
29
30
  const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
31
+ const RUNNING = '#06b6d4' // cyan-500: ui's info
30
32
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
31
33
 
32
34
  /**
@@ -85,6 +87,11 @@ export interface SummaryStats {
85
87
  aborted?: number
86
88
  /** Reached by the stop before they ran. Not in `total`. */
87
89
  notRun?: number
90
+ /** Live: running now, and handed to an executor whose machine has not begun them. */
91
+ running?: number
92
+ scheduled?: number
93
+ /** Live: not started because a dependency has not finished. */
94
+ blocked?: number
88
95
  total: number
89
96
  upToDate: number
90
97
  restoredLocal: number
@@ -120,13 +127,20 @@ export type CacheBucket =
120
127
  | 'restoredLocal'
121
128
  | 'restoredRemote'
122
129
  | 'saved'
130
+ | 'notSaved'
123
131
 
124
132
  /** A finished task's own cache bucket; none for a group, a skip or an abort. */
125
133
  export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
126
134
  if (isGroupTask(o.node)) return undefined
127
135
  switch (o.status) {
128
136
  case 'success':
129
- return ranNoCache(o) ? 'noCache' : o.saved === true ? 'saved' : 'miss'
137
+ return ranNoCache(o)
138
+ ? 'noCache'
139
+ : o.saved === true
140
+ ? 'saved'
141
+ : ranUnsaved(o)
142
+ ? 'notSaved'
143
+ : 'miss'
130
144
  case 'failed':
131
145
  return ranNoCache(o) ? 'noCache' : 'miss'
132
146
  case 'cache-hit':
@@ -149,8 +163,12 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
149
163
  */
150
164
  function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
151
165
  const saved = layers === undefined ? 0 : (stats.saved ?? 0)
166
+ // Its own yellow segment, as its legend count is: in the misses' red it
167
+ // was a cell with no count under it (M, 2026-10-21).
168
+ const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
152
169
  const b: Buckets = {
153
- miss: stats.miss - saved,
170
+ miss: stats.miss - saved - notSaved,
171
+ notSaved,
154
172
  noCache: stats.noCache ?? 0,
155
173
  upToDate: stats.upToDate,
156
174
  restoredLocal: stats.restoredLocal,
@@ -183,6 +201,10 @@ export interface RunContext {
183
201
  packageCount: number
184
202
  /** The run's whole id, as `vx last` takes it; shown on the `info` row. */
185
203
  runId?: string | undefined
204
+ /** The executors' own footer rows (`TaskExecutor.footer`), above the `info` row. */
205
+ executorRows?: () => readonly (readonly [string, ui.Inline])[]
206
+ /** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
207
+ whereRunning?: (taskId: string) => string | null | undefined
186
208
  /** Worker-pool size for this run; shown on the `info` row. */
187
209
  concurrency?: number
188
210
  remoteCacheEnabled: boolean
@@ -226,7 +248,15 @@ export function formatSummarySection(
226
248
  const left = stats.left ?? 0
227
249
  const aborted = stats.aborted ?? 0
228
250
  const notRun = stats.notRun ?? 0
229
- const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
251
+ const running = stats.running ?? 0
252
+ const scheduled = stats.scheduled ?? 0
253
+ const blocked = stats.blocked ?? 0
254
+ const remainder = {
255
+ n: Math.max(0, left - running - scheduled - blocked),
256
+ color: '',
257
+ glyph: '\u25b1',
258
+ dim: true,
259
+ }
230
260
 
231
261
  const dim = (txt: string) => paint('', txt, colors, { dim: true })
232
262
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
@@ -243,6 +273,9 @@ export function formatSummarySection(
243
273
  if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
244
274
  if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
245
275
  if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
276
+ if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
277
+ if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
278
+ if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
246
279
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
247
280
  const taskBar = segmentBar(
248
281
  [
@@ -251,6 +284,9 @@ export function formatSummarySection(
251
284
  { n: stats.skipped, color: WARN },
252
285
  { n: aborted, color: ABORTED },
253
286
  { n: notRun, color: NOT_RUN },
287
+ { n: running, color: RUNNING },
288
+ { n: scheduled, color: '', dim: true },
289
+ { n: blocked, color: '', dim: true },
254
290
  remainder,
255
291
  ],
256
292
  colors,
@@ -310,6 +346,7 @@ export function formatSummarySection(
310
346
  const cacheBar = segmentBar(
311
347
  [
312
348
  { n: b.miss, color: ERROR },
349
+ { n: b.notSaved, color: WARN },
313
350
  { n: b.noCache, color: '', dim: true },
314
351
  { n: b.upToDate, color: SUCCESS },
315
352
  { n: b.restoredLocal, color: LOCAL },
@@ -355,8 +392,8 @@ export function formatSummarySection(
355
392
  ]
356
393
  if (layers.remote && remote.length > 0)
357
394
  cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
358
- if ((stats.notSaved ?? 0) > 0)
359
- cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
395
+ if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
396
+ if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
360
397
  const notDownloaded = layers.notDownloaded()
361
398
  if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
362
399
  } else {
@@ -410,10 +447,27 @@ export function formatSummarySection(
410
447
  // Run-shape footer (final summary only): worker pool + cache mode,
411
448
  // grouped with time under a blank line below the meters.
412
449
  if (context !== undefined) {
450
+ const own = context.executorRows?.() ?? []
451
+ if (own.length > 0)
452
+ lines.push('', ...own.map(([label, text]) => row(label, ui.inline(text, colors))))
413
453
  const info: string[] = []
414
- if (context.concurrency !== undefined)
454
+ // An executor that says where tasks ran says it better than the pool.
455
+ if (context.concurrency !== undefined && own.length === 0)
415
456
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
416
- info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
457
+ const layers = context.cache
458
+ info.push(
459
+ layers === undefined
460
+ ? context.remoteCacheEnabled
461
+ ? 'local + remote cache'
462
+ : 'local cache'
463
+ : layers.local && layers.remote
464
+ ? 'local + remote cache'
465
+ : layers.local
466
+ ? 'local cache'
467
+ : layers.remote
468
+ ? 'remote cache'
469
+ : 'no cache',
470
+ )
417
471
  if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
418
472
  // The plugin's hand on the run: a task a policy held with a worker
419
473
  // free would otherwise have started; without the line nothing says
package/src/settings.ts CHANGED
@@ -50,6 +50,13 @@ export const SETTINGS: readonly Setting[] = [
50
50
  default: "origin/HEAD's branch, then main, then master",
51
51
  doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
52
52
  },
53
+ {
54
+ path: 'affectedBase',
55
+ kind: { type: 'string' },
56
+ default: "a plugin's base commit, else the merge base with `baseBranch`",
57
+ doc: "The commit a bare `--affected` compares with, on the base branch too. `--affected=<base>` beats it; it beats a plugin's base commit. Env only: a committed base would be stale on the next push.",
58
+ config: false,
59
+ },
53
60
  {
54
61
  path: 'run.concurrency',
55
62
  kind: { type: 'concurrency' },
@@ -34,6 +34,7 @@ export interface PaintOptions {
34
34
  * NO_COLOR non-empty → off (overrides FORCE_COLOR)
35
35
  * FORCE_COLOR=0 or =false → off, even on a TTY
36
36
  * FORCE_COLOR any other value → on (the empty string too)
37
+ * a CI whose log renders ANSI (`ciPaints`) → on
37
38
  * else: on iff stdout is a TTY that is not `TERM=dumb`.
38
39
  * `0` and `false` are the convention's "off" (supports-color, chalk,
39
40
  * Node); reading every non-empty value as on painted a piped CI log that
@@ -45,7 +46,7 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
45
46
  ? false
46
47
  : force !== undefined
47
48
  ? force !== '0' && force !== 'false'
48
- : stream.isTTY === true && !dumbTerminal()
49
+ : ciPaints() || (stream.isTTY === true && !dumbTerminal())
49
50
  return {
50
51
  enabled,
51
52
  get columns() {
@@ -54,6 +55,20 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * A CI whose log viewer renders ANSI colour though the job's output is a
60
+ * pipe (supports-color reads the same variables): its log was plain.
61
+ */
62
+ function ciPaints(): boolean {
63
+ const env = process.env
64
+ return (
65
+ env['GITHUB_ACTIONS'] === 'true' ||
66
+ env['GITLAB_CI'] === 'true' ||
67
+ env['BUILDKITE'] === 'true' ||
68
+ env['CIRCLECI'] === 'true'
69
+ )
70
+ }
71
+
57
72
  /**
58
73
  * `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
59
74
  * nor move the cursor: vx prints to it as off a terminal.
package/src/util/index.ts CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  anyTaskGlob,
73
73
  isInstalledPath,
74
74
  taskGlob,
75
+ outputDirPrefixes,
75
76
  wholeSubtreePrefixes,
76
77
  slashBraceExpansions,
77
78
  braceAlternatives,
package/src/util/paths.ts CHANGED
@@ -117,6 +117,36 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
117
117
  return [...new Set(out)]
118
118
  }
119
119
 
120
+ /**
121
+ * The directories whose snapshot covers every path the positive `globs`
122
+ * can match, or `null` when one reaches outside the project. A
123
+ * whole-subtree glob or a literal is its own tree
124
+ * ({@link wholeSubtreePrefixes}); any other glob is the tree under its
125
+ * static prefix, and a root-anchored one (`**\/dist/**`, Turbo's shape) is
126
+ * the whole project, `''`. Sound for the same reason: a file added or
127
+ * removed anywhere under a recorded directory bumps a recorded mtime. The
128
+ * snapshot's file list is then held to the entry's rows through the globs
129
+ * themselves, so a source file beside the outputs is no stray.
130
+ */
131
+ export function outputDirPrefixes(globs: readonly string[]): string[] | null {
132
+ if (globs.length === 0) return null
133
+ const dirs: string[] = []
134
+ for (const g of globs) {
135
+ const whole = wholeSubtreePrefixes([g])?.[0]
136
+ if (whole !== undefined) {
137
+ dirs.push(whole)
138
+ continue
139
+ }
140
+ const prefix = staticPrefix(g)
141
+ if (prefix === '.') return ['']
142
+ if (prefix.startsWith('/') || prefix.startsWith('!') || prefix.split('/').includes('..'))
143
+ return null
144
+ dirs.push(prefix)
145
+ }
146
+ const sorted = [...new Set(dirs)].sort()
147
+ return sorted.filter((d, i) => !sorted.slice(0, i).some((a) => d.startsWith(`${a}/`)))
148
+ }
149
+
120
150
  /**
121
151
  * The spellings a reader, Turbo and `.gitignore` all accept but a matcher
122
152
  * fed the raw string turns into NOTHING — and a task keyed on nothing
package/src/util/ui.ts CHANGED
@@ -40,10 +40,13 @@ export const path = (text: string): Inline => ({ style: 'bold', text })
40
40
  export const cmd = (text: string): Inline => ({ style: 'bold', text })
41
41
  /** A secondary fact. */
42
42
  export const dim = (text: string): Inline => ({ style: 'dim', text })
43
+ /** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
44
+ export const place = (text: string): Inline => (text === 'local' ? text : id(text))
43
45
  /** Text in a tone's color. */
44
46
  export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
45
47
 
46
- function inline(x: Inline, c: ColorSupport): string {
48
+ /** One inline as text, colored for `c`. */
49
+ export function inline(x: Inline, c: ColorSupport): string {
47
50
  if (typeof x === 'string') return x
48
51
  if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
49
52
  const { style, text } = x as { style: Style; text: string }
@@ -0,0 +1,276 @@
1
+ // Every run evaluates `.vx/workspace.ts`, and with it every plugin package
2
+ // it imports: Bun parses and compiles that TypeScript on each run, and a
3
+ // plugin's functions compile again on their first call, while core itself
4
+ // runs from the binary's bytecode. On a solid-sized workspace that was
5
+ // ~4 ms of a ~60 ms warm run (the `discover` stage alone 9 → 5 ms,
6
+ // 2026-10-22). So the config's module graph is bundled once into CommonJS
7
+ // with Bun's bytecode under `.vx/cache/compiled/`, and a later run whose
8
+ // every input still has the bytes it was built from requires that instead.
9
+ //
10
+ // The bundle is built from the bytes it records, file by file, and is used
11
+ // only while each recorded file still hashes the same and each bare import
12
+ // still resolves to the file it did; anything else loads the source as
13
+ // before and builds again. A graph the bundle could run differently is
14
+ // never built: an `import.meta` field beyond its location, a `require` or
15
+ // `import()` of a computed specifier (relative to the bundle, not the
16
+ // file), top-level await, a CommonJS config, a file this process already
17
+ // evaluated (a bundled copy would be a second state; run from source, core
18
+ // itself). The bundle goes
19
+ // into `.vx/cache` only once a run made it, so a reading verb on a fresh
20
+ // workspace writes nothing (item 900). `.vx/cache` is trusted as the rest
21
+ // of the cache is: what it holds is restored into the tree too.
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ realpathSync,
29
+ renameSync,
30
+ rmSync,
31
+ } from 'node:fs'
32
+ import { writeFile } from 'node:fs/promises'
33
+ import { createRequire } from 'node:module'
34
+ import path from 'node:path'
35
+ import { xxh3hex } from '../util/index.js'
36
+
37
+ /** Bumped when what a bundle holds, or how it is checked, changes. */
38
+ const COMPILED_VERSION = 1
39
+
40
+ /** What `import.meta` becomes in a bundled file: an identifier no source spells. */
41
+ const META = '__vxImportMeta'
42
+ const META_FIELDS = new Set(['dir', 'dirname', 'path', 'filename', 'file', 'url', 'main'])
43
+ const META_USE = new RegExp(`\\b${META}\\b(\\.[\\w$]+)?`, 'g')
44
+ const COMPUTED_LOAD = /\b(?:require|import)\(\s*(?![\s'"])|\brequire\.resolve\b/
45
+
46
+ interface Manifest {
47
+ version: number
48
+ /** The config file by real path; its bytes are in the bundle's name, so it is not read again (principle 9). */
49
+ entry: string
50
+ /** Every file the bundle was built from, by real path, and its bytes' hash. */
51
+ inputs: Record<string, string>
52
+ /** Each bare import: the importing file's directory, the specifier, the real path it resolved to. */
53
+ bare: [string, string, string][]
54
+ /** The graph cannot be bundled faithfully: load the source while the inputs stay as they are. */
55
+ skip?: true
56
+ }
57
+
58
+ function compiledDir(root: string): string {
59
+ return path.join(root, '.vx', 'cache', 'compiled')
60
+ }
61
+
62
+ function nameOf(configPath: string, bytes: Uint8Array): string {
63
+ return `workspace-${xxh3hex(`${COMPILED_VERSION}\0${Bun.revision}\0${configPath}\0${xxh3hex(bytes)}`)}`
64
+ }
65
+
66
+ function fresh(manifest: Manifest): boolean {
67
+ for (const [file, hash] of Object.entries(manifest.inputs)) {
68
+ if (file === manifest.entry) continue
69
+ let bytes: Uint8Array
70
+ try {
71
+ bytes = readFileSync(file)
72
+ } catch {
73
+ return false
74
+ }
75
+ if (xxh3hex(bytes) !== hash) return false
76
+ }
77
+ for (const [from, spec, to] of manifest.bare) {
78
+ try {
79
+ if (realpathSync(Bun.resolveSync(spec, from)) !== to) return false
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+ return true
85
+ }
86
+
87
+ /**
88
+ * The workspace config's module namespace from its bundle: `undefined` when
89
+ * there is none for these bytes or an input moved (`build` then makes one),
90
+ * `null` when the graph is one no bundle runs faithfully.
91
+ */
92
+ export function loadCompiled(
93
+ root: string,
94
+ configPath: string,
95
+ bytes: Uint8Array,
96
+ ): { default?: unknown } | null | undefined {
97
+ // A repeat load in this process (the run after the CLI's selection pass,
98
+ // a `vx watch` cycle) of the same bytes: the loader already refused an
99
+ // import edited since the first, so the answer stands (principle 9).
100
+ const seen = answered.get(configPath)
101
+ if (seen !== undefined && Buffer.from(seen.bytes).equals(bytes)) return seen.ns
102
+ const name = nameOf(configPath, bytes)
103
+ const dir = compiledDir(root)
104
+ let manifest: Manifest
105
+ try {
106
+ manifest = JSON.parse(readFileSync(path.join(dir, `${name}.json`), 'utf8')) as Manifest
107
+ } catch {
108
+ return undefined
109
+ }
110
+ if (manifest.version !== COMPILED_VERSION || !fresh(manifest)) return undefined
111
+ const ns =
112
+ manifest.skip === true
113
+ ? null
114
+ : (createRequire(path.join(dir, 'index.js'))(path.join(dir, `${name}.js`)) as {
115
+ default?: unknown
116
+ })
117
+ answered.set(configPath, { bytes, ns })
118
+ return ns
119
+ }
120
+
121
+ /** What `loadCompiled` answered for each config, by the bytes it was asked with. */
122
+ const answered = new Map<string, { bytes: Uint8Array; ns: { default?: unknown } | null }>()
123
+
124
+ /**
125
+ * The files this process has evaluated as modules. One the config imports
126
+ * by path (run from source, core's own `src/index.ts`) is shared with the
127
+ * process, and a bundle would run a second copy of its state.
128
+ */
129
+ export function loadedModules(root: string): Set<string> {
130
+ // Not `import.meta`: core's planner also runs as a browser script (the playground).
131
+ return new Set(Object.keys(createRequire(path.join(root, 'index.js')).cache))
132
+ }
133
+
134
+ /** The loader Bun reads `file` with, or null for a file the bundle must not hold. */
135
+ function loaderOf(file: string): 'ts' | 'tsx' | 'js' | 'jsx' | null {
136
+ const ext = path.extname(file)
137
+ if (ext === '.ts' || ext === '.mts') return 'ts'
138
+ if (ext === '.tsx') return 'tsx'
139
+ if (ext === '.js' || ext === '.mjs') return 'js'
140
+ if (ext === '.jsx') return 'jsx'
141
+ return null
142
+ }
143
+
144
+ /**
145
+ * Bundle the workspace config's graph for the next run. A speed-up only:
146
+ * every failure leaves the next run loading the source.
147
+ */
148
+ export async function buildCompiled(
149
+ root: string,
150
+ configPath: string,
151
+ bytes: Uint8Array,
152
+ /** `loadedModules()` before the config's source load. */
153
+ loaded: ReadonlySet<string>,
154
+ /** What that load evaluated: a repeat load in this process gets it, not a bundle or a second build. */
155
+ ns: { default?: unknown },
156
+ ): Promise<void> {
157
+ answered.set(configPath, { bytes, ns })
158
+ const dir = compiledDir(root)
159
+ if (!existsSync(path.dirname(dir))) return
160
+ const name = nameOf(configPath, bytes)
161
+ const entryFile = realpathSync(configPath)
162
+ const inputs: Record<string, string> = {}
163
+ let faithful = true
164
+ const transpilers = new Map<string, Bun.Transpiler>()
165
+ const tmp = path.join(dir, `.tmp-${process.pid}-${Date.now()}`)
166
+ try {
167
+ mkdirSync(tmp, { recursive: true })
168
+ const built = await Bun.build({
169
+ entrypoints: [configPath],
170
+ target: 'bun',
171
+ format: 'cjs',
172
+ bytecode: true,
173
+ external: ['@vzn/vx'],
174
+ outdir: tmp,
175
+ naming: `${name}.js`,
176
+ metafile: true,
177
+ throw: false,
178
+ plugins: [
179
+ {
180
+ name: 'vx-config-bytecode',
181
+ setup(build) {
182
+ build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
183
+ const file = realpathSync(args.path)
184
+ if (loaded.has(file)) {
185
+ faithful = false
186
+ inputs[file] = xxh3hex(await Bun.file(file).bytes())
187
+ return { contents: '', loader: 'js' }
188
+ }
189
+ const loader = loaderOf(file)
190
+ const source = file === entryFile ? bytes : await Bun.file(file).bytes()
191
+ inputs[file] = xxh3hex(source)
192
+ if (loader === null || /\.c[jt]s$/.test(file)) {
193
+ faithful = false
194
+ return { contents: source, loader: loader ?? 'js' }
195
+ }
196
+ const fileDir = path.dirname(file)
197
+ let t = transpilers.get(file)
198
+ if (t === undefined) {
199
+ t = new Bun.Transpiler({
200
+ loader,
201
+ define: {
202
+ 'import.meta': META,
203
+ __dirname: JSON.stringify(fileDir),
204
+ __filename: JSON.stringify(file),
205
+ },
206
+ })
207
+ transpilers.set(file, t)
208
+ }
209
+ const js = t.transformSync(new TextDecoder().decode(source))
210
+ for (const m of js.matchAll(META_USE)) {
211
+ if (m[1] !== undefined && !META_FIELDS.has(m[1].slice(1))) faithful = false
212
+ }
213
+ if (COMPUTED_LOAD.test(js)) faithful = false
214
+ const meta = {
215
+ dir: fileDir,
216
+ dirname: fileDir,
217
+ path: file,
218
+ filename: file,
219
+ file: path.basename(file),
220
+ url: Bun.pathToFileURL(file).href,
221
+ main: false,
222
+ }
223
+ return { contents: `const ${META} = ${JSON.stringify(meta)};\n${js}`, loader: 'js' }
224
+ })
225
+ },
226
+ },
227
+ ],
228
+ })
229
+ const out = path.join(tmp, `${name}.js`)
230
+ const bytecode = built.success && Bun.file(`${out}.jsc`).size > 0
231
+ const manifest: Manifest = {
232
+ version: COMPILED_VERSION,
233
+ entry: entryFile,
234
+ inputs,
235
+ bare: bareImports(built.metafile),
236
+ ...(faithful && bytecode ? {} : { skip: true as const }),
237
+ }
238
+ // The metafile names every input; one no onLoad saw (a JSON import) is
239
+ // bytes the manifest does not check.
240
+ for (const [input, entry] of Object.entries(built.metafile?.inputs ?? {})) {
241
+ const file = realpathSync(path.resolve(input))
242
+ if (inputs[file] === undefined) manifest.skip = true
243
+ // A CommonJS config's export is `module.exports`, not a namespace's `default`.
244
+ if (file === entryFile && entry.format === 'cjs') manifest.skip = true
245
+ }
246
+ for (const old of readdirSync(dir)) {
247
+ if (old.startsWith('workspace-')) rmSync(path.join(dir, old), { force: true })
248
+ }
249
+ if (manifest.skip !== true) {
250
+ renameSync(`${out}.jsc`, path.join(dir, `${name}.js.jsc`))
251
+ renameSync(out, path.join(dir, `${name}.js`))
252
+ }
253
+ // Last: a manifest names a bundle already in place.
254
+ await writeFile(path.join(tmp, 'manifest.json'), JSON.stringify(manifest))
255
+ renameSync(path.join(tmp, 'manifest.json'), path.join(dir, `${name}.json`))
256
+ } catch {
257
+ // A read-only cache directory, a full disk, a graph Bun cannot bundle:
258
+ // the next run loads the source, as this one did.
259
+ } finally {
260
+ rmSync(tmp, { recursive: true, force: true })
261
+ }
262
+ }
263
+
264
+ function bareImports(metafile: Bun.BuildMetafile | undefined): [string, string, string][] {
265
+ const out: [string, string, string][] = []
266
+ for (const [input, entry] of Object.entries(metafile?.inputs ?? {})) {
267
+ const from = path.dirname(realpathSync(path.resolve(input)))
268
+ for (const imp of entry.imports) {
269
+ const spec = imp.original
270
+ if (imp.external === true || spec === undefined) continue
271
+ if (spec.startsWith('.') || path.isAbsolute(spec)) continue
272
+ out.push([from, spec, realpathSync(path.resolve(imp.path))])
273
+ }
274
+ }
275
+ return out
276
+ }
@@ -77,5 +77,4 @@ export {
77
77
  type GeneratedTask,
78
78
  type MigrationPlan,
79
79
  type RawExpr,
80
- vxInvocation,
81
80
  } from './migration.js'
@@ -383,18 +383,18 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
383
383
  }
384
384
  }
385
385
  const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
386
- const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
387
386
  // The line is the next thing to type, so it names what that run would
388
387
  // refuse without: a git work tree (vx keys inputs on git's view) and,
389
388
  // with no scripts mapped, a task to run.
390
- const vx = vxInvocation(process.env['npm_config_user_agent'], installed)
391
- const run = `${vx} run ${firstTask} --all`
389
+ // `vx init` installs vx globally first: the line names vx alone, never
390
+ // a package manager's runner (owner, 2026-10-22: vx owns the command).
391
+ const run = `vx run ${firstTask} --all`
392
392
  // A run reads configs only from the lock; with no lock step here
393
393
  // (`--no-install`), that run stopped with "no lock: run `vx lock`".
394
394
  const steps = [
395
395
  ...(inGitWorkTree(root) ? [] : ['git init']),
396
396
  ...(empty ? [`declare a task as the example shows`] : []),
397
- ...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
397
+ ...(!dry && args.lock === undefined ? ['vx lock'] : []),
398
398
  ]
399
399
  const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
400
400
  const emptyReason = empty ? args.emptyReason : undefined
@@ -595,34 +595,6 @@ function nextTask(tasks: readonly GeneratedTask[]): string {
595
595
  return names.includes('build') ? 'build' : (fit?.name ?? names[0] ?? 'build')
596
596
  }
597
597
 
598
- /**
599
- * How the user runs vx, for the report's `next:` line. After `npx @vzn/vx init`
600
- * or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
601
- * walk, 2026-09-28), so the line names the runner that started this
602
- * process — npx, pnpm, yarn and Bun each set `npm_config_user_agent` — and
603
- * the spec it resolves. npx and bunx always name the package, which runs
604
- * the installed bin when there is one: their bare `vx` fetches an unrelated
605
- * package named `vx` wherever vx is not installed. No
606
- * runner (a global install, the compiled binary): `vx`.
607
- */
608
- export function vxInvocation(userAgent: string | undefined, installed: boolean): string {
609
- const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1]
610
- switch (runner) {
611
- case 'npm':
612
- return 'npx @vzn/vx'
613
- case 'bun':
614
- return 'bunx @vzn/vx'
615
- case 'pnpm':
616
- return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
617
- case 'yarn':
618
- // `dlx` is Yarn 2+; Yarn 1 answers `Command "dlx" not found`.
619
- if (installed) return 'yarn vx'
620
- return userAgent!.startsWith('yarn/1.') ? 'npx @vzn/vx' : 'yarn dlx @vzn/vx'
621
- default:
622
- return 'vx'
623
- }
624
- }
625
-
626
598
  /** Files named one per line; past this, the root's and a count (TanStack form: 97). */
627
599
  const FILES_SHOWN = 10
628
600