@vzn/vx 0.0.18 → 0.0.21
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/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/migrate-turbo.ts
DELETED
|
@@ -1,401 +0,0 @@
|
|
|
1
|
-
// Turbo → vx mapping. Reads turbo.json (tasks in turbo 2, pipeline in
|
|
2
|
-
// turbo 1), per-package turbo.json `extends` overlays, and each
|
|
3
|
-
// package's scripts. A task is emitted for a package only when the
|
|
4
|
-
// package declares the script (turbo semantics). Global fields become
|
|
5
|
-
// a root vx-preset.ts that configs import and spread — TypeScript
|
|
6
|
-
// composition replaces turbo's global config.
|
|
7
|
-
|
|
8
|
-
import path from 'node:path'
|
|
9
|
-
import { relPosix, UserError } from '../util/index.js'
|
|
10
|
-
import type { ProjectMeta } from '../workspace/index.js'
|
|
11
|
-
import { quote } from './migrate-emit.js'
|
|
12
|
-
import type { GeneratedProject, GeneratedTask, MigrationPlan, RawExpr } from './migrate.js'
|
|
13
|
-
|
|
14
|
-
interface TurboTask {
|
|
15
|
-
dependsOn?: string[]
|
|
16
|
-
inputs?: string[]
|
|
17
|
-
outputs?: string[]
|
|
18
|
-
env?: string[]
|
|
19
|
-
passThroughEnv?: string[]
|
|
20
|
-
cache?: boolean
|
|
21
|
-
persistent?: boolean
|
|
22
|
-
[key: string]: unknown
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface TurboJson {
|
|
26
|
-
tasks?: Record<string, TurboTask>
|
|
27
|
-
pipeline?: Record<string, TurboTask>
|
|
28
|
-
globalDependencies?: string[]
|
|
29
|
-
globalEnv?: string[]
|
|
30
|
-
globalPassThroughEnv?: string[]
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const KNOWN_TASK_KEYS = new Set([
|
|
34
|
-
'dependsOn',
|
|
35
|
-
'inputs',
|
|
36
|
-
'outputs',
|
|
37
|
-
'env',
|
|
38
|
-
'passThroughEnv',
|
|
39
|
-
'cache',
|
|
40
|
-
'persistent',
|
|
41
|
-
'extends',
|
|
42
|
-
])
|
|
43
|
-
|
|
44
|
-
const PRESET_FILE = 'vx-preset.ts'
|
|
45
|
-
|
|
46
|
-
interface Globals {
|
|
47
|
-
inputs: boolean
|
|
48
|
-
env: boolean
|
|
49
|
-
pass: boolean
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function readTurboJson(file: string, root: string): Promise<TurboJson> {
|
|
53
|
-
const text = await Bun.file(file).text()
|
|
54
|
-
try {
|
|
55
|
-
// turbo.json allows comments + trailing commas.
|
|
56
|
-
return (Bun.JSONC.parse(text) ?? {}) as TurboJson
|
|
57
|
-
} catch (err) {
|
|
58
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
59
|
-
throw new UserError(`failed to parse ${relPosix(root, file)}: ${msg}`)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
|
|
64
|
-
return cfg.tasks ?? cfg.pipeline ?? {}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
68
|
-
// package.json is a system boundary — a script value is whatever the
|
|
69
|
-
// file holds, not necessarily a string.
|
|
70
|
-
return (meta.packageJson as unknown as { scripts?: Record<string, unknown> }).scripts ?? {}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** A script value that can become `exec.command` verbatim. */
|
|
74
|
-
function usableScript(value: unknown): value is string {
|
|
75
|
-
return typeof value === 'string' && value.length > 0
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function describeScript(value: unknown): string {
|
|
79
|
-
if (value === null) return 'null'
|
|
80
|
-
if (typeof value === 'string') return 'an empty string'
|
|
81
|
-
if (Array.isArray(value)) return 'an array'
|
|
82
|
-
return `a ${typeof value}`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Declared task names for a package: plain root keys, `pkg#name` keys
|
|
86
|
-
* for this package, and per-package turbo.json keys — in that order. */
|
|
87
|
-
function taskNamesFor(
|
|
88
|
-
pkgName: string,
|
|
89
|
-
rootTasks: Record<string, TurboTask>,
|
|
90
|
-
pkgTasks: Record<string, TurboTask> | undefined,
|
|
91
|
-
): string[] {
|
|
92
|
-
const names: string[] = []
|
|
93
|
-
const push = (n: string): void => {
|
|
94
|
-
if (!names.includes(n)) names.push(n)
|
|
95
|
-
}
|
|
96
|
-
for (const key of Object.keys(rootTasks)) {
|
|
97
|
-
if (!key.includes('#')) push(key)
|
|
98
|
-
else if (key.startsWith(`${pkgName}#`)) push(key.slice(pkgName.length + 1))
|
|
99
|
-
}
|
|
100
|
-
for (const key of Object.keys(pkgTasks ?? {})) {
|
|
101
|
-
if (!key.includes('#')) push(key)
|
|
102
|
-
}
|
|
103
|
-
return names
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function migrateTurbo(
|
|
107
|
-
root: string,
|
|
108
|
-
metas: readonly ProjectMeta[],
|
|
109
|
-
): Promise<MigrationPlan> {
|
|
110
|
-
const rootCfg = await readTurboJson(path.join(root, 'turbo.json'), root)
|
|
111
|
-
const rootTasks = tasksOf(rootCfg)
|
|
112
|
-
|
|
113
|
-
const globalInputs = rootCfg.globalDependencies ?? []
|
|
114
|
-
const globalEnv = rootCfg.globalEnv ?? []
|
|
115
|
-
const globalPass = rootCfg.globalPassThroughEnv ?? []
|
|
116
|
-
const globals: Globals = {
|
|
117
|
-
inputs: globalInputs.length > 0,
|
|
118
|
-
env: globalEnv.length > 0,
|
|
119
|
-
pass: globalPass.length > 0,
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const pkgTasksByName = new Map<string, Record<string, TurboTask>>()
|
|
123
|
-
for (const meta of metas) {
|
|
124
|
-
const file = path.join(meta.dir, 'turbo.json')
|
|
125
|
-
if (await Bun.file(file).exists()) {
|
|
126
|
-
pkgTasksByName.set(meta.name, tasksOf(await readTurboJson(file, root)))
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const notes: string[] = []
|
|
131
|
-
for (const key of Object.keys(rootTasks)) {
|
|
132
|
-
if (key.startsWith('//#')) {
|
|
133
|
-
notes.push(`note: root task ${key} not migrated — vx has no workspace-root tasks`)
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// First pass: which tasks does each package emit? Needed so dependsOn
|
|
138
|
-
// edges can be validated/dropped against the real emitted set.
|
|
139
|
-
const emitted = new Map<string, Set<string>>()
|
|
140
|
-
for (const meta of metas) {
|
|
141
|
-
const scripts = scriptsOf(meta)
|
|
142
|
-
const set = new Set<string>()
|
|
143
|
-
for (const name of taskNamesFor(meta.name, rootTasks, pkgTasksByName.get(meta.name))) {
|
|
144
|
-
if (usableScript(scripts[name])) set.add(name)
|
|
145
|
-
}
|
|
146
|
-
emitted.set(meta.name, set)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const projects: GeneratedProject[] = []
|
|
150
|
-
for (const meta of metas) {
|
|
151
|
-
const scripts = scriptsOf(meta)
|
|
152
|
-
const pkgTasks = pkgTasksByName.get(meta.name)
|
|
153
|
-
const own = emitted.get(meta.name)!
|
|
154
|
-
const used = new Set<string>()
|
|
155
|
-
const tasks: GeneratedTask[] = []
|
|
156
|
-
for (const name of taskNamesFor(meta.name, rootTasks, pkgTasks)) {
|
|
157
|
-
const script = scripts[name]
|
|
158
|
-
if (!usableScript(script)) {
|
|
159
|
-
// The turbo task exists and so does the script KEY, but its value
|
|
160
|
-
// can't become a command. Report it instead of emitting
|
|
161
|
-
// `command: 42` / `command: null` — the first writes a config that
|
|
162
|
-
// fails to load, the second used to abort the whole migration in
|
|
163
|
-
// the emitter, and both landed AFTER "migrated clean, 0 TODOs".
|
|
164
|
-
// An ABSENT script stays silent: turbo skips the task there too.
|
|
165
|
-
if (script !== undefined) {
|
|
166
|
-
tasks.push({
|
|
167
|
-
name,
|
|
168
|
-
todos: [
|
|
169
|
-
`package.json script ${JSON.stringify(name)} is ${describeScript(script)}, not a ` +
|
|
170
|
-
'non-empty command string — task skipped; write the command by hand',
|
|
171
|
-
],
|
|
172
|
-
task: null,
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
continue
|
|
176
|
-
}
|
|
177
|
-
const def: TurboTask = {
|
|
178
|
-
...rootTasks[name],
|
|
179
|
-
...rootTasks[`${meta.name}#${name}`],
|
|
180
|
-
...pkgTasks?.[name],
|
|
181
|
-
}
|
|
182
|
-
tasks.push(buildTask(name, def, script, own, emitted, globals, used))
|
|
183
|
-
}
|
|
184
|
-
projects.push({
|
|
185
|
-
name: meta.name,
|
|
186
|
-
dir: meta.dir,
|
|
187
|
-
importLines: presetImportLines(used, root, meta.dir),
|
|
188
|
-
tasks,
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const extraFiles: MigrationPlan['extraFiles'] = []
|
|
193
|
-
if (globals.inputs || globals.env || globals.pass) {
|
|
194
|
-
extraFiles.push({
|
|
195
|
-
relPath: PRESET_FILE,
|
|
196
|
-
contents: renderPreset(globalInputs, globalEnv, globalPass),
|
|
197
|
-
})
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return { headerNotes: [], projects, extraFiles, notes }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function buildTask(
|
|
204
|
-
name: string,
|
|
205
|
-
def: TurboTask,
|
|
206
|
-
command: string,
|
|
207
|
-
own: ReadonlySet<string>,
|
|
208
|
-
emitted: ReadonlyMap<string, ReadonlySet<string>>,
|
|
209
|
-
globals: Globals,
|
|
210
|
-
used: Set<string>,
|
|
211
|
-
): GeneratedTask {
|
|
212
|
-
const todos: string[] = []
|
|
213
|
-
const persistent = def.persistent === true
|
|
214
|
-
const cacheEnabled = def.cache !== false && !persistent
|
|
215
|
-
|
|
216
|
-
for (const [key, value] of Object.entries(def)) {
|
|
217
|
-
if (KNOWN_TASK_KEYS.has(key)) continue
|
|
218
|
-
todos.push(
|
|
219
|
-
`turbo key ${JSON.stringify(key)} (${JSON.stringify(value)}) has no vx equivalent — ` +
|
|
220
|
-
'map it manually',
|
|
221
|
-
)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const deps: string[] = []
|
|
225
|
-
for (const d of def.dependsOn ?? []) {
|
|
226
|
-
if (d.includes('$TURBO_ROOT$')) {
|
|
227
|
-
todos.push(
|
|
228
|
-
`dependsOn ${JSON.stringify(d)} uses $TURBO_ROOT$ — vx has no workspace-root tasks; ` +
|
|
229
|
-
'restructure manually',
|
|
230
|
-
)
|
|
231
|
-
continue
|
|
232
|
-
}
|
|
233
|
-
if (d.startsWith('^')) {
|
|
234
|
-
deps.push(d)
|
|
235
|
-
continue
|
|
236
|
-
}
|
|
237
|
-
const hashAt = d.indexOf('#')
|
|
238
|
-
if (hashAt !== -1) {
|
|
239
|
-
const pkg = d.slice(0, hashAt)
|
|
240
|
-
const task = d.slice(hashAt + 1)
|
|
241
|
-
if (emitted.get(pkg)?.has(task)) deps.push(d)
|
|
242
|
-
else
|
|
243
|
-
todos.push(
|
|
244
|
-
`dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
|
|
245
|
-
)
|
|
246
|
-
continue
|
|
247
|
-
}
|
|
248
|
-
// Same-project dep on a script this package lacks: turbo silently
|
|
249
|
-
// skips the task there, so the edge simply doesn't exist.
|
|
250
|
-
if (own.has(d)) deps.push(d)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const envNames: string[] = []
|
|
254
|
-
for (const e of def.env ?? []) {
|
|
255
|
-
if (/[*?[\]!]/.test(e)) {
|
|
256
|
-
todos.push(
|
|
257
|
-
`env ${JSON.stringify(e)}: wildcards are not supported in vx env names — ` +
|
|
258
|
-
'list explicit names in cache.inputs.env + exec.env.passThrough',
|
|
259
|
-
)
|
|
260
|
-
} else envNames.push(e)
|
|
261
|
-
}
|
|
262
|
-
const passNames: string[] = []
|
|
263
|
-
for (const e of def.passThroughEnv ?? []) {
|
|
264
|
-
if (/[*?[\]!]/.test(e)) {
|
|
265
|
-
todos.push(
|
|
266
|
-
`passThroughEnv ${JSON.stringify(e)}: wildcards are not supported — list explicit names`,
|
|
267
|
-
)
|
|
268
|
-
} else passNames.push(e)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const passThrough: (string | RawExpr)[] = []
|
|
272
|
-
if (globals.env) {
|
|
273
|
-
passThrough.push({ raw: '...globalEnvInputs' })
|
|
274
|
-
used.add('globalEnvInputs')
|
|
275
|
-
}
|
|
276
|
-
if (globals.pass) {
|
|
277
|
-
passThrough.push({ raw: '...globalPassThroughEnv' })
|
|
278
|
-
used.add('globalPassThroughEnv')
|
|
279
|
-
}
|
|
280
|
-
passThrough.push(...envNames, ...passNames)
|
|
281
|
-
|
|
282
|
-
const exec: Record<string, unknown> = { command }
|
|
283
|
-
if (passThrough.length > 0) exec.env = { passThrough }
|
|
284
|
-
if (persistent) {
|
|
285
|
-
exec.persistent = {}
|
|
286
|
-
todos.push(
|
|
287
|
-
'persistent task — set persistent.readyWhen (regex matched against output) so ' +
|
|
288
|
-
'dependents unblock on readiness, and consider exec.timeout to bound the wait',
|
|
289
|
-
)
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const task: Record<string, unknown> = { exec }
|
|
293
|
-
if (deps.length > 0) task.dependsOn = deps
|
|
294
|
-
|
|
295
|
-
if (cacheEnabled) {
|
|
296
|
-
const files: (string | RawExpr)[] = []
|
|
297
|
-
const wsFiles: (string | RawExpr)[] = []
|
|
298
|
-
// globalDependencies are workspace-root-relative by definition —
|
|
299
|
-
// they map to inputs.workspaceFiles, not project-relative files.
|
|
300
|
-
if (globals.inputs) {
|
|
301
|
-
wsFiles.push({ raw: '...globalInputs' })
|
|
302
|
-
used.add('globalInputs')
|
|
303
|
-
}
|
|
304
|
-
if (def.inputs === undefined) {
|
|
305
|
-
// Turbo's default input set is every package file.
|
|
306
|
-
files.push('**/*')
|
|
307
|
-
} else {
|
|
308
|
-
for (const i of def.inputs) {
|
|
309
|
-
if (i === '$TURBO_DEFAULT$') {
|
|
310
|
-
files.push('**/*')
|
|
311
|
-
continue
|
|
312
|
-
}
|
|
313
|
-
const neg = i.startsWith('!')
|
|
314
|
-
const body = neg ? i.slice(1) : i
|
|
315
|
-
if (body.startsWith('$TURBO_ROOT$/')) {
|
|
316
|
-
wsFiles.push((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length))
|
|
317
|
-
} else if (i.includes('$TURBO_ROOT$')) {
|
|
318
|
-
todos.push(
|
|
319
|
-
`input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
|
|
320
|
-
'prefix (→ cache.inputs.workspaceFiles) — map manually',
|
|
321
|
-
)
|
|
322
|
-
} else files.push(i)
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const outFiles: string[] = []
|
|
327
|
-
const wsOutFiles: string[] = []
|
|
328
|
-
for (const o of def.outputs ?? []) {
|
|
329
|
-
if (o.startsWith('!')) {
|
|
330
|
-
todos.push(
|
|
331
|
-
`output ${JSON.stringify(o)}: vx outputs have no negation — narrow the positive ` +
|
|
332
|
-
'globs instead',
|
|
333
|
-
)
|
|
334
|
-
} else if (o.startsWith('$TURBO_ROOT$/')) {
|
|
335
|
-
wsOutFiles.push(o.slice('$TURBO_ROOT$/'.length))
|
|
336
|
-
} else if (o.includes('$TURBO_ROOT$')) {
|
|
337
|
-
todos.push(
|
|
338
|
-
`output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
|
|
339
|
-
'prefix (→ cache.outputs.workspaceFiles) — map manually',
|
|
340
|
-
)
|
|
341
|
-
} else outFiles.push(o)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const cacheEnv: (string | RawExpr)[] = []
|
|
345
|
-
if (globals.env) cacheEnv.push({ raw: '...globalEnvInputs' })
|
|
346
|
-
cacheEnv.push(...envNames)
|
|
347
|
-
|
|
348
|
-
const inputs: Record<string, unknown> = { files }
|
|
349
|
-
if (wsFiles.length > 0) inputs.workspaceFiles = wsFiles
|
|
350
|
-
if (cacheEnv.length > 0) inputs.env = cacheEnv
|
|
351
|
-
const outputs: Record<string, unknown> = { files: outFiles }
|
|
352
|
-
if (wsOutFiles.length > 0) outputs.workspaceFiles = wsOutFiles
|
|
353
|
-
task.cache = { inputs, outputs }
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return { name, todos, task }
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function presetImportLines(used: ReadonlySet<string>, root: string, dir: string): string[] {
|
|
360
|
-
if (used.size === 0) return []
|
|
361
|
-
const rel = relPosix(dir, path.join(root, PRESET_FILE))
|
|
362
|
-
const spec = rel.startsWith('.') ? rel : `./${rel}`
|
|
363
|
-
return [`import { ${[...used].sort().join(', ')} } from '${spec}'`]
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function renderPreset(inputs: string[], env: string[], pass: string[]): string {
|
|
367
|
-
// Escape each entry via the shared `quote()` — a turbo.json global (a file
|
|
368
|
-
// glob, or an env name a user hand-wrote) may contain a `'`/`\`/newline that
|
|
369
|
-
// would otherwise splice into a malformed, unloadable `vx-preset.ts`.
|
|
370
|
-
const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
|
|
371
|
-
const lines = [
|
|
372
|
-
'// Generated by `vx migrate` from turbo.json. TypeScript composition',
|
|
373
|
-
"// replaces turbo's global fields: each vx.config.ts imports these",
|
|
374
|
-
'// arrays and spreads them into the matching task fields.',
|
|
375
|
-
]
|
|
376
|
-
if (inputs.length > 0) {
|
|
377
|
-
lines.push(
|
|
378
|
-
'',
|
|
379
|
-
'// From globalDependencies — workspace-root-relative, spread into each',
|
|
380
|
-
'// task’s cache.inputs.workspaceFiles (the $TURBO_ROOT$ equivalent).',
|
|
381
|
-
`export const globalInputs = ${arr(inputs)}`,
|
|
382
|
-
)
|
|
383
|
-
}
|
|
384
|
-
if (env.length > 0) {
|
|
385
|
-
lines.push(
|
|
386
|
-
'',
|
|
387
|
-
'// From globalEnv: cache inputs AND passed through to every task',
|
|
388
|
-
'// (vx child environments are isolated; see docs/schema.md).',
|
|
389
|
-
`export const globalEnvInputs = ${arr(env)}`,
|
|
390
|
-
)
|
|
391
|
-
}
|
|
392
|
-
if (pass.length > 0) {
|
|
393
|
-
lines.push(
|
|
394
|
-
'',
|
|
395
|
-
'// From globalPassThroughEnv: forwarded to every task, never hashed.',
|
|
396
|
-
`export const globalPassThroughEnv = ${arr(pass)}`,
|
|
397
|
-
)
|
|
398
|
-
}
|
|
399
|
-
lines.push('')
|
|
400
|
-
return lines.join('\n')
|
|
401
|
-
}
|
package/src/cli/prune.ts
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
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
|
-
}
|