@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -9,7 +9,10 @@
9
9
  * and two module states — two `definePlugin` memos, two of everything
10
10
  * keyed by identity rather than by registry symbol.
11
11
  *
12
- * The alias is a Bun virtual module for the exact specifier `@vzn/vx`,
12
+ * The alias is a Bun virtual module for the exact specifiers `@vzn/vx`
13
+ * and `@vzn/vx/config` (the second for `import { defineProject }` in a
14
+ * config: with a node_modules and no `@vzn/vx` in it, the import failed
15
+ * though the page says vx runs a config without it),
13
16
  * served from the host's own façade, loaded lazily on the first import
14
17
  * so a verb that never loads a plugin never loads the façade. The
15
18
  * source form (`bun src/bin.ts`) resolves the specifier to the same file
@@ -18,11 +21,15 @@
18
21
  * (workspace/config-bytecode.ts) is CommonJS, and `require` refuses a
19
22
  * module whose loader is async.
20
23
  */
21
- export function registerCoreAlias(load: () => Record<string, unknown>): void {
24
+ export function registerCoreAlias(
25
+ load: () => Record<string, unknown>,
26
+ loadConfig: () => Record<string, unknown>,
27
+ ): void {
22
28
  Bun.plugin({
23
29
  name: 'vx-core-alias',
24
30
  setup(build) {
25
31
  build.module('@vzn/vx', () => ({ exports: load(), loader: 'object' }))
32
+ build.module('@vzn/vx/config', () => ({ exports: loadConfig(), loader: 'object' }))
26
33
  },
27
34
  })
28
35
  }
package/src/cli/docs.ts CHANGED
@@ -31,10 +31,12 @@ export function parseDocsArgs(args: readonly string[]): DocsArgs {
31
31
  out.json = fv === 'json'
32
32
  } else if (a === '--limit' || a.startsWith('--limit=')) {
33
33
  const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
34
- if (v === undefined || v === '') {
34
+ // The space form took the next flag as the count and refused it as one.
35
+ const flag = a === '--limit' && v !== undefined && /^-\D/.test(v) ? ` (got flag ${v})` : ''
36
+ if (v === undefined || v === '' || flag !== '') {
35
37
  return {
36
38
  ...out,
37
- error: `--limit requires a value: a whole number above 0${seeHelp('docs')}`,
39
+ error: `--limit requires a value: a whole number above 0${flag}${seeHelp('docs')}`,
38
40
  }
39
41
  }
40
42
  const n = parseDecimalInt(v)
package/src/cli/help.ts CHANGED
@@ -90,12 +90,33 @@ export function verbHelpText(verb: string): string {
90
90
  if (own.length > 0) kept.push(['Usage:', ...own].join('\n'))
91
91
  continue
92
92
  }
93
- if (lines.some(names)) kept.push(block)
93
+ if (lines.some(names)) kept.push(own(lines, names, quoted))
94
94
  }
95
95
  if (kept.length === 0) return helpText()
96
96
  return [title, ...kept, 'Full reference: vx help', ''].join('\n\n')
97
97
  }
98
98
 
99
+ /**
100
+ * A section's entries for one verb, when the section holds several verbs'
101
+ * entries (Introspection): an entry is a two-space line and the deeper
102
+ * lines under it; a shared one (`info, why and last:`) stays if it names
103
+ * the verb. A one-verb or `(for <verb>)` section stays whole.
104
+ */
105
+ function own(lines: string[], names: (line: string) => boolean, quoted: string): string {
106
+ const entries: string[][] = []
107
+ for (const line of lines.slice(1)) {
108
+ if (/^ {2}\S/.test(line) || entries.length === 0) entries.push([line])
109
+ else entries.at(-1)!.push(line)
110
+ }
111
+ const verbs = new Set(entries.map((e) => /^ {2}vx ([\w-]+)/.exec(e[0]!)?.[1]).filter(Boolean))
112
+ if (verbs.size < 2) return lines.join('\n')
113
+ const word = new RegExp(`\\b${quoted}\\b`)
114
+ const mine = entries.filter(
115
+ (e) => names(e[0]!) || (!e[0]!.startsWith(' vx ') && word.test(e[0]!)),
116
+ )
117
+ return [lines[0]!, ...mine.flat()].join('\n')
118
+ }
119
+
99
120
  export function helpText(pluginCommands: readonly string[] = []): string {
100
121
  return [
101
122
  'vx — task orchestration you own',
@@ -141,7 +162,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
141
162
  ' --no-cache No cache reads or writes; outputs are left',
142
163
  ' alone.',
143
164
  ' --force Skip cache reads but still write: every task',
144
- ' runs and refreshes its entry.',
165
+ ' runs; a key already stored is kept.',
145
166
  ' --cache-policy <spec> Per-layer read/write: a comma list of',
146
167
  ' <layer>:<flags> (layer local or remote, flags',
147
168
  ' from rw). A named layer is set to exactly its',
@@ -204,9 +225,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
204
225
  'Cache management:',
205
226
  ' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
206
227
  ' vx cache clean --max-size 1G Keep total cache under 1 GB (LRU eviction).',
207
- " vx cache clean Clear .vx/cache and this repo's store",
228
+ " vx cache clean Clear .vx/cache and this workspace's store",
208
229
  ' ~/.vx/<id>/cache.',
209
- " --global Clear every repo's store under ~/.vx.",
230
+ " --global Clear every workspace's store under ~/.vx.",
210
231
  ' --dry Say what the policy would reap; delete nothing.',
211
232
  ' --format <fmt> pretty (default) | json.',
212
233
  ' --cache-dir <path> The cache a run with the same flag uses (why,',
@@ -366,6 +387,8 @@ export function flagHint(verb: string, arg: string): string {
366
387
  // `--json` is gh's and Nx's spelling; a verb with `--format` reads it
367
388
  // there, and no edit budget reaches `--format json` from `--json`.
368
389
  if (name === '--json' && flags.includes('--format')) return ' (did you mean --format json?)'
390
+ // pnpm's and Turbo's short `--filter`.
391
+ if (name === '-F') return flags.includes('--filter') ? ' (did you mean --filter?)' : ''
369
392
  // `vx run` prints no JSON of its own, and edit distance reached `--force`
370
393
  // from `--format`: a run with its cache reads turned off.
371
394
  if ((name === '--format' || name === '--json') && flags.includes('--dry'))
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import { checkSettingsEnv } from '../settings.js'
6
6
  import { CORE_VERBS, flagHint, printHelp, refuse, refusedWord, seeHelp } from './help.js'
7
7
  import {
8
8
  errorDocument,
9
+ hasControl,
9
10
  hideEnvFromTasks,
10
11
  isFsRefusal,
11
12
  isOutOfFds,
@@ -30,6 +31,7 @@ const plugins = () => import('./plugin-commands.js')
30
31
  export async function run(argv: readonly string[]): Promise<number> {
31
32
  refuseLossyArgs(argv)
32
33
  const [command, ...rest] = argv
34
+ if (command !== undefined) refuseControlArgs(command, rest)
33
35
  await hideEnvFromTasks()
34
36
 
35
37
  // `vx <verb> --help` is the universal reflex, and every verb answered
@@ -41,12 +43,21 @@ export async function run(argv: readonly string[]): Promise<number> {
41
43
  await printHelp(await (await plugins()).pluginCommandHelp(), command)
42
44
  return 0
43
45
  }
46
+ if (command !== undefined && forwardsNothing(command, rest)) {
47
+ return refuse(
48
+ command,
49
+ rest,
50
+ 'forwards nothing after -- (only vx run and vx watch pass arguments on to a task)',
51
+ 'VX_E_USAGE',
52
+ )
53
+ }
44
54
  // Each setting's variable once, before any verb: a renamed one is named,
45
55
  // and a deep reader trusts what it reads. Not for help, as `vx run
46
56
  // --help` above is not: help is where a setting's values are read. Nor
47
- // for `vx info`, which reports a refused one (doctor.ts).
57
+ // for `vx info`, which reports a refused one (doctor.ts). Nor for one
58
+ // whose flag is given: the flag beats it.
48
59
  if (command !== 'help' && command !== '--help' && command !== '-h' && command !== 'info')
49
- checkSettingsEnv()
60
+ checkSettingsEnv(process.env, rest)
50
61
 
51
62
  try {
52
63
  return await dispatch(command, rest)
@@ -69,6 +80,43 @@ function refuseLossyArgs(argv: readonly string[]): void {
69
80
  )
70
81
  }
71
82
 
83
+ /**
84
+ * No core verb's argument holds a control character, and a refusal echoing
85
+ * one raw broke its headline at a newline. A forwarded one (after `--`) is
86
+ * the task's, a `--sql` query may span lines, and a plugin verb owns its own.
87
+ */
88
+ function refuseControlArgs(command: string, rest: readonly string[]): void {
89
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return
90
+ for (let i = 0; i < rest.length; i++) {
91
+ const a = rest[i]!
92
+ if (a === '--') return
93
+ if (a === '--sql') {
94
+ i++
95
+ continue
96
+ }
97
+ if (a.startsWith('--sql=') || !hasControl(a)) continue
98
+ throw new UserError(
99
+ `argument ${i + 2} (${JSON.stringify(a)}) holds a control character, which no \`vx ${command}\` argument takes`,
100
+ 'VX_E_USAGE',
101
+ )
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A `--` given to a core verb that hands no task its words: each parser
107
+ * refused it as `unknown flag: --`, true of no flag. A `--sql` query is
108
+ * the one value that may be `--`.
109
+ */
110
+ function forwardsNothing(command: string, rest: readonly string[]): boolean {
111
+ if (command === 'run' || command === 'watch') return false
112
+ if (!(CORE_VERBS as readonly string[]).includes(command)) return false
113
+ for (let i = 0; i < rest.length; i++) {
114
+ if (rest[i] === '--sql') i++
115
+ else if (rest[i] === '--') return true
116
+ }
117
+ return false
118
+ }
119
+
72
120
  /**
73
121
  * A crash from a hand-broken lock entry is that entry's fault: runs validate
74
122
  * no entry, so it surfaced as a raw TypeError (2026-10-14). The lock is
@@ -124,7 +172,9 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
124
172
  // A name that is no verb here is the typo `vx rnu` is, not a request
125
173
  // for the whole reference: it printed 145 lines and no hint.
126
174
  const resolved = await (await plugins()).resolvePluginCommand(verb)
127
- const unknown = resolved === null || 'declaredVerbs' in resolved
175
+ // A workspace that failed to load cannot say the verb exists: the
176
+ // whole reference and exit 0 hid the broken file (`vx okk` says so).
177
+ const unknown = resolved === null || !('plugin' in resolved)
128
178
  if (unknown && verb.startsWith('-')) {
129
179
  return refuse(
130
180
  'help',
@@ -134,15 +184,20 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
134
184
  )
135
185
  }
136
186
  if (unknown) {
137
- const declared = resolved === null ? [] : resolved.declaredVerbs
187
+ const declared =
188
+ resolved !== null && 'declaredVerbs' in resolved ? resolved.declaredVerbs : []
189
+ const loadNote =
190
+ resolved !== null && 'loadError' in resolved
191
+ ? ` (plugin verbs could not be looked up: .vx/workspace failed to load: ${resolved.loadError})`
192
+ : ''
138
193
  return refuse(
139
194
  'help',
140
195
  rest,
141
- `unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
196
+ `unknown command: ${verb}${didYouMeanVerb(verb, declared)}${loadNote} (see \`vx help\`)`,
142
197
  'VX_E_UNKNOWN_COMMAND',
143
198
  )
144
199
  }
145
- if (resolved !== null && !('loadError' in resolved)) return dispatch(verb, ['--help'])
200
+ return dispatch(verb, ['--help'])
146
201
  }
147
202
  await printHelp(await (await plugins()).pluginCommandHelp(), core ? verb : undefined)
148
203
  return 0
@@ -215,7 +270,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
215
270
  // that returned 256 (a count of failures, say) exited 0.
216
271
  if (!Number.isInteger(code) || code < 0 || code > 255) {
217
272
  throw new UserError(
218
- `plugin '${resolved.plugin.name}': command '${command}' resolved ${JSON.stringify(code)} instead of an exit code (an integer 0–255)`,
273
+ `plugin '${resolved.plugin.name}': command '${command}' resolved ${typeof code === 'bigint' ? `${String(code)}n` : typeof code === 'number' || code === undefined ? String(code) : JSON.stringify(code)} instead of an exit code (an integer 0–255)`,
219
274
  )
220
275
  }
221
276
  return code
package/src/cli/info.ts CHANGED
@@ -97,11 +97,11 @@ function infoReport(f: InfoFacts): ui.Block {
97
97
  'cache store',
98
98
  f.cacheStore === null
99
99
  ? 'none: the cache dir holds the entries'
100
- : `${tildePath(f.cacheStore)} (${formatBytes(f.cacheStoreBytes ?? 0)}, shared by this repo's checkouts)`,
100
+ : `${tildePath(f.cacheStore)} (${formatBytes(f.cacheStoreBytes ?? 0)}, shared by this workspace's checkouts)`,
101
101
  ],
102
102
  ['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
103
103
  ['cache entries', describeEntries(f)],
104
- // Only the settings a variable or the workspace changed.
104
+ // Only the settings a variable, the workspace or a plugin changed.
105
105
  ...(Object.values(f.settings).some((s) => s.source !== 'default')
106
106
  ? ([['settings', describeSettings(f.settings)]] as [string, string][])
107
107
  : []),
@@ -141,14 +141,15 @@ function describeEntries(f: InfoFacts): string {
141
141
 
142
142
  /**
143
143
  * The settings not at their default, with where each came from:
144
- * `run.timeout 30m (VX_RUN_TIMEOUT), cache.policy local:rw (.vx/workspace)`.
144
+ * `run.timeout 30m (VX_RUN_TIMEOUT), cache.policy local:rw (.vx/workspace)`,
145
+ * `cache.scope pr-2 (plugin @org/ci)`.
145
146
  */
146
147
  function describeSettings(settings: InfoFacts['settings']): string {
147
148
  return Object.entries(settings)
148
149
  .filter(([, s]) => s.source !== 'default')
149
150
  .map(
150
151
  ([path, s]) =>
151
- `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
152
+ `${path} ${String(s.value)} (${s.source === 'env' ? envName(path) : s.source === 'plugin' ? `plugin ${s.plugin}` : '.vx/workspace'}${s.refused === undefined ? '' : `, refused: ${s.refused}`})`,
152
153
  )
153
154
  .join(', ')
154
155
  }
package/src/cli/init.ts CHANGED
@@ -6,7 +6,7 @@
6
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, readFileSync } from 'node:fs'
9
+ import { existsSync, lstatSync, readFileSync, statSync } 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'
@@ -132,11 +132,17 @@ export async function initCmd(args: readonly string[]): Promise<number> {
132
132
  dirs.push(d)
133
133
  if (d === top || path.dirname(d) === d) break
134
134
  }
135
- if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
135
+ const pkgRoot = packageRoot(dirs, top !== null)
136
+ // Outside git `dirs` reach `/`: a stray turbo.json or nx.json above the
137
+ // project took init over, as a stray package.json did (2026-10-28).
138
+ const own = top !== null ? dirs : dirs.slice(0, dirs.indexOf(pkgRoot ?? cwd) + 1)
139
+ if (own.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
136
140
  return migrate(dirs, parsed)
137
141
  }
138
- const pkgRoot = packageRoot(dirs, top !== null)
139
- return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
142
+ // Asked of git itself: a kept workspace file is `top` too, and the
143
+ // `git init` hint went missing where the next run would refuse.
144
+ const noGit = marked === undefined ? top === null : (await gitTop(cwd)) === null
145
+ return starter(marked ?? pkgRoot ?? cwd, pkgRoot, noGit, parsed)
140
146
  }
141
147
 
142
148
  /**
@@ -314,6 +320,20 @@ function ownerNamed(dir: string): boolean {
314
320
  }
315
321
  }
316
322
 
323
+ /**
324
+ * Whether anything stands at `file`, a dangling symlink included: writing
325
+ * through one created its target outside the workspace.
326
+ */
327
+ function occupied(file: string): boolean {
328
+ try {
329
+ lstatSync(file)
330
+ return true
331
+ } catch {
332
+ // quiet: nothing there (a parent that is a file is refused at the write)
333
+ return false
334
+ }
335
+ }
336
+
317
337
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
318
338
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
319
339
  const template = PLUGIN_TEMPLATES[seam]!
@@ -329,13 +349,14 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
329
349
  ] as const
330
350
  if (!args.force) {
331
351
  for (const [rel] of files) {
332
- if (await Bun.file(path.join(root, rel)).exists()) {
352
+ if (occupied(path.join(root, rel))) {
333
353
  sayNotice('error', 'vx init', `${rel} exists. --force overwrites it`)
334
354
  return 1
335
355
  }
336
356
  }
337
357
  }
338
358
  if (!args.dry) {
359
+ for (const [rel] of files) refuseSymlink(path.join(root, rel), root)
339
360
  await mkdir(path.join(root, 'plugins'), { recursive: true }).catch(
340
361
  (err: NodeJS.ErrnoException) => {
341
362
  if (err.code !== 'EEXIST' && err.code !== 'ENOTDIR') throw err
@@ -344,7 +365,7 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
344
365
  )
345
366
  },
346
367
  )
347
- for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
368
+ for (const [rel, text] of files) await writeNoFollow(path.join(root, rel), text, root)
348
369
  }
349
370
  const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
350
371
  const workspaceFile =
@@ -423,7 +444,7 @@ async function starter(
423
444
  ): Promise<number> {
424
445
  if (args.mode !== undefined) {
425
446
  throw new UserError(
426
- `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
447
+ `vx init: --${args.mode} answers a Turbo or Nx workspace's question, and there is no turbo.json or nx.json here`,
427
448
  'VX_E_USAGE',
428
449
  )
429
450
  }
@@ -450,11 +471,20 @@ async function starter(
450
471
  ],
451
472
  [PROJECT_CONFIG_FILENAMES, config, starterProject(ext)],
452
473
  ] as const) {
453
- const existing = names.find((n) => existsSync(path.join(root, n)))
454
- if (existing !== undefined && !args.force) kept.push(existing)
474
+ const existing = names.filter((n) => existsSync(path.join(root, n)))
475
+ // Kept, a directory read as the workspace's marker, and every verb after
476
+ // then found none and sent the user back here.
477
+ const dir = existing.find((n) => statSync(path.join(root, n)).isDirectory())
478
+ if (dir !== undefined) {
479
+ throw new UserError(
480
+ `vx init: ${dir} is a directory, where the ${name === config ? 'project config' : 'workspace file'} goes; move it and re-run`,
481
+ )
482
+ }
483
+ if (existing.length > 0 && !args.force) kept.push(existing[0]!)
455
484
  else {
456
485
  files.push({ path: name, contents })
457
- if (existing !== undefined && existing !== name) replaced.push(existing)
486
+ // Every other name goes too: a second config beside the one written.
487
+ replaced.push(...existing.filter((n) => n !== name))
458
488
  }
459
489
  }
460
490
  // vx's own housekeeping, as a run's first cache open and `vx lock` write them.
@@ -465,7 +495,10 @@ async function starter(
465
495
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
466
496
  }
467
497
  const declare = `declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
468
- const next = `${noGit ? 'git init, ' : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
498
+ // Run in the cwd, a git init below the root it chose hides the workspace from every verb.
499
+ const gitInit =
500
+ root === process.cwd() ? 'git init' : `git init ${path.relative(process.cwd(), root)}`
501
+ const next = `${noGit ? `${gitInit}, ` : ''}${hasScripts(pkgRoot) ? `bunx @vzn/vx-migrate${args.mjs ? ' --mjs' : ''} to make package.json scripts tasks, or ${declare}` : declare}`
469
502
  if (args.json === true) {
470
503
  ui.data(
471
504
  'out',
@@ -473,7 +506,7 @@ async function starter(
473
506
  )
474
507
  return 0
475
508
  }
476
- const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
509
+ const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json']]
477
510
  // The files are root-relative: a root above the cwd is named, or they read as written here.
478
511
  if (root !== process.cwd()) rows.push(['root', root])
479
512
  if (args.dry) rows.push(['dry run', 'nothing written'])
@@ -486,17 +519,19 @@ async function starter(
486
519
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
487
520
  return 0
488
521
  }
489
- for (const f of files) refuseSymlink(path.join(root, f.path))
522
+ for (const f of files) refuseSymlink(path.join(root, f.path), root)
490
523
  const ran = await install(pkgRoot, false, plugins)
491
524
  if (ran.length > 0) rows.push(['installed', ...ran])
492
525
  for (const f of files) {
493
- await writeNoFollow(path.join(root, f.path), f.contents).catch((err: NodeJS.ErrnoException) => {
494
- // writeNoFollow's mkdir of a parent that is a file says EEXIST.
495
- if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
496
- throw new UserError(
497
- `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
498
- )
499
- })
526
+ await writeNoFollow(path.join(root, f.path), f.contents, root).catch(
527
+ (err: NodeJS.ErrnoException) => {
528
+ // writeNoFollow's mkdir of a parent that is a file says EEXIST.
529
+ if (err.code !== 'ENOTDIR' && err.code !== 'EEXIST') throw err
530
+ throw new UserError(
531
+ `vx init: cannot write ${f.path}: ${path.dirname(f.path)} is a file, not a directory; move it and re-run`,
532
+ )
533
+ },
534
+ )
500
535
  }
501
536
  for (const f of replaced) await unlink(path.join(root, f))
502
537
  // The lock that follows loads the workspace file just written.