@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -1,17 +1,20 @@
1
1
  // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
2
2
 
3
3
  import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
4
+ import { formatDuration } from '../orchestrator/index.js'
4
5
 
5
6
  /**
6
7
  * Human-readable preview. One line per real task (groups hidden, same
7
- * as the live runner), tagged with predicted cache outcome.
8
+ * as the live runner), tagged with predicted cache outcome. A would-run
9
+ * task with recorded history also shows its typical duration (~p50).
8
10
  *
9
11
  * would run:
10
12
  * ✓ @vzn/vx#lint cache hit (local) abc12345
11
13
  * ↓ @vzn/vx#test cache hit (remote) def67890
12
- * ▶ @vzn/vx#build cache miss — would exec fedcba98
14
+ * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.2s
13
15
  *
14
16
  * 3 task(s) planned: 2 cache hits (1 local, 1 remote), 1 would run.
17
+ * predicted: ~1.2s wall · ~1.2s total execution
15
18
  */
16
19
  export function formatPlanText(plan: RunPlan): string {
17
20
  const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
@@ -29,7 +32,16 @@ export function formatPlanText(plan: RunPlan): string {
29
32
  const sym = symbolFor(t.cacheStatus)
30
33
  const desc = describe(t.cacheStatus)
31
34
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
32
- lines.push(` ${sym} ${t.node.id.padEnd(idWidth)} ${desc.padEnd(tagWidth)} ${shortHash}`)
35
+ const executes = t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache'
36
+ const eta = executes && t.p50Ms !== undefined ? ` ~${formatDuration(t.p50Ms)}` : ''
37
+ // Placement, by EXECUTOR NAME rather than a local/remote word: the
38
+ // summary line below already spends "local" and "remote" on the cache
39
+ // tier, and a task placed on a named executor is what the reader can
40
+ // act on. Present only when the workspace declared a choice.
41
+ const where = t.executor !== undefined ? ` @${t.executor}` : ''
42
+ lines.push(
43
+ ` ${sym} ${t.node.id.padEnd(idWidth)} ${desc.padEnd(tagWidth)} ${shortHash}${eta}${where}`,
44
+ )
33
45
  // Optional one-line description from the task config, indented
34
46
  // under the id so the eye picks up the task → blurb mapping.
35
47
  const taskDesc = t.node.config.description
@@ -52,6 +64,35 @@ export function formatPlanText(plan: RunPlan): string {
52
64
  if (nocache > 0) summary.push(`${nocache} no-cache`)
53
65
  lines.push('')
54
66
  lines.push(summary.join(', ') + '.')
67
+
68
+ // `--download` is invisible without this: the eligibility gate silently
69
+ // keeps producers eager, so a user who asked for `none` and got no
70
+ // deferral has nothing to read. Say what WOULD stay remote, and name the
71
+ // refusals when nothing would.
72
+ const deferred = real.filter((t) => t.download === 'deferred')
73
+ const downgrades = plan.downloadDowngrades ?? []
74
+ if (deferred.length > 0 || downgrades.length > 0) {
75
+ lines.push('')
76
+ lines.push(
77
+ `download: ${deferred.length} task(s) would keep outputs remote` +
78
+ (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
79
+ )
80
+ for (const d of downgrades.slice(0, 3)) lines.push(` ${d.taskId} — ${d.reason}`)
81
+ if (downgrades.length > 3) lines.push(` …and ${downgrades.length - 3} more`)
82
+ }
83
+
84
+ // Time prediction — only when history gave us something to say: at least
85
+ // one would-run task has a p50 (an all-unknown prediction is pure noise).
86
+ const p = plan.predicted
87
+ const executes = miss + nocache
88
+ if (p !== undefined && executes > 0 && executes > p.unknownCount) {
89
+ const parts = [`predicted: ~${formatDuration(p.wallMs)} wall`]
90
+ parts.push(`~${formatDuration(p.workMs)} total execution`)
91
+ if (p.unknownCount > 0) {
92
+ parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
93
+ }
94
+ lines.push(parts.join(' · '))
95
+ }
55
96
  return lines.join('\n') + '\n'
56
97
  }
57
98
 
@@ -66,10 +107,24 @@ export function formatPlanJson(plan: RunPlan): string {
66
107
  hash: t.hash,
67
108
  cacheStatus: t.cacheStatus,
68
109
  deps: t.deps,
110
+ ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
111
+ ...(t.executor !== undefined ? { executor: t.executor } : {}),
112
+ ...(t.download !== undefined ? { download: t.download } : {}),
113
+
69
114
  ...(t.node.config.description !== undefined
70
115
  ? { description: t.node.config.description }
71
116
  : {}),
72
117
  })),
118
+ ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
119
+ // The gate's refusals belong on the SCRIPTING surface too: a CI job
120
+ // asking "did --download=none actually defer anything, and if not
121
+ // why" reads this, not the human table. (This object enumerates its
122
+ // fields deliberately — the plan's internal shape is not the wire —
123
+ // which is exactly why a new PlannedTask field does not appear here
124
+ // for free, and why `download` above had to be added by hand.)
125
+ ...(plan.downloadDowngrades !== undefined
126
+ ? { downloadDowngrades: plan.downloadDowngrades }
127
+ : {}),
73
128
  },
74
129
  null,
75
130
  2,
@@ -0,0 +1,96 @@
1
+ // Plugin-contributed CLI verbs (`VxPlugin.commands`).
2
+ //
3
+ // Consulted only when the dispatcher has no core verb for the word: the
4
+ // workspace config is loaded from the cwd, and the first plugin (in
5
+ // declaration order) declaring the verb runs it. Outside a workspace, or
6
+ // with no plugin declaring it, the answer is null and the dispatcher
7
+ // reports an unknown command as it always did. Core's verbs are matched
8
+ // before this is asked, so a plugin cannot shadow `run`.
9
+
10
+ import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
11
+ import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
12
+
13
+ export interface ResolvedPluginCommand {
14
+ plugin: VxPlugin
15
+ command: PluginCommand
16
+ ctx: CommandContext
17
+ }
18
+
19
+ /**
20
+ * Why a verb could not be looked up: the workspace file failed to load.
21
+ * Surfaced next to "unknown command" rather than as the whole answer — a
22
+ * typo'd verb must still read as a typo, and a real plugin verb must still
23
+ * point at the file that broke it.
24
+ */
25
+ export interface UnresolvedPluginCommand {
26
+ loadError: string
27
+ }
28
+
29
+ async function workspacePlugins(
30
+ cwd: string,
31
+ ): Promise<
32
+ | { workspaceRoot: string; cacheDir: string; plugins: readonly VxPlugin[] }
33
+ | UnresolvedPluginCommand
34
+ | null
35
+ > {
36
+ let workspaceRoot: string
37
+ try {
38
+ workspaceRoot = await findWorkspaceRoot(cwd)
39
+ } catch {
40
+ return null
41
+ }
42
+ let config
43
+ try {
44
+ config = await loadWorkspaceConfig(workspaceRoot)
45
+ } catch (err) {
46
+ return { loadError: err instanceof Error ? err.message : String(err) }
47
+ }
48
+ return {
49
+ workspaceRoot,
50
+ cacheDir: resolveCacheDir(workspaceRoot, config),
51
+ plugins: (config?.plugins ?? []) as readonly VxPlugin[],
52
+ }
53
+ }
54
+
55
+ /**
56
+ * The plugin verb named `verb` for the workspace around `cwd`; null when
57
+ * there is none; the load error when the workspace file could not say.
58
+ */
59
+ export async function resolvePluginCommand(
60
+ verb: string,
61
+ cwd = process.cwd(),
62
+ ): Promise<ResolvedPluginCommand | UnresolvedPluginCommand | null> {
63
+ const ws = await workspacePlugins(cwd)
64
+ if (ws === null) return null
65
+ if ('loadError' in ws) return ws
66
+ for (const plugin of ws.plugins) {
67
+ const command = plugin.commands?.[verb]
68
+ if (command === undefined) continue
69
+ return {
70
+ plugin,
71
+ command,
72
+ ctx: {
73
+ workspaceRoot: ws.workspaceRoot,
74
+ cacheDir: ws.cacheDir,
75
+ warn: (m) => process.stderr.write(`${m}\n`),
76
+ },
77
+ }
78
+ }
79
+ return null
80
+ }
81
+
82
+ /** `vx help` lines for every plugin verb in the workspace around `cwd`. */
83
+ export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
84
+ const ws = await workspacePlugins(cwd).catch(() => null)
85
+ if (ws === null || 'loadError' in ws) return []
86
+ const lines: string[] = []
87
+ const seen = new Set<string>()
88
+ for (const plugin of ws.plugins) {
89
+ for (const [verb, command] of Object.entries(plugin.commands ?? {})) {
90
+ if (seen.has(verb)) continue
91
+ seen.add(verb)
92
+ lines.push(` vx ${verb.padEnd(17)} ${command.description} (${plugin.name})`)
93
+ }
94
+ }
95
+ return lines
96
+ }
@@ -0,0 +1,291 @@
1
+ // `vx prune <project> [--out-dir <dir>] [--docker]` — emit a self-contained
2
+ // SUBSET of the workspace containing one project and its transitive
3
+ // workspace dependencies, for Docker builds (Turbo `turbo prune` parity,
4
+ // comparison gap #10).
5
+ //
6
+ // What lands in the output:
7
+ // <out>/ the pruned workspace (or <out>/full/ with --docker)
8
+ // package.json root manifest, copied as-is
9
+ // pnpm-workspace.yaml REWRITTEN to the exact subset dirs (a glob that
10
+ // matches dirs absent from the subset would make
11
+ // pnpm error on install)
12
+ // <lockfile> copied UNPRUNED — pnpm/bun/npm/yarn all tolerate
13
+ // a superset lockfile; real lockfile pruning is a
14
+ // per-format project (Turbo ships a crate per
15
+ // format) and a wrong pruned lockfile is worse
16
+ // than a big correct one. Documented, deliberate.
17
+ // vx.workspace.* copied when present, together with any WORKSPACE
18
+ // package it imports (a plugin lives in the subset
19
+ // or `vx run` cannot load the config at all)
20
+ // .npmrc / .nvmrc copied when present
21
+ // <pkg dirs> full source of the project + every transitive
22
+ // workspace dep (node_modules / .git / .vx / .turbo
23
+ // excluded)
24
+ // <out>/json/ with --docker: manifests only (root files + each
25
+ // package's package.json) — COPY this layer first
26
+ // so `pnpm install` caches independently of source
27
+ // edits, then COPY full/ and build.
28
+ //
29
+ // Boundaries note: a package dir is copied WHOLE (minus the exclusions).
30
+ // vx's own nested-project input exclusions do not apply here — prune
31
+ // reproduces the tree, it does not hash it.
32
+ //
33
+ // RUNNABILITY IS NOT GUARANTEED, and this deliberately does not claim it.
34
+ // A config may import any path it likes; only imports that name a workspace
35
+ // PACKAGE can be resolved and pulled in. A relative import escaping its own
36
+ // package dir (`../../src/index.ts`, the shape this repo's own dogfooding
37
+ // configs use) reaches a file prune never copies, and no subset short of the
38
+ // whole tree would contain it. Those are reported at prune time — a warning
39
+ // the user reads now beats a module-not-found inside a docker build.
40
+ // The import scan is STATIC and deliberately simple: it sees `from '…'`,
41
+ // `import '…'` and `import('…')`, and nothing computed.
42
+
43
+ import { cp, mkdir, stat, writeFile } from 'node:fs/promises'
44
+ import path from 'node:path'
45
+ import {
46
+ buildPackageGraph,
47
+ findWorkspaceRoot,
48
+ loadWorkspace,
49
+ listProjects,
50
+ } from '../workspace/index.js'
51
+ import { UserError } from '../util/index.js'
52
+
53
+ interface PruneWorkspaceArgs {
54
+ project?: string
55
+ outDir: string
56
+ docker: boolean
57
+ error?: string
58
+ }
59
+
60
+ export function parsePruneWorkspaceArgs(args: readonly string[]): PruneWorkspaceArgs {
61
+ const out: PruneWorkspaceArgs = { outDir: 'out', docker: false }
62
+ for (let i = 0; i < args.length; i++) {
63
+ const a = args[i]!
64
+ if (a === '--out-dir' || a.startsWith('--out-dir=')) {
65
+ const v = a === '--out-dir' ? args[++i] : a.slice('--out-dir='.length)
66
+ if (v === undefined || v === '') return { ...out, error: 'invalid --out-dir: empty' }
67
+ out.outDir = v
68
+ continue
69
+ }
70
+ if (a === '--docker') {
71
+ out.docker = true
72
+ continue
73
+ }
74
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
75
+ if (out.project !== undefined) return { ...out, error: `unexpected argument: ${a}` }
76
+ out.project = a
77
+ }
78
+ return out
79
+ }
80
+
81
+ const COPY_EXCLUDES = new Set(['node_modules', '.git', '.vx', '.turbo'])
82
+
83
+ /** Root-level files worth carrying into the subset, when they exist. */
84
+ const ROOT_FILES = ['package.json', '.npmrc', '.nvmrc']
85
+ const WORKSPACE_CONFIGS = [
86
+ 'vx.workspace.ts',
87
+ 'vx.workspace.mts',
88
+ 'vx.workspace.js',
89
+ 'vx.workspace.mjs',
90
+ ]
91
+ const LOCKFILES = ['pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'package-lock.json', 'yarn.lock']
92
+
93
+ async function exists(p: string): Promise<boolean> {
94
+ return stat(p).then(
95
+ () => true,
96
+ () => false,
97
+ )
98
+ }
99
+
100
+ async function copyDirFiltered(src: string, dest: string): Promise<void> {
101
+ await cp(src, dest, {
102
+ recursive: true,
103
+ filter: (source) => !COPY_EXCLUDES.has(path.basename(source)),
104
+ })
105
+ }
106
+
107
+ const PROJECT_CONFIGS = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
108
+ /** `from '…'`, `import '…'`, `import('…')`. Static specifiers only. */
109
+ const SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
110
+
111
+ async function readImports(dir: string, names: readonly string[]): Promise<string[]> {
112
+ for (const f of names) {
113
+ const p = path.join(dir, f)
114
+ if (!(await exists(p))) continue
115
+ const text = await Bun.file(p).text()
116
+ return [...text.matchAll(SPECIFIER_RE)].map((m) => m[1]!)
117
+ }
118
+ return []
119
+ }
120
+
121
+ export async function pruneWorkspaceCmd(args: readonly string[]): Promise<number> {
122
+ const parsed = parsePruneWorkspaceArgs(args)
123
+ if (parsed.error !== undefined) throw new UserError(`vx prune: ${parsed.error}`)
124
+ if (parsed.project === undefined) {
125
+ throw new UserError('vx prune: <project> required (e.g. vx prune @acme/api)')
126
+ }
127
+
128
+ const root = await findWorkspaceRoot(process.cwd())
129
+ const workspace = await loadWorkspace(root)
130
+ const projects = await listProjects(workspace)
131
+ const byName = new Map(projects.map((p) => [p.name, p]))
132
+
133
+ const target = byName.get(parsed.project)
134
+ if (target === undefined) {
135
+ const names = [...byName.keys()].sort()
136
+ const q = parsed.project.toLowerCase()
137
+ const near = names.filter((n) => n.toLowerCase().includes(q)).slice(0, 3)
138
+ throw new UserError(
139
+ `vx prune: no project named "${parsed.project}"` +
140
+ (near.length > 0 ? ` — did you mean ${near.join(', ')}?` : ''),
141
+ )
142
+ }
143
+
144
+ const graph = buildPackageGraph(projects)
145
+ const names = new Set([target.name, ...graph.transitiveDeps(target.name)])
146
+
147
+ // The workspace config runs BEFORE any task, so a plugin it imports from a
148
+ // workspace package is as load-bearing as a package dependency — without it
149
+ // `vx run` in the container fails to load the config at all. Pull those in
150
+ // (with their own dep closure). The root member is the one that cannot be:
151
+ // copying it would mean copying the whole tree, so it is reported instead.
152
+ const projectFor = (spec: string): (typeof projects)[number] | undefined =>
153
+ projects.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
154
+ const uncopyable = new Set<string>()
155
+ for (const spec of await readImports(root, WORKSPACE_CONFIGS)) {
156
+ const owner = projectFor(spec)
157
+ if (owner === undefined || names.has(owner.name)) continue
158
+ if (owner.dir === root) {
159
+ uncopyable.add(`${owner.name} (the workspace root itself)`)
160
+ continue
161
+ }
162
+ names.add(owner.name)
163
+ for (const d of graph.transitiveDeps(owner.name)) names.add(d)
164
+ }
165
+
166
+ const subset = [...names].map((n) => byName.get(n)!).sort((a, b) => a.name.localeCompare(b.name))
167
+
168
+ const outAbs = path.resolve(process.cwd(), parsed.outDir)
169
+ // Three shapes that would eat their own tail: the out dir IS the root
170
+ // (overwrites the workspace), CONTAINS the root (copies land above the
171
+ // repo), or sits INSIDE a package being copied (cp would recurse into
172
+ // its own output). A root-level ./out is fine — only package dirs are
173
+ // copied wholesale, never the root itself.
174
+ if (outAbs === root || root.startsWith(outAbs + path.sep)) {
175
+ throw new UserError('vx prune: --out-dir must not be, or contain, the workspace root')
176
+ }
177
+ for (const p of subset) {
178
+ if (outAbs === p.dir || outAbs.startsWith(p.dir + path.sep)) {
179
+ throw new UserError(
180
+ `vx prune: --out-dir is inside ${p.name}, which is being copied — pick a path outside the pruned packages`,
181
+ )
182
+ }
183
+ }
184
+ const fullDir = parsed.docker ? path.join(outAbs, 'full') : outAbs
185
+ const jsonDir = path.join(outAbs, 'json')
186
+ await mkdir(fullDir, { recursive: true })
187
+ if (parsed.docker) await mkdir(jsonDir, { recursive: true })
188
+
189
+ const rels = subset.map((p) => path.relative(root, p.dir).split(path.sep).join('/'))
190
+ // pnpm-workspace.yaml rewritten to the exact subset — globs that match
191
+ // nothing in the subset make installs fail.
192
+ const workspaceYaml = `packages:\n${rels.map((r) => ` - "${r}"\n`).join('')}`
193
+
194
+ // package.json's `workspaces` is where bun/npm/yarn read membership, and it
195
+ // needs the SAME rewrite as pnpm-workspace.yaml for the same reason — except
196
+ // the failure is sharper: a GLOB matching nothing is tolerated, but an entry
197
+ // naming an exact dir that the subset does not contain is fatal
198
+ // (`bun install` → `error: Workspace not found "packages/b"`, exit 1), so the
199
+ // emitted context would not install at all. A `.` entry is kept when it was
200
+ // there: it names the root package, whose manifest is copied.
201
+ const rewriteRootManifest = async (dest: string): Promise<boolean> => {
202
+ const src = path.join(root, 'package.json')
203
+ if (!(await exists(src))) return false
204
+ let pkg: Record<string, unknown>
205
+ try {
206
+ pkg = JSON.parse(await Bun.file(src).text()) as Record<string, unknown>
207
+ } catch {
208
+ return false // unparseable: copy it verbatim rather than lose it
209
+ }
210
+ const field = pkg['workspaces']
211
+ const list = Array.isArray(field)
212
+ ? field
213
+ : typeof field === 'object' && field !== null && Array.isArray((field as never)['packages'])
214
+ ? ((field as never)['packages'] as string[])
215
+ : undefined
216
+ if (list === undefined) return false
217
+ const next = (list as string[]).includes('.') ? ['.', ...rels] : [...rels]
218
+ pkg['workspaces'] = Array.isArray(field) ? next : { ...(field as object), packages: next }
219
+ await writeFile(path.join(dest, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`)
220
+ return true
221
+ }
222
+
223
+ const emitRoots = async (dest: string): Promise<void> => {
224
+ await writeFile(path.join(dest, 'pnpm-workspace.yaml'), workspaceYaml)
225
+ const manifestWritten = await rewriteRootManifest(dest)
226
+ for (const f of [...ROOT_FILES, ...WORKSPACE_CONFIGS, ...LOCKFILES]) {
227
+ if (f === 'package.json' && manifestWritten) continue
228
+ const src = path.join(root, f)
229
+ if (await exists(src)) await cp(src, path.join(dest, f))
230
+ }
231
+ }
232
+
233
+ await emitRoots(fullDir)
234
+ for (const [i, p] of subset.entries()) {
235
+ await copyDirFiltered(p.dir, path.join(fullDir, rels[i]!))
236
+ }
237
+
238
+ if (parsed.docker) {
239
+ await emitRoots(jsonDir)
240
+ for (const [i, p] of subset.entries()) {
241
+ const pkgJson = path.join(p.dir, 'package.json')
242
+ if (await exists(pkgJson)) {
243
+ const destDir = path.join(jsonDir, rels[i]!)
244
+ await mkdir(destDir, { recursive: true })
245
+ await cp(pkgJson, path.join(destDir, 'package.json'))
246
+ }
247
+ }
248
+ }
249
+
250
+ // Every copied config is re-read to report what the subset canNOT satisfy:
251
+ // a relative import reaching outside the copied dirs, or a workspace package
252
+ // that is not here. Copied root files (the manifest, the lockfile, the
253
+ // workspace config) count as present — a config importing one of those is
254
+ // fine, and flagging it would be a false alarm.
255
+ const copiedRootFiles = new Set([...ROOT_FILES, ...WORKSPACE_CONFIGS, ...LOCKFILES])
256
+ const escapes: string[] = []
257
+ for (const p of subset) {
258
+ for (const spec of await readImports(p.dir, PROJECT_CONFIGS)) {
259
+ if (spec.startsWith('.')) {
260
+ const resolved = path.resolve(p.dir, spec)
261
+ const inSubset = subset.some(
262
+ (q) => resolved === q.dir || resolved.startsWith(q.dir + path.sep),
263
+ )
264
+ const isCopiedRootFile =
265
+ path.dirname(resolved) === root && copiedRootFiles.has(path.basename(resolved))
266
+ if (!inSubset && !isCopiedRootFile) {
267
+ escapes.push(`${p.name}: ${spec} resolves outside the pruned subset`)
268
+ }
269
+ continue
270
+ }
271
+ const owner = projectFor(spec)
272
+ if (owner !== undefined && !subset.includes(owner)) {
273
+ escapes.push(`${p.name}: imports ${owner.name}, which is not in the subset`)
274
+ }
275
+ }
276
+ }
277
+
278
+ process.stdout.write(
279
+ `pruned ${subset.length} package${subset.length === 1 ? '' : 's'} for ${target.name} → ${parsed.outDir}${parsed.docker ? ' (docker layout: json/ + full/)' : ''}\n` +
280
+ subset.map((p) => ` ${p.name}\n`).join(''),
281
+ )
282
+ for (const u of uncopyable) {
283
+ process.stderr.write(
284
+ `vx prune: WARNING the workspace config imports ${u} — it cannot be copied into a subset, so \`vx run\` inside the container will not load it\n`,
285
+ )
286
+ }
287
+ for (const e of escapes) {
288
+ process.stderr.write(`vx prune: WARNING ${e}\n`)
289
+ }
290
+ return 0
291
+ }