@vzn/vx 0.0.635 → 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 +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  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 +13 -4
  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 -61
  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 +123 -144
  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 +191 -71
  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 +25 -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 +192 -70
  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 +43 -28
  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 +114 -73
  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 +127 -88
  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 +251 -241
  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 +22 -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
package/src/cli/init.ts CHANGED
@@ -1,27 +1,25 @@
1
- // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
2
- // one vx.config.ts per package from its package.json scripts, and the
3
- // workspace file every run needs. In a Turbo or Nx repo it hands over to
4
- // `@vzn/vx-migrate` (`migrate`), which asks a terminal whether to write
5
- // native config; `--keep` writes only the workspace file, declaring
6
- // `turbo()` or `nx()`, which read the runner's own config live (`adopt`).
1
+ // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
2
+ // Core reads no package.json (M, 2026-10-14): a repo with one (package.json
3
+ // scripts, Turbo, Nx) is `@vzn/vx-migrate`'s, which maps it and installs
4
+ // what it writes (`migrate`); without one, the workspace file and an empty
5
+ // root project are written here, then the lock (`starter`).
7
6
  // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
8
7
  // test instead (plugin-templates.ts, the examples the gate runs).
9
8
 
10
- import { existsSync, readFileSync } from 'node:fs'
9
+ import { existsSync } from 'node:fs'
11
10
  import { mkdir, unlink } from 'node:fs/promises'
12
11
  import path from 'node:path'
12
+ import { gitTop, workspaceFileWritten } from '../handoff.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
+ import { lockAfterMigration } from './lock.js'
14
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
15
16
  import { isCompiledBinary } from './upgrade.js'
16
17
  import { VERSION } from '../version.js'
17
- import { isUserError, relPosix, UserError } from '../util/index.js'
18
+ import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, UserError } from '../util/index.js'
18
19
  import {
19
- applyMigration,
20
- discoverProjects,
21
20
  findWorkspaceRoot,
22
- type LoadReads,
23
- loadWorkspace,
24
- migrateScripts,
21
+ PROJECT_CONFIG_FILENAMES,
22
+ vxInvocation,
25
23
  WORKSPACE_CONFIG_FILENAMES,
26
24
  } from '../workspace/index.js'
27
25
 
@@ -97,94 +95,28 @@ export async function initCmd(args: readonly string[]): Promise<number> {
97
95
  'VX_E_USAGE',
98
96
  )
99
97
  }
100
- if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
101
- const reads: LoadReads = new Map()
102
- // "a workspace from nowhere" still needs a package.json to start from,
103
- // and the lookup's own refusal named no next step.
104
- const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
105
- if (isUserError(err) && err.code === 'VX_E_NO_WORKSPACE') {
106
- throw new UserError(
107
- 'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
108
- )
109
- }
110
- throw err
111
- })
112
- const nameless: string[] = []
113
- const workspace = await loadWorkspace(root, reads)
114
- const metas = await discoverProjects(workspace, nameless)
115
- // `init` reads scripts only; a runner's own config beside them is the
116
- // richer source (dependsOn, inputs, outputs) and was ignored without a
117
- // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
118
- // TODOs and none of the edges turbo.json already declared.
119
- // Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
120
- let turbo: string | undefined
121
- for (const name of ['turbo.json', 'turbo.jsonc']) {
122
- if (await Bun.file(path.join(root, name)).exists()) {
123
- turbo = name
124
- break
125
- }
126
- }
127
- // A Turbo or Nx repo already says its tasks: writing a config per package
128
- // from the scripts dropped every edge turbo.json declares (the
129
- // first-five-minutes walk, 2026-09-28). vx-migrate maps the runner's own
130
- // config, so `vx init` is the one command (M, 2026-10-08); its `--keep`
131
- // comes back here for the workspace file alone.
132
- const runner: ['turbo' | 'nx', string] | undefined =
133
- turbo !== undefined
134
- ? ['turbo', turbo]
135
- : (await Bun.file(path.join(root, 'nx.json')).exists())
136
- ? ['nx', 'nx.json']
137
- : undefined
138
- if (runner !== undefined) {
139
- if (parsed.mode === 'keep') return adopt(root, ...runner, parsed)
140
- // A second `vx init` after the native migration refused to overwrite
141
- // the configs it wrote, exit 1, after a package fetch: the first-run
142
- // walk (2026-10-09). Set up is an answer, with the next step.
143
- const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
144
- if (
145
- existing !== undefined &&
146
- !parsed.force &&
147
- !parsed.dry &&
148
- parsed.mode === undefined &&
149
- metas.some((m) => m.configPath !== null) &&
150
- !readFileSync(path.join(root, existing), 'utf8').includes(`${runner[0]}(`)
151
- ) {
152
- process.stdout.write(
153
- `vx is already set up here (${existing} and the projects' vx.config files); --force regenerates them from ${runner[1]}.\n\nnext: ${adoptionNext(root, ...runner, ['@vzn/vx'])}\n`,
154
- )
155
- return 0
156
- }
157
- return migrate(root, parsed)
98
+ const cwd = process.cwd()
99
+ const marked = await findWorkspaceRoot(cwd).catch(() => undefined)
100
+ if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, marked ?? cwd, parsed)
101
+ // The directories from here up to the workspace root, else the git
102
+ // top-level, else `/`: where a package manager's root or an installed
103
+ // vx-migrate may be.
104
+ const top = marked ?? (await gitTop(cwd))
105
+ const dirs: string[] = []
106
+ for (let d = cwd; ; d = path.dirname(d)) {
107
+ dirs.push(d)
108
+ if (d === top || path.dirname(d) === d) break
158
109
  }
159
- if (parsed.mode !== undefined) {
160
- throw new UserError(
161
- `vx init: --${parsed.mode} answers a Turbo or Nx repo's question, and no turbo.json or nx.json is at ${relPosix(process.cwd(), root) || '.'}`,
162
- )
163
- }
164
- const unmapped = namelessNotes(root, nameless)
165
- return applyMigration({
166
- root,
167
- metas,
168
- plan: migrateScripts(
169
- metas,
170
- metas.some((m) => m.dir === root) ? undefined : await readRootManifest(root),
171
- root,
172
- ),
173
- source: 'package.json scripts',
174
- verb: 'vx init',
175
- notes: [...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
176
- unmapped: unmapped.length > 0,
177
- dry: parsed.dry,
178
- json: parsed.json === true,
179
- force: parsed.force,
180
- init: true,
181
- format: parsed.mjs ? 'mjs' : 'ts',
182
- })
110
+ const manifest = dirs.some((d) => MANIFESTS.some((f) => existsSync(path.join(d, f))))
111
+ if (manifest) return migrate(dirs, parsed)
112
+ return starter(marked ?? cwd, top === null, parsed)
183
113
  }
184
114
 
185
- /** Write `plugins/<seam>.ts` and its test under the workspace root (the cwd when there is none). */
186
- async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
187
- const root = await findWorkspaceRoot(process.cwd(), new Map()).catch(() => process.cwd())
115
+ /** What makes a repo vx-migrate's to map: a package manager's root, a Turbo or Nx config. */
116
+ const MANIFESTS = ['package.json', 'pnpm-workspace.yaml', 'turbo.json', 'turbo.jsonc', 'nx.json']
117
+
118
+ /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
119
+ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
188
120
  const template = PLUGIN_TEMPLATES[seam]!
189
121
  const files = [
190
122
  [path.join('plugins', `${seam}.ts`), template.plugin],
@@ -204,338 +136,115 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
204
136
  }
205
137
  const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
206
138
  const workspaceFile =
207
- WORKSPACE_CONFIG_FILENAMES.find((n) => existsSync(path.join(root, n))) ?? 'vx.workspace.ts'
139
+ WORKSPACE_CONFIG_FILENAMES.find((n) => existsSync(path.join(root, n))) ?? '.vx/workspace.ts'
208
140
  process.stdout.write(
209
141
  `${args.dry ? 'would write' : 'wrote'} ${files.map(([rel]) => rel).join(' and ')}\n` +
210
- `Declare it in ${workspaceFile}: import { ${factory} } from './plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
142
+ `Declare it in ${workspaceFile}: import { ${factory} } from '../plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
211
143
  `Test it: bun test plugins/${seam}.test.ts (needs @vzn/vx installed)\n`,
212
144
  )
213
145
  return 0
214
146
  }
215
147
 
216
- /**
217
- * `vx init` in a Turbo or Nx repo runs `@vzn/vx-migrate`: the installed one,
218
- * else this version's through `bun x`. It asks a terminal native or keep,
219
- * installs what it writes, and runs `vx init --keep` for keep.
220
- */
221
- async function migrate(root: string, args: InitArgs): Promise<number> {
222
- const flags = [
223
- args.dry && '--dry',
224
- args.force && '--force',
225
- args.mjs && '--mjs',
226
- args.mode === 'native' && '--native',
227
- args.json === true && '--format=json',
228
- ].filter((f): f is string => typeof f === 'string')
229
- return Bun.spawn(migrateCommand(root, flags), {
230
- cwd: root,
231
- stdio: ['inherit', 'inherit', 'inherit'],
232
- // A compiled vx is a Bun runtime only when told so.
233
- env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
234
- }).exited
235
- }
236
-
237
- export function migrateCommand(root: string, flags: readonly string[]): string[] {
238
- const installed = path.join(root, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
239
- if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
240
- // A source checkout is 0.0.0, which npm never had.
241
- const version = VERSION === '0.0.0' ? 'latest' : VERSION
242
- return [process.execPath, 'x', `@vzn/vx-migrate@${version}`, ...flags]
243
- }
148
+ const WORKSPACE =
149
+ "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
150
+ const PROJECT =
151
+ "import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
244
152
 
245
153
  /**
246
- * `vx init --keep` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
247
- * that runs the repo as it is, nothing else, and the one command that gets
248
- * from here to a run.
154
+ * No package.json: `.vx/workspace.ts` declaring no plugin and a root
155
+ * `vx.config.ts` with no task, then the lock. `@vzn/vx` resolves to the
156
+ * running vx (core-alias.ts), so the two load with nothing installed.
157
+ * A file already here is kept; `--force` replaces it.
249
158
  */
250
- async function adopt(
251
- root: string,
252
- runner: 'turbo' | 'nx',
253
- source: string,
254
- args: InitArgs,
255
- ): Promise<number> {
256
- const name = `vx.workspace.${args.mjs ? 'mjs' : 'ts'}`
257
- const remote = remoteCacheSignal(root, runner)
258
- const cache = REMOTE_CACHE[runner]
259
- const imports = remote === undefined ? runner : `${runner}, ${cache}`
260
- const plugins = remote === undefined ? `${runner}()` : `${runner}(), ${cache}()`
261
- const body = `import { ${imports} } from '@vzn/vx-migrate'\n\nexport default { plugins: [${plugins}] }`
262
- const text = args.mjs
263
- ? `${body}\n`
264
- : `import type { WorkspaceConfig } from '@vzn/vx/config'\n${body} satisfies WorkspaceConfig\n`
265
- const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
266
- if (existing !== undefined && !args.force) {
267
- const declared = readFileSync(path.join(root, existing), 'utf8').includes(`${runner}(`)
268
- if (!declared) {
269
- throw new UserError(
270
- `vx init: ${existing} exists; add ${runner}() from @vzn/vx-migrate to its plugins, or --force replaces it`,
271
- )
159
+ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<number> {
160
+ if (args.mode !== undefined) {
161
+ throw new UserError(
162
+ `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no package.json here`,
163
+ )
164
+ }
165
+ const ext = args.mjs ? 'mjs' : 'ts'
166
+ const config = `vx.config.${ext}`
167
+ const files: { path: string; contents: string }[] = []
168
+ const kept: string[] = []
169
+ const replaced: string[] = []
170
+ for (const [names, name, contents] of [
171
+ [WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, WORKSPACE],
172
+ [PROJECT_CONFIG_FILENAMES, config, PROJECT],
173
+ ] as const) {
174
+ const existing = names.find((n) => existsSync(path.join(root, n)))
175
+ if (existing !== undefined && !args.force) kept.push(existing)
176
+ else {
177
+ files.push({ path: name, contents })
178
+ if (existing !== undefined && existing !== name) replaced.push(existing)
272
179
  }
273
- } else if (args.dry) {
274
- if (args.json !== true) process.stdout.write(`── ${name} ──\n${text}\n`)
275
- } else {
276
- if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
277
- await Bun.write(path.join(root, name), text)
278
180
  }
279
- const kept = existing !== undefined && !args.force
280
- const wrote = kept
281
- ? `${existing} already declares ${runner}().`
282
- : args.dry
283
- ? `would write ${name} (dry run, nothing written).`
284
- : `wrote ${name}.`
285
- const cacheLine =
286
- remote === undefined
287
- ? runner === 'nx' && usesNxCloud(root)
288
- ? 'nx.json connects Nx Cloud, whose cache vx cannot share: runs cache on this machine (nxCache() serves a self-hosted Nx cache).\n'
289
- : ''
290
- : kept && !readFileSync(path.join(root, existing), 'utf8').includes(`${cache}(`)
291
- ? `${remote}: add ${cache}() from @vzn/vx-migrate to its plugins and vx shares that remote cache.\n`
292
- : kept
293
- ? ''
294
- : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
181
+ // vx's own housekeeping, as a run's first cache open and `vx lock` write them.
182
+ for (const [name, contents] of [
183
+ ['.vx/.gitignore', DOT_VX_GITIGNORE],
184
+ ['.vx/.gitattributes', DOT_VX_GITATTRIBUTES],
185
+ ] as const) {
186
+ if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
187
+ }
188
+ const vx = vxInvocation(process.env['npm_config_user_agent'], false)
189
+ const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
295
190
  if (args.json === true) {
296
191
  process.stdout.write(
297
- `${JSON.stringify({
298
- dry: true,
299
- source,
300
- files: kept ? [] : [{ path: name, contents: text }],
301
- kept: kept ? [existing] : [],
302
- replaced: !kept && existing !== undefined && existing !== name ? [existing] : [],
303
- todos: [],
304
- notes: cacheLine === '' ? [] : [cacheLine.trimEnd()],
305
- next: adoptionNext(root, runner, source),
306
- })}\n`,
192
+ `${JSON.stringify({ dry: true, source: 'no package.json', files, kept, replaced, todos: [], notes: [], next })}\n`,
307
193
  )
308
194
  return 0
309
195
  }
310
- process.stdout.write(
311
- `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
312
- `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
313
- )
196
+ const report = [
197
+ 'vx init: no package.json, so no scripts to map: a workspace with one project, the root.',
198
+ ]
199
+ const named = files.map((f) => f.path).join(', ')
200
+ if (files.length > 0)
201
+ report.push(args.dry ? `would write ${named} (dry run, nothing written).` : `wrote ${named}.`)
202
+ if (kept.length > 0) report.push(`kept ${kept.join(', ')}.`)
203
+ if (replaced.length > 0)
204
+ report.push(`${args.dry ? 'would replace' : 'replaced'} ${replaced.join(', ')}.`)
205
+ if (args.dry) {
206
+ for (const f of files) process.stdout.write(`── ${f.path} ──\n${f.contents}\n`)
207
+ process.stdout.write(`${report.join('\n')}\n\nnext: ${next}\n`)
208
+ return 0
209
+ }
210
+ for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
211
+ for (const f of replaced) await unlink(path.join(root, f))
212
+ // The lock that follows loads the workspace file just written.
213
+ workspaceFileWritten()
214
+ process.stdout.write(`${report.join('\n')}\n\n`)
215
+ const locked = (await lockAfterMigration(root)) === 0
216
+ process.stdout.write(`\nnext: ${locked ? next : `fix the above and run vx lock, then ${next}`}\n`)
314
217
  return 0
315
218
  }
316
219
 
317
- const REMOTE_CACHE = { turbo: 'turboCache', nx: 'nxCache' } as const
318
-
319
220
  /**
320
- * nx.json names an Nx Cloud workspace (`nxCloudId`, `nxCloudAccessToken`,
321
- * or the legacy `nx-cloud` runner). Its wire is Nx's own, so a user who
322
- * expected their remote cache under vx heard nothing and ran cold.
221
+ * A repo with a package.json is `@vzn/vx-migrate`'s: the installed one,
222
+ * else this version's through `bun x`. It finds the root itself, asks a
223
+ * terminal native or keep in a Turbo or Nx repo, and installs what it writes.
323
224
  */
324
- function usesNxCloud(root: string): boolean {
325
- let json: {
326
- nxCloudId?: unknown
327
- nxCloudAccessToken?: unknown
328
- tasksRunnerOptions?: { default?: { runner?: unknown } }
329
- } | null
330
- try {
331
- json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
332
- } catch {
333
- return false
334
- }
335
- const set = (v: unknown) => typeof v === 'string' && v !== ''
336
- const runner = json?.tasksRunnerOptions?.default?.runner
337
- return (
338
- set(json?.nxCloudId) ||
339
- set(json?.nxCloudAccessToken) ||
340
- (typeof runner === 'string' && /(^|\/)nx-cloud$/.test(runner))
341
- )
225
+ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
226
+ const flags = [
227
+ args.dry && '--dry',
228
+ args.force && '--force',
229
+ args.mjs && '--mjs',
230
+ args.mode !== undefined && `--${args.mode}`,
231
+ args.json === true && '--format=json',
232
+ ].filter((f): f is string => typeof f === 'string')
233
+ return Bun.spawn(migrateCommand(dirs, flags), {
234
+ stdio: ['inherit', 'inherit', 'inherit'],
235
+ // A compiled vx is a Bun runtime only when told so.
236
+ env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
237
+ }).exited
342
238
  }
343
239
 
344
- /** The variable each runner's self-hosted remote cache is set by, as its plugin reads it. */
345
- const REMOTE_CACHE_ENV = { turbo: 'TURBO_TOKEN', nx: 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER' } as const
346
-
347
- const CI_FILES = ['.gitlab-ci.yml', '.circleci/config.yml']
348
-
349
- /**
350
- * Where the repo shows a remote cache its runner uses, or undefined: turbo.json's
351
- * enabled `remoteCache`, a `turbo link`ed `.turbo/config.json`, or a CI file that sets the runner's cache variable (a
352
- * local repo holds no token; CI does).
353
- */
354
- function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string | undefined {
355
- if (runner === 'turbo') {
356
- for (const f of ['turbo.json', 'turbo.jsonc']) {
357
- let rc: unknown
358
- try {
359
- rc = (
360
- Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
361
- )?.remoteCache
362
- } catch {
363
- continue
364
- }
365
- if (typeof rc === 'object' && rc !== null) {
366
- // Turned off in turbo.json is off, whatever CI sets.
367
- if ((rc as { enabled?: unknown }).enabled === false) return undefined
368
- return `${f} names a remoteCache`
369
- }
370
- }
371
- // `turbo link` writes the team here (gitignored, on the machine that
372
- // linked); turboCache() reads it, so a linked repo has a remote cache.
373
- let linked: unknown
374
- try {
375
- linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
376
- } catch {}
377
- if (
378
- typeof linked === 'object' &&
379
- linked !== null &&
380
- Object.entries(linked).some(
381
- ([k, v]) => /^(teamid|teamslug|token)$/i.test(k) && typeof v === 'string' && v !== '',
382
- )
383
- ) {
384
- return '.turbo/config.json links a team (turbo link)'
385
- }
240
+ export function migrateCommand(dirs: readonly string[], flags: readonly string[]): string[] {
241
+ for (const d of dirs) {
242
+ const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
243
+ if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
386
244
  }
387
- const variable = REMOTE_CACHE_ENV[runner]
388
- // A line that sets it (`TURBO_TOKEN: …`, `TURBO_TOKEN=…`), not one that
389
- // names it: a comment or a doc line counted as "sets" (J's lead).
390
- const sets = new RegExp(`^(?!\\s*#).*\\b${variable}\\s*[:=]`, 'm')
391
- const workflows = new Bun.Glob('.github/workflows/*.{yml,yaml}')
392
- const files = [...workflows.scanSync({ cwd: root, dot: true })].sort().concat(CI_FILES)
393
- for (const f of files) {
394
- let text: string
395
- try {
396
- text = readFileSync(path.join(root, f), 'utf8')
397
- } catch {
398
- continue
399
- }
400
- if (sets.test(text)) return `${f} sets ${variable}`
401
- }
402
- return undefined
403
- }
404
-
405
- type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'
406
-
407
- const LOCKFILES: ReadonlyArray<[string, PackageManager]> = [
408
- ['pnpm-lock.yaml', 'pnpm'],
409
- ['yarn.lock', 'yarn'],
410
- ['bun.lock', 'bun'],
411
- ['bun.lockb', 'bun'],
412
- ['package-lock.json', 'npm'],
413
- ]
414
-
415
- const INSTALL: Record<PackageManager, string> = {
416
- pnpm: 'pnpm add -D -w',
417
- yarn: 'yarn add -D',
418
- bun: 'bun add -d',
419
- npm: 'npm install -D',
420
- }
421
-
422
- const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
423
-
424
- /**
425
- * Install what the workspace file imports, if missing, then run the repo's
426
- * build. Pinned to this vx's version: every @vzn package releases in
427
- * lockstep, and `latest` beside an older vx mismatched them (agent trial 2).
428
- */
429
- export function adoptionNext(
430
- root: string,
431
- runner: 'turbo' | 'nx',
432
- source: string,
433
- needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
434
- version: string = VERSION,
435
- ): string {
436
- const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
437
- const missing = needs.filter(
438
- (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
439
- )
440
- // A global vx (no runner started this one) runs the workspace's plugins as they are.
441
- const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : `${EXEC[pm]} vx`
442
- const task = firstTask(path.join(root, source), runner)
443
- const run = `${vx} run ${task} --all`
444
- if (missing.length === 0) return run
445
- // Yarn 1 refuses `add` at a workspace root without `-W`; Berry has no such
446
- // flag and refuses it. A Berry lockfile carries `__metadata`.
447
- const install =
448
- pm === 'yarn' && !readText(path.join(root, 'yarn.lock')).includes('__metadata:')
449
- ? `${INSTALL.yarn} -W`
450
- : INSTALL[pm]
451
245
  // A source checkout is 0.0.0, which npm never had.
452
- const pinned = version === '0.0.0' ? missing : missing.map((p) => `${p}@${version}`)
453
- return `${install} ${pinned.join(' ')} && ${run}`
454
- }
455
-
456
- function readText(file: string): string {
457
- try {
458
- return readFileSync(file, 'utf8')
459
- } catch {
460
- return ''
461
- }
462
- }
463
-
464
- /** `build` when the runner's config names it, else the first task it names. */
465
- function firstTask(file: string, runner: 'turbo' | 'nx'): string {
466
- let text: string
467
- try {
468
- text = readFileSync(file, 'utf8')
469
- } catch {
470
- return 'build'
471
- }
472
- if (/"build"\s*:/.test(text)) return 'build'
473
- const field = runner === 'turbo' ? '(?:tasks|pipeline)' : 'targetDefaults'
474
- const first = new RegExp(`"${field}"\\s*:\\s*\\{\\s*"([^"]+)"`).exec(text)?.[1]
475
- return first ?? 'build'
476
- }
477
-
478
- /**
479
- * A member whose package.json has no `name` is no project, so its scripts
480
- * map to nothing, and init said nothing of them (D-106).
481
- */
482
- function namelessNotes(root: string, dirs: readonly string[]): string[] {
483
- const withScripts = dirs
484
- .filter((dir) => {
485
- const scripts = (
486
- JSON.parse(readText(path.join(dir, 'package.json')).replace(/^\uFEFF/, '') || '{}') as {
487
- scripts?: unknown
488
- }
489
- ).scripts
490
- return (
491
- typeof scripts === 'object' &&
492
- scripts !== null &&
493
- Object.values(scripts).some((v) => typeof v === 'string' && v !== '')
494
- )
495
- })
496
- .map((dir) => relPosix(root, dir))
497
- .sort()
498
- // The root's own manifest is the empty relative path: a nameless
499
- // single-package repo read `not mapped: — its package.json…` (M-54).
500
- .map((rel) => (rel === '' ? 'the root' : rel))
501
- if (withScripts.length === 0) return []
502
- const shown =
503
- withScripts.slice(0, 3).join(', ') +
504
- (withScripts.length > 3 ? `, and ${withScripts.length - 3} more` : '')
505
- return [
506
- `not mapped: ${shown} — ${withScripts.length === 1 ? 'its package.json has' : 'their package.json files have'} no "name", and vx names a project by it; give ${withScripts.length === 1 ? 'it one' : 'each one'} and run \`vx init\` again`,
507
- ]
508
- }
509
-
510
- /**
511
- * `pnpm-workspace.yaml`'s `packages` decides the members, as pnpm reads it,
512
- * and a root `workspaces` that lists other globs was dropped without a word:
513
- * a bun or npm repo with a stale yaml lost those members (M-51).
514
- */
515
- async function unreadWorkspaces(root: string, globs: readonly string[]): Promise<string[]> {
516
- if (!existsSync(path.join(root, 'pnpm-workspace.yaml'))) return []
517
- const ws = (await readRootManifest(root))['workspaces']
518
- const list = Array.isArray(ws)
519
- ? ws
520
- : typeof ws === 'object' &&
521
- ws !== null &&
522
- Array.isArray((ws as { packages?: unknown }).packages)
523
- ? (ws as { packages: unknown[] }).packages
524
- : undefined
525
- if (list === undefined) return []
526
- const pkg = list.filter((g): g is string => typeof g === 'string')
527
- if ([...pkg].sort().join('\0') === [...globs].sort().join('\0')) return []
528
- return [
529
- `pnpm-workspace.yaml's \`packages\` lists the members, as pnpm reads them; package.json's \`workspaces\` (${pkg.map((g) => `"${g}"`).join(', ')}) is not read: if bun, npm or yarn installs this repo, copy those globs into pnpm-workspace.yaml's \`packages\`, or delete that key so package.json decides`,
530
- ]
531
- }
532
-
533
- /** The root package.json as an object, `{}` when unreadable or not one. */
534
- async function readRootManifest(root: string): Promise<Record<string, unknown>> {
535
- const pj: unknown = await Bun.file(path.join(root, 'package.json'))
536
- .json()
537
- .catch(() => ({}))
538
- return typeof pj === 'object' && pj !== null && !Array.isArray(pj)
539
- ? (pj as Record<string, unknown>)
540
- : {}
246
+ const version = VERSION === '0.0.0' ? 'latest' : VERSION
247
+ // Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
248
+ // repo reads as a stranger speaking (Growth's first-run funnel F2).
249
+ return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${version}`, ...flags]
541
250
  }