@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
package/src/cli/init.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
2
2
  // It installs vx globally and, where a package.json is, as its devDependency
3
- // (M, 2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
3
+ // (2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
4
4
  // and installs what it writes (`migrate`); any other gets the workspace file
5
5
  // and an empty root project, then the lock (`starter`).
6
- // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
6
+ // `vx init --plugin <hook>` writes a runnable plugin for one hook and its
7
7
  // test instead (plugin-templates.ts, the examples the gate runs).
8
8
 
9
- import { existsSync } from 'node:fs'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
10
  import { mkdir, unlink } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { gitTop, workspaceFileWritten } from '../handoff.js'
@@ -22,11 +22,23 @@ import {
22
22
  type ManagedInstall,
23
23
  } from './upgrade.js'
24
24
  import { VERSION } from '../version.js'
25
- import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
25
+ import {
26
+ detectColors,
27
+ DOT_VX_GITATTRIBUTES,
28
+ DOT_VX_GITIGNORE,
29
+ paint,
30
+ refuseSymlink,
31
+ sayNotice,
32
+ SUCCESS,
33
+ ui,
34
+ UserError,
35
+ writeNoFollow,
36
+ } from '../util/index.js'
26
37
  import {
27
38
  findWorkspaceRoot,
28
39
  PROJECT_CONFIG_FILENAMES,
29
- vxInvocation,
40
+ renderWorkspace,
41
+ starterProject,
30
42
  WORKSPACE_CONFIG_FILENAMES,
31
43
  } from '../workspace/index.js'
32
44
 
@@ -35,7 +47,7 @@ interface InitArgs {
35
47
  force: boolean
36
48
  /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
37
49
  mjs: boolean
38
- /** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
50
+ /** `--plugin <hook>`: scaffold that hook's plugin instead of the workspace. */
39
51
  plugin?: string
40
52
  /** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
41
53
  mode?: 'native' | 'keep'
@@ -54,7 +66,16 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
54
66
  if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
55
67
  return {
56
68
  ...out,
57
- error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
69
+ error:
70
+ seam === undefined || seam === ''
71
+ ? `--plugin requires a value: one of ${seams}${seeHelp('init')}`
72
+ : `--plugin must be one of ${seams} (got ${seam})${seeHelp('init')}`,
73
+ }
74
+ }
75
+ if (out.plugin !== undefined && out.plugin !== seam) {
76
+ return {
77
+ ...out,
78
+ error: `--plugin takes one hook; run vx init --plugin once per hook${seeHelp('init')}`,
58
79
  }
59
80
  }
60
81
  out.plugin = seam
@@ -117,16 +138,47 @@ export async function initCmd(args: readonly string[]): Promise<number> {
117
138
  if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
118
139
  return migrate(dirs, parsed)
119
140
  }
120
- // The topmost package.json: a monorepo's root, wherever below it init ran.
121
- const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
141
+ const pkgRoot = packageRoot(dirs, top !== null)
122
142
  return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
123
143
  }
124
144
 
145
+ /**
146
+ * Inside git, the topmost package.json: a monorepo's root, wherever below
147
+ * it init ran. Outside git `dirs` reach `/`, and the topmost took a stray
148
+ * package.json in the home directory (2026-10-28); there it is the nearest
149
+ * one, or the outermost above it that declares a workspace.
150
+ */
151
+ function packageRoot(dirs: readonly string[], inGit: boolean): string | null {
152
+ const has = (d: string, f: string): boolean => existsSync(path.join(d, f))
153
+ if (inGit) return dirs.findLast((d) => has(d, 'package.json')) ?? null
154
+ const at = dirs.findIndex((d) => has(d, 'package.json'))
155
+ if (at === -1) return null
156
+ const outer = dirs
157
+ .slice(at + 1)
158
+ .findLast(
159
+ (d) => has(d, 'package.json') && (has(d, 'pnpm-workspace.yaml') || declaresWorkspaces(d)),
160
+ )
161
+ return outer ?? dirs[at]!
162
+ }
163
+
164
+ function declaresWorkspaces(dir: string): boolean {
165
+ try {
166
+ const pj = JSON.parse(
167
+ readFileSync(path.join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''),
168
+ ) as {
169
+ workspaces?: unknown
170
+ }
171
+ return pj.workspaces !== undefined
172
+ } catch {
173
+ return false
174
+ }
175
+ }
176
+
125
177
  /** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
126
178
  const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
127
179
 
128
180
  /**
129
- * The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
181
+ * The installs `vx init` runs (2026-10-20): vx at `version` globally,
130
182
  * with the manager that started init, so a bare `vx` is on PATH, unless
131
183
  * the global vx already answers `version`; and, where `pkgRoot` has a
132
184
  * package.json, as its devDependency with the repo's own manager.
@@ -137,10 +189,13 @@ export function initInstalls(
137
189
  pkgRoot: string | null,
138
190
  globalAt: string | undefined,
139
191
  execPath = process.execPath,
192
+ plugins: readonly string[] = [],
140
193
  ): { argv: string[]; cwd: string | null }[] {
141
194
  const out: { argv: string[]; cwd: string | null }[] = []
142
195
  if (globalAt !== version) {
143
- const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm'
196
+ // Yarn 2+ has no `yarn global`: Bun, which vx needs anyway, as with no runner.
197
+ const ua = /^(npm|pnpm|yarn|bun)\/(\d+)/.exec(userAgent ?? '')
198
+ const runner = ua === null || (ua[1] === 'yarn' && Number(ua[2]) >= 2) ? 'bun' : ua[1]!
144
199
  const manager = runner as ManagedInstall['manager']
145
200
  out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
146
201
  }
@@ -150,6 +205,7 @@ export function initInstalls(
150
205
  scope: 'project',
151
206
  cwd: pkgRoot,
152
207
  field: 'devDependencies',
208
+ plugins: [...plugins],
153
209
  }
154
210
  out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
155
211
  }
@@ -168,11 +224,30 @@ function globalVersion(): string | undefined {
168
224
  return undefined
169
225
  }
170
226
 
227
+ /**
228
+ * The package a manager's output says the registry answered 404 for, as
229
+ * `name@version`: a release's platform tarball stays unserved for minutes
230
+ * after publish (v0.0.646, 2026-10-23). bun: `GET <url> - 404`; npm:
231
+ * `404 Not Found - GET <url>`; pnpm: `GET <url>: Not Found - 404`.
232
+ */
233
+ export function notServed(output: string): string | undefined {
234
+ for (const line of output.split('\n')) {
235
+ if (!line.includes('404')) continue
236
+ const m = /https?:\/\/\S+?\/((?:@[^/\s]+\/)?[^/\s]+)\/-\/[^/\s]+?-(\d[^/\s]*?)\.tgz/.exec(line)
237
+ if (m !== null) return `${decodeURIComponent(m[1]!)}@${m[2]}`
238
+ }
239
+ return undefined
240
+ }
241
+
171
242
  /**
172
243
  * Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
173
244
  * had) installs nothing: the release would replace the code under test.
174
245
  */
175
- async function install(pkgRoot: string | null, dry: boolean): Promise<string[]> {
246
+ async function install(
247
+ pkgRoot: string | null,
248
+ dry: boolean,
249
+ plugins: readonly string[] = [],
250
+ ): Promise<string[]> {
176
251
  if (VERSION === '0.0.0') return []
177
252
  const version = await targetVersion(undefined, 'vx init')
178
253
  const steps = initInstalls(
@@ -180,6 +255,8 @@ async function install(pkgRoot: string | null, dry: boolean): Promise<string[]>
180
255
  process.env['npm_config_user_agent'],
181
256
  pkgRoot,
182
257
  globalVersion(),
258
+ process.execPath,
259
+ plugins,
183
260
  )
184
261
  if (dry) return steps.map((s) => s.argv.join(' '))
185
262
  for (const s of steps) {
@@ -193,22 +270,62 @@ async function install(pkgRoot: string | null, dry: boolean): Promise<string[]>
193
270
  'out',
194
271
  ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
195
272
  )
196
- const proc = Bun.spawnSync({
273
+ const proc = Bun.spawn({
197
274
  cmd: [bin, ...s.argv.slice(1)],
198
275
  cwd: s.cwd ?? process.cwd(),
199
- stdio: ['inherit', 'inherit', 'inherit'],
276
+ stdio: ['inherit', 'inherit', 'pipe'],
200
277
  })
201
- if (proc.exitCode !== 0) {
202
- throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
278
+ // Shown as it comes, and kept: a registry 404 is the cause to name.
279
+ let said = ''
280
+ const decoder = new TextDecoder()
281
+ for await (const chunk of proc.stderr) {
282
+ process.stderr.write(chunk)
283
+ said += decoder.decode(chunk, { stream: true })
284
+ }
285
+ const code = await proc.exited
286
+ if (code !== 0) {
287
+ const missing = notServed(said)
288
+ throw new UserError(
289
+ missing === undefined
290
+ ? `vx init: ${s.argv.join(' ')} failed (exit ${code})`
291
+ : `vx init: the npm registry does not serve ${missing} yet (404), as a release can take minutes to reach it; run bunx @vzn/vx init again`,
292
+ )
203
293
  }
204
294
  }
205
295
  return steps.map((s) => s.argv.join(' '))
206
296
  }
207
297
 
298
+ const PLUGINS_MANIFEST = `${JSON.stringify({ name: 'workspace-plugins', private: true }, null, 2)}\n`
299
+
300
+ /** Whether the nearest package.json above `dir` has a name, as `definePlugin` needs. */
301
+ function ownerNamed(dir: string): boolean {
302
+ for (let d = dir; ; d = path.dirname(d)) {
303
+ let text: string
304
+ try {
305
+ text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^/, '')
306
+ } catch {
307
+ if (path.dirname(d) === d) return false
308
+ continue
309
+ }
310
+ try {
311
+ const name = (JSON.parse(text) as { name?: unknown } | null)?.name
312
+ return typeof name === 'string' && name !== ''
313
+ } catch {
314
+ return false
315
+ }
316
+ }
317
+ }
318
+
208
319
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
209
320
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
210
321
  const template = PLUGIN_TEMPLATES[seam]!
322
+ // `definePlugin` names the plugin by the nearest package.json, and a
323
+ // monorepo root often has no name: the plugin refused to load.
324
+ const manifest: [string, string][] = ownerNamed(path.join(root, 'plugins'))
325
+ ? []
326
+ : [[path.join('plugins', 'package.json'), PLUGINS_MANIFEST]]
211
327
  const files = [
328
+ ...manifest,
212
329
  [path.join('plugins', `${seam}.ts`), template.plugin],
213
330
  [path.join('plugins', `${seam}.test.ts`), template.test],
214
331
  ] as const
@@ -221,24 +338,69 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
221
338
  }
222
339
  }
223
340
  if (!args.dry) {
224
- await mkdir(path.join(root, 'plugins'), { recursive: true })
341
+ await mkdir(path.join(root, 'plugins'), { recursive: true }).catch(
342
+ (err: NodeJS.ErrnoException) => {
343
+ if (err.code !== 'EEXIST' && err.code !== 'ENOTDIR') throw err
344
+ throw new UserError(
345
+ `vx init: cannot write ${files[0][0]}: plugins is a file, not a directory; move it and re-run`,
346
+ )
347
+ },
348
+ )
225
349
  for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
226
350
  }
227
351
  const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
228
352
  const workspaceFile =
229
353
  WORKSPACE_CONFIG_FILENAMES.find((n) => existsSync(path.join(root, n))) ?? '.vx/workspace.ts'
354
+ const c = detectColors(process.stdout)
355
+ const dim = (t: string): string => paint('', t, c, { dim: true })
356
+ const bold = (t: string): string => paint('', t, c, { bold: true })
230
357
  process.stdout.write(
231
- `${args.dry ? 'would write' : 'wrote'} ${files.map(([rel]) => rel).join(' and ')}\n` +
232
- `Declare it in ${workspaceFile}: import { ${factory} } from '../plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
233
- `Test it: bun test plugins/${seam}.test.ts (needs @vzn/vx installed)\n`,
358
+ `${paint(SUCCESS, args.dry ? 'would write' : 'wrote', c, { bold: true })} ${files.map(([rel]) => bold(rel)).join(' and ')}\n` +
359
+ `${dim('Declare it in')} ${workspaceFile}${dim(':')} ${bold(`import { ${factory} } from '../plugins/${seam}.ts'`)}${dim(', then')} ${bold(`plugins: [${factory}(…)]`)}\n` +
360
+ `${dim('Test it:')} ${bold(`bun test plugins/${seam}.test.ts`)} ${dim('(needs @vzn/vx installed)')}\n`,
234
361
  )
235
362
  return 0
236
363
  }
237
364
 
238
- const WORKSPACE =
239
- "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
240
- const PROJECT =
241
- "import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
365
+ /**
366
+ * The `@vzn/vx-lockfile` plugin the lockfile at `pkgRoot` calls for: without
367
+ * it a JS workspace has no project edges, and the first `^build` a user
368
+ * writes is refused at `vx lock`.
369
+ */
370
+ function lockfilePlugin(pkgRoot: string | null): 'pnpm' | 'bun' | 'yarn' | 'npm' | null {
371
+ if (pkgRoot === null) return null
372
+ const has = (f: string): boolean => existsSync(path.join(pkgRoot, f))
373
+ if (has('pnpm-lock.yaml')) return 'pnpm'
374
+ if (has('bun.lock') || has('bun.lockb')) return 'bun'
375
+ if (has('yarn.lock')) return 'yarn'
376
+ if (has('package-lock.json') || has('npm-shrinkwrap.json')) return 'npm'
377
+ return null
378
+ }
379
+
380
+ /**
381
+ * Whether `pkgRoot` has package.json scripts to map: its own, or a
382
+ * workspace's members (`workspaces`, `pnpm-workspace.yaml`). Core maps none
383
+ * (2026-10-20), so `next` names the tool that does.
384
+ */
385
+ function hasScripts(pkgRoot: string | null): boolean {
386
+ if (pkgRoot === null) return false
387
+ if (existsSync(path.join(pkgRoot, 'pnpm-workspace.yaml'))) return true
388
+ try {
389
+ // A byte-order mark is stripped, as npm strips it.
390
+ const pj = JSON.parse(
391
+ readFileSync(path.join(pkgRoot, 'package.json'), 'utf8').replace(/^/, ''),
392
+ ) as {
393
+ scripts?: unknown
394
+ workspaces?: unknown
395
+ }
396
+ return (
397
+ pj.workspaces !== undefined ||
398
+ (typeof pj.scripts === 'object' && pj.scripts !== null && Object.keys(pj.scripts).length > 0)
399
+ )
400
+ } catch {
401
+ return false
402
+ }
403
+ }
242
404
 
243
405
  /**
244
406
  * No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
@@ -255,17 +417,31 @@ async function starter(
255
417
  if (args.mode !== undefined) {
256
418
  throw new UserError(
257
419
  `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
420
+ 'VX_E_USAGE',
258
421
  )
259
422
  }
260
- const installs = args.json === true || args.dry ? await install(pkgRoot, true) : []
423
+ // A source checkout installs nothing, so it declares the plugin only where one is.
424
+ const found = lockfilePlugin(pkgRoot)
425
+ const plugin =
426
+ found !== null &&
427
+ (VERSION !== '0.0.0' ||
428
+ existsSync(path.join(pkgRoot!, 'node_modules', '@vzn', 'vx-lockfile', 'package.json')))
429
+ ? found
430
+ : null
431
+ const plugins = plugin === null ? [] : ['@vzn/vx-lockfile']
432
+ const installs = args.json === true || args.dry ? await install(pkgRoot, true, plugins) : []
261
433
  const ext = args.mjs ? 'mjs' : 'ts'
262
434
  const config = `vx.config.${ext}`
263
435
  const files: { path: string; contents: string }[] = []
264
436
  const kept: string[] = []
265
437
  const replaced: string[] = []
266
438
  for (const [names, name, contents] of [
267
- [WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, WORKSPACE],
268
- [PROJECT_CONFIG_FILENAMES, config, PROJECT],
439
+ [
440
+ WORKSPACE_CONFIG_FILENAMES,
441
+ `.vx/workspace.${ext}`,
442
+ renderWorkspace(ext, plugin === null ? [] : [{ pkg: '@vzn/vx-lockfile', factory: plugin }]),
443
+ ],
444
+ [PROJECT_CONFIG_FILENAMES, config, starterProject(ext)],
269
445
  ] as const) {
270
446
  const existing = names.find((n) => existsSync(path.join(root, n)))
271
447
  if (existing !== undefined && !args.force) kept.push(existing)
@@ -281,8 +457,8 @@ async function starter(
281
457
  ] as const) {
282
458
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
283
459
  }
284
- const vx = VERSION === '0.0.0' ? vxInvocation(process.env['npm_config_user_agent'], false) : 'vx'
285
- const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
460
+ const declare = `declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
461
+ const next = `${noGit ? 'git init, ' : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
286
462
  if (args.json === true) {
287
463
  process.stdout.write(
288
464
  `${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
@@ -290,19 +466,30 @@ async function starter(
290
466
  return 0
291
467
  }
292
468
  const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
469
+ // The files are root-relative: a root above the cwd is named, or they read as written here.
470
+ if (root !== process.cwd()) rows.push(['root', root])
293
471
  if (args.dry) rows.push(['dry run', 'nothing written'])
294
472
  if (installs.length > 0) rows.push(['install', ...installs])
295
473
  if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
296
474
  if (kept.length > 0) rows.push(['kept', ...kept])
297
475
  if (replaced.length > 0) rows.push(['replaced', ...replaced])
298
476
  if (args.dry) {
299
- for (const f of files) process.stdout.write(`── ${f.path} ──\n${f.contents}\n`)
477
+ for (const f of files) ui.print('out', ui.excerpt(f.path, f.contents))
300
478
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
301
479
  return 0
302
480
  }
303
- const ran = await install(pkgRoot, false)
481
+ for (const f of files) refuseSymlink(path.join(root, f.path))
482
+ const ran = await install(pkgRoot, false, plugins)
304
483
  if (ran.length > 0) rows.push(['installed', ...ran])
305
- for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
484
+ for (const f of files) {
485
+ await writeNoFollow(path.join(root, f.path), f.contents).catch((err: NodeJS.ErrnoException) => {
486
+ // writeNoFollow's mkdir of a parent that is a file says EEXIST.
487
+ if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
488
+ throw new UserError(
489
+ `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
490
+ )
491
+ })
492
+ }
306
493
  for (const f of replaced) await unlink(path.join(root, f))
307
494
  // The lock that follows loads the workspace file just written.
308
495
  workspaceFileWritten()
@@ -312,14 +499,15 @@ async function starter(
312
499
  lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
313
500
  ])
314
501
  ui.print('out', ui.report(`vx init ${VERSION}`, rows))
315
- return 0
502
+ // A run reads only the lock: init without one is not done, as vx-migrate exits.
503
+ return lock.code === 0 ? 0 : 1
316
504
  }
317
505
 
318
506
  /**
319
507
  * A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
320
508
  * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
321
509
  * at its own version, so init always lands on latest with all of them
322
- * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
510
+ * together, as `vx upgrade` moves them (2026-10-16: an installed 0.0.637
323
511
  * pinned core back from 0.0.639). It finds the root itself, asks a
324
512
  * terminal native or keep in a Turbo or Nx repo, and installs what it writes.
325
513
  */