@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,16 +1,16 @@
1
1
  // The migration seam: what any adoption tool needs once it has a plan —
2
2
  // the plan's shape, TS emission, the overwrite guard, the writes and the
3
- // report. `vx init` (package.json scripts, in core) and `@vzn/vx-migrate`
4
- // (Turbo, Nx) share it, so a generated config reads the same whichever
5
- // tool wrote it. Core knows no source format here: a mapper returns a
3
+ // report. `@vzn/vx-migrate` (package.json scripts, Turbo, Nx) and any
4
+ // other adoption tool share it, so a generated config reads the same
5
+ // whichever tool wrote it. Core knows no source format here: a mapper returns a
6
6
  // `MigrationPlan` and this file does the rest.
7
7
 
8
- import { existsSync } from 'node:fs'
8
+ import { existsSync, readFileSync } from 'node:fs'
9
9
  import { unlink } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
- import { executablePath, relPosix, UserError } from '../util/index.js'
12
- import type { ProjectMeta } from './workspace.js'
13
- import { loadWorkspace, reachesManifest, unreachedHint, unreachedPackages } from './workspace.js'
11
+ import { workspaceFileWritten } from '../handoff.js'
12
+ import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError, ui } from '../util/index.js'
13
+ import { VERSION } from '../version.js'
14
14
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
15
15
 
16
16
  /**
@@ -29,19 +29,6 @@ export function quoteTsLiteral(s: string): string {
29
29
  .replaceAll('\r', '\\r')}'`
30
30
  }
31
31
 
32
- /**
33
- * Task names that conventionally never exit. Every mapper guesses the same
34
- * way from a name — a source that SAYS a task is long-running (turbo's
35
- * `persistent: true`, an Nx dev-server executor) is believed instead.
36
- */
37
- export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
38
- 'dev',
39
- 'start',
40
- 'serve',
41
- 'watch',
42
- 'preview',
43
- ])
44
-
45
32
  /**
46
33
  * A package.json script with the `pre<name>` / `post<name>` hooks npm runs
47
34
  * around it, as ONE sh command. Each part runs in its own subshell, so a
@@ -80,73 +67,6 @@ const QUOTE_ARGS =
80
67
  `vx_r="$vx_r\${vx_s%%"$vx_q"*}'\\''"; vx_s=\${vx_s#*"$vx_q"};; *) break;; esac; done; ` +
81
68
  `vx_a="$vx_a '$vx_r$vx_s'"; done`
82
69
 
83
- /**
84
- * Where a framework's build writes by default, for the cache TODO: the
85
- * hint said `dist/**` to every build, `next build` included, which writes
86
- * `.next`. Each tool's documented default; anything else keeps `dist/**`.
87
- */
88
- const BUILD_OUTPUTS: readonly [RegExp, readonly string[]][] = [
89
- [/\snext build\s/, ['.next/**', '!.next/cache/**']],
90
- [/\snux[ti] build\s/, ['.output/**']],
91
- [/\s(?:remix|react-router|react-scripts|docusaurus) build\s/, ['build/**']],
92
- [/\sgatsby build\s/, ['public/**']],
93
- [/\s(?:storybook build|build-storybook)\s/, ['storybook-static/**']],
94
- ]
95
-
96
- /** A directory a TODO may name: relative, plain, no hidden or tool dir. */
97
- const OUTPUT_DIR = /^(?:\.\/)?((?![./])[\w@-][\w@./-]*?)\/?$/
98
-
99
- /**
100
- * The directory the command says it writes, when no tool above names one:
101
- * an out-dir flag (`tsc --outDir lib`, `babel -d lib`), else the
102
- * directories it cleans first (`del-cli distribution`, `rimraf dist
103
- * types`). ky's build writes `distribution/`, and the TODO said `dist/**`
104
- * (D-90). A file (`tsconfig.tsbuildinfo`) and `node_modules` are no output.
105
- */
106
- function namedOutputs(command: string): string[] | undefined {
107
- const dir = (w: string | undefined): string | undefined => {
108
- const m = w === undefined ? null : OUTPUT_DIR.exec(w.replace(/^(["'])(.*)\1$/, '$2'))
109
- if (m === null) return undefined
110
- const d = m[1]!
111
- return /\.[a-z]+$/i.test(d) || d.split('/').includes('node_modules') ? undefined : d
112
- }
113
- const flag = /\s(?:--outDir|--out-dir|--outdir|-d)[\s=](\S+)/.exec(` ${command} `)
114
- const out = dir(flag?.[1])
115
- if (out !== undefined) return [`${out}/**`]
116
- for (const segment of command.split(/[;&|()]+/)) {
117
- const words = segment.trim().split(/\s+/)
118
- const at =
119
- words[0] === 'rimraf' || words[0] === 'del-cli' || words[0] === 'del'
120
- ? 1
121
- : words[0] === 'rm' || (words[0] === 'shx' && words[1] === 'rm')
122
- ? words.indexOf('rm') + 1
123
- : -1
124
- if (at < 0) continue
125
- const args = words.slice(at).filter((w) => !w.startsWith('-'))
126
- // A dir the command makes again (kit's `rm -rf ./missing_ids && mkdir
127
- // ./missing_ids && vite build`) is a scratch dir it prepares, not output.
128
- const made = new Set([...command.matchAll(/\bmkdir\s+(?:-p\s+)?(\S+)/g)].map((m) => dir(m[1])))
129
- const dirs = args
130
- .map(dir)
131
- .filter((d): d is string => d !== undefined)
132
- .filter((d) => !made.has(d))
133
- // Every cleaned path a directory vx can name, or the guess is not this.
134
- return dirs.length > 0 && dirs.length === args.filter((w) => !/\.[a-z]+$/i.test(w)).length
135
- ? dirs.map((d) => `${d}/**`)
136
- : undefined
137
- }
138
- return undefined
139
- }
140
-
141
- /** The cache block the task that builds `command` should declare, as a TODO on it. */
142
- export function cacheTodo(command: string): string {
143
- // Padded, separators as spaces: `next build && …` and `(next build)` match.
144
- const words = ` ${command.replace(/[;&|()]/g, ' ')} `
145
- const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
146
- const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
147
- return `cache: add \`cache: { inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task`
148
- }
149
-
150
70
  const isCacheTodo = (todo: string): boolean => todo.startsWith('cache: add `cache: {')
151
71
 
152
72
  /** The one wording every mapper emits for a task it made persistent. */
@@ -217,6 +137,8 @@ export interface GeneratedProject {
217
137
  importLines: string[]
218
138
  /** The project's `tags` (an Nx project's); none written when empty. */
219
139
  tags?: readonly string[]
140
+ /** Writes `name`: no manifest names the project, so its path would. */
141
+ writeName?: boolean
220
142
  tasks: GeneratedTask[]
221
143
  }
222
144
 
@@ -230,9 +152,21 @@ export interface MigrationPlan {
230
152
  notes: string[]
231
153
  }
232
154
 
155
+ /**
156
+ * A project the adoption tool found: its name, directory and the vx config
157
+ * it already has. `packageJson.type` picks `vx.config.mts` for a CommonJS
158
+ * TypeScript package.
159
+ */
160
+ export interface MigrationMember {
161
+ readonly name: string
162
+ readonly dir: string
163
+ readonly configPath: string | null
164
+ readonly packageJson?: object
165
+ }
166
+
233
167
  export interface ApplyMigrationArgs {
234
168
  root: string
235
- metas: readonly ProjectMeta[]
169
+ metas: readonly MigrationMember[]
236
170
  plan: MigrationPlan
237
171
  /** Named in the report and the generated header, e.g. `turbo.json`. */
238
172
  source: string
@@ -241,12 +175,12 @@ export interface ApplyMigrationArgs {
241
175
  dry: boolean
242
176
  force: boolean
243
177
  /**
244
- * `vx init` on a workspace with no scripts still has a job (the workspace
245
- * file, and a worked example); a migration with nothing to convert is an
246
- * error. Default false.
178
+ * A scripts migration (what `vx init` hands over) on a workspace with no
179
+ * scripts still has a job (the workspace file, and a worked example); a
180
+ * migration with nothing to convert is an error. Default false.
247
181
  */
248
182
  init?: boolean
249
- /** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
183
+ /** The report's `note` lines (e.g. "turbo.json found and not read"). */
250
184
  notes?: readonly string[]
251
185
  /**
252
186
  * Scripts exist but no project can carry them (a member without a
@@ -254,6 +188,11 @@ export interface ApplyMigrationArgs {
254
188
  * of them (M-59).
255
189
  */
256
190
  unmapped?: boolean
191
+ /**
192
+ * Why an `init` plan is empty, when the tool knows (members its globs
193
+ * never reach): the report's one line in place of the example config.
194
+ */
195
+ emptyReason?: string
257
196
  /**
258
197
  * `ts` (default) writes `vx.config.ts` with the type-only import and
259
198
  * `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
@@ -266,6 +205,24 @@ export interface ApplyMigrationArgs {
266
205
  * of the files and the report, so an agent reads the plan as data.
267
206
  */
268
207
  json?: boolean
208
+ /**
209
+ * Run after the files are written (not under `dry`): `vx lock`'s step, so
210
+ * the migrated workspace has its .vx/projects.lock.json and its graph rules checked
211
+ * (owner, 2026-10-14). On success `said` is its line, shown as the
212
+ * report's `lock` row; a refusal (a graph rule, a plugin not installed
213
+ * yet) prints above the report and the migration still succeeds: its
214
+ * files are written, and `vx lock` is the step to re-run.
215
+ */
216
+ lock?: (root: string) => Promise<{ code: number; said: string }>
217
+ /** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
218
+ steps?: readonly (readonly [label: string, value: string])[]
219
+ /** What the written workspace file declares, e.g. `pnpm()` from `@vzn/vx-lockfile`. */
220
+ plugins?: readonly WorkspacePlugin[]
221
+ }
222
+
223
+ export interface WorkspacePlugin {
224
+ readonly pkg: string
225
+ readonly factory: string
269
226
  }
270
227
 
271
228
  export type MigrationFormat = 'ts' | 'mjs'
@@ -279,15 +236,25 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
279
236
  const init = args.init === true
280
237
  const format: MigrationFormat = args.format ?? 'ts'
281
238
  const configName = `vx.config.${format}`
282
- const workspaceName = `vx.workspace.${format}`
239
+ const workspaceName = `.vx/workspace.${format}`
283
240
  const empty = plan.projects.length === 0
284
- // `vx init` never overwrites a member's config: a half-adopted workspace
241
+ // An `init` migration never overwrites a member's config: a half-adopted workspace
285
242
  // keeps each one it has, by name, and gets the rest (M-52). It refused
286
243
  // the whole workspace, and `--force` would have replaced the hand-written
287
244
  // file; `--force` still replaces, as asked.
288
245
  const kept: string[] = []
289
- const projects = plan.projects.filter((p) => {
290
- if (!init || force || p.tasks.length === 0) return true
246
+ // Every project gets a config, a task-less one too (owner, 2026-10-14:
247
+ // "no skips. every project gets a config"): solidjs/solid's solid-ssr,
248
+ // with no script Turbo runs, was left with none and read as a project
249
+ // whose tasks are not locked.
250
+ const planned = new Set(plan.projects.map((p) => p.dir))
251
+ const unplanned: GeneratedProject[] = metas
252
+ .filter((m) => !planned.has(m.dir))
253
+ .map((m) => ({ name: m.name, dir: m.dir, importLines: [], tasks: [] }))
254
+ const projects = [...plan.projects, ...unplanned].filter((p) => {
255
+ // An empty config never replaces one a project has, --force or not.
256
+ if (p.tasks.length === 0) return !metas.some((m) => m.dir === p.dir && m.configPath)
257
+ if (!init || force) return true
291
258
  const existing = metas.find((m) => m.dir === p.dir)?.configPath
292
259
  if (!existing) return true
293
260
  kept.push(relPosix(root, existing))
@@ -305,14 +272,14 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
305
272
  )
306
273
  }
307
274
 
275
+ const names = new Map(projects.map((p) => [p.dir, projectConfigName(p.dir, metas, format)]))
308
276
  const files: { relPath: string; abs: string; contents: string }[] = []
309
277
  for (const p of projects) {
310
- if (p.tasks.length === 0) continue
311
- const abs = path.join(p.dir, configName)
278
+ const abs = path.join(p.dir, names.get(p.dir)!)
312
279
  files.push({
313
280
  relPath: relPosix(root, abs),
314
281
  abs,
315
- contents: renderConfigFile(p, format),
282
+ contents: renderConfigFile(ownConfigNamed(p, names.get(p.dir)!, format), format),
316
283
  })
317
284
  }
318
285
  for (const f of plan.extraFiles) {
@@ -328,7 +295,17 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
328
295
  // A plan may bring its own (vx-migrate declares the plugins it detects).
329
296
  if (!hasWorkspaceFile && !plan.extraFiles.some((f) => f.relPath === workspaceName)) {
330
297
  const abs = path.join(root, workspaceName)
331
- files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
298
+ files.push({
299
+ relPath: relPosix(root, abs),
300
+ abs,
301
+ contents: workspaceFile(format, args.plugins ?? []),
302
+ })
303
+ }
304
+ // `.vx/.gitignore` keeps the machine-local `.vx/cache` out of git and the
305
+ // workspace config and lock in; one already there is the user's.
306
+ const dotVxIgnore = path.join(root, '.vx', '.gitignore')
307
+ if (!(await Bun.file(dotVxIgnore).exists())) {
308
+ files.push({ relPath: '.vx/.gitignore', abs: dotVxIgnore, contents: DOT_VX_GITIGNORE })
332
309
  }
333
310
 
334
311
  if (!dry && !force) {
@@ -336,7 +313,6 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
336
313
  // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
337
314
  // so we never shadow a hand-written config with a fresh .ts.
338
315
  for (const p of projects) {
339
- if (p.tasks.length === 0) continue
340
316
  const meta = metas.find((m) => m.dir === p.dir)
341
317
  if (meta?.configPath) conflicts.add(relPosix(root, meta.configPath))
342
318
  }
@@ -361,9 +337,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
361
337
  // (item 1033).
362
338
  const replaced: string[] = []
363
339
  for (const p of projects) {
364
- if (p.tasks.length === 0) continue
365
340
  const existing = metas.find((m) => m.dir === p.dir)?.configPath
366
- if (existing && existing !== path.join(p.dir, configName)) replaced.push(existing)
341
+ if (existing && existing !== path.join(p.dir, names.get(p.dir)!)) replaced.push(existing)
367
342
  }
368
343
  if (dry && args.json !== true) {
369
344
  for (const f of files) {
@@ -372,7 +347,16 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
372
347
  } else if (!dry) {
373
348
  for (const f of files) await Bun.write(f.abs, f.contents)
374
349
  for (const f of replaced) await unlink(f)
350
+ // The lock that follows loads its plugins from the file just written.
351
+ workspaceFileWritten()
375
352
  }
353
+ const formatted = dry
354
+ ? []
355
+ : formatWritten(
356
+ root,
357
+ // A formatter has no parser for `.vx/.gitignore`.
358
+ files.map((f) => f.relPath).filter((f) => /\.[cm]?[jt]s$/.test(f)),
359
+ )
376
360
 
377
361
  // One reason, its tasks: on a Turbo template the same two-line cache TODO
378
362
  // printed five times and the persistent one eight (the first-five-minutes
@@ -383,120 +367,44 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
383
367
  root,
384
368
  files.map((f) => f.relPath),
385
369
  )
386
- const ignoredNote =
387
- ignored.length === 0
388
- ? []
389
- : [
390
- `.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): add ${[...new Set(ignored.map((f) => `!${path.posix.basename(f)}`))].join(' and ')} to .gitignore, or commit them with git add -f`,
391
- ]
370
+ const ignoredNote = ignored.length === 0 ? [] : [ignoreAdvice(root, ignored, dry)]
392
371
  const todos = new Map<string, string[]>()
393
- let todoCount = 0
394
372
  let clean = 0
395
- let cached = false
373
+ let todo = 0
396
374
  for (const p of projects) {
397
375
  for (const t of p.tasks) {
398
376
  if (t.todos.length === 0 && t.task !== null) clean++
399
- if (t.task !== null && 'cache' in t.task) cached = true
377
+ if (t.todos.length > 0) todo++
400
378
  for (const reason of t.todos) {
401
- todoCount++
402
379
  const ids = todos.get(reason) ?? []
403
380
  ids.push(`${p.name}#${t.name}`)
404
381
  todos.set(reason, ids)
405
382
  }
406
383
  }
407
384
  }
408
- const report: string[] = []
409
- // Single-project mode with packages the root's missing `workspaces` never
410
- // reaches: the scripts exist, the globs do not (item 248).
411
- const workspace = empty ? await loadWorkspace(root) : undefined
412
- const unreached = workspace === undefined ? [] : await unreachedPackages(workspace)
413
- // Globs that reach no package: "no scripts" was true and named nothing
414
- // to fix (M-42).
415
- const globs = workspace?.packageGlobs.filter((g) => g !== '.') ?? []
416
- // Asked of the globs, not of `metas`: a nameless or shared-name member
417
- // is matched yet absent from `metas` (M-46).
418
- const noMembers =
419
- workspace !== undefined && globs.length > 0 && !(await reachesManifest(workspace))
420
- ? [
421
- `the workspace globs (${globs.map((g) => `"${g}"`).join(', ')}) match no package.json: add a package under one, or fix the glob`,
422
- ]
423
- : []
424
- if (empty && unreached.length > 0) {
425
- report.push(
426
- `${verb}: ${unreachedHint(unreached, root)}`,
427
- hasWorkspaceFile
428
- ? `${workspaceName} already exists.`
429
- : dry
430
- ? `would write ${workspaceName} (dry run, nothing written).`
431
- : `wrote ${workspaceName}.`,
432
- )
433
- } else if (empty) {
434
- report.push(
435
- args.unmapped === true
436
- ? `${verb}: no package.json scripts became tasks.`
437
- : `${verb}: no package.json scripts to turn into tasks.`,
438
- ...[...noMembers, ...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
439
- hasWorkspaceFile
440
- ? `${workspaceName} already exists.`
441
- : dry
442
- ? `would write ${workspaceName} (dry run, nothing written).`
443
- : `wrote ${workspaceName}.`,
444
- `Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
445
- '',
446
- ...exampleConfig(format)
447
- .trimEnd()
448
- .split('\n')
449
- .map((l) => ` ${l}`),
450
- )
451
- } else {
452
- report.push(`${verb}: ${source} → ${configName}`)
453
- for (const n of [...(args.notes ?? []), ...ignoredNote]) report.push(`note: ${n}`)
454
- for (const n of plan.headerNotes) report.push(`note: ${n}`)
455
- report.push(
456
- '',
457
- `${clean} task${clean === 1 ? '' : 's'} migrated clean, ` +
458
- `${todoCount} TODO${todoCount === 1 ? '' : 's'}${todoCount > 0 ? ':' : ''}`,
459
- )
460
- for (const [reason, ids] of todos) {
461
- if (ids.length === 1) report.push(` ${ids[0]}: ${reason}`)
462
- else {
463
- const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
464
- const more = ids.length - TODO_IDS_SHOWN
465
- report.push(` ${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
466
- }
467
- }
468
- report.push(...plan.notes)
469
- if (files.length === 0) report.push(dry ? 'no files (dry run)' : 'no files written')
470
- else report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
471
- for (const f of files) report.push(` ${f.relPath}`)
472
- if (kept.length > 0) {
473
- report.push('kept (each already has a vx config):')
474
- for (const f of kept) report.push(` ${f}`)
475
- }
476
- if (replaced.length > 0) {
477
- report.push(dry ? 'would replace (dry run):' : 'replaced:')
478
- for (const f of replaced) report.push(` ${relPosix(root, f)}`)
479
- }
480
- }
481
385
  const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
482
- // A run of tasks none of which caches is never a hit, and a first try
483
- // that runs twice to see the cache work saw it run twice.
484
- if (!cached && [...todos.keys()].some(isCacheTodo)) {
485
- report.push(
486
- '',
487
- 'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
488
- )
489
- }
490
386
  const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
491
387
  // The line is the next thing to type, so it names what that run would
492
388
  // refuse without: a git work tree (vx keys inputs on git's view) and,
493
389
  // with no scripts mapped, a task to run.
494
- const run = `${vxInvocation(process.env['npm_config_user_agent'], installed)} run ${firstTask} --all`
390
+ const vx = vxInvocation(process.env['npm_config_user_agent'], installed)
391
+ const run = `${vx} run ${firstTask} --all`
392
+ // A run reads configs only from the lock; with no lock step here
393
+ // (`--no-install`), that run stopped with "no lock: run `vx lock`".
495
394
  const steps = [
496
395
  ...(inGitWorkTree(root) ? [] : ['git init']),
497
396
  ...(empty ? [`declare a task as the example shows`] : []),
397
+ ...(!dry && args.lock === undefined ? [`${vx} lock`] : []),
498
398
  ]
499
399
  const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
400
+ const emptyReason = empty ? args.emptyReason : undefined
401
+ const notes = [
402
+ ...(args.notes ?? []),
403
+ ...ignoredNote,
404
+ ...formatted,
405
+ ...plan.headerNotes,
406
+ ...plan.notes,
407
+ ]
500
408
  if (args.json === true) {
501
409
  process.stdout.write(
502
410
  `${JSON.stringify({
@@ -507,29 +415,117 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
507
415
  replaced: replaced.map((f) => relPosix(root, f)),
508
416
  todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
509
417
  notes: [
510
- ...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
511
- ...(empty ? noMembers : []),
512
- ...(args.notes ?? []),
513
- ...ignoredNote,
514
- ...plan.headerNotes,
515
- ...plan.notes,
418
+ ...(emptyReason === undefined ? [] : [emptyReason]),
419
+ ...(args.steps ?? []).map(([label, value]) => `${label}: ${value}`),
420
+ ...notes,
516
421
  ],
517
422
  next,
518
423
  })}\n`,
519
424
  )
520
425
  return 0
521
426
  }
522
- report.push('', `next: ${next}`)
523
- process.stdout.write(`${report.join('\n')}\n`)
427
+
428
+ // The lock's own words become a row; a refusal prints above the report.
429
+ const lock = dry || args.lock === undefined ? undefined : await args.lock(root)
430
+ if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
431
+
432
+ const rows: ui.FieldRow[] = [['from', source]]
433
+ if (dry) rows.push(['dry run', 'nothing written'])
434
+ for (const [label, value] of args.steps ?? []) rows.push([label, value])
435
+ if (empty) {
436
+ const why =
437
+ emptyReason ??
438
+ (args.unmapped === true
439
+ ? 'no package.json script became a task'
440
+ : 'no package.json scripts to turn into tasks')
441
+ rows.push(['tasks', [ui.dim('none'), `: ${why}`]])
442
+ } else {
443
+ const parts = [
444
+ ...(clean > 0 ? [ui.tone('ok', `${clean} clean`)] : []),
445
+ ...(todo > 0 ? [ui.tone('warn', `${todo} todo`)] : []),
446
+ ]
447
+ if (parts.length > 0)
448
+ rows.push(['tasks', parts.flatMap((p, i) => (i === 0 ? [p] : [ui.dim(' · '), p]))])
449
+ const todoLines: string[] = []
450
+ for (const [reason, ids] of todos) {
451
+ if (ids.length === 1) todoLines.push(`${ids[0]}: ${reason}`)
452
+ else {
453
+ const shown = ids.slice(0, TODO_IDS_SHOWN).join(', ')
454
+ const more = ids.length - TODO_IDS_SHOWN
455
+ todoLines.push(`${shown}${more > 0 ? ` and ${more} more` : ''}:`, ` ${reason}`)
456
+ }
457
+ }
458
+ if (todoLines.length > 0) rows.push(['todo', ...todoLines])
459
+ }
460
+ if (files.length > 0) rows.push(['files', ...fileLines(files.map((f) => f.relPath))])
461
+ const keptLines = [
462
+ ...kept.map((f) => `${f} (has a vx config)`),
463
+ ...(empty && hasWorkspaceFile ? [workspaceName] : []),
464
+ ]
465
+ if (keptLines.length > 0) rows.push(['kept', ...keptLines])
466
+ if (replaced.length > 0) rows.push(['replaced', ...replaced.map((f) => relPosix(root, f))])
467
+ if (lock !== undefined) {
468
+ rows.push([
469
+ 'lock',
470
+ lock.code === 0
471
+ ? lock.said.trim().replace(/^✓ vx lock\s+/, '')
472
+ : ui.tone('error', 'not written'),
473
+ ])
474
+ }
475
+ if (notes.length > 0) rows.push(['note', ...notes])
476
+ if (empty && emptyReason === undefined) {
477
+ rows.push([
478
+ 'example',
479
+ `a ${configName} beside a package.json, with your own command:`,
480
+ '',
481
+ exampleConfig(format).trimEnd(),
482
+ ])
483
+ }
484
+ const said =
485
+ lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
486
+ rows.push(null, ['next', said])
487
+ ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
524
488
  return 0
525
489
  }
526
490
 
491
+ /**
492
+ * What to do about written files .gitignore drops. A `!file` line cures a
493
+ * file, but git re-includes nothing inside an ignored directory: an
494
+ * old-layout `/.vx/` line (when `.vx` held only the cache) hides the
495
+ * workspace config and lock, so that advice names the directory instead.
496
+ */
497
+ function ignoreAdvice(root: string, ignored: readonly string[], dry: boolean): string {
498
+ const ancestors = (f: string): string[] =>
499
+ f
500
+ .split('/')
501
+ .slice(0, -1)
502
+ .map((_, i, parts) => `${parts.slice(0, i + 1).join('/')}/`)
503
+ const dirs = new Set(gitIgnored(root, [...new Set(ignored.flatMap(ancestors))]))
504
+ const blocking = new Set<string>()
505
+ const names = new Set<string>()
506
+ for (const f of ignored) {
507
+ const dir = ancestors(f).find((d) => dirs.has(d))
508
+ if (dir === undefined) names.add(`!${path.posix.basename(f)}`)
509
+ else blocking.add(dir)
510
+ }
511
+ const cures = [
512
+ ...(names.size === 0 ? [] : [`add ${[...names].join(' and ')} to .gitignore`]),
513
+ ...(blocking.size === 0
514
+ ? []
515
+ : [
516
+ `stop ignoring ${[...blocking].join(' and ')} (a ! line cannot re-include a file inside an ignored directory)`,
517
+ ]),
518
+ ]
519
+ return `.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): ${cures.join(', ')}, or commit them with git add -f`
520
+ }
521
+
527
522
  /** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
528
523
  function gitIgnored(root: string, relPaths: readonly string[]): string[] {
529
524
  if (relPaths.length === 0) return []
530
525
  try {
531
526
  const r = Bun.spawnSync({
532
527
  cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
528
+ env: gitEnv(),
533
529
  cwd: root,
534
530
  stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
535
531
  stdout: 'pipe',
@@ -551,6 +547,7 @@ function inGitWorkTree(root: string): boolean {
551
547
  return (
552
548
  Bun.spawnSync({
553
549
  cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
550
+ env: gitEnv(),
554
551
  cwd: root,
555
552
  stdout: 'pipe',
556
553
  stderr: 'ignore',
@@ -583,21 +580,22 @@ function nextTask(tasks: readonly GeneratedTask[]): string {
583
580
  }
584
581
 
585
582
  /**
586
- * How the user runs vx, for the report's `next:` line. After `npx vx init`
583
+ * How the user runs vx, for the report's `next:` line. After `npx @vzn/vx init`
587
584
  * or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
588
585
  * walk, 2026-09-28), so the line names the runner that started this
589
586
  * process — npx, pnpm, yarn and Bun each set `npm_config_user_agent` — and
590
- * the spec it resolves: the installed bin, else the package, since a bare
591
- * `bunx vx` or `npx vx` would fetch an unrelated package named `vx`. No
587
+ * the spec it resolves. npx and bunx always name the package, which runs
588
+ * the installed bin when there is one: their bare `vx` fetches an unrelated
589
+ * package named `vx` wherever vx is not installed. No
592
590
  * runner (a global install, the compiled binary): `vx`.
593
591
  */
594
592
  export function vxInvocation(userAgent: string | undefined, installed: boolean): string {
595
593
  const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1]
596
594
  switch (runner) {
597
595
  case 'npm':
598
- return installed ? 'npx vx' : 'npx @vzn/vx'
596
+ return 'npx @vzn/vx'
599
597
  case 'bun':
600
- return installed ? 'bunx vx' : 'bunx @vzn/vx'
598
+ return 'bunx @vzn/vx'
601
599
  case 'pnpm':
602
600
  return installed ? 'pnpm vx' : 'pnpm dlx @vzn/vx'
603
601
  case 'yarn':
@@ -609,6 +607,15 @@ export function vxInvocation(userAgent: string | undefined, installed: boolean):
609
607
  }
610
608
  }
611
609
 
610
+ /** Files named one per line; past this, the root's and a count (TanStack form: 97). */
611
+ const FILES_SHOWN = 10
612
+
613
+ function fileLines(paths: readonly string[]): string[] {
614
+ if (paths.length <= FILES_SHOWN) return [...paths]
615
+ const root = paths.filter((p) => !p.includes('/') || p.startsWith('.vx/'))
616
+ return [...root, `and ${paths.length - root.length} more in package directories`]
617
+ }
618
+
612
619
  /** Ids named per shared TODO; the files carry each one (sveltejs/kit: 92 persistent tasks). */
613
620
  const TODO_IDS_SHOWN = 5
614
621
 
@@ -635,12 +642,12 @@ function exampleConfig(format: MigrationFormat): string {
635
642
  // into every run — measured 2026-09-09 at ~17 ms on a two-package
636
643
  // workspace, a fifth of the whole run — for an identity function.
637
644
  // No explanatory comment: the file is the user's (owner, 2026-10-06).
638
- const WORKSPACE_BODY = `export default { plugins: [] }`
639
-
640
- function workspaceFile(format: MigrationFormat): string {
645
+ function workspaceFile(format: MigrationFormat, plugins: readonly WorkspacePlugin[]): string {
646
+ const imports = plugins.map((p) => `import { ${p.factory} } from '${p.pkg}'\n`).join('')
647
+ const body = `export default { plugins: [${plugins.map((p) => `${p.factory}()`).join(', ')}] }`
641
648
  return format === 'ts'
642
- ? `import type { WorkspaceConfig } from '@vzn/vx/config'\n\n${WORKSPACE_BODY} satisfies WorkspaceConfig\n`
643
- : `${WORKSPACE_BODY}\n`
649
+ ? `import type { WorkspaceConfig } from '@vzn/vx/config'\n${imports}\n${body} satisfies WorkspaceConfig\n`
650
+ : `${imports}${imports === '' ? '' : '\n'}${body}\n`
644
651
  }
645
652
 
646
653
  const IDENT = /^[A-Za-z_$][\w$]*$/
@@ -679,8 +686,9 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
679
686
  if (p.importLines.length > 0) lines.push(...p.importLines)
680
687
  if (lines.length > 0) lines.push('')
681
688
  lines.push('export default {')
689
+ if (p.writeName === true) lines.push(` name: ${quoteTsLiteral(p.name)},`)
682
690
  if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
683
- lines.push(' tasks: {')
691
+ lines.push(p.tasks.length === 0 ? ' tasks: {},' : ' tasks: {')
684
692
  for (const t of p.tasks) {
685
693
  // A reason can quote a manifest's own text (an Nx `env` key), and a
686
694
  // line break there ended the comment: the rest was code in the file
@@ -709,6 +717,90 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
709
717
  ` ${key}: ${block === undefined ? rendered : rendered.replace('{\n', `{\n // ${block},\n`)},`,
710
718
  )
711
719
  }
712
- lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
720
+ if (p.tasks.length > 0) lines.push(' },')
721
+ lines.push(format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
713
722
  return lines.join('\n')
714
723
  }
724
+
725
+ /**
726
+ * `vx.config.mts` in a TypeScript package that is CommonJS: tsc (node16 /
727
+ * nodenext) reads a `.ts` there as CommonJS, and its type-only import of
728
+ * the ESM `@vzn/vx/config` fails (TS1479, TS1541: medusa's plugin builds).
729
+ */
730
+ function projectConfigName(
731
+ dir: string,
732
+ metas: readonly MigrationMember[],
733
+ format: MigrationFormat,
734
+ ): string {
735
+ if (format !== 'ts' || !existsSync(path.join(dir, 'tsconfig.json'))) return `vx.config.${format}`
736
+ const meta = metas.find((m) => m.dir === dir)
737
+ const manifest = (meta?.packageJson as { type?: unknown } | undefined) ?? readManifest(dir)
738
+ return manifest?.type === 'module' ? 'vx.config.ts' : 'vx.config.mts'
739
+ }
740
+
741
+ /**
742
+ * The plan takes the config back from an output as `!vx.config.<format>`;
743
+ * where the file written is the `.mts`, the take-back names it, or a `*`
744
+ * output covers it and `vx lock` refuses the config (medusa, 80 of 84).
745
+ */
746
+ function ownConfigNamed(
747
+ p: GeneratedProject,
748
+ name: string,
749
+ format: MigrationFormat,
750
+ ): GeneratedProject {
751
+ const planned = `!vx.config.${format}`
752
+ if (name === `vx.config.${format}`) return p
753
+ return {
754
+ ...p,
755
+ tasks: p.tasks.map((t) => {
756
+ const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
757
+ if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
758
+ const cache = t.task!['cache'] as Record<string, unknown>
759
+ const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
760
+ return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
761
+ }),
762
+ }
763
+ }
764
+
765
+ function readManifest(dir: string): { type?: unknown } | undefined {
766
+ try {
767
+ return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) as { type?: unknown }
768
+ } catch {
769
+ return undefined
770
+ }
771
+ }
772
+
773
+ /** The formatters a repo installs at its root, each with its write command. */
774
+ const FORMATTERS: readonly (readonly [string, readonly string[]])[] = [
775
+ ['prettier', ['--write']],
776
+ ['biome', ['format', '--write']],
777
+ ['oxfmt', []],
778
+ ['dprint', ['fmt']],
779
+ ]
780
+
781
+ /**
782
+ * Runs the repo's own formatter over the files just written, as Nx's
783
+ * generators do: create-t3-turbo's `prettier --check` failed on the quotes
784
+ * of a config vx wrote. Returns the report's note.
785
+ */
786
+ function formatWritten(root: string, relPaths: readonly string[]): string[] {
787
+ if (relPaths.length === 0) return []
788
+ for (const [name, args] of FORMATTERS) {
789
+ const bin = path.join(root, 'node_modules', '.bin', name)
790
+ if (!existsSync(bin)) continue
791
+ const r = Bun.spawnSync({
792
+ cmd: [bin, ...args, ...relPaths],
793
+ cwd: root,
794
+ stdin: 'ignore',
795
+ stdout: 'pipe',
796
+ stderr: 'pipe',
797
+ timeout: 60_000,
798
+ })
799
+ if (r.exitCode === 0) {
800
+ return [`formatted ${relPaths.length} file${relPaths.length === 1 ? '' : 's'} with ${name}`]
801
+ }
802
+ const why = (r.stderr.toString() || r.stdout.toString()).trim().split('\n')[0] ?? ''
803
+ return [`${name} did not format the written files (exit ${r.exitCode ?? 'signal'}): ${why}`]
804
+ }
805
+ return []
806
+ }