@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
package/src/util/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  isPermissionError,
14
14
  isUserError,
15
15
  PERMISSION_HINT,
16
+ refusal,
17
+ type RefusalParts,
16
18
  UserError,
17
19
  gitSpawnRefusal,
18
20
  isExecutableMissing,
@@ -21,7 +23,12 @@ export {
21
23
  OUT_OF_FDS_HINT,
22
24
  wantsJson,
23
25
  } from './errors.js'
24
- export { configErrorFrame } from './config-frame.js'
26
+ export { configErrorFrame, configRefusal } from './config-frame.js'
27
+ export { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
28
+ export { gradientRule } from './report.js'
29
+ import * as ui from './ui.js'
30
+ export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
31
+ export { ui }
25
32
  export { xxh3, xxh3hex } from './hash.js'
26
33
  export {
27
34
  beginRun,
@@ -45,6 +52,8 @@ export {
45
52
  outputMatcher,
46
53
  printable,
47
54
  relPosix,
55
+ DOT_VX_GITATTRIBUTES,
56
+ DOT_VX_GITIGNORE,
48
57
  splitNegations,
49
58
  staticPrefix,
50
59
  stripTrailingSlash,
@@ -58,10 +67,12 @@ export {
58
67
  claimExitForSignal,
59
68
  exitClaimedBySignal,
60
69
  killGraceMs,
70
+ setSettleBounds,
71
+ type SettleBound,
61
72
  settleWithin,
62
73
  teardownTimeoutMs,
63
74
  } from './settle.js'
64
- export { formatBytes, parseDuration, parseSize } from './size.js'
75
+ export { formatBytes, parseDuration, parseSize, treeBytes } from './size.js'
65
76
  export {
66
77
  cgroupCpuQuota,
67
78
  cgroupMemoryLimitBytes,
@@ -73,11 +84,13 @@ export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.j
73
84
  export { ulid } from './ulid.js'
74
85
  export { splitTaskId } from './task-id.js'
75
86
  export { editDistance, listed, nearMatches, nearest } from './edit-distance.js'
76
- export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
87
+ export { CORE_VERBS } from './verbs.js'
77
88
  export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
78
89
  export { executablePath, shellArgv, taskShell } from './which.js'
79
90
  export { procfsIsOwn } from './procfs.js'
80
91
  export { hangupIgnored } from './hangup.js'
92
+ export { ESSENTIAL_ENV } from './essential-env.js'
93
+ export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
81
94
  export { realPath, realpathOf } from './real-path.js'
82
95
  export {
83
96
  maskedCommand,
package/src/util/paths.ts CHANGED
@@ -11,6 +11,16 @@ export function toPosix(p: string): string {
11
11
  return IS_POSIX ? p : p.split(path.sep).join('/')
12
12
  }
13
13
 
14
+ /**
15
+ * `.vx/.gitignore`: git ignores the machine-local `.vx/cache` and nothing
16
+ * else in `.vx`, so the workspace config and the lock beside it are
17
+ * committed. `vx init` and the cache write it when missing.
18
+ */
19
+ export const DOT_VX_GITIGNORE = '/cache\n'
20
+
21
+ /** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
22
+ export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
23
+
14
24
  export function relPosix(from: string, to: string): string {
15
25
  return toPosix(path.relative(from, to))
16
26
  }
@@ -0,0 +1,111 @@
1
+ // Every refusal vx prints, as one `ui.notice`: the parts a refusal
2
+ // carries, a config refusal's frame, or a plain message laid out a fact a
3
+ // line. The flat message stays what `--format json` and logs carry.
4
+
5
+ import { configRefusal } from './config-frame.js'
6
+ import type { RefusalParts } from './errors.js'
7
+ import { notice, print, type Block, type Inline, type Tone } from './ui.js'
8
+
9
+ // A sentence break before a capital, a backtick or a paren: vx joins
10
+ // its facts with these.
11
+ const BREAK = /(?<=[.?!])\s+(?=[A-Z`(])/
12
+ // `(see \`vx run --help\`)`: where to read more.
13
+ const SEE = /\s*\(see `([^`]+)`\)/
14
+ // `(did you mean --concurrency?)`, `. Did you mean build?`: the fix.
15
+ const MEANT = /\s*(?:\(did you mean ([^?]+)\?\)|(?:^|[.;—]\s*)[Dd]id you mean ([^?]+)\?)/
16
+
17
+ const bare = (s: string): string => s.trim().replace(/[.:]$/, '')
18
+
19
+ /** A plain message in parts: its first sentence heads, each later one is a line. */
20
+ export function messageParts(message: string): RefusalParts {
21
+ const [head = '', ...rest] = message.split('\n')
22
+ let first = head
23
+ const see = SEE.exec(first)
24
+ if (see !== null) first = first.replace(SEE, '')
25
+ const meant = MEANT.exec(first)
26
+ if (meant !== null) first = first.replace(MEANT, '')
27
+ const sentences = first
28
+ .split(BREAK)
29
+ .map(bare)
30
+ .filter((s) => s !== '')
31
+ const [headline = bare(first), ...what] = sentences
32
+ for (const l of rest) {
33
+ if (l.trim() !== '') what.push(...l.replace(/^ {2}/, '').split(BREAK).map(bare))
34
+ }
35
+ const fix = meant !== null ? (meant[1] ?? meant[2])!.trim() : undefined
36
+ return {
37
+ headline,
38
+ ...(what.length > 0 ? { what } : {}),
39
+ ...(see !== null ? { why: `see ${see[1]}` } : {}),
40
+ ...(fix !== undefined ? { fix } : {}),
41
+ }
42
+ }
43
+
44
+ /**
45
+ * `✗ <verb> <headline>` with what is wrong, why, and the fix under it.
46
+ * `headline` set: that is the status line and the refusal's own headline
47
+ * joins what is wrong (`vx lock lock not written`).
48
+ */
49
+ export function refusalNotice(
50
+ verb: string,
51
+ message: string,
52
+ parts?: RefusalParts,
53
+ opts: { headline?: string; cwd?: string } = {},
54
+ ): Block {
55
+ const c = parts === undefined ? configRefusal(message, opts.cwd ?? process.cwd()) : undefined
56
+ if (c !== undefined) {
57
+ const what: Inline[] = [{ style: 'bold', text: c.where }, c.problem]
58
+ return notice({
59
+ tone: 'error',
60
+ verb,
61
+ headline: opts.headline ?? 'config refused',
62
+ what,
63
+ ...(c.allowed !== undefined ? { why: `allowed: ${c.allowed}` } : {}),
64
+ code: c.frame,
65
+ ...(c.fix !== undefined ? { fix: c.fix } : {}),
66
+ })
67
+ }
68
+ const p = parts ?? messageParts(message)
69
+ const what = opts.headline !== undefined ? [p.headline, ...(p.what ?? [])] : (p.what ?? [])
70
+ return notice({
71
+ tone: p.tone ?? 'error',
72
+ verb,
73
+ headline: opts.headline ?? p.headline,
74
+ ...(what.length > 0 ? { what } : {}),
75
+ ...(p.why !== undefined ? { why: p.why } : {}),
76
+ ...(p.fix !== undefined ? { fix: p.fix } : {}),
77
+ })
78
+ }
79
+
80
+ /** A message of `tone` from `verb`, a fact a line. */
81
+ function messageNotice(tone: Tone, verb: string, message: string): Block {
82
+ const p = messageParts(message)
83
+ return notice({
84
+ tone,
85
+ verb,
86
+ headline: p.headline,
87
+ ...(p.what !== undefined ? { what: p.what } : {}),
88
+ ...(p.why !== undefined ? { why: p.why } : {}),
89
+ ...(p.fix !== undefined ? { fix: p.fix } : {}),
90
+ })
91
+ }
92
+
93
+ /** A line that may open with its verb (`vx why: …`), as `messageNotice` draws it. */
94
+ export function lineNotice(tone: Tone, line: string, verb = 'vx'): Block {
95
+ const named = /^(vx(?: [a-z][\w-]*)?): /.exec(line)
96
+ return messageNotice(
97
+ tone,
98
+ named?.[1] ?? verb,
99
+ named === null ? line : line.slice(named[0].length),
100
+ )
101
+ }
102
+
103
+ /** `messageNotice` on stderr. */
104
+ export function sayNotice(tone: Tone, verb: string, message: string): void {
105
+ print('err', messageNotice(tone, verb, message))
106
+ }
107
+
108
+ /** `lineNotice` on stderr. */
109
+ export function sayLine(tone: Tone, line: string, verb = 'vx'): void {
110
+ print('err', lineNotice(tone, line, verb))
111
+ }
@@ -0,0 +1,31 @@
1
+ import { paint, type ColorSupport } from './colors.js'
2
+
3
+ // Brand gradient for the rule: identity violet → pink (the project /
4
+ // task hues), faded across the dash run. Plain dashes when colors
5
+ // are off.
6
+ const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
7
+ const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
8
+ // Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
9
+ const RULE_DASHES = 57
10
+
11
+ /** Total visible rule width: `─ <mark> ` + dashes. Shared by the run summary and every report. */
12
+ export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
13
+ const head = `─ `
14
+ const dashes = '─'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
15
+ if (!colors.enabled) return `${head}${mark} ${dashes}`
16
+ const chunks = 8
17
+ const per = Math.ceil(RULE_DASHES / chunks)
18
+ let out = ''
19
+ for (let i = 0; i < chunks; i++) {
20
+ const seg = dashes.slice(i * per, (i + 1) * per)
21
+ if (seg.length === 0) break
22
+ const f = i / (chunks - 1)
23
+ const hex = `#${GRADIENT_FROM.map((c, j) =>
24
+ Math.round(c + (GRADIENT_TO[j]! - c) * f)
25
+ .toString(16)
26
+ .padStart(2, '0'),
27
+ ).join('')}`
28
+ out += paint(hex, seg, colors)
29
+ }
30
+ return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
31
+ }
@@ -2,49 +2,68 @@
2
2
  // wrote; it must not hold the run's exit hostage.
3
3
 
4
4
  import { MAX_TIMEOUT_MS } from './num.js'
5
+ import { parseDuration } from './size.js'
5
6
 
6
7
  /** Default upper bound on one end-of-run flush/teardown await. */
7
8
  const DEFAULT_TIMEOUT_MS = 3000
8
9
 
10
+ /** A bound the run resolved from its flag or `.vx/workspace`. */
11
+ export interface SettleBound {
12
+ readonly ms: number
13
+ readonly from: 'flag' | 'workspace'
14
+ }
15
+
16
+ let teardownSet: SettleBound | undefined
17
+ let killGraceSet: SettleBound | undefined
18
+
9
19
  /**
10
- * The bound to apply, read per call so a test can drive the deadline
11
- * instead of waiting it out.
20
+ * The run's `run.teardownTimeout` and `run.killGrace` from its flag or
21
+ * workspace (the orchestrator resolves them; util sits below settings).
22
+ * The variable, read per call, sits between the two, so a test drives the
23
+ * deadline instead of waiting it out.
12
24
  */
13
- export function teardownTimeoutMs(): number {
14
- const raw = process.env['VX_TEARDOWN_TIMEOUT_MS']
15
- // Out-of-range falls back to the DEFAULT rather than clamping to
16
- // MAX_TIMEOUT_MS, and the distinction matters. This value is a BOUND, not a
17
- // duration: there is no "no limit" option here, because the whole reason the
18
- // deadline exists is that a plugin's flush must not hold the run's exit
19
- // hostage. Clamping would honour "wait ~24.8 days", which defeats it — the
20
- // run would hang. Unbounded is worse still (the delay becomes 1 ms, so every
21
- // flush times out and every buffered record is dropped). Falling back keeps
22
- // the bound real in both directions.
23
- if (raw !== undefined && /^[0-9]+$/.test(raw)) {
24
- const n = Number(raw)
25
- if (n <= MAX_TIMEOUT_MS) return n
25
+ export function setSettleBounds(b: { teardown?: SettleBound; killGrace?: SettleBound }): void {
26
+ teardownSet = b.teardown
27
+ killGraceSet = b.killGrace
28
+ }
29
+
30
+ /**
31
+ * A flag's bound, else the variable's (a positive duration inside the
32
+ * timer's range: the CLI refuses any other before a run, and an embedder's
33
+ * falls back), else the workspace's.
34
+ */
35
+ function bound(name: string, set: SettleBound | undefined, zero = false): number | undefined {
36
+ if (set?.from === 'flag') return set.ms
37
+ const raw = process.env[name]
38
+ if (raw !== undefined && raw !== '') {
39
+ const n = parseDuration(raw)
40
+ if (n !== null && (n > 0 || zero) && n <= MAX_TIMEOUT_MS) return n
26
41
  }
27
- return DEFAULT_TIMEOUT_MS
42
+ return set?.ms
28
43
  }
29
44
 
30
45
  /**
31
- * The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM: the
32
- * one-shot timeout escalation (`exec/runner.ts`), the signal teardown
33
- * (`orchestrator/signals.ts`) and the end-of-run persistent shutdown
34
- * (`orchestrator/persistent.ts`) share it. Two seconds
46
+ * The bound on one end-of-run flush or teardown (`run.teardownTimeout`).
47
+ * There is no "no limit": the deadline exists so a plugin's flush cannot
48
+ * hold the run's exit hostage, so a timer past the platform's range (which
49
+ * would fire at once and drop every buffered record) is no value.
50
+ */
51
+ export function teardownTimeoutMs(): number {
52
+ return bound('VX_RUN_TEARDOWN_TIMEOUT', teardownSet, true) ?? DEFAULT_TIMEOUT_MS
53
+ }
54
+
55
+ /**
56
+ * The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM
57
+ * (`run.killGrace`): the one-shot timeout escalation (`exec/runner.ts`),
58
+ * the signal teardown (`orchestrator/signals.ts`) and the end-of-run
59
+ * persistent shutdown (`orchestrator/persistent.ts`) share it. Two seconds
35
60
  * is generous for a real server's cleanup and is what every run waits when
36
- * a child wedges. `VX_KILL_GRACE_MS` overrides it, read per call and bounded
37
- * like the teardown deadline (out of range falls back to the default): the
38
- * tests that prove the escalation used to wait the full two seconds each,
39
- * a third of the suite's wall time for a claim a 200 ms grace proves too.
61
+ * a child wedges; the tests that prove the escalation used to wait the
62
+ * full two seconds each, a third of the suite's wall time for a claim a
63
+ * 200 ms grace proves too.
40
64
  */
41
65
  export function killGraceMs(defaultMs: number): number {
42
- const raw = process.env['VX_KILL_GRACE_MS']
43
- if (raw !== undefined && /^[0-9]+$/.test(raw)) {
44
- const n = Number(raw)
45
- if (n > 0 && n <= MAX_TIMEOUT_MS) return n
46
- }
47
- return defaultMs
66
+ return bound('VX_RUN_KILL_GRACE', killGraceSet) ?? defaultMs
48
67
  }
49
68
 
50
69
  /**
package/src/util/size.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { lstatSync, readdirSync } from 'node:fs'
2
+ import path from 'node:path'
1
3
  import { parseDecimalInt } from './num.js'
2
4
 
3
5
  /**
@@ -20,25 +22,34 @@ export function parseSize(input: string): number | null {
20
22
  }
21
23
 
22
24
  /**
23
- * Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
25
+ * Parse `<n><unit>` where unit is ms/s/m/h/d, case-insensitively (`parseSize`
24
26
  * has always been case-insensitive; this matched only lowercase, so `30D`
25
27
  * was rejected while `1GB` and `1gb` both worked).
26
28
  */
27
29
  export function parseDuration(input: string): number | null {
28
- const m = input.match(/^(\d+)([smhd])$/i)
30
+ const m = input.match(/^(\d+)(ms|[smhd])$/i)
29
31
  if (!m) return null
30
32
  const n = parseDecimalInt(m[1]!)
31
33
  if (n === null) return null
32
34
  // Lowercase before the switch: with the /i flag an uppercase `M` would
33
35
  // otherwise fall through to the days branch.
34
36
  const unit = m[2]!.toLowerCase()
35
- const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
37
+ const mult =
38
+ unit === 'ms'
39
+ ? 1
40
+ : unit === 's'
41
+ ? 1000
42
+ : unit === 'm'
43
+ ? 60_000
44
+ : unit === 'h'
45
+ ? 3_600_000
46
+ : 86_400_000
36
47
  return n * mult
37
48
  }
38
49
 
39
50
  /**
40
51
  * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
41
- * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache prune` and the
52
+ * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache clean` and the
42
53
  * run's `cacheRetention` line.
43
54
  */
44
55
  export function formatBytes(n: number): string {
@@ -51,3 +62,11 @@ export function formatBytes(n: number): string {
51
62
  }
52
63
  return `${v.toFixed(0)} PB`
53
64
  }
65
+
66
+ /** Bytes under `p` on disk, links not followed; 0 when it is missing. */
67
+ export function treeBytes(p: string): number {
68
+ const st = lstatSync(p, { throwIfNoEntry: false })
69
+ if (st === undefined) return 0
70
+ if (!st.isDirectory()) return st.size
71
+ return readdirSync(p).reduce((n, e) => n + treeBytes(path.join(p, e)), 0)
72
+ }
@@ -1,4 +1,5 @@
1
- // Stage timing for the run path, printed to stderr when `VX_TIMING` is set.
1
+ // Stage timing for the run path, printed to stderr when `VX_DEBUG_TIMING` is
2
+ // true (`debug.timing`; util reads it itself, below src/settings.ts).
2
3
  //
3
4
  // The CPU profiler attributes a tight loop's cost unreliably and hides
4
5
  // where an `await` waited; a stage table answers "where did the warm run
@@ -8,7 +9,7 @@
8
9
  // prints (a push per stage, ~15 a run): a telemetry sink draws them as
9
10
  // spans (`stageTimes`). The per-call `span`s stay off unless enabled.
10
11
 
11
- const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
12
+ const enabled = process.env.VX_DEBUG_TIMING === 'true' || process.env.VX_DEBUG_TIMING === '1'
12
13
  let t0 = Bun.nanoseconds()
13
14
  // The wall clock at t0, read then: `performance.timeOrigin` plus the
14
15
  // monotonic clock drifted 34 ms from `Date.now()` on macOS CI, so stages
package/src/util/ui.ts ADDED
@@ -0,0 +1,186 @@
1
+ // vx's output components (org/devex/log-design.md). A verb or a plugin
2
+ // composes these instead of writing raw lines: each component owns its
3
+ // color, indent and spacing, so every surface shares one look. Colors
4
+ // are decided at print time, per stream.
5
+
6
+ import { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
7
+ import { gradientRule } from './report.js'
8
+
9
+ export type Tone = 'ok' | 'warn' | 'error' | 'info'
10
+
11
+ const INFO = '#06b6d4'
12
+ const PROJECT = '#a78bfa'
13
+ const TASK = '#f472b6'
14
+ const HUE: Record<Tone, string> = { ok: SUCCESS, warn: WARN, error: ERROR, info: INFO }
15
+ const SYMBOL: Record<Tone, string> = { ok: '✓', warn: '!', error: '✗', info: '›' }
16
+ const INDENT = ' '
17
+ const LABEL_MIN = 8
18
+
19
+ type Style = Tone | 'dim' | 'bold' | 'id'
20
+
21
+ /** Styled text inside a component: a plain string, a token, or a run of them. */
22
+ export type Inline = string | { readonly style: Style; readonly text: string } | readonly Inline[]
23
+
24
+ /** A laid-out piece of output; `render` and `print` space blocks apart. */
25
+ export interface Block {
26
+ readonly lines: (colors: ColorSupport) => string[]
27
+ }
28
+
29
+ /** A `project#task` id, in the run's project and task hues. */
30
+ export const id = (text: string): Inline => ({ style: 'id', text })
31
+ /** A path, glob or file: bold. */
32
+ export const path = (text: string): Inline => ({ style: 'bold', text })
33
+ /** A command to type: bold. */
34
+ export const cmd = (text: string): Inline => ({ style: 'bold', text })
35
+ /** A secondary fact. */
36
+ export const dim = (text: string): Inline => ({ style: 'dim', text })
37
+ /** Text in a tone's color. */
38
+ export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
39
+
40
+ function inline(x: Inline, c: ColorSupport): string {
41
+ if (typeof x === 'string') return x
42
+ if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
43
+ const { style, text } = x as { style: Style; text: string }
44
+ switch (style) {
45
+ case 'dim':
46
+ return paint('', text, c, { dim: true })
47
+ case 'bold':
48
+ return paint('', text, c, { bold: true })
49
+ case 'id': {
50
+ const hash = text.lastIndexOf('#')
51
+ if (hash < 0) return paint(PROJECT, text, c)
52
+ return paint(PROJECT, text.slice(0, hash), c) + paint(TASK, text.slice(hash), c)
53
+ }
54
+ default:
55
+ return paint(HUE[style], text, c)
56
+ }
57
+ }
58
+
59
+ const block = (lines: (c: ColorSupport) => string[]): Block => ({ lines })
60
+ const lines = (x: Inline, c: ColorSupport): string[] => inline(x, c).split('\n')
61
+ const visible = (s: string): number => Bun.stringWidth(s)
62
+
63
+ /** `✗ vx lock lock not written`: the tone's symbol, the bold verb, the headline. */
64
+ export function status(t: Tone, verb: string, headline: Inline): Block {
65
+ return block((c) => [
66
+ `${paint(HUE[t], SYMBOL[t], c)} ${paint('', verb, c, { bold: true })} ${inline(headline, c)}`,
67
+ ])
68
+ }
69
+
70
+ /** Indented short lines: what is wrong, or what happened. */
71
+ export function text(...items: Inline[]): Block {
72
+ return block((c) => items.flatMap((i) => lines(i, c).map((l) => `${INDENT}${l}`)))
73
+ }
74
+
75
+ /** An indented dim line: why it matters. */
76
+ export function why(line: Inline): Block {
77
+ return block((c) => lines(line, c).map((l) => `${INDENT}${paint('', l, c, { dim: true })}`))
78
+ }
79
+
80
+ /** A label and its values; values after the first sit under it. `null` is a blank line. */
81
+ export type FieldRow = readonly [label: string, ...values: Inline[]] | null
82
+
83
+ /** A dim label column with the values aligned beside it; `next` and `fix` are bold. */
84
+ export function fields(rows: readonly FieldRow[]): Block {
85
+ return block((c) => {
86
+ const width = Math.max(LABEL_MIN, ...rows.map((r) => (r === null ? 0 : r[0].length)))
87
+ const out: string[] = []
88
+ for (const row of rows) {
89
+ if (row === null) {
90
+ out.push('')
91
+ continue
92
+ }
93
+ const [label, ...values] = row
94
+ const strong = label === 'next' || label === 'fix'
95
+ values
96
+ .flatMap((v) => lines(v, c))
97
+ .forEach((v, i) => {
98
+ const head =
99
+ i === 0 ? paint('', label.padEnd(width), c, { dim: true }) : ' '.repeat(width)
100
+ const value = strong ? paint('', v, c, { bold: true }) : v
101
+ out.push(`${INDENT}${head} ${value}`.trimEnd())
102
+ })
103
+ }
104
+ return out
105
+ })
106
+ }
107
+
108
+ /** `fix <action>`: what to do about it. */
109
+ export function fix(action: Inline): Block {
110
+ return labelled('fix', action)
111
+ }
112
+
113
+ /** `next <command>`: the command to type next. */
114
+ export function next(command: Inline): Block {
115
+ return labelled('next', command)
116
+ }
117
+
118
+ function labelled(label: string, value: Inline): Block {
119
+ return block((c) => [
120
+ `${INDENT}${paint('', label, c, { dim: true })} ${paint('', inline(value, c), c, { bold: true })}`,
121
+ ])
122
+ }
123
+
124
+ /** A report: a blank line, the brand rule naming `mark`, then its fields. */
125
+ export function report(mark: string, rows: readonly FieldRow[]): Block {
126
+ return block((c) => ['', gradientRule(c, mark), ...fields(rows).lines(c)])
127
+ }
128
+
129
+ /** Aligned columns under a dim header. */
130
+ export function table(columns: readonly string[], rows: readonly (readonly Inline[])[]): Block {
131
+ return block((c) => {
132
+ const cells = rows.map((r) => r.map((x) => inline(x, c)))
133
+ const widths = columns.map((h, i) =>
134
+ Math.max(h.length, ...cells.map((r) => visible(r[i] ?? ''))),
135
+ )
136
+ const pad = (s: string, i: number): string =>
137
+ i === columns.length - 1 ? s : s + ' '.repeat(widths[i]! - visible(s))
138
+ return [
139
+ `${INDENT}${paint('', columns.map((h, i) => pad(h, i)).join(' '), c, { dim: true })}`.trimEnd(),
140
+ ...cells.map((r) => `${INDENT}${r.map(pad).join(' ')}`.trimEnd()),
141
+ ]
142
+ })
143
+ }
144
+
145
+ /** A source frame (`> 1 | …` and its caret), indented. */
146
+ export function code(frame: string): Block {
147
+ return block(() => frame.split('\n').map((l) => (l === '' ? '' : `${INDENT}${l}`)))
148
+ }
149
+
150
+ export interface NoticeParts {
151
+ tone: Tone
152
+ verb: string
153
+ headline: Inline
154
+ /** What is wrong, one fact a line. */
155
+ what?: readonly Inline[]
156
+ why?: Inline
157
+ /** A source frame, below the facts. */
158
+ code?: string
159
+ fix?: Inline
160
+ }
161
+
162
+ /** A refusal or a warning: status, then what and why, the frame, and the fix, a blank line apart. */
163
+ export function notice(p: NoticeParts): Block {
164
+ return block((c) => {
165
+ const parts: Block[] = [status(p.tone, p.verb, p.headline)]
166
+ const body = [
167
+ ...(p.what !== undefined ? [text(...p.what)] : []),
168
+ ...(p.why !== undefined ? [why(p.why)] : []),
169
+ ]
170
+ if (body.length > 0) parts.push(block((cc) => body.flatMap((b) => b.lines(cc))))
171
+ if (p.code !== undefined) parts.push(code(p.code))
172
+ if (p.fix !== undefined) parts.push(fix(p.fix))
173
+ return parts.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
174
+ })
175
+ }
176
+
177
+ /** The blocks as text, a blank line apart, ending in a newline. */
178
+ export function render(blocks: readonly Block[], colors: ColorSupport): string {
179
+ return `${blocks.map((b) => b.lines(colors).join('\n')).join('\n\n')}\n`
180
+ }
181
+
182
+ /** Write the blocks to stdout (`out`) or stderr (`err`), colored for that stream. */
183
+ export function print(stream: 'out' | 'err', ...blocks: Block[]): void {
184
+ const s = stream === 'out' ? process.stdout : process.stderr
185
+ s.write(render(blocks, detectColors(s)))
186
+ }
package/src/util/ulid.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Run-id generator. Thin wrapper over `Bun.randomUUIDv7()` so the
2
2
  // `runId` column on `runs` carries a timestamp-ordered identifier —
3
- // `vx stats --since` queries can range-scan without an index on the
3
+ // time-range queries can range-scan without an index on the
4
4
  // time column, and the IDs lexicographically sort by creation time
5
5
  // for grouping.
6
6
  //
package/src/util/verbs.ts CHANGED
@@ -19,18 +19,3 @@ export const CORE_VERBS = [
19
19
  'help',
20
20
  'version',
21
21
  ] as const
22
-
23
- /**
24
- * Verbs core owned once and a package owns now. Not core verbs: a plugin
25
- * may declare them, and the dispatcher prints the
26
- * pointer only when no declared plugin claimed the name.
27
- */
28
- export const MOVED_VERBS: Readonly<Record<string, string>> = {
29
- migrate:
30
- 'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
31
- 'project graph → vx.config.ts); `vx init` reads package.json scripts',
32
- prune:
33
- 'vx prune comes from @vzn/vx-lockfile: declare pnpm(), bun(), npm() or yarn() in ' +
34
- 'vx.workspace.ts',
35
- stats: 'vx stats was removed (H-19): run `vx info`, which prints the same report',
36
- }