@vzn/vx 0.0.650 → 0.0.651
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.
- package/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// .vx/dependencies.lock.json — what the workspace's dependencies resolve
|
|
2
|
+
// to, written by `vx lock` beside the config lock and committed.
|
|
3
|
+
//
|
|
4
|
+
// Each project's edges (the `discover` stage's `deps` and `reach`), by
|
|
5
|
+
// directory, and each claimed file's per-project digests
|
|
6
|
+
// (`FingerprintClaim.digests`) with the hash of the bytes they came from
|
|
7
|
+
// and of each other file the digests read. A run keys from these digests
|
|
8
|
+
// and never parses the file: one whose bytes moved since refuses
|
|
9
|
+
// (orchestrator/locked-digests.ts). `--affected` reads the base commit's
|
|
10
|
+
// copy with git: a deleted project's dependants are in its edges, and a
|
|
11
|
+
// changed lockfile's base digests are there, so the base side is never
|
|
12
|
+
// parsed. `vx lock --check` holds it to a fresh derivation.
|
|
13
|
+
|
|
14
|
+
import { rename, rm } from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import type { ProjectMeta } from './workspace.js'
|
|
17
|
+
import { lockKey } from './lockfile.js'
|
|
18
|
+
import { type LoadReads, readOnce } from './load-reads.js'
|
|
19
|
+
|
|
20
|
+
export const DEPENDENCIES_LOCK_NAME = '.vx/dependencies.lock.json'
|
|
21
|
+
const DEPENDENCIES_LOCK_VERSION = 3
|
|
22
|
+
|
|
23
|
+
// Flat: each directory once, everything else an index into `projects`, so
|
|
24
|
+
// a parse builds few strings and a lookup is an array read.
|
|
25
|
+
export interface DependenciesLock {
|
|
26
|
+
version: number
|
|
27
|
+
/** Project directories (root-relative POSIX, `.` for the root), sorted. */
|
|
28
|
+
projects: string[]
|
|
29
|
+
/** Per project, the indexes of the projects it builds after. */
|
|
30
|
+
deps: number[][]
|
|
31
|
+
/** Per project, the indexes of the projects it reaches for selection only. */
|
|
32
|
+
reach: number[][]
|
|
33
|
+
/** Claimed file → its bytes' xxh3, each project's digest by index ('' when none), and each other file read → its hash. */
|
|
34
|
+
files: Record<string, LockedFile>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LockedFile {
|
|
38
|
+
hash: string
|
|
39
|
+
digests: string[]
|
|
40
|
+
/** Root-relative files besides this one whose bytes the digests read → their hash (`hashExtra`). */
|
|
41
|
+
extras?: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The lock for `metas`, with `files` keyed by project name as the claims answer. */
|
|
45
|
+
export function dependenciesLock(
|
|
46
|
+
root: string,
|
|
47
|
+
metas: readonly ProjectMeta[],
|
|
48
|
+
files: ReadonlyMap<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
hash: string
|
|
52
|
+
projects: Readonly<Record<string, string>>
|
|
53
|
+
extras?: Readonly<Record<string, string>>
|
|
54
|
+
}
|
|
55
|
+
>,
|
|
56
|
+
): DependenciesLock {
|
|
57
|
+
const dirOf = new Map(metas.map((m) => [m.name, lockKey(root, m.dir)]))
|
|
58
|
+
const projects = [...new Set(dirOf.values())].sort(cmp)
|
|
59
|
+
const at = new Map(projects.map((d, i) => [d, i]))
|
|
60
|
+
const byDir = new Map(metas.map((m) => [dirOf.get(m.name)!, m]))
|
|
61
|
+
const indexes = (names: readonly string[] | undefined): number[] =>
|
|
62
|
+
[...new Set((names ?? []).flatMap((n) => at.get(dirOf.get(n) ?? '') ?? []))].sort(
|
|
63
|
+
(a, b) => a - b,
|
|
64
|
+
)
|
|
65
|
+
const out: DependenciesLock['files'] = {}
|
|
66
|
+
for (const file of [...files.keys()].sort(cmp)) {
|
|
67
|
+
const { hash, projects: byName, extras } = files.get(file)!
|
|
68
|
+
const digests = projects.map(() => '')
|
|
69
|
+
for (const [name, digest] of Object.entries(byName)) {
|
|
70
|
+
const i = at.get(dirOf.get(name) ?? '')
|
|
71
|
+
if (i !== undefined) digests[i] = digest
|
|
72
|
+
}
|
|
73
|
+
const rels = Object.keys(extras ?? {}).sort(cmp)
|
|
74
|
+
out[file] =
|
|
75
|
+
rels.length === 0
|
|
76
|
+
? { hash, digests }
|
|
77
|
+
: { hash, digests, extras: Object.fromEntries(rels.map((r) => [r, extras![r]!])) }
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
version: DEPENDENCIES_LOCK_VERSION,
|
|
81
|
+
projects,
|
|
82
|
+
deps: projects.map((d) => indexes(byDir.get(d)!.deps)),
|
|
83
|
+
reach: projects.map((d) => indexes(byDir.get(d)!.reach)),
|
|
84
|
+
files: out,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Minified, as the config lock: only vx reads it, and minified JSON parses faster. */
|
|
89
|
+
function dependenciesLockText(lock: DependenciesLock): string {
|
|
90
|
+
return `${JSON.stringify(lock)}\n`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Written beside its name and renamed over it, as `writeLockfile`: a reader never sees half a file. */
|
|
94
|
+
export async function writeDependenciesLock(root: string, lock: DependenciesLock): Promise<void> {
|
|
95
|
+
const file = path.join(root, DEPENDENCIES_LOCK_NAME)
|
|
96
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
97
|
+
try {
|
|
98
|
+
await Bun.write(tmp, dependenciesLockText(lock))
|
|
99
|
+
await rename(tmp, file)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
102
|
+
const e = err as NodeJS.ErrnoException
|
|
103
|
+
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
104
|
+
if (e.path === tmp) e.path = file
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The lock's bytes at `root`, read once per `reads`; null when there is none. */
|
|
110
|
+
export function readDependenciesLock(root: string, reads?: LoadReads): Promise<Uint8Array | null> {
|
|
111
|
+
return readOnce(reads, path.join(root, DEPENDENCIES_LOCK_NAME))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The lock in `bytes`; null when absent, another version, or not this shape. */
|
|
115
|
+
export function parseDependenciesLock(bytes: Uint8Array | null): DependenciesLock | null {
|
|
116
|
+
if (bytes === null) return null
|
|
117
|
+
try {
|
|
118
|
+
const lock = JSON.parse(new TextDecoder().decode(bytes)) as Partial<DependenciesLock>
|
|
119
|
+
return lock.version === DEPENDENCIES_LOCK_VERSION && wellFormed(lock)
|
|
120
|
+
? (lock as DependenciesLock)
|
|
121
|
+
: null
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A committed file a merge or a hand edit can break inside, so every
|
|
128
|
+
// entry is checked: one bad entry read on would throw a raw TypeError at
|
|
129
|
+
// its first lookup instead of the "invalid, run `vx lock`" refusal.
|
|
130
|
+
function wellFormed(lock: Partial<DependenciesLock>): boolean {
|
|
131
|
+
const { projects, deps, reach, files } = lock
|
|
132
|
+
if (!Array.isArray(projects) || !projects.every((d) => typeof d === 'string')) return false
|
|
133
|
+
const n = projects.length
|
|
134
|
+
const edges = (lists: unknown): boolean =>
|
|
135
|
+
Array.isArray(lists) &&
|
|
136
|
+
lists.length === n &&
|
|
137
|
+
lists.every((l) => Array.isArray(l) && l.every((i) => Number.isInteger(i) && i >= 0 && i < n))
|
|
138
|
+
if (!edges(deps) || !edges(reach) || !isRecord(files)) return false
|
|
139
|
+
return Object.values(files).every(
|
|
140
|
+
(rec: unknown) =>
|
|
141
|
+
isRecord(rec) &&
|
|
142
|
+
typeof rec['hash'] === 'string' &&
|
|
143
|
+
Array.isArray(rec['digests']) &&
|
|
144
|
+
rec['digests'].length === n &&
|
|
145
|
+
rec['digests'].every((d) => typeof d === 'string') &&
|
|
146
|
+
(rec['extras'] === undefined ||
|
|
147
|
+
(isRecord(rec['extras']) &&
|
|
148
|
+
Object.values(rec['extras']).every((h) => typeof h === 'string'))),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
153
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The project directories whose edges reach `dir`. */
|
|
157
|
+
export function dependantsOf(lock: DependenciesLock, dir: string): string[] {
|
|
158
|
+
const i = lock.projects.indexOf(dir)
|
|
159
|
+
if (i < 0) return []
|
|
160
|
+
return lock.projects.filter((_, j) => lock.deps[j]!.includes(i) || lock.reach[j]!.includes(i))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `file`'s recorded hashes and each project directory's digest (undefined when none). */
|
|
164
|
+
export function recordedDigests(
|
|
165
|
+
lock: DependenciesLock,
|
|
166
|
+
file: string,
|
|
167
|
+
):
|
|
168
|
+
| {
|
|
169
|
+
hash: string
|
|
170
|
+
extras: Readonly<Record<string, string>>
|
|
171
|
+
has: (dir: string) => boolean
|
|
172
|
+
of: (dir: string) => string | undefined
|
|
173
|
+
}
|
|
174
|
+
| undefined {
|
|
175
|
+
const rec = Object.hasOwn(lock.files, file) ? lock.files[file] : undefined
|
|
176
|
+
if (rec === undefined) return undefined
|
|
177
|
+
const at = new Map(lock.projects.map((d, i) => [d, i]))
|
|
178
|
+
return {
|
|
179
|
+
hash: rec.hash,
|
|
180
|
+
extras: rec.extras ?? {},
|
|
181
|
+
has: (dir) => at.has(dir),
|
|
182
|
+
of: (dir) => {
|
|
183
|
+
const i = at.get(dir)
|
|
184
|
+
return i === undefined || rec.digests[i] === '' ? undefined : rec.digests[i]
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cmp(a: string, b: string): number {
|
|
190
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
191
|
+
}
|
package/src/workspace/filter.ts
CHANGED
|
@@ -66,6 +66,8 @@ export interface ParsedFilter {
|
|
|
66
66
|
* `./apps/*` when it names no project, as Nx's `--projects` reads it.
|
|
67
67
|
*/
|
|
68
68
|
dirFallback?: ParsedFilter
|
|
69
|
+
/** `<pattern>{<dir>}`: the `{<dir>}` path form the name matches are narrowed to. */
|
|
70
|
+
within?: ParsedFilter
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export function parseFilter(
|
|
@@ -175,6 +177,27 @@ export function parseFilter(
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// `<pattern>{<dir>}`: the packages the name selects in the directory, as
|
|
181
|
+
// pnpm and Turbo read it (turborepo#887, `pattern{foo}[master]`). Read as
|
|
182
|
+
// one name glob, it matched nothing.
|
|
183
|
+
const named = /^([^{}]+)\{([^{}]*)\}$/.exec(s)
|
|
184
|
+
if (named !== null && !named[1]!.startsWith('./') && named[1] !== '.') {
|
|
185
|
+
if (named[2]!.trim() === '') throw new UserError(`filter "${raw}" names no project`)
|
|
186
|
+
return {
|
|
187
|
+
raw,
|
|
188
|
+
negate,
|
|
189
|
+
withDeps,
|
|
190
|
+
withDependents,
|
|
191
|
+
onlyDeps,
|
|
192
|
+
onlyDependents,
|
|
193
|
+
isPath: false,
|
|
194
|
+
matcher: named[1]!,
|
|
195
|
+
within: parseFilter(`{${named[2]}}`, workspaceRoot, raw),
|
|
196
|
+
...(gitSince !== undefined ? { gitSince } : {}),
|
|
197
|
+
...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
178
201
|
let isPath = false
|
|
179
202
|
let matcher = s
|
|
180
203
|
let pathGlob: Bun.Glob | undefined
|
|
@@ -266,6 +289,11 @@ function matchSelector(
|
|
|
266
289
|
projects: ProjectMeta[],
|
|
267
290
|
tags: ProjectTags | undefined,
|
|
268
291
|
): string[] {
|
|
292
|
+
if (filter.within !== undefined) {
|
|
293
|
+
const { within, ...name } = filter
|
|
294
|
+
const inDir = new Set(matchSelector(within, projects, tags))
|
|
295
|
+
return matchSelector(name, projects, tags).filter((n) => inDir.has(n))
|
|
296
|
+
}
|
|
269
297
|
const out: string[] = []
|
|
270
298
|
if (filter.tag === true) {
|
|
271
299
|
const re = compileNameGlob(filter.matcher)
|
package/src/workspace/index.ts
CHANGED
|
@@ -32,6 +32,15 @@ export {
|
|
|
32
32
|
type Lockfile,
|
|
33
33
|
type LockedProject,
|
|
34
34
|
} from './lockfile.js'
|
|
35
|
+
export {
|
|
36
|
+
DEPENDENCIES_LOCK_NAME,
|
|
37
|
+
dependenciesLock,
|
|
38
|
+
parseDependenciesLock,
|
|
39
|
+
readDependenciesLock,
|
|
40
|
+
recordedDigests,
|
|
41
|
+
writeDependenciesLock,
|
|
42
|
+
type DependenciesLock,
|
|
43
|
+
} from './dependencies-lock.js'
|
|
35
44
|
export {
|
|
36
45
|
ConfigsRefused,
|
|
37
46
|
loadProjectConfig,
|
|
@@ -21,7 +21,7 @@ import { validateProjectConfig } from './config-schema.js'
|
|
|
21
21
|
import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
|
|
22
22
|
|
|
23
23
|
export const LOCKFILE_NAME = '.vx/projects.lock.json'
|
|
24
|
-
export const LOCKFILE_VERSION =
|
|
24
|
+
export const LOCKFILE_VERSION = 5
|
|
25
25
|
|
|
26
26
|
/** One project as the lock holds it. */
|
|
27
27
|
export interface LockedProject {
|
|
@@ -55,14 +55,19 @@ export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
|
|
|
55
55
|
* raw TypeError; asked only on that crash's path, so a run pays nothing.
|
|
56
56
|
*/
|
|
57
57
|
export function lockEntryBroken(root: string): boolean {
|
|
58
|
-
let
|
|
58
|
+
let lock: Pooled
|
|
59
59
|
try {
|
|
60
|
-
|
|
61
|
-
.projects
|
|
60
|
+
lock = JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as Pooled
|
|
62
61
|
} catch {
|
|
63
62
|
return false
|
|
64
63
|
}
|
|
65
|
-
if (typeof projects !== 'object' || projects === null) return false
|
|
64
|
+
if (typeof lock?.projects !== 'object' || lock.projects === null) return false
|
|
65
|
+
try {
|
|
66
|
+
pooledIn(lock)
|
|
67
|
+
} catch {
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
const projects = lock.projects
|
|
66
71
|
for (const [dir, entry] of Object.entries(projects)) {
|
|
67
72
|
try {
|
|
68
73
|
validateProjectConfig((entry as LockedProject).config, `${LOCKFILE_NAME}: "${dir}"`, null)
|
|
@@ -158,10 +163,157 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
158
163
|
)
|
|
159
164
|
}
|
|
160
165
|
}
|
|
166
|
+
pooledIn(parsed as Pooled)
|
|
161
167
|
digests.set(parsed as Lockfile, xxh3hex(text))
|
|
162
168
|
return parsed as Lockfile
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The project entries of a lock's bytes with its pool put back, or null
|
|
173
|
+
* for bytes that do not parse as a lock: an entry compares across two
|
|
174
|
+
* locks only once both are read the same way.
|
|
175
|
+
*/
|
|
176
|
+
export function lockEntries(bytes: Uint8Array | null): Record<string, unknown> | null {
|
|
177
|
+
if (bytes === null) return null
|
|
178
|
+
try {
|
|
179
|
+
const lock = JSON.parse(new TextDecoder().decode(bytes)) as Pooled
|
|
180
|
+
if (typeof lock?.projects !== 'object' || lock.projects === null) return null
|
|
181
|
+
pooledIn(lock)
|
|
182
|
+
return lock.projects
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A task or task part that two places in the lock hold alike is written once,
|
|
190
|
+
* in `pool`, and each place holds its index. Each slot is one the schema
|
|
191
|
+
* types as an object, so a number there can only be an index. Big
|
|
192
|
+
* workspaces repeat the same tasks: 2,000 projects of five tasks read in
|
|
193
|
+
* 0.71 ms against 2.62 ms, a fifth of the bytes. A run reads them shared,
|
|
194
|
+
* once; a stage that edits a config in place copies it first.
|
|
195
|
+
*/
|
|
196
|
+
const POOLS = ['env', 'sandbox', 'exec', 'inputs', 'cache', 'task'] as const
|
|
197
|
+
type PoolName = (typeof POOLS)[number]
|
|
198
|
+
type Part = Exclude<PoolName, 'task'>
|
|
199
|
+
type Pools = Partial<Record<PoolName, unknown[]>>
|
|
200
|
+
type Pooled = { pool?: Pools; projects: Record<string, LockedProject> }
|
|
201
|
+
type Obj = Record<string, unknown>
|
|
202
|
+
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
203
|
+
const TASK_PARTS = ['exec', 'inputs', 'cache'] as const
|
|
204
|
+
const EXEC_PARTS = ['env', 'sandbox'] as const
|
|
205
|
+
|
|
206
|
+
/** `lock.projects` with every index replaced by the value it names, in place. */
|
|
207
|
+
function pooledIn(lock: Pooled): void {
|
|
208
|
+
const pool = lock.pool
|
|
209
|
+
delete lock.pool
|
|
210
|
+
if (pool === undefined) return
|
|
211
|
+
if (!isObj(pool) || Object.values(pool).some((list) => !Array.isArray(list))) {
|
|
212
|
+
throw new UserError(
|
|
213
|
+
`${LOCKFILE_NAME}: \`pool\` must be an object of lists — re-run \`vx lock\``,
|
|
214
|
+
'VX_E_LOCK',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
const at = (name: PoolName, i: number): unknown => {
|
|
218
|
+
const hit = pool[name]?.[i]
|
|
219
|
+
if (hit === undefined) {
|
|
220
|
+
throw new UserError(
|
|
221
|
+
`${LOCKFILE_NAME}: no ${name} ${i} in its pool — re-run \`vx lock\``,
|
|
222
|
+
'VX_E_LOCK',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return hit
|
|
226
|
+
}
|
|
227
|
+
const parts = (o: unknown, names: readonly Part[]): void => {
|
|
228
|
+
if (!isObj(o)) return
|
|
229
|
+
for (const n of names) if (typeof o[n] === 'number') o[n] = at(n, o[n])
|
|
230
|
+
}
|
|
231
|
+
const task = (t: unknown): void => {
|
|
232
|
+
parts(t, TASK_PARTS)
|
|
233
|
+
if (isObj(t)) parts(t['exec'], EXEC_PARTS)
|
|
234
|
+
}
|
|
235
|
+
// Inner pools first: a pooled task's exec may itself be pooled.
|
|
236
|
+
for (const e of pool.exec ?? []) parts(e, EXEC_PARTS)
|
|
237
|
+
for (const t of pool.task ?? []) task(t)
|
|
238
|
+
for (const entry of Object.values(lock.projects)) {
|
|
239
|
+
const tasks = (entry as { config?: { tasks?: unknown } } | null)?.config?.tasks
|
|
240
|
+
if (!isObj(tasks)) continue
|
|
241
|
+
for (const name of Object.keys(tasks)) {
|
|
242
|
+
const t = tasks[name]
|
|
243
|
+
if (typeof t === 'number') tasks[name] = at('task', t)
|
|
244
|
+
else task(t)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `projects` as written: each value two slots hold alike, once in `pool`. */
|
|
250
|
+
function pooledOut(projects: Record<string, LockedProject>): Pooled {
|
|
251
|
+
const seen = new Map<string, number>()
|
|
252
|
+
const keyOf = (name: PoolName, v: Obj): string => `${name}\0${JSON.stringify(v)}`
|
|
253
|
+
const count = (name: PoolName, v: unknown): void => {
|
|
254
|
+
if (!isObj(v)) return
|
|
255
|
+
const k = keyOf(name, v)
|
|
256
|
+
seen.set(k, (seen.get(k) ?? 0) + 1)
|
|
257
|
+
}
|
|
258
|
+
const tasksOf = (p: LockedProject): Obj | undefined =>
|
|
259
|
+
isObj(p.config.tasks) ? p.config.tasks : undefined
|
|
260
|
+
for (const p of Object.values(projects)) {
|
|
261
|
+
for (const t of Object.values(tasksOf(p) ?? {})) {
|
|
262
|
+
if (!isObj(t)) continue
|
|
263
|
+
count('task', t)
|
|
264
|
+
for (const n of TASK_PARTS) count(n, t[n])
|
|
265
|
+
if (isObj(t['exec'])) for (const n of EXEC_PARTS) count(n, t['exec'][n])
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const pool: Pools = {}
|
|
269
|
+
const index = new Map<string, number>()
|
|
270
|
+
const out = (name: PoolName, v: unknown, inner: (v: Obj) => Obj): unknown => {
|
|
271
|
+
if (!isObj(v)) return v
|
|
272
|
+
const k = keyOf(name, v)
|
|
273
|
+
if ((seen.get(k) ?? 0) < 2) return inner(v)
|
|
274
|
+
let i = index.get(k)
|
|
275
|
+
if (i === undefined) {
|
|
276
|
+
const list = (pool[name] ??= [])
|
|
277
|
+
index.set(k, (i = list.length))
|
|
278
|
+
list.push(inner(v))
|
|
279
|
+
}
|
|
280
|
+
return i
|
|
281
|
+
}
|
|
282
|
+
const same = (v: Obj): Obj => v
|
|
283
|
+
const withParts = (o: Obj, names: readonly Part[]): Obj => {
|
|
284
|
+
const r = { ...o }
|
|
285
|
+
for (const n of names) if (n in o) r[n] = out(n, o[n], n === 'exec' ? exec : same)
|
|
286
|
+
return r
|
|
287
|
+
}
|
|
288
|
+
const exec = (e: Obj): Obj => withParts(e, EXEC_PARTS)
|
|
289
|
+
const task = (t: Obj): Obj => withParts(t, TASK_PARTS)
|
|
290
|
+
// Entries, not assignment: a directory named `__proto__` set the prototype (D-152).
|
|
291
|
+
const written = Object.fromEntries(
|
|
292
|
+
Object.entries(projects).map(([dir, p]) => {
|
|
293
|
+
const tasks = tasksOf(p)
|
|
294
|
+
return [
|
|
295
|
+
dir,
|
|
296
|
+
tasks === undefined
|
|
297
|
+
? p
|
|
298
|
+
: {
|
|
299
|
+
...p,
|
|
300
|
+
config: {
|
|
301
|
+
...p.config,
|
|
302
|
+
tasks: Object.fromEntries(
|
|
303
|
+
Object.entries(tasks).map(([name, t]) => [name, out('task', t, task)]),
|
|
304
|
+
),
|
|
305
|
+
} as ProjectConfig,
|
|
306
|
+
},
|
|
307
|
+
]
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
const ordered: Pools = {}
|
|
311
|
+
for (const n of POOLS) if (pool[n] !== undefined) ordered[n] = pool[n]
|
|
312
|
+
return POOLS.some((n) => pool[n] !== undefined)
|
|
313
|
+
? { pool: ordered, projects: written }
|
|
314
|
+
: { projects: written }
|
|
315
|
+
}
|
|
316
|
+
|
|
165
317
|
/**
|
|
166
318
|
* `vx lock` evaluates and validates every config, then writes a digest of
|
|
167
319
|
* the bytes it wrote, under vx's version, as the last field: the lock's
|
|
@@ -194,7 +346,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
|
|
|
194
346
|
// Minified: the file only vx reads, and it loads faster than pretty
|
|
195
347
|
// JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
|
|
196
348
|
// beside each config is the copy for people.
|
|
197
|
-
const
|
|
349
|
+
const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
|
|
350
|
+
const body = JSON.stringify({ version: lock.version, ...pooledOut(sorted) })
|
|
198
351
|
await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
|
|
199
352
|
await rename(tmp, file)
|
|
200
353
|
} catch (err) {
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
refuseSymlink,
|
|
17
17
|
relPosix,
|
|
18
18
|
UserError,
|
|
19
|
-
ui,
|
|
20
19
|
writeNoFollow,
|
|
21
20
|
} from '../util/index.js'
|
|
21
|
+
import * as ui from '../ui/index.js'
|
|
22
22
|
import { VERSION } from '../version.js'
|
|
23
23
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
24
24
|
|
|
@@ -451,7 +451,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
451
451
|
...plan.notes,
|
|
452
452
|
]
|
|
453
453
|
if (args.json === true) {
|
|
454
|
-
|
|
454
|
+
ui.data(
|
|
455
|
+
'out',
|
|
455
456
|
`${JSON.stringify({
|
|
456
457
|
dry,
|
|
457
458
|
source,
|
|
@@ -472,7 +473,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
472
473
|
|
|
473
474
|
// The lock's own words become a row; a refusal prints above the report.
|
|
474
475
|
const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
|
|
475
|
-
if (lock !== undefined && lock.code !== 0)
|
|
476
|
+
if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
|
|
476
477
|
|
|
477
478
|
const rows: ui.FieldRow[] = [['from', source]]
|
|
478
479
|
if (dry) rows.push(['dry run', 'nothing written'])
|
|
@@ -159,6 +159,9 @@ export async function resolveStoreRoot(
|
|
|
159
159
|
let id = repoIds.get(root)
|
|
160
160
|
if (id === undefined) repoIds.set(root, (id = repoIdOf(root)))
|
|
161
161
|
const resolved = await id
|
|
162
|
+
// No identity yet (no commit, no remote) is asked again: the first
|
|
163
|
+
// commit gives one, and `vx watch` runs on in the same process.
|
|
164
|
+
if (resolved === null) repoIds.delete(root)
|
|
162
165
|
return resolved === null ? null : path.join(home, '.vx', resolved, 'cache')
|
|
163
166
|
}
|
|
164
167
|
|
package/src/util/colors.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// Tiny color helper — gates Bun.color through a `ColorSupport` token
|
|
2
|
-
// so the formatters stay pure. The token is computed once at logger
|
|
3
|
-
// construction; tests pass `{ enabled: false }` so assertions match
|
|
4
|
-
// plain strings.
|
|
5
|
-
//
|
|
6
|
-
// Bun.color('red', 'ansi') is broken in current Bun versions (emits
|
|
7
|
-
// the color number as a raw byte instead of decimal text), so we use
|
|
8
|
-
// 'ansi-16m' which emits 24-bit truecolor sequences. Every modern
|
|
9
|
-
// terminal supports them, and the `tput colors`-style downgrade isn't
|
|
10
|
-
// worth the complexity for our handful of UI accents.
|
|
11
|
-
|
|
12
|
-
const RESET = '\x1b[0m'
|
|
13
|
-
const BOLD = '\x1b[1m'
|
|
14
|
-
const DIM = '\x1b[2m'
|
|
15
|
-
const UNDERLINE = '\x1b[4m'
|
|
16
|
-
|
|
17
|
-
// State hues every report shares with the run summary.
|
|
18
|
-
export const SUCCESS = '#22c55e'
|
|
19
|
-
export const WARN = '#eab308'
|
|
20
|
-
export const ERROR = '#ef4444'
|
|
21
|
-
|
|
22
|
-
export interface ColorSupport {
|
|
23
|
-
enabled: boolean
|
|
24
|
-
/** The terminal's width, read live; none off a terminal. Bars and rules fit it. */
|
|
25
|
-
readonly columns?: number | undefined
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PaintOptions {
|
|
29
|
-
bold?: boolean
|
|
30
|
-
dim?: boolean
|
|
31
|
-
underline?: boolean
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Standard env precedence:
|
|
36
|
-
* NO_COLOR non-empty → off (overrides FORCE_COLOR)
|
|
37
|
-
* FORCE_COLOR=0 or =false → off, even on a TTY
|
|
38
|
-
* FORCE_COLOR any other value → on (the empty string too)
|
|
39
|
-
* a CI whose log renders ANSI (`ciPaints`) → on
|
|
40
|
-
* else: on iff stdout is a TTY that is not `TERM=dumb`.
|
|
41
|
-
* `0` and `false` are the convention's "off" (supports-color, chalk,
|
|
42
|
-
* Node); reading every non-empty value as on painted a piped CI log that
|
|
43
|
-
* set `FORCE_COLOR=0` (nx#35292's bug).
|
|
44
|
-
*/
|
|
45
|
-
export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
|
|
46
|
-
const force = process.env['FORCE_COLOR']
|
|
47
|
-
const enabled = process.env['NO_COLOR']
|
|
48
|
-
? false
|
|
49
|
-
: force !== undefined
|
|
50
|
-
? force !== '0' && force !== 'false'
|
|
51
|
-
: ciPaints() || (stream.isTTY === true && !dumbTerminal())
|
|
52
|
-
return {
|
|
53
|
-
enabled,
|
|
54
|
-
get columns() {
|
|
55
|
-
return stream.isTTY === true ? stream.columns : undefined
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* A CI whose log viewer renders ANSI colour though the job's output is a
|
|
62
|
-
* pipe (supports-color reads the same variables): its log was plain.
|
|
63
|
-
*/
|
|
64
|
-
function ciPaints(): boolean {
|
|
65
|
-
const env = process.env
|
|
66
|
-
return (
|
|
67
|
-
env['GITHUB_ACTIONS'] === 'true' ||
|
|
68
|
-
env['GITLAB_CI'] === 'true' ||
|
|
69
|
-
env['BUILDKITE'] === 'true' ||
|
|
70
|
-
env['CIRCLECI'] === 'true'
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
|
|
76
|
-
* nor move the cursor: vx prints to it as off a terminal.
|
|
77
|
-
*/
|
|
78
|
-
export function dumbTerminal(): boolean {
|
|
79
|
-
return process.env['TERM'] === 'dumb'
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Bun.color() is pure for a given (hex, mode) pair but does FFI work
|
|
83
|
-
// on each call. The framed-output renderer paints the same handful of
|
|
84
|
-
// accent colors thousands of times per run; memoizing here turns those
|
|
85
|
-
// into hash-map hits.
|
|
86
|
-
const ansiCache = new Map<string, string>()
|
|
87
|
-
function ansiFor(color: string): string {
|
|
88
|
-
const cached = ansiCache.get(color)
|
|
89
|
-
if (cached !== undefined) return cached
|
|
90
|
-
const v = Bun.color(color, 'ansi-16m') ?? ''
|
|
91
|
-
ansiCache.set(color, v)
|
|
92
|
-
return v
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function paint(
|
|
96
|
-
color: string,
|
|
97
|
-
text: string,
|
|
98
|
-
colors: ColorSupport,
|
|
99
|
-
opts: PaintOptions = {},
|
|
100
|
-
): string {
|
|
101
|
-
// Empty text paints nothing: an escape pair around no text is bytes and no
|
|
102
|
-
// mark (a full bar's empty remainder).
|
|
103
|
-
if (!colors.enabled || text === '') return text
|
|
104
|
-
let prefix = ''
|
|
105
|
-
if (opts.bold) prefix += BOLD
|
|
106
|
-
if (opts.dim) prefix += DIM
|
|
107
|
-
if (opts.underline) prefix += UNDERLINE
|
|
108
|
-
if (color) prefix += ansiFor(color)
|
|
109
|
-
if (prefix.length === 0) return text
|
|
110
|
-
return `${prefix}${text}${RESET}`
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Identity hues for the two halves of a `project#task` id, never a status
|
|
115
|
-
* colour: one name, one hue, in every run, list and row (2026-10-15:
|
|
116
|
-
* every `#build` matches every other `#build`).
|
|
117
|
-
*/
|
|
118
|
-
const IDENTITY_PALETTE = [
|
|
119
|
-
'#a78bfa', // violet-400
|
|
120
|
-
'#60a5fa', // blue-400
|
|
121
|
-
'#e879f9', // fuchsia-400
|
|
122
|
-
'#818cf8', // indigo-400
|
|
123
|
-
'#c084fc', // purple-400
|
|
124
|
-
'#93c5fd', // blue-300
|
|
125
|
-
'#f472b6', // pink-400
|
|
126
|
-
] as const
|
|
127
|
-
|
|
128
|
-
/** A project's or a target's hue: a fold of the name into the palette. */
|
|
129
|
-
export function identityColor(name: string): string {
|
|
130
|
-
let h = 0
|
|
131
|
-
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
|
|
132
|
-
return IDENTITY_PALETTE[h % IDENTITY_PALETTE.length]!
|
|
133
|
-
}
|
package/src/util/report.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
// The bars' label column: 2 indent + 8 label + 2 gap.
|
|
9
|
-
const LABEL = 12
|
|
10
|
-
|
|
11
|
-
/** A meter's cells: 50, or fewer so the bar fits a narrower terminal. */
|
|
12
|
-
export function barWidth(colors: ColorSupport): number {
|
|
13
|
-
const columns = colors.columns
|
|
14
|
-
return columns === undefined ? 50 : Math.max(10, Math.min(50, columns - LABEL))
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** A rule's width, flush with the bars: the footer's and a task frame's section rules. */
|
|
18
|
-
export function ruleWidth(colors: ColorSupport): number {
|
|
19
|
-
return LABEL + barWidth(colors)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** `─ <mark> ` + dashes, flush with the bars. Shared by the run summary and every report. */
|
|
23
|
-
export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
24
|
-
const head = `─ `
|
|
25
|
-
const width = ruleWidth(colors)
|
|
26
|
-
const dashes = '─'.repeat(Math.max(8, width - 3 - mark.length))
|
|
27
|
-
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
28
|
-
const chunks = 8
|
|
29
|
-
// The gradient spans the rule as if its mark were `vx`.
|
|
30
|
-
const per = Math.ceil((width - 5) / chunks)
|
|
31
|
-
let out = ''
|
|
32
|
-
for (let i = 0; i < chunks; i++) {
|
|
33
|
-
const seg = dashes.slice(i * per, (i + 1) * per)
|
|
34
|
-
if (seg.length === 0) break
|
|
35
|
-
const f = i / (chunks - 1)
|
|
36
|
-
const hex = `#${GRADIENT_FROM.map((c, j) =>
|
|
37
|
-
Math.round(c + (GRADIENT_TO[j]! - c) * f)
|
|
38
|
-
.toString(16)
|
|
39
|
-
.padStart(2, '0'),
|
|
40
|
-
).join('')}`
|
|
41
|
-
out += paint(hex, seg, colors)
|
|
42
|
-
}
|
|
43
|
-
return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
|
|
44
|
-
}
|