@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -1,7 +1,17 @@
1
1
  // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
- import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
4
+ import { skippedReason } from '../report/index.js'
5
+ import {
6
+ formatBytes,
7
+ formatElapsed,
8
+ maskedCommand,
9
+ paint,
10
+ SUCCESS,
11
+ ui,
12
+ WARN,
13
+ type ColorSupport,
14
+ } from '../util/index.js'
5
15
 
6
16
  /**
7
17
  * Human-readable preview. One line per real task (groups hidden, same
@@ -16,25 +26,36 @@ import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
16
26
  * 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
17
27
  * predicted: ~1.20s wall · ~1.20s total execution
18
28
  */
19
- export function formatPlanText(plan: RunPlan): string {
20
- const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
21
- if (real.length === 0) return 'No tasks planned.\n'
29
+ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false }): string {
30
+ // A group the platform skip reaches is still a group: it runs nothing.
31
+ const real = plan.tasks.filter(
32
+ (t) =>
33
+ t.cacheStatus !== 'group' &&
34
+ (t.cacheStatus !== 'skipped' || t.node.config.exec !== undefined),
35
+ )
36
+ const dim = (s: string): string => paint('', s, c, { dim: true })
37
+ if (real.length === 0) return `${dim('No tasks planned.')}\n`
22
38
 
23
39
  const idWidth = Math.max(...real.map((t) => t.node.id.length))
24
40
  const tagWidth = 24
25
41
 
26
- const lines: string[] = ['would run:']
42
+ const lines: string[] = [paint('', 'would run:', c, { bold: true })]
27
43
  let local = 0
28
44
  let remote = 0
29
45
  let miss = 0
30
46
  let nocache = 0
31
47
  let noop = 0
32
48
  let idle = 0
49
+ let platform = 0
33
50
  for (const t of real) {
34
51
  // `@noop`: the run succeeds it without running, whatever its key says.
35
- const skipped = t.executor === 'noop'
52
+ const skipped = t.executor === 'noop' && t.cacheStatus !== 'skipped'
36
53
  const sym = skipped ? '·' : symbolFor(t.cacheStatus)
37
- const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
54
+ const desc = skipped
55
+ ? 'noop — would not run'
56
+ : t.cacheStatus === 'skipped'
57
+ ? `skipped — ${skippedReason(t)}`
58
+ : describe(t.cacheStatus)
38
59
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
60
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
40
61
  const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
@@ -43,17 +64,18 @@ export function formatPlanText(plan: RunPlan): string {
43
64
  // tier, and a task placed on a named executor is what the reader can
44
65
  // act on. Present only when the workspace declared a choice.
45
66
  const where = t.executor !== undefined ? ` @${t.executor}` : ''
67
+ const hue = skipped ? '' : HUE[t.cacheStatus]
46
68
  lines.push(
47
- ` ${sym} ${t.node.id.padEnd(idWidth)} ${desc.padEnd(tagWidth)} ${shortHash}${eta}${where}`,
69
+ ` ${paint(hue, sym, c, { dim: hue === '' })} ${ui.inline(ui.id(t.node.id), c)}${' '.repeat(idWidth - t.node.id.length)} ${paint(hue, desc.padEnd(tagWidth), c, { dim: hue === '' })} ${dim(shortHash)}${dim(eta)}${where}`,
48
70
  )
49
71
  // Optional one-line description from the task config, indented
50
72
  // under the id so the eye picks up the task → blurb mapping.
51
73
  const taskDesc = shownDescription(t)
52
74
  if (taskDesc) {
53
- lines.push(` ${' '.repeat(idWidth)} ${taskDesc}`)
75
+ lines.push(` ${' '.repeat(idWidth)} ${dim(taskDesc)}`)
54
76
  }
55
77
  if (t.affected !== undefined) {
56
- lines.push(` ${' '.repeat(idWidth)} affected: ${describeReason(t.affected)}`)
78
+ lines.push(` ${' '.repeat(idWidth)} ${dim(`affected: ${describeReason(t.affected)}`)}`)
57
79
  }
58
80
  if (skipped) noop++
59
81
  else if (t.cacheStatus === 'hit-local') local++
@@ -61,6 +83,7 @@ export function formatPlanText(plan: RunPlan): string {
61
83
  else if (t.cacheStatus === 'miss') miss++
62
84
  else if (t.cacheStatus === 'no-cache') nocache++
63
85
  else if (t.cacheStatus === 'not-started') idle++
86
+ else if (t.cacheStatus === 'skipped') platform++
64
87
  }
65
88
 
66
89
  const hitParts: string[] = []
@@ -77,8 +100,9 @@ export function formatPlanText(plan: RunPlan): string {
77
100
  if (nocache > 0) summary.push(`${nocache} no-cache`)
78
101
  if (noop > 0) summary.push(`${noop} noop`)
79
102
  if (idle > 0) summary.push(`${idle} not started`)
103
+ if (platform > 0) summary.push(`${platform} skipped`)
80
104
  lines.push('')
81
- lines.push(summary.join(', ') + '.')
105
+ lines.push(dim(summary.join(', ') + '.'))
82
106
 
83
107
  // `--cache-download` is invisible without this: the eligibility gate silently
84
108
  // keeps producers eager, so a user who asked for `none` and got no
@@ -89,11 +113,14 @@ export function formatPlanText(plan: RunPlan): string {
89
113
  if (deferred.length > 0 || downgrades.length > 0) {
90
114
  lines.push('')
91
115
  lines.push(
92
- `download: ${deferred.length} task(s) would keep outputs remote` +
93
- (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
116
+ dim(
117
+ `download: ${deferred.length} task(s) would keep outputs remote` +
118
+ (downgrades.length > 0 ? `, ${downgrades.length} kept eager:` : '.'),
119
+ ),
94
120
  )
95
- for (const d of downgrades.slice(0, 3)) lines.push(` ${d.taskId} — ${d.reason}`)
96
- if (downgrades.length > 3) lines.push(` …and ${downgrades.length - 3} more`)
121
+ for (const d of downgrades.slice(0, 3))
122
+ lines.push(` ${ui.inline(ui.id(d.taskId), c)} ${dim(`— ${d.reason}`)}`)
123
+ if (downgrades.length > 3) lines.push(dim(` …and ${downgrades.length - 3} more`))
97
124
  }
98
125
 
99
126
  // Time prediction — only when history gave us something to say: at least
@@ -106,7 +133,7 @@ export function formatPlanText(plan: RunPlan): string {
106
133
  if (p.unknownCount > 0) {
107
134
  parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
108
135
  }
109
- lines.push(parts.join(' · '))
136
+ lines.push(dim(parts.join(' · ')))
110
137
  }
111
138
  return lines.join('\n') + '\n'
112
139
  }
@@ -149,6 +176,8 @@ export interface PlanTaskJson {
149
176
  download?: 'deferred'
150
177
  description?: string
151
178
  affected?: AffectedReason
179
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
180
+ blockedBy?: string
152
181
  }
153
182
 
154
183
  /** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
@@ -157,42 +186,51 @@ export function formatPlanJson(
157
186
  affectedBase?: string,
158
187
  ): string {
159
188
  return (
160
- JSON.stringify(
161
- {
162
- ...(affectedBase !== undefined ? { affectedBase } : {}),
163
- tasks: plan.tasks.map((t): PlanTaskJson => {
164
- const description = shownDescription(t)
165
- return {
166
- id: t.node.id,
167
- project: t.node.projectName,
168
- task: t.node.taskName,
169
- hash: t.hash,
170
- cacheStatus: t.cacheStatus,
171
- deps: t.deps,
172
- ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
173
- ...(t.executor !== undefined ? { executor: t.executor } : {}),
174
- ...(t.download !== undefined ? { download: t.download } : {}),
175
- ...(description !== undefined ? { description } : {}),
176
- ...(t.affected !== undefined ? { affected: t.affected } : {}),
177
- }
178
- }),
179
- ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
180
- // The gate's refusals belong on the SCRIPTING surface too: a CI job
181
- // asking "did --cache-download=none actually defer anything, and if not
182
- // why" reads this, not the human table. (This object enumerates its
183
- // fields deliberately — the plan's internal shape is not the wire —
184
- // which is exactly why a new PlannedTask field does not appear here
185
- // for free, and why `download` above had to be added by hand.)
186
- ...(plan.downloadDowngrades !== undefined
187
- ? { downloadDowngrades: plan.downloadDowngrades }
188
- : {}),
189
- },
190
- null,
191
- 2,
192
- ) + '\n'
189
+ JSON.stringify({
190
+ ...(affectedBase !== undefined ? { affectedBase } : {}),
191
+ tasks: plan.tasks.map((t): PlanTaskJson => {
192
+ const description = shownDescription(t)
193
+ return {
194
+ id: t.node.id,
195
+ project: t.node.projectName,
196
+ task: t.node.taskName,
197
+ hash: t.hash,
198
+ cacheStatus: t.cacheStatus,
199
+ deps: t.deps,
200
+ ...(t.p50Ms !== undefined ? { p50Ms: t.p50Ms } : {}),
201
+ ...(t.executor !== undefined ? { executor: t.executor } : {}),
202
+ ...(t.download !== undefined ? { download: t.download } : {}),
203
+ ...(description !== undefined ? { description } : {}),
204
+ ...(t.affected !== undefined ? { affected: t.affected } : {}),
205
+ ...(t.otherPlatform !== undefined ? { otherPlatform: t.otherPlatform } : {}),
206
+ ...(t.blockedBy !== undefined ? { blockedBy: t.blockedBy } : {}),
207
+ }
208
+ }),
209
+ ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
210
+ // The gate's refusals belong on the SCRIPTING surface too: a CI job
211
+ // asking "did --cache-download=none actually defer anything, and if not
212
+ // why" reads this, not the human table. (This object enumerates its
213
+ // fields deliberately — the plan's internal shape is not the wire —
214
+ // which is exactly why a new PlannedTask field does not appear here
215
+ // for free, and why `download` above had to be added by hand.)
216
+ ...(plan.downloadDowngrades !== undefined
217
+ ? { downloadDowngrades: plan.downloadDowngrades }
218
+ : {}),
219
+ }) + '\n'
193
220
  )
194
221
  }
195
222
 
223
+ /** A hit is green, a task that would run is yellow, the rest dim. */
224
+ const HUE: Record<CacheStatus, string> = {
225
+ 'hit-local': SUCCESS,
226
+ 'hit-remote': SUCCESS,
227
+ miss: WARN,
228
+ 'no-cache': WARN,
229
+ group: '',
230
+ 'not-started': '',
231
+ skipped: '',
232
+ }
233
+
196
234
  function symbolFor(s: CacheStatus): string {
197
235
  switch (s) {
198
236
  case 'hit-local':
@@ -207,6 +245,8 @@ function symbolFor(s: CacheStatus): string {
207
245
  return '○'
208
246
  case 'not-started':
209
247
  return '−'
248
+ case 'skipped':
249
+ return '·'
210
250
  }
211
251
  }
212
252
 
@@ -224,5 +264,7 @@ function describe(s: CacheStatus): string {
224
264
  return 'group task'
225
265
  case 'not-started':
226
266
  return 'not started (unneeded)'
267
+ case 'skipped':
268
+ return 'skipped'
227
269
  }
228
270
  }
@@ -18,7 +18,14 @@ import type {
18
18
  TaskArtifacts,
19
19
  } from '../orchestrator/index.js'
20
20
  import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
21
- import { machineParallelism, maskedLine, SecretBroker } from '../util/index.js'
21
+ import {
22
+ machineParallelism,
23
+ maskedLine,
24
+ SecretBroker,
25
+ thrownMessage,
26
+ ui,
27
+ UserError,
28
+ } from '../util/index.js'
22
29
  import { resolveSetting } from '../settings.js'
23
30
 
24
31
  export interface ResolvedPluginCommand {
@@ -52,7 +59,7 @@ async function workspacePlugins(cwd: string): Promise<
52
59
  | {
53
60
  workspaceRoot: string
54
61
  cacheDir: string
55
- storeRoot: Promise<string | null>
62
+ storeRoot: () => Promise<string | null>
56
63
  workspaceConfig: WorkspaceConfig | null
57
64
  concurrency: number
58
65
  plugins: readonly VxPlugin[]
@@ -72,7 +79,7 @@ async function workspacePlugins(cwd: string): Promise<
72
79
  const { loadCliWorkspace } = await import('./workspace-config.js')
73
80
  ws = await loadCliWorkspace(workspaceRoot)
74
81
  } catch (err) {
75
- return { loadError: err instanceof Error ? err.message : String(err) }
82
+ return { loadError: thrownMessage(err) }
76
83
  }
77
84
  return {
78
85
  workspaceRoot,
@@ -104,8 +111,10 @@ export async function resolvePluginCommand(
104
111
  const declaredVerbs = new Set<string>()
105
112
  for (const plugin of ws.plugins) {
106
113
  for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
107
- const command = plugin.commands?.[verb]
108
- if (command === undefined) continue
114
+ // Own keys only: `vx toString` found Object.prototype's and crashed.
115
+ const commands = plugin.commands
116
+ if (commands === undefined || !Object.hasOwn(commands, verb)) continue
117
+ const command = commands[verb]!
109
118
  return {
110
119
  plugin,
111
120
  command,
@@ -127,7 +136,7 @@ export async function resolvePluginCommand(
127
136
  }
128
137
 
129
138
  const warn = (m: string): void => {
130
- process.stderr.write(`${maskedLine(m)}\n`)
139
+ ui.say('warn', maskedLine(m))
131
140
  }
132
141
 
133
142
  /** Read-only: a verb reads what runs stored, and only a run's policy may write. */
@@ -161,7 +170,7 @@ async function verbCache(
161
170
  ws.workspaceRoot,
162
171
  undefined,
163
172
  'open',
164
- await ws.storeRoot,
173
+ await ws.storeRoot(),
165
174
  )
166
175
  const remotes: ResolvedRemote[] = []
167
176
  await resolveCache(ws.plugins, {
@@ -169,7 +178,7 @@ async function verbCache(
169
178
  cacheDir: ws.cacheDir,
170
179
  warn,
171
180
  scope,
172
- refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
181
+ refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig), ws.branch.refs),
173
182
  localCache,
174
183
  policy: VERB_POLICY,
175
184
  remotes,
@@ -188,7 +197,7 @@ async function verbArtifacts(
188
197
  facts?: () => CacheFacts | undefined,
189
198
  ): Promise<TaskArtifacts> {
190
199
  const [
191
- { Cache, FULL_CACHE_POLICY },
200
+ { Cache, FULL_CACHE_POLICY, parseCachePolicy },
192
201
  {
193
202
  cacheRefs,
194
203
  namesRemote,
@@ -204,9 +213,29 @@ async function verbArtifacts(
204
213
  import('../orchestrator/index.js'),
205
214
  import('./run.js'),
206
215
  ])
207
- const asked = workspaceCachePolicy(ws.workspaceConfig) ?? FULL_CACHE_POLICY
216
+ // `VX_CACHE_POLICY` beats the workspace's `cache.policy`, as it does for a
217
+ // run, refused as `parseRunArgs` refuses it (cli/run.ts).
218
+ const envSpec = process.env['VX_CACHE_POLICY'] || undefined
219
+ let asked: CachePolicy
220
+ if (envSpec === undefined) {
221
+ asked = workspaceCachePolicy(ws.workspaceConfig) ?? FULL_CACHE_POLICY
222
+ } else {
223
+ if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
224
+ throw new UserError(
225
+ 'VX_CACHE_POLICY needs a spec like local:r, local:rw, remote:, or local:,remote:rw',
226
+ 'VX_E_USAGE',
227
+ )
228
+ try {
229
+ asked = parseCachePolicy(envSpec)
230
+ } catch (err) {
231
+ throw new UserError(
232
+ `VX_CACHE_POLICY: ${err instanceof Error ? err.message : String(err)}`,
233
+ 'VX_E_USAGE',
234
+ )
235
+ }
236
+ }
208
237
  const policy: CachePolicy =
209
- onCI(process.env['CI']) || namesRemote(ws.workspaceConfig?.cache?.policy)
238
+ onCI(process.env['CI']) || namesRemote(envSpec ?? ws.workspaceConfig?.cache?.policy)
210
239
  ? asked
211
240
  : { ...asked, remoteWrite: false }
212
241
  const scope = resolveCacheScope(
@@ -221,7 +250,7 @@ async function verbArtifacts(
221
250
  ws.workspaceRoot,
222
251
  undefined,
223
252
  'open',
224
- await ws.storeRoot,
253
+ await ws.storeRoot(),
225
254
  )
226
255
  const remotes: ResolvedRemote[] = []
227
256
  const cache = await resolveCache(ws.plugins, {
@@ -229,7 +258,9 @@ async function verbArtifacts(
229
258
  cacheDir: ws.cacheDir,
230
259
  warn,
231
260
  scope,
232
- refs: facts?.()?.refs ?? cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
261
+ refs:
262
+ facts?.()?.refs ??
263
+ cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig), ws.branch.refs),
233
264
  localCache,
234
265
  policy,
235
266
  remotes,
@@ -258,6 +289,9 @@ function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
258
289
  return {
259
290
  save: async (key, task, run) => (await artifacts()).save(key, task, run),
260
291
  restore: async (key, task) => (await artifacts()).restore(key, task),
292
+ output: async (key) => (await artifacts()).output(key),
293
+ share: async (key) => (await artifacts()).share(key),
294
+ resend: async (key) => (await artifacts()).resend(key),
261
295
  }
262
296
  }
263
297
 
@@ -295,8 +329,30 @@ export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]>
295
329
  for (const [verb, command] of Object.entries(plugin.commands ?? {})) {
296
330
  if (seen.has(verb)) continue
297
331
  seen.add(verb)
298
- lines.push(` vx ${verb.padEnd(17)} ${command.description} (${plugin.name})`)
332
+ lines.push(
333
+ ...helpLines(` vx ${verb.padEnd(17)} `, `${command.description} (${plugin.name})`),
334
+ )
299
335
  }
300
336
  }
301
337
  return lines
302
338
  }
339
+
340
+ /**
341
+ * One help entry: `head` then `text` wrapped at word breaks under the
342
+ * column `head` ends at, at core's reference width, so a plugin's long
343
+ * description reads as core's own entries do.
344
+ */
345
+ function helpLines(head: string, text: string, width = 88): string[] {
346
+ const indent = ' '.repeat(head.length)
347
+ const lines: string[] = []
348
+ let line = head
349
+ for (const word of text.split(/\s+/)) {
350
+ const fresh = line === head || line === indent
351
+ if (!fresh && line.length + 1 + word.length > width) {
352
+ lines.push(line)
353
+ line = indent + word
354
+ } else line += fresh ? word : ` ${word}`
355
+ }
356
+ lines.push(line)
357
+ return lines
358
+ }
@@ -1,4 +1,4 @@
1
- // Generated from packages/vx-plugin-examples/plugins by
1
+ // Generated from private/vx-plugin-examples/plugins by
2
2
  // tests/plugin-templates.unsafe.test.ts; edit the examples, then
3
3
  // VX_UPDATE_CONTRACT=1 that test. `vx init --plugin <seam>` writes these.
4
4
 
@@ -10,33 +10,33 @@ 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 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
- 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",
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. The aside\n // name is the writer's own: a pid is not (two puts here, containers on\n // one volume).\n const tmp = path.join(this.endpoint, `.${hash}.${crypto.randomUUID()}`)\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 // From the root, not the cwd: a run in a project's directory reads\n // the same store as one at the root.\n return new DirRemote(path.resolve(ctx.workspaceRoot, 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, readdir, 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'\nimport { dirCache } from './cache.ts'\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\n// Read against the process's cwd, a relative directory was another store\n// for a run started in a project's directory than for one at the root.\nit('reads a relative directory from the workspace root', async () => {\n const layer = await dirCache('remote').cache!({ workspaceRoot: root } as never)\n expect(layer?.endpoint).toBe(path.join(root, 'remote'))\n})\n\n// Two writers of one key shared one temporary name (`.<hash>.<pid>`, and\n// containers on one volume share pids): one unlinked the other's file\n// mid-put, or linked it while the other rewrote it.\nit('two puts of one key at once both land, as one entry', async () => {\n const store = path.join(root, 'remote')\n const layer = (await dirCache(store).cache!({ workspaceRoot: root } as never))!\n const bytes = new Blob(['x'.repeat(1 << 20)])\n const puts = await Promise.allSettled([\n layer.put('k', bytes, { durationMs: 1 }),\n layer.put('k', bytes, { durationMs: 1 }),\n ])\n expect(puts.map((p) => p.status)).toEqual(['fulfilled', 'fulfilled'])\n expect(await readdir(store)).toEqual(['k'])\n expect((await (await layer.get('k'))!.body.arrayBuffer()).byteLength).toBe(1 << 20)\n})\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 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",
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: {\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n },\n artifactsFor: () => ({\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\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
- "// `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 // 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",
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 // A stop before the spawn is work not to start: an abort fires no\n // listener added after it, so the command would run unstopped.\n if (req.signal?.aborted === true) {\n const exitCode = req.signal.reason === 'SIGINT' ? 130 : 143\n return { exitCode, durationMs: 0, stdout: '', stderr: '', violations: [], where: 'shell' }\n }\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 kill = (signal: NodeJS.Signals): void => {\n try {\n process.kill(-child.pid, signal)\n } catch {\n // already gone\n }\n }\n // Core abandons an executor 2 s after the abort: a group that ignores\n // SIGTERM is killed before then, or it would run on unreached.\n let killTimer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => {\n kill('SIGTERM')\n killTimer = setTimeout(() => kill('SIGKILL'), 1_000)\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const readers: { cancel(): Promise<void> }[] = []\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const reader = stream.getReader()\n readers.push(reader)\n const decoder = new TextDecoder()\n let all = ''\n for (let r = await reader.read(); !r.done; r = await reader.read()) {\n const text = decoder.decode(r.value, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const drained = Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n ])\n const exitCode = await child.exited\n // A pipe ends only when every writer closes it: a process the command\n // left running (`server &`) would hold the task open. What it writes\n // after a short grace is not the task's.\n const cut = setTimeout(() => readers.forEach((r) => void r.cancel()), 250)\n const [stdout, stderr] = await drained\n clearTimeout(cut)\n req.signal?.removeEventListener('abort', stop)\n clearTimeout(killTimer)\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'\nimport { shellExecutor } from './executor.ts'\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\n// An abort fires no listener added after it: a request stopped before\n// `execute` ran its command anyway, in a group nothing would stop.\nit('starts nothing for a request already aborted', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n stop.abort('SIGTERM')\n const r = await executor.execute({\n command: 'sleep 0.3; echo ran > ran.txt',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n await Bun.sleep(600)\n expect([r.exitCode, await Bun.file(path.join(root, 'ran.txt')).exists()]).toEqual([143, false])\n})\n\n// Core abandons an executor 2 s after its abort: a group that ignored\n// SIGTERM ran on, unreached, after the run had ended.\nit('kills a group that ignores SIGTERM after a grace', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n const started = Date.now()\n setTimeout(() => stop.abort('SIGTERM'), 200)\n const r = await executor.execute({\n command: \"trap '' TERM; sleep 30\",\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n expect([r.exitCode, Date.now() - started < 10_000]).toEqual([137, true])\n}, 40_000)\n\n// A pipe ends only when every writer closes it: a process the command\n// left running (`server &`) held the task open until it exited.\nit('ends when the shell exits, though a process it left holds the pipes', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const started = Date.now()\n const r = await executor.execute({\n command: 'sleep 5 & echo up',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n } as never)\n expect([r.exitCode, r.stdout, Date.now() - started < 3_000]).toEqual([0, 'up\\n', true])\n}, 20_000)\n",
25
25
  },
26
26
  graph: {
27
27
  plugin:
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 // 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",
28
+ "// `graph`: the run's edges. Every task named `task` waits for the one\n// before it, so they run one after another across projects: in dependency\n// order, then id order, so an added edge never closes a cycle. Core checks\n// 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 named = (id: string): boolean => id.endsWith(`#${task}`)\n const ids: string[] = []\n const seen = new Set<string>()\n const visit = (id: string): void => {\n if (seen.has(id)) return\n seen.add(id)\n for (const dep of nodes.get(id)?.deps ?? []) visit(dep)\n if (named(id)) ids.push(id)\n }\n for (const id of [...nodes.keys()].filter(named).sort()) visit(id)\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 // 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\n// Id order alone put a#build before b#build even when a#build already\n// waits on b#build: the added edge closed a cycle and the run was refused.\nit('chains in dependency order, never closing a cycle', async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'true' }, dependsOn: ['b#build'] } } }`,\n )\n for (const name of ['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 <- b#build',\n 'b#build <- ',\n 'c#build <- a#build',\n ])\n}, 20_000)\n",
30
30
  },
31
31
  key: {
32
32
  plugin:
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",
33
+ "// `key`: extra cache-key material. The value of each named environment\n// variable goes into every cached task's key and is 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
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 inputs: { files: ['package.json'] }, cache: { 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 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",
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, so a blank\n// script, or one no task can be named after (`taskNameProblem`: a `#`\n// comment key), is left out.\nimport { definePlugin, taskNameProblem, 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' || command.trim() === '') continue\n if (config.tasks[name] !== undefined || taskNameProblem(name) !== null) 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\n// A blank script, or one no task can be named after (a `#` comment key),\n// was handed to core, which refused the project: every run of it failed.\nit('leaves out a blank script and one no task can be named after', async () => {\n const dir = await pkg('a', `export default { tasks: {} }`, {\n '# notes': 'see README',\n test: '',\n hello: 'echo hi > hello.txt',\n })\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`)).toEqual(['a#hello success'])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n}, 20_000)\n",
40
40
  },
41
41
  schedule: {
42
42
  plugin:
@@ -45,7 +45,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
45
45
  },
46
46
  telemetry: {
47
47
  plugin:
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 // 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",
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 path from 'node:path'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry(ctx) {\n // From the root, not the cwd: a run in a project's directory appends\n // to the same file as one at the root.\n const to = path.resolve(ctx.workspaceRoot, file)\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(to, 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, type TelemetrySink } from '@vzn/vx'\nimport { jsonlTelemetry } from './telemetry.ts'\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\n// Read against the process's cwd, a relative file was another file for a\n// run started in a project's directory, written inside that project.\nit('writes a relative file at the workspace root', async () => {\n const elsewhere = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-cwd-'))\n const cwd = process.cwd()\n process.chdir(elsewhere)\n try {\n const plugin = jsonlTelemetry('runs.jsonl')\n const sink = (await plugin.telemetry!({ workspaceRoot: root } as never)) as TelemetrySink\n sink.onRunSummary!({ taskCount: 2, failedCount: 0, hitCount: 1 } as never)\n await sink.flush!(new AbortController().signal)\n } finally {\n process.chdir(cwd)\n await rm(elsewhere, { recursive: true, force: true })\n }\n expect(await Bun.file(path.join(root, 'runs.jsonl')).text()).toBe(\n '{\"tasks\":2,\"failed\":0,\"cached\":1}\\n',\n )\n})\n",
50
50
  },
51
51
  }