@vzn/vx 0.0.637 → 0.0.638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
package/src/cli/last.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  shortRunId,
25
25
  taskLog,
26
26
  } from '../orchestrator/index.js'
27
- import { formatElapsed, splitTaskId, UserError } from '../util/index.js'
27
+ import { formatElapsed, refusal, splitTaskId, UserError, sayNotice, ui } from '../util/index.js'
28
28
  import { findWorkspaceRoot } from '../workspace/index.js'
29
29
  import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
30
30
 
@@ -242,7 +242,7 @@ async function printStats(
242
242
  return 0
243
243
  }
244
244
  if (stats.length === 0) {
245
- process.stdout.write('no recorded runs\n')
245
+ ui.print('out', ui.status('info', 'vx last', 'no runs yet'))
246
246
  return 0
247
247
  }
248
248
  const pct = (r: number): string => `${Math.round(r * 100)}%`
@@ -468,8 +468,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
468
468
  return 0
469
469
  }
470
470
  if (log.source === null) {
471
- process.stderr.write(
472
- `vx last: ${log.taskId} ended ${log.status}; vx keeps output only for failed and cached tasks\n`,
471
+ sayNotice(
472
+ 'info',
473
+ 'vx last',
474
+ `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
473
475
  )
474
476
  }
475
477
  process.stdout.write(log.output)
@@ -486,9 +488,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
486
488
  return 0
487
489
  }
488
490
  if (invocations.length === 0) {
489
- process.stdout.write(
490
- parsed.failed === true && !noRuns() ? 'no recorded run failed\n' : 'no recorded runs\n',
491
- )
491
+ const none = parsed.failed === true && !noRuns() ? 'no failed runs' : 'no runs yet'
492
+ ui.print('out', ui.status('info', 'vx last', none))
492
493
  return 0
493
494
  }
494
495
  const lines = formatRunList(
@@ -504,6 +505,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
504
505
  : parsed.failed === true
505
506
  ? (failedInvocations(db, 1)[0] ?? null)
506
507
  : (listInvocations(db, { limit: 1 })[0] ?? null)
508
+ if ((inv === null || inv === undefined) && noRuns()) {
509
+ throw refusal(
510
+ { tone: 'info', headline: 'no runs yet', fix: 'vx run' },
511
+ { code: 'VX_E_NO_HISTORY', message: 'vx last: no recorded runs yet — run something first' },
512
+ )
513
+ }
507
514
  if (inv === null || inv === undefined) {
508
515
  throw new UserError(
509
516
  noRuns()
package/src/cli/lock.ts CHANGED
@@ -17,11 +17,14 @@ import {
17
17
  } from '../orchestrator/index.js'
18
18
  import { resolveSetting, runRules } from '../settings.js'
19
19
  import {
20
- configErrorFrame,
20
+ refusalNotice,
21
21
  DOT_VX_GITATTRIBUTES,
22
22
  isUserError,
23
23
  relPosix,
24
+ type RefusalParts,
24
25
  secretMask,
26
+ ui,
27
+ sayNotice,
25
28
  } from '../util/index.js'
26
29
  import {
27
30
  findProjectConfigs,
@@ -84,7 +87,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
84
87
 
85
88
  /**
86
89
  * Projects with tasks and no vx.config: a plugin's tasks, which load live
87
- * in every run, so both verbs name them — `locked 0 project configs`
90
+ * in every run, so both verbs name them — `0 project configs`
88
91
  * on a turbo() repo read like an audit of something (2026-09-16). One with
89
92
  * no task is left out: solidjs/solid's solid-ssr, which has no script Turbo
90
93
  * runs, read as a project whose tasks come live from the plugin `project` stage.
@@ -100,13 +103,14 @@ function bareProjects(all: readonly ProjectMeta[], tasked: ReadonlySet<string>):
100
103
  export async function lockWorkspace(
101
104
  root: string,
102
105
  warn: (message: string) => void = warnToStderr,
106
+ say: (line: string) => void = (line) => ui.print('out', ui.status('ok', 'vx lock', line)),
103
107
  ): Promise<number> {
104
108
  const check = await audit(root, warn)
105
109
  if (check.broken !== null) {
106
- process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(check.broken)}\n`)
110
+ notWritten(check.broken, check.brokenParts)
107
111
  return 1
108
112
  }
109
- return await writeLock(root, check)
113
+ return await writeLock(root, check, say)
110
114
  }
111
115
 
112
116
  /**
@@ -159,6 +163,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
159
163
  closure: false,
160
164
  configs: lock,
161
165
  warn,
166
+ lock: true,
162
167
  })
163
168
  for (const entry of projects.values()) resolved.set(entry.name, entry.config)
164
169
  const names = new Set<string>()
@@ -193,7 +198,8 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
193
198
  rules,
194
199
  })
195
200
  } catch (err) {
196
- if (isUserError(err)) return { ...out, broken: err.message }
201
+ if (isUserError(err))
202
+ return { ...out, broken: err.message, ...(err.parts ? { brokenParts: err.parts } : {}) }
197
203
  throw err
198
204
  }
199
205
  return out
@@ -202,6 +208,7 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
202
208
  interface GraphCheck {
203
209
  /** A graph rule's refusal, or a config that does not load; null when sound. */
204
210
  broken: string | null
211
+ brokenParts?: RefusalParts
205
212
  /** The lock the configs evaluate to here. */
206
213
  fresh: Lockfile
207
214
  /** Every project, as a run discovers them from `fresh`. */
@@ -219,23 +226,25 @@ function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
219
226
  }
220
227
 
221
228
  /**
222
- * `vx init`'s last step: the lock, as `vx lock` writes it. A refusal (a
223
- * graph rule, a config that does not load) is printed and leaves the
224
- * written configs in place.
229
+ * `vx init`'s last step: the lock, as `vx lock` writes it, its line
230
+ * returned for the report's `lock` row. A refusal (a graph rule, a config
231
+ * that does not load) is printed and leaves the written configs in place.
225
232
  */
226
- export async function lockAfterMigration(root: string): Promise<number> {
233
+ export async function lockAfterMigration(root: string): Promise<{ code: number; said: string }> {
234
+ let said = ''
227
235
  try {
228
- return await lockWorkspace(root)
236
+ const code = await lockWorkspace(root, warnToStderr, (line) => (said = line))
237
+ return { code, said }
229
238
  } catch (err) {
230
239
  if (!isUserError(err)) throw err
231
- process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(err.message)}\n`)
232
- return 1
240
+ notWritten(err.message, err.parts)
241
+ return { code: 1, said }
233
242
  }
234
243
  }
235
244
 
236
- /** A config refusal as a run used to print it: path:line:col and the source line. */
237
- function framed(message: string): string {
238
- return configErrorFrame(message, process.cwd()) ?? message
245
+ /** The lock refused: the refusal's own parts, or its config line and frame, or its message. */
246
+ function notWritten(message: string, parts: RefusalParts | undefined): void {
247
+ ui.print('err', refusalNotice('vx lock', message, parts, { headline: 'lock not written' }))
239
248
  }
240
249
 
241
250
  /** Names shown per note: a turbo() repo can have hundreds of bare projects. */
@@ -295,7 +304,11 @@ async function writeIfMissing(file: string, text: string): Promise<void> {
295
304
  }
296
305
  }
297
306
 
298
- async function writeLock(root: string, check: GraphCheck): Promise<number> {
307
+ async function writeLock(
308
+ root: string,
309
+ check: GraphCheck,
310
+ say: (line: string) => void,
311
+ ): Promise<number> {
299
312
  const metas = configured(check.all)
300
313
  const bare = bareProjects(check.all, check.tasked)
301
314
  // By project name, as the refusal names them.
@@ -310,11 +323,12 @@ async function writeLock(root: string, check: GraphCheck): Promise<number> {
310
323
  for (const l of secretsIn(Object.fromEntries(reviews.map((r) => [r.name, r.config]))))
311
324
  if (!named.has(l.at)) leaks.push(l)
312
325
  if (leaks.length > 0) {
313
- process.stderr.write(
314
- `vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
315
- leaks.map((l) => ` ${l.at}\n`).join('') +
316
- `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
317
- )
326
+ notWritten('', {
327
+ headline: 'these configs evaluated to a secret value',
328
+ what: leaks.map((l) => l.at),
329
+ why: 'the lock is committed, so the secret would be too',
330
+ fix: `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config`,
331
+ })
318
332
  return 1
319
333
  }
320
334
  await writeLockfile(root, check.fresh)
@@ -323,9 +337,7 @@ async function writeLock(root: string, check: GraphCheck): Promise<number> {
323
337
  writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
324
338
  ])
325
339
  const n = metas.length
326
- process.stdout.write(
327
- `vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
328
- )
340
+ say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
329
341
  return 0
330
342
  }
331
343
 
@@ -389,7 +401,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
389
401
  const lock = await readLockfile(root)
390
402
  if (!lock) {
391
403
  const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
392
- if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
404
+ if (!json) sayNotice('error', 'vx lock --check', problem)
393
405
  return answer([problem])
394
406
  }
395
407
  const failures: string[] = []
@@ -451,12 +463,12 @@ async function checkLock(root: string, json: boolean): Promise<number> {
451
463
  }
452
464
  if (json) return answer(failures)
453
465
  if (failures.length > 0) {
454
- for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
466
+ for (const f of failures) sayNotice('error', 'vx lock --check', f)
455
467
  return 1
456
468
  }
457
469
  const n = metas.length
458
470
  const audited = `${n} project${n === 1 ? '' : 's'}`
459
471
  const skipped = bare.length === 0 ? '' : `; not audited, no vx.config: ${bareNames(bare)}`
460
- process.stdout.write(`vx lock --check: up to date (${audited}${skipped})\n`)
472
+ ui.print('out', ui.status('ok', 'vx lock --check', `up to date (${audited}${skipped})`))
461
473
  return 0
462
474
  }
@@ -118,9 +118,11 @@ function describeReason(r: AffectedReason): string {
118
118
  ? `a ^ edge passes changed package ${r.project}`
119
119
  : r.kind === 'input'
120
120
  ? `${r.file} changed (an input)`
121
- : r.file !== undefined
122
- ? `${r.file} changed (in its project)`
123
- : 'its project changed as a whole'
121
+ : r.kind === 'workspace'
122
+ ? `${r.file} changed (workspace-wide)`
123
+ : r.file !== undefined
124
+ ? `${r.file} changed (in its project)`
125
+ : 'its project changed as a whole'
124
126
  return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
125
127
  }
126
128
 
@@ -7,8 +7,10 @@
7
7
  // reports an unknown command as it always did. Core's verbs are matched
8
8
  // before this is asked, so a plugin cannot shadow `run`.
9
9
 
10
+ import type { CachePolicy, PluginCache, PluginCacheRemote } from '../cache/index.js'
11
+ import type { WorkspaceConfig } from '../config.js'
10
12
  import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
11
- import { findWorkspaceRoot } from '../workspace/index.js'
13
+ import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
12
14
  import { machineParallelism, maskedLine } from '../util/index.js'
13
15
  import { resolveSetting } from '../settings.js'
14
16
 
@@ -39,10 +41,15 @@ export interface UnresolvedPluginCommand {
39
41
  loadError: string
40
42
  }
41
43
 
42
- async function workspacePlugins(
43
- cwd: string,
44
- ): Promise<
45
- | { workspaceRoot: string; cacheDir: string; concurrency: number; plugins: readonly VxPlugin[] }
44
+ async function workspacePlugins(cwd: string): Promise<
45
+ | {
46
+ workspaceRoot: string
47
+ cacheDir: string
48
+ storeRoot: Promise<string | null>
49
+ workspaceConfig: WorkspaceConfig | null
50
+ concurrency: number
51
+ plugins: readonly VxPlugin[]
52
+ }
46
53
  | UnresolvedPluginCommand
47
54
  | null
48
55
  > {
@@ -62,6 +69,8 @@ async function workspacePlugins(
62
69
  return {
63
70
  workspaceRoot,
64
71
  cacheDir: ws.cacheDir,
72
+ storeRoot: ws.storeRoot,
73
+ workspaceConfig: ws.workspaceConfig,
65
74
  // What run.ts takes when no --concurrency is given.
66
75
  concurrency:
67
76
  (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
@@ -98,13 +107,75 @@ export async function resolvePluginCommand(
98
107
  vx: (await import('./upgrade.js')).isCompiledBinary()
99
108
  ? [process.execPath]
100
109
  : [process.execPath, Bun.main],
101
- warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
110
+ warn,
111
+ cache: lazyCache(() => verbCache(ws, plugin.name)),
102
112
  },
103
113
  }
104
114
  }
105
115
  return { declaredVerbs: [...declaredVerbs] }
106
116
  }
107
117
 
118
+ const warn = (m: string): void => {
119
+ process.stderr.write(`${maskedLine(m)}\n`)
120
+ }
121
+
122
+ /** Read-only: a verb reads what runs stored, and only a run's policy may write. */
123
+ const VERB_POLICY: CachePolicy = {
124
+ localRead: true,
125
+ localWrite: false,
126
+ remoteRead: true,
127
+ remoteWrite: false,
128
+ }
129
+
130
+ /**
131
+ * The verb's `ctx.cache`: the plugin's entries in the local store, then each
132
+ * remote the workspace's cache plugins supply, under the run's scope. Opened
133
+ * on first use, so a verb that never reads it opens nothing.
134
+ */
135
+ async function verbCache(
136
+ ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
137
+ plugin: string,
138
+ ): Promise<PluginCache> {
139
+ const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
140
+ import('../cache/index.js'),
141
+ import('../orchestrator/index.js'),
142
+ ])
143
+ const envScope = process.env['VX_CACHE_SCOPE'] || undefined
144
+ if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
145
+ const scope = envScope ?? ws.workspaceConfig?.cache?.scope
146
+ const localCache = new Cache(
147
+ ws.cacheDir,
148
+ { read: true, write: false },
149
+ ws.workspaceRoot,
150
+ undefined,
151
+ 'open',
152
+ await ws.storeRoot,
153
+ )
154
+ const remotes: PluginCacheRemote[] = []
155
+ await resolveCache(ws.plugins, {
156
+ workspaceRoot: ws.workspaceRoot,
157
+ cacheDir: ws.cacheDir,
158
+ warn,
159
+ scope,
160
+ localCache,
161
+ policy: VERB_POLICY,
162
+ remotes,
163
+ })
164
+ return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
165
+ }
166
+
167
+ function lazyCache(open: () => Promise<PluginCache>): PluginCache {
168
+ let opened: Promise<PluginCache> | undefined
169
+ const cache = (): Promise<PluginCache> => (opened ??= open())
170
+ return {
171
+ get: async (key) => (await cache()).get(key),
172
+ put: async (key, bytes) => (await cache()).put(key, bytes),
173
+ has: async (key) => (await cache()).has(key),
174
+ hasMany: async (keys) => (await cache()).hasMany(keys),
175
+ getRun: async (name) => (await cache()).getRun(name),
176
+ }
177
+ }
178
+
108
179
  /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
109
180
  export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
110
181
  const ws = await workspacePlugins(cwd).catch(() => null)
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
16
16
  commands: {
17
17
  plugin:
18
18
  "// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
19
- test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
19
+ test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
20
20
  },
21
21
  executor: {
22
22
  plugin:
package/src/cli/run.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from '../cache/index.js'
24
24
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
25
25
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
26
- import { parseDecimalInt } from '../util/index.js'
26
+ import { parseDecimalInt, sayNotice, sayLine } from '../util/index.js'
27
27
  import { kindHint, parseSettingValue, type Setting, SETTINGS, settingRefusal } from '../settings.js'
28
28
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
29
29
 
@@ -556,7 +556,7 @@ export async function resolveRunOptions(
556
556
  // scope. An empty project list keeps the bare tasks unselected
557
557
  // while the anchored ones still run.
558
558
  if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
559
- process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
559
+ sayNotice('info', 'vx run', `${resolved.empty} — running ${anchoredTasks.join(', ')} only`)
560
560
  projects = []
561
561
  } else {
562
562
  projects = resolved.names
@@ -665,7 +665,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
665
665
  // Every run needs git: refused here, not after the user has chosen.
666
666
  const refusal = gitRefusal(await findWorkspaceRoot(cwd))
667
667
  if (refusal !== undefined) {
668
- process.stderr.write(`${refusal.message}\n`)
668
+ sayLine('error', refusal.message, 'vx run')
669
669
  return 1
670
670
  }
671
671
  const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
@@ -679,7 +679,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
679
679
  if ('error' in selected)
680
680
  return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
681
681
  if ('empty' in selected) {
682
- process.stderr.write(`vx run: ${selected.empty}\n`)
682
+ sayNotice('info', 'vx run', selected.empty)
683
683
  return 0
684
684
  }
685
685
  only = new Set(selected.names)
@@ -695,7 +695,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
695
695
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
696
696
  if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
697
697
  if ('nothingSelected' in resolved) {
698
- process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
698
+ sayNotice('info', 'vx run', resolved.nothingSelected)
699
699
  return 0
700
700
  }
701
701
  const opts = resolved
@@ -715,8 +715,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
715
715
  if (parsed.dry !== undefined || parsed.graph !== undefined) {
716
716
  const plan = await planRun(opts)
717
717
  if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
718
- process.stderr.write(
719
- `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}\n`,
718
+ sayNotice(
719
+ 'error',
720
+ 'vx run',
721
+ `no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}`,
720
722
  )
721
723
  return 1
722
724
  }
@@ -724,14 +726,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
724
726
  // The run's own line: a dry run that guessed from the scope alone
725
727
  // said no affected project declared a task one did (X-141).
726
728
  if (plan.noneAffected !== undefined) {
727
- process.stderr.write(`${plan.noneAffected}\n`)
729
+ sayLine('info', plan.noneAffected, 'vx run')
728
730
  return 0
729
731
  }
730
732
  if (opts.selectedByDiff === true) {
731
- process.stderr.write(`vx run: no affected project declares task(s): ${tasks.join(', ')}.\n`)
733
+ sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
732
734
  return 0
733
735
  }
734
- process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
736
+ sayNotice('error', 'vx run', `no projects declare task(s): ${tasks.join(', ')}`)
735
737
  return 1
736
738
  }
737
739
  if (parsed.graph !== undefined) {
@@ -747,7 +749,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
747
749
  await Bun.write(target, out)
748
750
  } catch (err) {
749
751
  const message = err instanceof Error ? err.message : String(err)
750
- process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
752
+ sayNotice('error', 'vx run', `failed to write graph to ${target}: ${message}`)
751
753
  return 1
752
754
  }
753
755
  }
@@ -813,7 +815,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
813
815
  // Same contract as --summarize / --profile: the run already
814
816
  // happened, so a write failure is reported, not fatal.
815
817
  const message = err instanceof Error ? err.message : String(err)
816
- process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
818
+ sayNotice('warn', 'vx run', `failed to write report to ${target}: ${message}`)
817
819
  }
818
820
  }
819
821
  }
package/src/cli/select.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  maskedLine,
33
33
  nearest,
34
34
  UserError,
35
+ sayNotice,
35
36
  } from '../util/index.js'
36
37
  import {
37
38
  claimedAffected,
@@ -434,8 +435,10 @@ export async function resolveFilters(
434
435
  // Something matched, so the run proceeds; a pattern that matched nothing
435
436
  // alongside it is still worth a line — it is probably a typo.
436
437
  for (const f of unmatched)
437
- process.stderr.write(
438
- `vx: filter "${f}" matched no projects${didYouMean([f], projects, tags)}\n`,
438
+ sayNotice(
439
+ 'warn',
440
+ 'vx run',
441
+ `filter "${f}" matched no projects${didYouMean([f], projects, tags)}`,
439
442
  )
440
443
  // Each include is a union: what `--filter other` selected runs its tasks
441
444
  // whether or not the `--affected` diff reaches them, and an exclude
@@ -511,14 +514,18 @@ export async function pickTask(
511
514
  }
512
515
  }
513
516
  if (entries.length === 0 && only !== undefined) {
514
- process.stderr.write(
515
- `vx run: no tasks declared in the selected projects (${[...only].sort().join(', ')})\n`,
517
+ sayNotice(
518
+ 'error',
519
+ 'vx run',
520
+ `no tasks declared in the selected projects (${[...only].sort().join(', ')})`,
516
521
  )
517
522
  return null
518
523
  }
519
524
  if (entries.length === 0) {
520
- process.stderr.write(
521
- 'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts, then `vx lock`\n',
525
+ sayNotice(
526
+ 'error',
527
+ 'vx run',
528
+ 'no tasks declared in any project. Declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts, then `vx lock`',
522
529
  )
523
530
  return null
524
531
  }
@@ -562,13 +569,15 @@ export async function pickTask(
562
569
  if (!(err instanceof Error) || err.name !== 'AbortError') throw err
563
570
  if (interrupted) return 'interrupted'
564
571
  out.write('\n')
565
- process.stderr.write(`vx run: no task picked\n`)
572
+ sayNotice('info', 'vx run', 'no task picked')
566
573
  return null
567
574
  }
568
575
  const n = Number(answer)
569
576
  if (!Number.isInteger(n) || n < 1 || n > entries.length) {
570
- process.stderr.write(
571
- `vx run: invalid selection: ${answer} (type a number from 1 to ${entries.length})\n`,
577
+ sayNotice(
578
+ 'error',
579
+ 'vx run',
580
+ `invalid selection: ${answer} (type a number from 1 to ${entries.length})`,
572
581
  )
573
582
  return null
574
583
  }