@vzn/vx 0.0.636 → 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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -7,11 +7,12 @@
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 { existsSync } from 'node:fs'
12
- import path from 'node:path'
13
- import { findWorkspaceRoot, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
13
+ import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
14
14
  import { machineParallelism, maskedLine } from '../util/index.js'
15
+ import { resolveSetting } from '../settings.js'
15
16
 
16
17
  export interface ResolvedPluginCommand {
17
18
  plugin: VxPlugin
@@ -40,10 +41,15 @@ export interface UnresolvedPluginCommand {
40
41
  loadError: string
41
42
  }
42
43
 
43
- async function workspacePlugins(
44
- cwd: string,
45
- ): Promise<
46
- | { 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
+ }
47
53
  | UnresolvedPluginCommand
48
54
  | null
49
55
  > {
@@ -53,11 +59,6 @@ async function workspacePlugins(
53
59
  } catch {
54
60
  return null
55
61
  }
56
- // Plugins are declared only in a workspace file. Without one, the load
57
- // below is the orchestrator's import graph (~50 ms) for an empty list,
58
- // paid by `vx help` and by the `vx completions` a shell sources at start.
59
- if (!WORKSPACE_CONFIG_FILENAMES.some((f) => existsSync(path.join(workspaceRoot, f))))
60
- return { workspaceRoot, cacheDir: '', concurrency: 0, plugins: [] }
61
62
  let ws
62
63
  try {
63
64
  const { loadCliWorkspace } = await import('./workspace-config.js')
@@ -68,8 +69,12 @@ async function workspacePlugins(
68
69
  return {
69
70
  workspaceRoot,
70
71
  cacheDir: ws.cacheDir,
72
+ storeRoot: ws.storeRoot,
73
+ workspaceConfig: ws.workspaceConfig,
71
74
  // What run.ts takes when no --concurrency is given.
72
- concurrency: ws.workspaceConfig?.concurrency ?? machineParallelism(),
75
+ concurrency:
76
+ (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
77
+ machineParallelism(),
73
78
  plugins: ws.plugins,
74
79
  }
75
80
  }
@@ -102,13 +107,75 @@ export async function resolvePluginCommand(
102
107
  vx: (await import('./upgrade.js')).isCompiledBinary()
103
108
  ? [process.execPath]
104
109
  : [process.execPath, Bun.main],
105
- warn: (m) => process.stderr.write(`${maskedLine(m)}\n`),
110
+ warn,
111
+ cache: lazyCache(() => verbCache(ws, plugin.name)),
106
112
  },
107
113
  }
108
114
  }
109
115
  return { declaredVerbs: [...declaredVerbs] }
110
116
  }
111
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
+
112
179
  /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
113
180
  export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
114
181
  const ws = await workspacePlugins(cwd).catch(() => null)
@@ -6,46 +6,46 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
6
6
  admit: {
7
7
  plugin:
8
8
  "// `admit`: whether a ready task starts now, beside what runs here. At most\n// one task of the named `task` runs at a time (say, one that holds a port).\n// Synchronous and cheap: it is asked at every dispatch.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function oneAtATime(task: string): VxPlugin {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n return definePlugin(import.meta, {\n admit(node, ctx) {\n return !named(node.id) || !ctx.running.some((r) => named(r.id))\n },\n })\n}\n",
9
- test: "// A test for the example `admit` 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-admit-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `oneAtATime()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'admit.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { oneAtATime } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n",
9
+ test: "// A test for the example `admit` 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-admit-'))\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. `oneAtATime()`, 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, 'admit.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { oneAtATime } 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('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n",
10
10
  },
11
11
  cache: {
12
12
  plugin:
13
- "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, 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(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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 cache: { inputs: { files: ['package.json'] }, 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'), { 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",
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",
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 cache: { inputs: { files: ['package.json'] }, 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}\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:
23
23
  "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
- test: "// A test for the example `executor` 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-executor-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
24
+ test: "// A test for the example `executor` 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-executor-'))\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. `shellExecutor()`, 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, 'executor.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { shellExecutor } 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('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: '300ms' } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n",
25
25
  },
26
26
  graph: {
27
27
  plugin:
28
28
  "// `graph`: the run's edges. Every task named `task` waits for the one\n// before it in id order, so they run one after another across projects.\n// Core checks the edited graph again (a missing dep, a cycle).\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function chain(task: string): VxPlugin {\n return definePlugin(import.meta, {\n graph(nodes) {\n const ids = [...nodes.keys()].filter((id) => id.endsWith(`#${task}`)).sort()\n for (const [i, id] of ids.entries()) {\n const prev = ids[i - 1]\n const node = nodes.get(id)!\n if (prev !== undefined && !node.deps.includes(prev)) node.deps.push(prev)\n }\n },\n })\n}\n",
29
- test: "// A test for the example `graph` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `chain()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'graph.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { chain } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n",
29
+ test: "// A test for the example `graph` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\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. `chain()`, 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, 'graph.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { chain } 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('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n",
30
30
  },
31
31
  key: {
32
32
  plugin:
33
33
  "// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
34
- test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
34
+ test: "// A test for the example `key` 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 { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\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. `envKey()`, 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, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } 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(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
35
35
  },
36
36
  project: {
37
37
  plugin:
38
- "// `project`: a project's tasks. Every package.json script becomes a task of\n// the same name that runs it, unless the project already declares one.\n// Core re-validates each edit as it would the user's own config.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n project(config, ctx) {\n const scripts = ctx.packageJson['scripts']\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || config.tasks[name] !== undefined) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
39
- test: "// A test for the example `project` 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-project-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `scriptTasks()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'project.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { scriptTasks } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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 package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n",
38
+ "// `project`: a project's tasks. Every script in the project's package.json\n// becomes a task of the same name that runs it, unless the project already\n// declares one. Core reads no package.json, so the plugin does. Core\n// re-validates each edit as it would the user's own config.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n async project(config, ctx) {\n const pkg: unknown = await Bun.file(`${ctx.dir}/package.json`)\n .json()\n .catch(() => null)\n const scripts = (pkg as { scripts?: unknown } | null)?.scripts\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || config.tasks[name] !== undefined) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
39
+ test: "// A test for the example `project` 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-project-'))\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. `scriptTasks()`, 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, 'project.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { scriptTasks } 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 package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n",
40
40
  },
41
41
  schedule: {
42
42
  plugin:
43
43
  "// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
44
- test: "// A test for the example `schedule` 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-schedule-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
44
+ test: "// A test for the example `schedule` 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-schedule-'))\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. `prioritize()`, 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, 'schedule.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { prioritize } 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('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
45
45
  },
46
46
  telemetry: {
47
47
  plugin:
48
48
  "// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry() {\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(file, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
49
- test: "// A test for the example `telemetry` 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-telemetry-'))\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 await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\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('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n",
49
+ test: "// A test for the example `telemetry` 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-telemetry-'))\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. `jsonlTelemetry()`, 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, 'telemetry.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { jsonlTelemetry } 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('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n",
50
50
  },
51
51
  }