@vzn/vx 0.0.16 → 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 +66 -89
- 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 +88 -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 +12 -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/last.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// `vx last [runId]` — replay a recorded run's summary from the terminal,
|
|
2
|
+
// without re-executing anything. Comparison gap #12: with the dashboard's
|
|
3
|
+
// run-detail page gone (the 2026-08-23 cloud removal), the local run
|
|
4
|
+
// history in cache.db is the only replay surface, and this verb reads it.
|
|
5
|
+
// Read-only — no config evaluation, no re-hash, no cache probe.
|
|
6
|
+
|
|
7
|
+
import { Cache } from '../cache/index.js'
|
|
8
|
+
import { getInvocation, getRun, listInvocations } from '../orchestrator/index.js'
|
|
9
|
+
import { UserError } from '../util/index.js'
|
|
10
|
+
import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
|
|
11
|
+
|
|
12
|
+
interface LastArgs {
|
|
13
|
+
runId?: string
|
|
14
|
+
list?: number
|
|
15
|
+
format: 'pretty' | 'json'
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
20
|
+
const out: LastArgs = { format: 'pretty' }
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const a = args[i]!
|
|
23
|
+
if (a === '--list' || a.startsWith('--list=')) {
|
|
24
|
+
const lv = a === '--list' ? '10' : a.slice(7)
|
|
25
|
+
const n = Number(lv)
|
|
26
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
27
|
+
return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
|
|
28
|
+
}
|
|
29
|
+
out.list = n
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (a === '--format' || a.startsWith('--format=')) {
|
|
33
|
+
const fv = a === '--format' ? args[++i] : a.slice(9)
|
|
34
|
+
if (fv !== 'pretty' && fv !== 'json') {
|
|
35
|
+
return { ...out, error: `invalid --format: ${fv ?? ''} (expected pretty | json)` }
|
|
36
|
+
}
|
|
37
|
+
out.format = fv
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
|
|
41
|
+
if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
42
|
+
out.runId = a
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fmtWhen = (ms: number): string => new Date(ms).toISOString()
|
|
48
|
+
|
|
49
|
+
function fmtMs(ms: number): string {
|
|
50
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
51
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
|
|
52
|
+
const m = Math.floor(ms / 60_000)
|
|
53
|
+
return `${m}m ${Math.round((ms - m * 60_000) / 1000)}s`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
57
|
+
const parsed = parseLastArgs(args)
|
|
58
|
+
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
|
|
59
|
+
|
|
60
|
+
const root = await findWorkspaceRoot(process.cwd())
|
|
61
|
+
const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
|
|
62
|
+
const cache = new Cache(cacheDir)
|
|
63
|
+
try {
|
|
64
|
+
const db = cache.dbHandle()
|
|
65
|
+
|
|
66
|
+
if (parsed.list !== undefined) {
|
|
67
|
+
const invocations = listInvocations(db, { limit: parsed.list })
|
|
68
|
+
if (parsed.format === 'json') {
|
|
69
|
+
process.stdout.write(`${JSON.stringify(invocations)}\n`)
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
if (invocations.length === 0) {
|
|
73
|
+
process.stdout.write('no recorded runs\n')
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
for (const inv of invocations) {
|
|
77
|
+
const verdict = inv.exitOk ? 'ok ' : 'FAILED'
|
|
78
|
+
process.stdout.write(
|
|
79
|
+
`${verdict} ${fmtWhen(inv.startedAt)} ${inv.runId} ` +
|
|
80
|
+
`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
|
|
81
|
+
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inv =
|
|
88
|
+
parsed.runId !== undefined
|
|
89
|
+
? getInvocation(db, parsed.runId)
|
|
90
|
+
: (listInvocations(db, { limit: 1 })[0] ?? null)
|
|
91
|
+
if (inv === null || inv === undefined) {
|
|
92
|
+
throw new UserError(
|
|
93
|
+
parsed.runId !== undefined
|
|
94
|
+
? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
|
|
95
|
+
: 'vx last: no recorded runs yet — run something first',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const detail = getRun(db, inv.runId)
|
|
99
|
+
|
|
100
|
+
if (parsed.format === 'json') {
|
|
101
|
+
process.stdout.write(`${JSON.stringify({ invocation: inv, tasks: detail?.tasks ?? [] })}\n`)
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lines: string[] = []
|
|
106
|
+
lines.push(`run ${inv.runId} — ${inv.exitOk ? 'ok' : 'FAILED'}`)
|
|
107
|
+
lines.push(` $ ${inv.command}`)
|
|
108
|
+
lines.push(
|
|
109
|
+
` ${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
|
|
110
|
+
`${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
|
|
111
|
+
`${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
|
|
112
|
+
`${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
|
|
113
|
+
)
|
|
114
|
+
lines.push(
|
|
115
|
+
` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
|
|
116
|
+
` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
|
|
117
|
+
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
118
|
+
)
|
|
119
|
+
const tasks = detail?.tasks ?? []
|
|
120
|
+
if (tasks.length > 0) {
|
|
121
|
+
lines.push('')
|
|
122
|
+
const failedFirst = [
|
|
123
|
+
...tasks.filter((t) => t.status === 'failed'),
|
|
124
|
+
...tasks.filter((t) => t.status !== 'failed'),
|
|
125
|
+
]
|
|
126
|
+
const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
|
|
127
|
+
for (const t of failedFirst) {
|
|
128
|
+
const id = `${t.project}#${t.task}`
|
|
129
|
+
lines.push(
|
|
130
|
+
` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
131
|
+
`${t.hash !== '' ? ` ${t.hash}` : ''}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
136
|
+
return 0
|
|
137
|
+
} finally {
|
|
138
|
+
cache.close()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared TS-literal emission helper for the migrate mappers. Lives in its own
|
|
2
|
+
// leaf module so both migrate.ts (the config emitter) and migrate-turbo.ts (the
|
|
3
|
+
// preset renderer) can escape strings identically without a dependency cycle
|
|
4
|
+
// (migrate.ts already imports the turbo/nx mappers, so a back-import of a value
|
|
5
|
+
// from migrate.ts would close a runtime cycle).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Escape an arbitrary string into a single-quoted TS literal. Escapes
|
|
9
|
+
* backslash + quote AND raw newlines/CR — a value with an embedded newline
|
|
10
|
+
* (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
|
|
11
|
+
* otherwise splice into a single-quoted literal as an unterminated / malformed
|
|
12
|
+
* string that fails to load (generated files must round-trip through the
|
|
13
|
+
* loader).
|
|
14
|
+
*/
|
|
15
|
+
export function quote(s: string): string {
|
|
16
|
+
return `'${s
|
|
17
|
+
.replaceAll('\\', '\\\\')
|
|
18
|
+
.replaceAll("'", "\\'")
|
|
19
|
+
.replaceAll('\n', '\\n')
|
|
20
|
+
.replaceAll('\r', '\\r')}'`
|
|
21
|
+
}
|
package/src/cli/migrate-nx.ts
CHANGED
|
@@ -416,6 +416,17 @@ function mapCommand(
|
|
|
416
416
|
return PLACEHOLDER
|
|
417
417
|
}
|
|
418
418
|
if (executor === undefined && typeof target.command === 'string') return target.command
|
|
419
|
+
// The common executors wrap one CLI each; the task runs on the first
|
|
420
|
+
// try and the TODO still asks for a look, since executor options
|
|
421
|
+
// (config paths, watch flags) are not carried over.
|
|
422
|
+
const known = executor === undefined ? undefined : KNOWN_EXECUTORS[executor]
|
|
423
|
+
if (known !== undefined) {
|
|
424
|
+
todos.push(
|
|
425
|
+
`command ${JSON.stringify(known)} mapped from executor ${JSON.stringify(executor)} — ` +
|
|
426
|
+
`verify it against the executor's options: ${JSON.stringify(options)}`,
|
|
427
|
+
)
|
|
428
|
+
return known
|
|
429
|
+
}
|
|
419
430
|
todos.push(
|
|
420
431
|
`executor ${JSON.stringify(executor ?? '(none)')} has no shell equivalent — fill in the ` +
|
|
421
432
|
`CLI command. options: ${JSON.stringify(options)}`,
|
|
@@ -423,6 +434,18 @@ function mapCommand(
|
|
|
423
434
|
return PLACEHOLDER
|
|
424
435
|
}
|
|
425
436
|
|
|
437
|
+
/** Nx executors whose CLI is unambiguous; anything else is a placeholder. */
|
|
438
|
+
const KNOWN_EXECUTORS: Record<string, string> = {
|
|
439
|
+
'@nx/vite:build': 'vite build',
|
|
440
|
+
'@nx/vite:dev-server': 'vite',
|
|
441
|
+
'@nx/vite:preview-server': 'vite preview',
|
|
442
|
+
'@nx/vite:test': 'vitest run',
|
|
443
|
+
'@nx/vitest:test': 'vitest run',
|
|
444
|
+
'@nx/jest:jest': 'jest',
|
|
445
|
+
'@nx/eslint:lint': 'eslint .',
|
|
446
|
+
'@nx/js:tsc': 'tsc -p tsconfig.json',
|
|
447
|
+
}
|
|
448
|
+
|
|
426
449
|
function countImplicitDeps(
|
|
427
450
|
dependencies: unknown,
|
|
428
451
|
metaByNode: ReadonlyMap<string, ProjectMeta>,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// package.json scripts → vx tasks: the source for a workspace that comes
|
|
2
|
+
// from nowhere (no turbo.json, no nx). `vx init` is this mapper.
|
|
3
|
+
//
|
|
4
|
+
// Scripts carry a command and nothing else, so the mapping is honest about
|
|
5
|
+
// what it cannot know: every task gets `exec.command` verbatim; `build`
|
|
6
|
+
// gets the conventional `dependsOn: ['^build']` and `test` / `lint` /
|
|
7
|
+
// `typecheck` wait for `build` when the package has one; a dev-server
|
|
8
|
+
// shaped script becomes persistent. Caching is opt-in and needs declared
|
|
9
|
+
// inputs AND outputs, which a script cannot tell us — so `build` is
|
|
10
|
+
// emitted with a cache block whose inputs are the whole project and whose
|
|
11
|
+
// outputs are EMPTY, under a TODO naming what to fill in. A cache block
|
|
12
|
+
// that guessed `dist/**` would restore the wrong tree for every package
|
|
13
|
+
// that writes somewhere else, and a wrong restore is the worst failure
|
|
14
|
+
// vx has.
|
|
15
|
+
//
|
|
16
|
+
// Two npm conventions are mapped rather than copied, because copying them
|
|
17
|
+
// loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
|
|
18
|
+
// without being named, are folded into `x`'s command in that order (a
|
|
19
|
+
// standalone `prebuild` task is one `vx run build` never runs — and the
|
|
20
|
+
// hook is usually `rimraf dist`); and a script that is nothing but
|
|
21
|
+
// `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
|
|
22
|
+
// dependency instead of a package-manager subprocess it cannot cache.
|
|
23
|
+
|
|
24
|
+
import type { ProjectMeta } from '../workspace/index.js'
|
|
25
|
+
import type { GeneratedProject, GeneratedTask, MigrationPlan } from './migrate.js'
|
|
26
|
+
|
|
27
|
+
const PERSISTENT = new Set(['dev', 'start', 'serve', 'watch', 'preview'])
|
|
28
|
+
const AFTER_BUILD = new Set(['test', 'lint', 'typecheck', 'check', 'e2e'])
|
|
29
|
+
const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The script a command delegates to when it is NOTHING but a package-manager
|
|
33
|
+
* invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
|
|
34
|
+
* x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
|
|
35
|
+
* make it a real command again, which is left verbatim.
|
|
36
|
+
*/
|
|
37
|
+
export function delegatedScript(command: string): string | null {
|
|
38
|
+
const m =
|
|
39
|
+
/^(?:(?:npm run|pnpm(?: run)?|yarn(?: run)?|bun(?: run)?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
|
|
40
|
+
command.trim(),
|
|
41
|
+
)
|
|
42
|
+
return m === null ? null : (m[1] ?? m[2])!
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
46
|
+
// package.json is a boundary: `scripts` is whatever the file holds. A
|
|
47
|
+
// string or an array would enumerate its indices as script names.
|
|
48
|
+
const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
|
|
49
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
|
|
50
|
+
return raw as Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
54
|
+
const projects: GeneratedProject[] = []
|
|
55
|
+
for (const meta of metas) {
|
|
56
|
+
const scripts = scriptsOf(meta)
|
|
57
|
+
const names = Object.keys(scripts).filter(
|
|
58
|
+
(n) => typeof scripts[n] === 'string' && scripts[n] !== '',
|
|
59
|
+
)
|
|
60
|
+
if (names.length === 0) continue
|
|
61
|
+
const hasBuild = names.includes('build')
|
|
62
|
+
const has = (n: string): boolean => names.includes(n)
|
|
63
|
+
const tasks: GeneratedTask[] = []
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
// Lifecycle hooks are npm's, not tasks anyone runs by name.
|
|
66
|
+
if (LIFECYCLE.test(name)) continue
|
|
67
|
+
// A hook of a script that exists rides inside that script's command.
|
|
68
|
+
const hookOf = /^(pre|post)(.+)$/.exec(name)
|
|
69
|
+
if (hookOf !== null && has(hookOf[2]!) && !LIFECYCLE.test(hookOf[2]!)) continue
|
|
70
|
+
|
|
71
|
+
const todos: string[] = []
|
|
72
|
+
const own = scripts[name] as string
|
|
73
|
+
const delegate = delegatedScript(own)
|
|
74
|
+
// npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
|
|
75
|
+
// package manager and never ride inside a task — `pack` stays alone.
|
|
76
|
+
const hook = (h: string): boolean => has(h) && !LIFECYCLE.test(h)
|
|
77
|
+
const hooks = [`pre${name}`, `post${name}`].filter(hook)
|
|
78
|
+
if (delegate !== null && has(delegate) && delegate !== name && hooks.length === 0) {
|
|
79
|
+
// A group: no exec, the graph runs the target. `npm test` calling
|
|
80
|
+
// `vitest` through `npm run test:unit` is two tasks, not a subprocess.
|
|
81
|
+
const task: Record<string, unknown> = { dependsOn: [delegate] }
|
|
82
|
+
if (AFTER_BUILD.has(name) && hasBuild && delegate !== 'build') {
|
|
83
|
+
task['dependsOn'] = [delegate, 'build']
|
|
84
|
+
}
|
|
85
|
+
tasks.push({ name, todos, task })
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const command = [
|
|
90
|
+
...(hook(`pre${name}`) ? [scripts[`pre${name}`] as string] : []),
|
|
91
|
+
own,
|
|
92
|
+
...(hook(`post${name}`) ? [scripts[`post${name}`] as string] : []),
|
|
93
|
+
].join(' && ')
|
|
94
|
+
if (hooks.length > 0) {
|
|
95
|
+
todos.push(
|
|
96
|
+
`npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
const task: Record<string, unknown> = { exec: { command } }
|
|
100
|
+
if (PERSISTENT.has(name)) {
|
|
101
|
+
task['exec'] = { command, persistent: {} }
|
|
102
|
+
todos.push(
|
|
103
|
+
'persistent: add `readyWhen: "<line the server prints when ready>"` so dependents wait for it',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
if (name === 'build') {
|
|
107
|
+
task['dependsOn'] = ['^build']
|
|
108
|
+
task['cache'] = { inputs: { files: ['**/*'] }, outputs: { files: [] } }
|
|
109
|
+
todos.push(
|
|
110
|
+
"cache: inputs default to the whole project ('**/*'); narrow them (e.g. 'src/**') and declare outputs (e.g. 'dist/**') — nothing is cached until outputs are declared",
|
|
111
|
+
)
|
|
112
|
+
} else if (AFTER_BUILD.has(name) && hasBuild) {
|
|
113
|
+
task['dependsOn'] = ['build']
|
|
114
|
+
}
|
|
115
|
+
tasks.push({ name, todos, task })
|
|
116
|
+
}
|
|
117
|
+
if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
headerNotes: [
|
|
121
|
+
'each script became a task with its command verbatim; caching needs declared inputs and outputs, so only `build` got a cache block, with empty outputs to fill in',
|
|
122
|
+
],
|
|
123
|
+
projects,
|
|
124
|
+
extraFiles: [],
|
|
125
|
+
notes: [],
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/cli/migrate-turbo.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { relPosix, UserError } from '../util/index.js'
|
|
10
10
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
11
|
+
import { quote } from './migrate-emit.js'
|
|
11
12
|
import type { GeneratedProject, GeneratedTask, MigrationPlan, RawExpr } from './migrate.js'
|
|
12
13
|
|
|
13
14
|
interface TurboTask {
|
|
@@ -63,8 +64,22 @@ function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
|
|
|
63
64
|
return cfg.tasks ?? cfg.pipeline ?? {}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function scriptsOf(meta: ProjectMeta): Record<string,
|
|
67
|
-
|
|
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}`
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/** Declared task names for a package: plain root keys, `pkg#name` keys
|
|
@@ -126,7 +141,7 @@ export async function migrateTurbo(
|
|
|
126
141
|
const scripts = scriptsOf(meta)
|
|
127
142
|
const set = new Set<string>()
|
|
128
143
|
for (const name of taskNamesFor(meta.name, rootTasks, pkgTasksByName.get(meta.name))) {
|
|
129
|
-
if (scripts[name]
|
|
144
|
+
if (usableScript(scripts[name])) set.add(name)
|
|
130
145
|
}
|
|
131
146
|
emitted.set(meta.name, set)
|
|
132
147
|
}
|
|
@@ -139,13 +154,32 @@ export async function migrateTurbo(
|
|
|
139
154
|
const used = new Set<string>()
|
|
140
155
|
const tasks: GeneratedTask[] = []
|
|
141
156
|
for (const name of taskNamesFor(meta.name, rootTasks, pkgTasks)) {
|
|
142
|
-
|
|
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
|
+
}
|
|
143
177
|
const def: TurboTask = {
|
|
144
178
|
...rootTasks[name],
|
|
145
179
|
...rootTasks[`${meta.name}#${name}`],
|
|
146
180
|
...pkgTasks?.[name],
|
|
147
181
|
}
|
|
148
|
-
tasks.push(buildTask(name, def,
|
|
182
|
+
tasks.push(buildTask(name, def, script, own, emitted, globals, used))
|
|
149
183
|
}
|
|
150
184
|
projects.push({
|
|
151
185
|
name: meta.name,
|
|
@@ -330,7 +364,10 @@ function presetImportLines(used: ReadonlySet<string>, root: string, dir: string)
|
|
|
330
364
|
}
|
|
331
365
|
|
|
332
366
|
function renderPreset(inputs: string[], env: string[], pass: string[]): string {
|
|
333
|
-
|
|
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(', ')}]`
|
|
334
371
|
const lines = [
|
|
335
372
|
'// Generated by `vx migrate` from turbo.json. TypeScript composition',
|
|
336
373
|
"// replaces turbo's global fields: each vx.config.ts imports these",
|
package/src/cli/migrate.ts
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { relPosix, UserError } from '../util/index.js'
|
|
10
10
|
import { findWorkspaceRoot, listProjects, loadWorkspace } from '../workspace/index.js'
|
|
11
|
+
import { quote } from './migrate-emit.js'
|
|
11
12
|
import { migrateNx } from './migrate-nx.js'
|
|
13
|
+
import { migrateScripts } from './migrate-scripts.js'
|
|
12
14
|
import { migrateTurbo } from './migrate-turbo.js'
|
|
13
15
|
|
|
14
16
|
export interface MigrateArgs {
|
|
15
17
|
dry: boolean
|
|
16
18
|
force: boolean
|
|
17
|
-
from?: 'turbo' | 'nx'
|
|
19
|
+
from?: 'turbo' | 'nx' | 'scripts'
|
|
18
20
|
error?: string
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -26,7 +28,9 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
|
26
28
|
else if (a === '--force') out.force = true
|
|
27
29
|
else if (a === '--from' || a?.startsWith('--from=')) {
|
|
28
30
|
const v = a === '--from' ? args[++i] : a.slice('--from='.length)
|
|
29
|
-
if (v !== 'turbo' && v !== 'nx'
|
|
31
|
+
if (v !== 'turbo' && v !== 'nx' && v !== 'scripts') {
|
|
32
|
+
return { ...out, error: `--from must be turbo, nx or scripts` }
|
|
33
|
+
}
|
|
30
34
|
out.from = v
|
|
31
35
|
} else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
|
|
32
36
|
else return { ...out, error: `unexpected argument: ${a}` }
|
|
@@ -67,7 +71,10 @@ export interface MigrationPlan {
|
|
|
67
71
|
notes: string[]
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
export async function migrateCmd(
|
|
74
|
+
export async function migrateCmd(
|
|
75
|
+
args: readonly string[],
|
|
76
|
+
opts: { init?: boolean } = {},
|
|
77
|
+
): Promise<number> {
|
|
71
78
|
const parsed = parseMigrateArgs(args)
|
|
72
79
|
if (parsed.error) {
|
|
73
80
|
process.stderr.write(`vx migrate: ${parsed.error}\n`)
|
|
@@ -94,7 +101,10 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
94
101
|
|
|
95
102
|
let source: string
|
|
96
103
|
let plan: MigrationPlan
|
|
97
|
-
if (parsed.from === '
|
|
104
|
+
if (parsed.from === 'scripts') {
|
|
105
|
+
source = 'package.json scripts'
|
|
106
|
+
plan = migrateScripts(metas)
|
|
107
|
+
} else if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
|
|
98
108
|
if (hasGraph) {
|
|
99
109
|
source = '.nx/workspace-data/project-graph.json'
|
|
100
110
|
plan = await migrateNx(root, metas)
|
|
@@ -106,13 +116,23 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
106
116
|
'`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx migrate',
|
|
107
117
|
)
|
|
108
118
|
} else {
|
|
109
|
-
|
|
119
|
+
// A workspace from nowhere: the scripts are the source (`vx init`).
|
|
120
|
+
source = 'package.json scripts'
|
|
121
|
+
plan = migrateScripts(metas)
|
|
110
122
|
}
|
|
111
|
-
} else
|
|
123
|
+
} else {
|
|
112
124
|
source = 'turbo.json'
|
|
113
125
|
plan = await migrateTurbo(root, metas)
|
|
114
|
-
}
|
|
115
|
-
|
|
126
|
+
}
|
|
127
|
+
// `vx init` on a workspace with no scripts still has a job: the
|
|
128
|
+
// workspace file, which every run needs, and a worked example of the
|
|
129
|
+
// config the user writes next. `vx migrate` with nothing to convert is
|
|
130
|
+
// an error: the user asked for a conversion that has no input.
|
|
131
|
+
const empty = plan.projects.length === 0
|
|
132
|
+
if (empty && opts.init !== true) {
|
|
133
|
+
throw new UserError(
|
|
134
|
+
`nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
|
|
135
|
+
)
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
const files: { relPath: string; abs: string; contents: string }[] = []
|
|
@@ -124,21 +144,41 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
124
144
|
for (const f of plan.extraFiles) {
|
|
125
145
|
files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
|
|
126
146
|
}
|
|
147
|
+
// A migrated workspace must declare its executor and cache — nothing is
|
|
148
|
+
// applied by default. Emit the workspace file unless the repo already has
|
|
149
|
+
// one in any supported extension.
|
|
150
|
+
const hasWorkspaceFile = (
|
|
151
|
+
await Promise.all(
|
|
152
|
+
['vx.workspace.ts', 'vx.workspace.mjs', 'vx.workspace.js'].map((n) =>
|
|
153
|
+
Bun.file(path.join(root, n)).exists(),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
).some(Boolean)
|
|
157
|
+
if (!hasWorkspaceFile) {
|
|
158
|
+
const abs = path.join(root, 'vx.workspace.ts')
|
|
159
|
+
files.push({ relPath: relPosix(root, abs), abs, contents: WORKSPACE_FILE })
|
|
160
|
+
}
|
|
127
161
|
|
|
128
162
|
if (!parsed.dry && !parsed.force) {
|
|
129
|
-
const conflicts
|
|
163
|
+
const conflicts = new Set<string>()
|
|
164
|
+
// A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
|
|
165
|
+
// so we never shadow a hand-written config with a fresh .ts.
|
|
130
166
|
for (const p of plan.projects) {
|
|
131
167
|
if (p.tasks.length === 0) continue
|
|
132
168
|
const meta = metas.find((m) => m.dir === p.dir)
|
|
133
|
-
if (meta?.configPath) conflicts.
|
|
169
|
+
if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
|
|
134
170
|
}
|
|
135
|
-
|
|
136
|
-
|
|
171
|
+
// ALSO stat every actual write target. A SYNTHESIZED project (e.g. the Nx
|
|
172
|
+
// workspace-root node, dir === root) has no discovered meta, so the meta
|
|
173
|
+
// scan alone would miss an existing vx.config.ts at that path and clobber
|
|
174
|
+
// it. This also covers the extraFiles (vx-preset.ts).
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
if (await Bun.file(f.abs).exists()) conflicts.add(f.relPath)
|
|
137
177
|
}
|
|
138
|
-
if (conflicts.
|
|
178
|
+
if (conflicts.size > 0) {
|
|
139
179
|
throw new UserError(
|
|
140
180
|
'refusing to overwrite existing files (pass --force to overwrite):\n' +
|
|
141
|
-
` ${conflicts.join('\n ')}`,
|
|
181
|
+
` ${[...conflicts].join('\n ')}`,
|
|
142
182
|
)
|
|
143
183
|
}
|
|
144
184
|
}
|
|
@@ -159,28 +199,68 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
159
199
|
for (const reason of t.todos) todoList.push(`${p.name}#${t.name}: ${reason}`)
|
|
160
200
|
}
|
|
161
201
|
}
|
|
162
|
-
const report: string[] = [
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
202
|
+
const report: string[] = []
|
|
203
|
+
if (empty) {
|
|
204
|
+
report.push(
|
|
205
|
+
'vx init: no package.json scripts to turn into tasks.',
|
|
206
|
+
hasWorkspaceFile
|
|
207
|
+
? 'vx.workspace.ts already exists.'
|
|
208
|
+
: parsed.dry
|
|
209
|
+
? 'would write vx.workspace.ts (dry run, nothing written).'
|
|
210
|
+
: 'wrote vx.workspace.ts.',
|
|
211
|
+
'Declare tasks in a vx.config.ts beside a package.json — your own command, for example:',
|
|
212
|
+
'',
|
|
213
|
+
...EXAMPLE_CONFIG.trimEnd()
|
|
214
|
+
.split('\n')
|
|
215
|
+
.map((l) => ` ${l}`),
|
|
216
|
+
)
|
|
217
|
+
} else {
|
|
218
|
+
report.push(`vx migrate: ${source} → vx.config.ts`)
|
|
219
|
+
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
220
|
+
report.push(
|
|
221
|
+
'',
|
|
222
|
+
`${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
|
|
223
|
+
`${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
|
|
224
|
+
)
|
|
225
|
+
for (const line of todoList) report.push(` ${line}`)
|
|
226
|
+
report.push(...plan.notes)
|
|
227
|
+
report.push(parsed.dry ? 'files (dry run, nothing written):' : 'files written:')
|
|
228
|
+
for (const f of files) report.push(` ${f.relPath}`)
|
|
229
|
+
}
|
|
230
|
+
const firstTask =
|
|
231
|
+
plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
|
|
232
|
+
plan.projects[0]?.tasks[0]?.name ??
|
|
233
|
+
'build'
|
|
234
|
+
report.push('', `next: vx run ${firstTask} --all`)
|
|
173
235
|
process.stdout.write(`${report.join('\n')}\n`)
|
|
174
236
|
return 0
|
|
175
237
|
}
|
|
176
238
|
|
|
239
|
+
const EXAMPLE_CONFIG = `import type { ProjectConfig } from '@vzn/vx'
|
|
240
|
+
|
|
241
|
+
export default {
|
|
242
|
+
tasks: {
|
|
243
|
+
build: {
|
|
244
|
+
exec: { command: 'tsc -b' },
|
|
245
|
+
dependsOn: ['^build'],
|
|
246
|
+
cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
} satisfies ProjectConfig
|
|
250
|
+
`
|
|
251
|
+
|
|
177
252
|
// ─── TS emission ──────────────────────────────────────────────────────
|
|
178
253
|
|
|
179
|
-
const
|
|
254
|
+
const WORKSPACE_FILE = `import { defineWorkspace } from '@vzn/vx'
|
|
255
|
+
import { localExecutorPlugin } from '@vzn/vx/plugins/local-executor'
|
|
256
|
+
import { localCachePlugin } from '@vzn/vx/plugins/local-cache'
|
|
180
257
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
258
|
+
// Nothing runs that is not declared here. Put a remote executor or cache
|
|
259
|
+
// plugin BEFORE the local one to prefer it.
|
|
260
|
+
export default defineWorkspace({ plugins: [localExecutorPlugin(), localCachePlugin()] })
|
|
261
|
+
`
|
|
262
|
+
|
|
263
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/
|
|
184
264
|
|
|
185
265
|
function isRawExpr(v: unknown): v is RawExpr {
|
|
186
266
|
return typeof v === 'object' && v !== null && typeof (v as RawExpr).raw === 'string'
|
|
@@ -188,6 +268,10 @@ function isRawExpr(v: unknown): v is RawExpr {
|
|
|
188
268
|
|
|
189
269
|
function renderValue(v: unknown, indent: string): string {
|
|
190
270
|
if (isRawExpr(v)) return v.raw
|
|
271
|
+
// `Object.entries(null)` throws, which would abort the whole migration
|
|
272
|
+
// with a raw stack mid-write. Mappers no longer produce a null, but a
|
|
273
|
+
// literal is a readable thing to leave behind if one ever does.
|
|
274
|
+
if (v === null) return 'null'
|
|
191
275
|
if (typeof v === 'string') return quote(v)
|
|
192
276
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
193
277
|
if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
|
|
@@ -201,10 +285,12 @@ function renderValue(v: unknown, indent: string): string {
|
|
|
201
285
|
}
|
|
202
286
|
|
|
203
287
|
function renderConfigFile(source: string, p: GeneratedProject): string {
|
|
288
|
+
// A type-only import: the editor type-checks against the installed
|
|
289
|
+
// package, and Bun erases it, so the config loads in a workspace that
|
|
290
|
+
// runs the vx binary without the package installed.
|
|
204
291
|
const lines: string[] = [
|
|
205
|
-
`// Generated by \`vx migrate\` from ${source}. Review the TODO(vx-migrate)
|
|
206
|
-
"
|
|
207
|
-
'// editor type-checking.',
|
|
292
|
+
`// Generated by \`vx migrate\` from ${source}. Review the TODO(vx-migrate) comments.`,
|
|
293
|
+
"import type { ProjectConfig } from '@vzn/vx'",
|
|
208
294
|
]
|
|
209
295
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
210
296
|
lines.push('', 'export default {', ' tasks: {')
|
|
@@ -214,6 +300,6 @@ function renderConfigFile(source: string, p: GeneratedProject): string {
|
|
|
214
300
|
const key = IDENT.test(t.name) ? t.name : quote(t.name)
|
|
215
301
|
lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
|
|
216
302
|
}
|
|
217
|
-
lines.push(' },', '}', '')
|
|
303
|
+
lines.push(' },', '} satisfies ProjectConfig', '')
|
|
218
304
|
return lines.join('\n')
|
|
219
305
|
}
|