@vzn/vx 0.0.636 → 0.0.637

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