@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.
- package/README.md +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cli/plan-format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/cli/prune.ts
ADDED
|
@@ -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
|
+
}
|