@vzn/vx 0.0.644 → 0.0.646

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -48
  3. package/package.json +5 -5
  4. package/schemas/init.json +5 -0
  5. package/schemas/last.json +15 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/bin.ts +1 -1
  8. package/src/cache/archive.ts +12 -76
  9. package/src/cache/cache.ts +72 -42
  10. package/src/cache/git-inputs.ts +55 -10
  11. package/src/cache/index.ts +5 -2
  12. package/src/cache/inputs.ts +61 -11
  13. package/src/cache/layer.ts +30 -8
  14. package/src/cache/layered-cache.ts +138 -44
  15. package/src/cache/output-index.ts +25 -11
  16. package/src/cache/plugin-cache.ts +9 -14
  17. package/src/cache/remote-lane.ts +60 -22
  18. package/src/cache/run-history.ts +13 -6
  19. package/src/cache/schema.ts +19 -2
  20. package/src/cache/scoped-cache.ts +3 -80
  21. package/src/cli/core-alias.ts +5 -2
  22. package/src/cli/help.ts +8 -10
  23. package/src/cli/index.ts +0 -12
  24. package/src/cli/init.ts +159 -25
  25. package/src/cli/last.ts +4 -2
  26. package/src/cli/plan-format.ts +6 -2
  27. package/src/cli/plugin-commands.ts +98 -6
  28. package/src/cli/plugin-templates.ts +2 -2
  29. package/src/cli/run.ts +2 -8
  30. package/src/cli/select.ts +5 -3
  31. package/src/cli/upgrade.ts +5 -6
  32. package/src/config.ts +2 -4
  33. package/src/exec/executor.ts +112 -9
  34. package/src/exec/index.ts +2 -0
  35. package/src/exec/proc-sample.ts +67 -5
  36. package/src/exec/runner.ts +4 -0
  37. package/src/exec/sandbox-runtime.ts +11 -4
  38. package/src/graph/scheduler.ts +59 -8
  39. package/src/history/history.ts +119 -7
  40. package/src/history/metrics.ts +53 -16
  41. package/src/index.ts +16 -1
  42. package/src/orchestrator/affected-tasks.ts +0 -18
  43. package/src/orchestrator/execute-args.ts +3 -0
  44. package/src/orchestrator/execute-task.ts +28 -7
  45. package/src/orchestrator/held-servers.ts +86 -0
  46. package/src/orchestrator/hit-restore.ts +11 -4
  47. package/src/orchestrator/index.ts +18 -1
  48. package/src/orchestrator/local-floor.ts +104 -0
  49. package/src/orchestrator/local-shortcircuit.ts +26 -7
  50. package/src/orchestrator/miss-save.ts +10 -4
  51. package/src/orchestrator/options.ts +0 -8
  52. package/src/orchestrator/persistent-task.ts +14 -0
  53. package/src/orchestrator/placement.ts +84 -9
  54. package/src/orchestrator/plan.ts +7 -1
  55. package/src/orchestrator/plugin-host.ts +22 -7
  56. package/src/orchestrator/plugin.ts +48 -7
  57. package/src/orchestrator/prepare.ts +14 -49
  58. package/src/orchestrator/run-context.ts +39 -1
  59. package/src/orchestrator/run-keys.ts +164 -0
  60. package/src/orchestrator/run-records.ts +6 -0
  61. package/src/orchestrator/run.ts +95 -6
  62. package/src/orchestrator/sandbox-request.ts +9 -3
  63. package/src/orchestrator/task-artifacts.ts +142 -0
  64. package/src/orchestrator/task-hash.ts +12 -4
  65. package/src/orchestrator/task-process.ts +16 -0
  66. package/src/orchestrator/telemetry-host.ts +2 -1
  67. package/src/orchestrator/telemetry.ts +60 -17
  68. package/src/report/forecast.ts +6 -27
  69. package/src/report/framed-output.ts +41 -5
  70. package/src/report/logger.ts +60 -20
  71. package/src/report/status-line.ts +38 -18
  72. package/src/report/summary.ts +75 -9
  73. package/src/settings.ts +8 -7
  74. package/src/util/colors.ts +16 -1
  75. package/src/util/index.ts +1 -0
  76. package/src/util/paths.ts +30 -0
  77. package/src/util/ui.ts +4 -1
  78. package/src/workspace/affected.ts +0 -39
  79. package/src/workspace/config-bytecode.ts +276 -0
  80. package/src/workspace/index.ts +0 -2
  81. package/src/workspace/migration.ts +4 -32
  82. package/src/workspace/project-loader.ts +92 -14
  83. package/src/cli/foreign-flags.ts +0 -727
package/src/cli/init.ts CHANGED
@@ -1,8 +1,8 @@
1
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`).
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
4
+ // and installs what it writes (`migrate`); any other gets the workspace file
5
+ // and an empty root project, then the lock (`starter`).
6
6
  // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
7
7
  // test instead (plugin-templates.ts, the examples the gate runs).
8
8
 
@@ -13,13 +13,19 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { lockAfterMigration } from './lock.js'
15
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
16
- import { isCompiledBinary, targetVersion } from './upgrade.js'
16
+ import {
17
+ isCompiledBinary,
18
+ projectManager,
19
+ startedVersion,
20
+ targetVersion,
21
+ upgradeArgv,
22
+ type ManagedInstall,
23
+ } from './upgrade.js'
17
24
  import { VERSION } from '../version.js'
18
25
  import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
19
26
  import {
20
27
  findWorkspaceRoot,
21
28
  PROJECT_CONFIG_FILENAMES,
22
- vxInvocation,
23
29
  WORKSPACE_CONFIG_FILENAMES,
24
30
  } from '../workspace/index.js'
25
31
 
@@ -107,13 +113,104 @@ export async function initCmd(args: readonly string[]): Promise<number> {
107
113
  dirs.push(d)
108
114
  if (d === top || path.dirname(d) === d) break
109
115
  }
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)
116
+ if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
117
+ return migrate(dirs, parsed)
118
+ }
119
+ // The topmost package.json: a monorepo's root, wherever below it init ran.
120
+ const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
121
+ return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
122
+ }
123
+
124
+ /** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
125
+ const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
126
+
127
+ /**
128
+ * The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
129
+ * with the manager that started init, so a bare `vx` is on PATH, unless
130
+ * the global vx already answers `version`; and, where `pkgRoot` has a
131
+ * package.json, as its devDependency with the repo's own manager.
132
+ */
133
+ export function initInstalls(
134
+ version: string,
135
+ userAgent: string | undefined,
136
+ pkgRoot: string | null,
137
+ globalAt: string | undefined,
138
+ execPath = process.execPath,
139
+ plugins: readonly string[] = [],
140
+ ): { argv: string[]; cwd: string | null }[] {
141
+ const out: { argv: string[]; cwd: string | null }[] = []
142
+ if (globalAt !== version) {
143
+ const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'bun'
144
+ const manager = runner as ManagedInstall['manager']
145
+ out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
146
+ }
147
+ if (pkgRoot !== null) {
148
+ const install: ManagedInstall = {
149
+ manager: projectManager(pkgRoot, execPath),
150
+ scope: 'project',
151
+ cwd: pkgRoot,
152
+ field: 'devDependencies',
153
+ plugins: [...plugins],
154
+ }
155
+ out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
156
+ }
157
+ return out
113
158
  }
114
159
 
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']
160
+ /** The version the global `vx` on PATH answers: not a runner's cache, not a project's bin. */
161
+ function globalVersion(): string | undefined {
162
+ for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) {
163
+ const p = dir.split(path.sep).join('/')
164
+ if (p.includes('/_npx/') || /\/(bunx|dlx)-/.test(p) || p.includes('/node_modules/.bin'))
165
+ continue
166
+ const bin = path.join(dir, 'vx')
167
+ if (existsSync(bin)) return startedVersion(bin)?.split(' ')[1]
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ /**
173
+ * Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
174
+ * had) installs nothing: the release would replace the code under test.
175
+ */
176
+ async function install(
177
+ pkgRoot: string | null,
178
+ dry: boolean,
179
+ plugins: readonly string[] = [],
180
+ ): Promise<string[]> {
181
+ if (VERSION === '0.0.0') return []
182
+ const version = await targetVersion(undefined, 'vx init')
183
+ const steps = initInstalls(
184
+ version,
185
+ process.env['npm_config_user_agent'],
186
+ pkgRoot,
187
+ globalVersion(),
188
+ process.execPath,
189
+ plugins,
190
+ )
191
+ if (dry) return steps.map((s) => s.argv.join(' '))
192
+ for (const s of steps) {
193
+ const bin = Bun.which(s.argv[0]!, { PATH: process.env['PATH'] ?? '' })
194
+ if (bin === null) {
195
+ throw new UserError(
196
+ `vx init: ${s.argv[0]} is not on PATH; run it yourself: ${s.argv.join(' ')}`,
197
+ )
198
+ }
199
+ ui.print(
200
+ 'out',
201
+ ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
202
+ )
203
+ const proc = Bun.spawnSync({
204
+ cmd: [bin, ...s.argv.slice(1)],
205
+ cwd: s.cwd ?? process.cwd(),
206
+ stdio: ['inherit', 'inherit', 'inherit'],
207
+ })
208
+ if (proc.exitCode !== 0) {
209
+ throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
210
+ }
211
+ }
212
+ return steps.map((s) => s.argv.join(' '))
213
+ }
117
214
 
118
215
  /** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
119
216
  async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
@@ -145,30 +242,63 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
145
242
  return 0
146
243
  }
147
244
 
148
- const WORKSPACE =
149
- "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
245
+ /**
246
+ * The `@vzn/vx-lockfile` plugin the lockfile at `pkgRoot` calls for: without
247
+ * it a JS workspace has no project edges, and the first `^build` a user
248
+ * writes is refused at `vx lock`.
249
+ */
250
+ function lockfilePlugin(pkgRoot: string | null): 'pnpm' | 'bun' | 'yarn' | 'npm' | null {
251
+ if (pkgRoot === null) return null
252
+ const has = (f: string): boolean => existsSync(path.join(pkgRoot, f))
253
+ if (has('pnpm-lock.yaml')) return 'pnpm'
254
+ if (has('bun.lock') || has('bun.lockb')) return 'bun'
255
+ if (has('yarn.lock')) return 'yarn'
256
+ if (has('package-lock.json') || has('npm-shrinkwrap.json')) return 'npm'
257
+ return null
258
+ }
259
+
260
+ function workspaceSource(plugin: string | null): string {
261
+ if (plugin === null)
262
+ return "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
263
+ return `import { defineWorkspace } from '@vzn/vx'\nimport { ${plugin} } from '@vzn/vx-lockfile'\n\nexport default defineWorkspace({ plugins: [${plugin}()] })\n`
264
+ }
150
265
  const PROJECT =
151
266
  "import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
152
267
 
153
268
  /**
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.
269
+ * No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
270
+ * no plugin and a root `vx.config.ts` with no task, then the lock.
271
+ * `@vzn/vx` resolves to the running vx (core-alias.ts), so the two load
272
+ * whatever got installed. A file already here is kept; `--force` replaces it.
158
273
  */
159
- async function starter(root: string, noGit: boolean, args: InitArgs): Promise<number> {
274
+ async function starter(
275
+ root: string,
276
+ pkgRoot: string | null,
277
+ noGit: boolean,
278
+ args: InitArgs,
279
+ ): Promise<number> {
160
280
  if (args.mode !== undefined) {
161
281
  throw new UserError(
162
- `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no package.json here`,
282
+ `vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
163
283
  )
164
284
  }
285
+ // A source checkout installs nothing, so it declares the plugin only where one is.
286
+ const found = lockfilePlugin(pkgRoot)
287
+ const plugin =
288
+ found !== null &&
289
+ (VERSION !== '0.0.0' ||
290
+ existsSync(path.join(pkgRoot!, 'node_modules', '@vzn', 'vx-lockfile', 'package.json')))
291
+ ? found
292
+ : null
293
+ const plugins = plugin === null ? [] : ['@vzn/vx-lockfile']
294
+ const installs = args.json === true || args.dry ? await install(pkgRoot, true, plugins) : []
165
295
  const ext = args.mjs ? 'mjs' : 'ts'
166
296
  const config = `vx.config.${ext}`
167
297
  const files: { path: string; contents: string }[] = []
168
298
  const kept: string[] = []
169
299
  const replaced: string[] = []
170
300
  for (const [names, name, contents] of [
171
- [WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, WORKSPACE],
301
+ [WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, workspaceSource(plugin)],
172
302
  [PROJECT_CONFIG_FILENAMES, config, PROJECT],
173
303
  ] as const) {
174
304
  const existing = names.find((n) => existsSync(path.join(root, n)))
@@ -185,16 +315,16 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
185
315
  ] as const) {
186
316
  if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
187
317
  }
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>`
318
+ const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
190
319
  if (args.json === true) {
191
320
  process.stdout.write(
192
- `${JSON.stringify({ dry: true, source: 'no package.json', files, kept, replaced, todos: [], notes: [], next })}\n`,
321
+ `${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
193
322
  )
194
323
  return 0
195
324
  }
196
- const rows: ui.FieldRow[] = [['from', 'no package.json: one project, the root']]
325
+ const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
197
326
  if (args.dry) rows.push(['dry run', 'nothing written'])
327
+ if (installs.length > 0) rows.push(['install', ...installs])
198
328
  if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
199
329
  if (kept.length > 0) rows.push(['kept', ...kept])
200
330
  if (replaced.length > 0) rows.push(['replaced', ...replaced])
@@ -203,6 +333,8 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
203
333
  ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
204
334
  return 0
205
335
  }
336
+ const ran = await install(pkgRoot, false, plugins)
337
+ if (ran.length > 0) rows.push(['installed', ...ran])
206
338
  for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
207
339
  for (const f of replaced) await unlink(path.join(root, f))
208
340
  // The lock that follows loads the workspace file just written.
@@ -217,7 +349,7 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
217
349
  }
218
350
 
219
351
  /**
220
- * A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
352
+ * A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
221
353
  * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
222
354
  * at its own version, so init always lands on latest with all of them
223
355
  * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
@@ -232,6 +364,8 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
232
364
  args.mode !== undefined && `--${args.mode}`,
233
365
  args.json === true && '--format=json',
234
366
  ].filter((f): f is string => typeof f === 'string')
367
+ // vx-migrate installs the project's own vx.
368
+ if (!args.dry) await install(null, false)
235
369
  // An exact version: `bun x` may answer `@latest` from its cache.
236
370
  const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
237
371
  return Bun.spawn(migrateCommand(dirs, flags, latest), {
package/src/cli/last.ts CHANGED
@@ -365,10 +365,12 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
365
365
  ? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
366
366
  : hits
367
367
  const ordered = [...failed, ...rest, ...shownHits]
368
- const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
368
+ const at = (t: (typeof ordered)[number]): string =>
369
+ t.placement !== null ? `@${t.placement}` : ''
370
+ const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}${at(t)}`.length), 4)
369
371
  const lines = ['']
370
372
  for (const t of ordered) {
371
- const id = `${t.project}#${t.task}`
373
+ const id = `${t.project}#${t.task}${at(t)}`
372
374
  // The terminal summary's own word for a task that runs every time
373
375
  // by design; a reader must not take its row for a miss.
374
376
  // A failure's row reads as the frame did — `failed (exit 137)` —
@@ -1,7 +1,7 @@
1
1
  // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatElapsed, maskedCommand } from '../util/index.js'
4
+ import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
5
5
 
6
6
  /**
7
7
  * Human-readable preview. One line per real task (groups hidden, same
@@ -65,7 +65,11 @@ export function formatPlanText(plan: RunPlan): string {
65
65
 
66
66
  const hitParts: string[] = []
67
67
  if (local > 0) hitParts.push(`${local} local`)
68
- if (remote > 0) hitParts.push(`${remote} remote`)
68
+ if (remote > 0) {
69
+ // The remote's sizes, summed: what restoring the hits would download.
70
+ const bytes = real.reduce((sum, t) => sum + (t.remoteBytes ?? 0), 0)
71
+ hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
72
+ }
69
73
  const hits = local + remote
70
74
  const summary: string[] = [`${real.length} task(s) planned`]
71
75
  if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
@@ -9,9 +9,16 @@
9
9
 
10
10
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
12
+ import type {
13
+ BranchFacts,
14
+ VxPlugin,
15
+ PluginCommand,
16
+ CommandContext,
17
+ CacheFacts,
18
+ TaskArtifacts,
19
+ } from '../orchestrator/index.js'
13
20
  import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
14
- import { machineParallelism, maskedLine } from '../util/index.js'
21
+ import { machineParallelism, maskedLine, SecretBroker } from '../util/index.js'
15
22
  import { resolveSetting } from '../settings.js'
16
23
 
17
24
  export interface ResolvedPluginCommand {
@@ -111,6 +118,8 @@ export async function resolvePluginCommand(
111
118
  : [process.execPath, Bun.main],
112
119
  warn,
113
120
  cache: lazyCache(() => verbCache(ws, plugin.name)),
121
+ artifacts: lazyArtifacts(() => verbArtifacts(ws)),
122
+ artifactsFor: (facts) => lazyArtifacts(() => verbArtifacts(ws, facts)),
114
123
  },
115
124
  }
116
125
  }
@@ -138,10 +147,8 @@ async function verbCache(
138
147
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
139
148
  plugin: string,
140
149
  ): Promise<PluginCache> {
141
- const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
142
- import('../cache/index.js'),
143
- import('../orchestrator/index.js'),
144
- ])
150
+ const [{ Cache, pluginCache }, { cacheRefs, openRemotes, resolveCache, resolveCacheScope }] =
151
+ await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
145
152
  const scope = resolveCacheScope(
146
153
  ws.workspaceRoot,
147
154
  ws.branch.trusted === true ? null : ws.branch.scope,
@@ -162,13 +169,98 @@ async function verbCache(
162
169
  cacheDir: ws.cacheDir,
163
170
  warn,
164
171
  scope,
172
+ refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
165
173
  localCache,
166
174
  policy: VERB_POLICY,
167
175
  remotes,
168
176
  })
177
+ await openVerbRemotes(remotes, openRemotes)
169
178
  return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
170
179
  }
171
180
 
181
+ /**
182
+ * The verb's `ctx.artifacts`: the workspace's cache as a run opens it, its
183
+ * policy and scope, a laptop writing no remote unless the policy names it.
184
+ * With `facts` (`ctx.artifactsFor`), under another run's refs and lent access.
185
+ */
186
+ async function verbArtifacts(
187
+ ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
188
+ facts?: () => CacheFacts | undefined,
189
+ ): Promise<TaskArtifacts> {
190
+ const [
191
+ { Cache, FULL_CACHE_POLICY },
192
+ {
193
+ cacheRefs,
194
+ namesRemote,
195
+ openRemotes,
196
+ resolveCache,
197
+ resolveCacheScope,
198
+ taskArtifacts,
199
+ workspaceCachePolicy,
200
+ },
201
+ { onCI },
202
+ ] = await Promise.all([
203
+ import('../cache/index.js'),
204
+ import('../orchestrator/index.js'),
205
+ import('./run.js'),
206
+ ])
207
+ const asked = workspaceCachePolicy(ws.workspaceConfig) ?? FULL_CACHE_POLICY
208
+ const policy: CachePolicy =
209
+ onCI(process.env['CI']) || namesRemote(ws.workspaceConfig?.cache?.policy)
210
+ ? asked
211
+ : { ...asked, remoteWrite: false }
212
+ const scope = resolveCacheScope(
213
+ ws.workspaceRoot,
214
+ ws.branch.trusted === true ? null : ws.branch.scope,
215
+ ws.plugins.some((p) => p.cache !== undefined),
216
+ configuredBaseBranch(ws.workspaceConfig),
217
+ )
218
+ const localCache = new Cache(
219
+ ws.cacheDir,
220
+ { read: policy.localRead, write: policy.localWrite },
221
+ ws.workspaceRoot,
222
+ undefined,
223
+ 'open',
224
+ await ws.storeRoot,
225
+ )
226
+ const remotes: ResolvedRemote[] = []
227
+ const cache = await resolveCache(ws.plugins, {
228
+ workspaceRoot: ws.workspaceRoot,
229
+ cacheDir: ws.cacheDir,
230
+ warn,
231
+ scope,
232
+ refs: facts?.()?.refs ?? cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
233
+ localCache,
234
+ policy,
235
+ remotes,
236
+ ...(facts === undefined ? {} : { lentBy: () => facts()?.lent }),
237
+ })
238
+ await openVerbRemotes(remotes, openRemotes)
239
+ return taskArtifacts(cache, ws.workspaceRoot)
240
+ }
241
+
242
+ /**
243
+ * Each remote's `open`, as a run's: a remote that takes its token there
244
+ * (turboCache's OIDC exchange, a lent token) had none, so an agent's
245
+ * uploads went nowhere and its run could not restore them.
246
+ */
247
+ async function openVerbRemotes(
248
+ remotes: readonly ResolvedRemote[],
249
+ open: (typeof import('../orchestrator/index.js'))['openRemotes'],
250
+ ): Promise<void> {
251
+ const ci = (await import('./run.js')).onCI(process.env['CI'])
252
+ await open(remotes, ci, new SecretBroker(ci, warn))
253
+ }
254
+
255
+ function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
256
+ let opened: Promise<TaskArtifacts> | undefined
257
+ const artifacts = (): Promise<TaskArtifacts> => (opened ??= open())
258
+ return {
259
+ save: async (key, task, run) => (await artifacts()).save(key, task, run),
260
+ restore: async (key, task) => (await artifacts()).restore(key, task),
261
+ }
262
+ }
263
+
172
264
  function lazyCache(open: () => Promise<PluginCache>): PluginCache {
173
265
  let opened: Promise<PluginCache> | undefined
174
266
  const cache = (): Promise<PluginCache> => (opened ??= open())
@@ -10,13 +10,13 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
10
10
  },
11
11
  cache: {
12
12
  plugin:
13
- "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type ArtifactInfo, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<ArtifactInfo | null> {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { size: file.size } : null\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
14
14
  test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
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 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",
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 artifacts: { save: async () => {}, restore: async () => null },\n artifactsFor: () => ({ save: async () => {}, restore: async () => null }),\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
@@ -1,6 +1,5 @@
1
1
  import { isatty } from 'node:tty'
2
2
  import { findWorkspaceRoot } from '../workspace/index.js'
3
- import { translateForeign } from './foreign-flags.js'
4
3
  import { flagHint, refuse, seeHelp } from './help.js'
5
4
  import {
6
5
  planRun,
@@ -116,7 +115,7 @@ const SETTING_OPTIONS = [
116
115
 
117
116
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
118
117
  * its flag hints named `vx run` (M-58). The caller appends the help pointer. */
119
- export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
118
+ export function parseRunArgs(args: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
120
119
  const out: RunArgs = {
121
120
  tasks: [],
122
121
  filters: [],
@@ -132,10 +131,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
132
131
  affected: undefined,
133
132
  }
134
133
 
135
- // A Turbo or Nx spelling becomes vx's, or a refusal naming vx's.
136
- const translated = translateForeign(rawArgs)
137
- if ('error' in translated) return { ...out, error: translated.error }
138
- const args = translated
139
134
  const sepIdx = args.indexOf('--')
140
135
  const before = sepIdx === -1 ? args : args.slice(0, sepIdx)
141
136
  out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
@@ -518,7 +513,6 @@ export async function resolveRunOptions(
518
513
  }
519
514
  if (parsed.profile !== undefined) opts.profile = parsed.profile
520
515
  // `--affected`'s base is the branch's for the cache scope too.
521
- if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
522
516
 
523
517
  return opts
524
518
  }
@@ -664,6 +658,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
664
658
  }
665
659
 
666
660
  /** CI=0 and CI=false are "not CI": several vendors set CI=true. */
667
- function onCI(v: string | undefined): boolean {
661
+ export function onCI(v: string | undefined): boolean {
668
662
  return v !== undefined && v !== '' && v !== '0' && v !== 'false'
669
663
  }
package/src/cli/select.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // here goes through the staged load, so the answer is the run's.
5
5
 
6
6
  import path from 'node:path'
7
+ import { settingEnv } from '../settings.js'
7
8
  import fs from 'node:fs'
8
9
  import {
9
10
  affectedChanges,
@@ -244,12 +245,13 @@ function refusalOf(err: unknown): Refusal {
244
245
 
245
246
  /**
246
247
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
247
- * projects and their dependents. An empty base is a plugin's (`setBranch`:
248
- * a pull request's base commit), else the merge base with `baseBranch`.
248
+ * projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
249
+ * else a plugin's (`setBranch`: a pull request's base commit), else the
250
+ * merge base with `baseBranch`.
249
251
  */
250
252
  export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
251
253
  const root = await findWorkspaceRoot(cwd)
252
- let base = affected
254
+ let base = affected || ((settingEnv('affectedBase') as string | undefined) ?? '')
253
255
  if (base === '') {
254
256
  const ws = await loadCliWorkspace(root)
255
257
  try {
@@ -228,7 +228,7 @@ export function verifiedRelease(
228
228
  const ours = signatures.filter((s) => s.keyid === key.id)
229
229
  if (ours.length === 0) {
230
230
  throw new UserError(
231
- `vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with npm install -g @vzn/vx@latest, or from https://github.com/vznjs/vx/releases. Nothing replaced`,
231
+ `vx upgrade: ${pkg}@${version} is signed with an npm key this vx does not know (${[...new Set(signatures.map((s) => s.keyid))].join(', ')}) — if npm rotated its signing key, this vx's installer needs updating: install the new release with bun add -g @vzn/vx@latest, or from https://github.com/vznjs/vx/releases. Nothing replaced`,
232
232
  )
233
233
  }
234
234
  const publicKey = createPublicKey({
@@ -475,7 +475,7 @@ function installedVersion(dir: string, pkg: string): string | undefined {
475
475
  }
476
476
 
477
477
  /** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
478
- function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
478
+ export function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
479
479
  for (let d = dir; ; d = path.dirname(d)) {
480
480
  if (existsSync(path.join(d, 'pnpm-lock.yaml'))) return 'pnpm'
481
481
  if (existsSync(path.join(d, 'bun.lock')) || existsSync(path.join(d, 'bun.lockb'))) return 'bun'
@@ -511,7 +511,7 @@ export function managedInstall(execPath: string, cwd: string): ManagedInstall |
511
511
  const root = p.slice(0, `${p}/`.indexOf('/node_modules/'))
512
512
  if (p.includes('/_npx/') || /\/bunx-[^/]*\//.test(p)) {
513
513
  throw new UserError(
514
- `vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead: npx @vzn/vx@latest, or bunx @vzn/vx@latest`,
514
+ `vx upgrade: this vx runs from a package-manager cache (${execPath}) — nothing is installed to upgrade; ask for the version you want instead: bunx @vzn/vx@latest`,
515
515
  )
516
516
  }
517
517
  if (p.includes('/.bun/install/global/')) return { manager: 'bun', scope: 'global', cwd: root }
@@ -733,7 +733,7 @@ export async function upgradeOnlyGlobal(
733
733
  const install = managedInstall(self, path.dirname(self))
734
734
  if (install?.scope === 'project') {
735
735
  throw new UserError(
736
- `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
736
+ `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (bun add -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
737
737
  )
738
738
  }
739
739
  if (install === null) await upgradeBinary(self, version, current, 'global ')
@@ -771,8 +771,7 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
771
771
  }
772
772
  if (!isCompiledBinary()) {
773
773
  throw new UserError(
774
- 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull ' +
775
- '(npm installs update with: npm install -g @vzn/vx@latest)',
774
+ 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull',
776
775
  )
777
776
  }
778
777
  const dest = process.execPath
package/src/config.ts CHANGED
@@ -102,11 +102,9 @@ export interface CacheSettings {
102
102
  * How the local cache keeps an entry. `embeddings` (default `'32KiB'`):
103
103
  * an entry up to this size inside the index; `false`, none. `decompress` (default true): a larger one as its
104
104
  * plain files, which a hit copies or clones out with no decode; off, as
105
- * one `.tar.zst`. `verify` (default `'stat'`): what a hit on plain files
106
- * checks first, each file's size and mtime, every byte (`'hash'`), or
107
- * nothing (`'none'`).
105
+ * one `.tar.zst`.
108
106
  */
109
- store?: { embeddings?: false | string; decompress?: boolean; verify?: 'stat' | 'hash' | 'none' }
107
+ store?: { embeddings?: false | string; decompress?: boolean }
110
108
  /**
111
109
  * Cache transfers at once. `download` and `upload` (per remote) default
112
110
  * to min(96, max(cores × 3, 64)); `restore`, local hits writing their