@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.
Files changed (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -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
+ }
@@ -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
+ }
@@ -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, string> {
67
- return (meta.packageJson as unknown as { scripts?: Record<string, string> }).scripts ?? {}
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] !== undefined) set.add(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
- if (!own.has(name)) continue
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, scripts[name]!, own, emitted, globals, used))
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
- const arr = (xs: string[]): string => `[${xs.map((x) => `'${x}'`).join(', ')}]`
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",
@@ -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') return { ...out, error: `--from must be turbo or 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(args: readonly string[]): Promise<number> {
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 === 'nx' || (parsed.from === undefined && !hasTurbo)) {
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
- throw new UserError('nothing to migrate: no turbo.json or nx.json at the workspace root')
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 if (hasTurbo) {
123
+ } else {
112
124
  source = 'turbo.json'
113
125
  plan = await migrateTurbo(root, metas)
114
- } else {
115
- throw new UserError('nothing to migrate: no turbo.json or nx.json at the workspace root')
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: string[] = []
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.push(relPosix(root, meta.configPath))
169
+ if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
134
170
  }
135
- for (const f of plan.extraFiles) {
136
- if (await Bun.file(path.join(root, f.relPath)).exists()) conflicts.push(f.relPath)
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.length > 0) {
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[] = [`vx migrate: ${source} → vx.config.ts`]
163
- for (const n of plan.headerNotes) report.push(`note: ${n}`)
164
- report.push(
165
- '',
166
- `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
167
- `${todoList.length} TODO${todoList.length === 1 ? '' : 's'}${todoList.length > 0 ? ':' : ''}`,
168
- )
169
- for (const line of todoList) report.push(` ${line}`)
170
- report.push(...plan.notes)
171
- report.push(parsed.dry ? 'files (dry run, nothing written):' : 'files written:')
172
- for (const f of files) report.push(` ${f.relPath}`)
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 IDENT = /^[A-Za-z_$][\w$]*$/
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
- function quote(s: string): string {
182
- return `'${s.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
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
- "// comments, then wrap the object in defineProject() from '@vzn/vx' for",
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
  }