@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- 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 +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- 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 +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- 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-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- 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 +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- 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
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// given, the base set is "all projects" and excluded packages are removed.
|
|
23
23
|
|
|
24
24
|
import path from 'node:path'
|
|
25
|
-
import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
|
|
25
|
+
import { BUN_GLOB_WILDCARDS, hasControl, printable, relPosix, UserError } from '../util/index.js'
|
|
26
26
|
import type { PackageGraph } from './package-graph.js'
|
|
27
27
|
import { canonicalDir } from './canonical-dir.js'
|
|
28
28
|
import type { ProjectMeta } from './workspace.js'
|
|
@@ -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(
|
|
@@ -74,6 +76,13 @@ export function parseFilter(
|
|
|
74
76
|
/** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
|
|
75
77
|
typed = raw,
|
|
76
78
|
): ParsedFilter {
|
|
79
|
+
// No project name, path or git ref holds one; echoed raw, a newline split
|
|
80
|
+
// the refusal in two and an escape painted the terminal.
|
|
81
|
+
if (hasControl(raw)) {
|
|
82
|
+
throw new UserError(
|
|
83
|
+
`filter "${printable(raw)}" holds a control character: no project name, path or git ref does`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
77
86
|
let s = raw
|
|
78
87
|
const negate = s.startsWith('!')
|
|
79
88
|
if (negate) s = s.slice(1)
|
|
@@ -175,6 +184,27 @@ export function parseFilter(
|
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
|
|
187
|
+
// `<pattern>{<dir>}`: the packages the name selects in the directory, as
|
|
188
|
+
// pnpm and Turbo read it (turborepo#887, `pattern{foo}[master]`). Read as
|
|
189
|
+
// one name glob, it matched nothing.
|
|
190
|
+
const named = /^([^{}]+)\{([^{}]*)\}$/.exec(s)
|
|
191
|
+
if (named !== null && !named[1]!.startsWith('./') && named[1] !== '.') {
|
|
192
|
+
if (named[2]!.trim() === '') throw new UserError(`filter "${raw}" names no project`)
|
|
193
|
+
return {
|
|
194
|
+
raw,
|
|
195
|
+
negate,
|
|
196
|
+
withDeps,
|
|
197
|
+
withDependents,
|
|
198
|
+
onlyDeps,
|
|
199
|
+
onlyDependents,
|
|
200
|
+
isPath: false,
|
|
201
|
+
matcher: named[1]!,
|
|
202
|
+
within: parseFilter(`{${named[2]}}`, workspaceRoot, raw),
|
|
203
|
+
...(gitSince !== undefined ? { gitSince } : {}),
|
|
204
|
+
...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
let isPath = false
|
|
179
209
|
let matcher = s
|
|
180
210
|
let pathGlob: Bun.Glob | undefined
|
|
@@ -266,6 +296,11 @@ function matchSelector(
|
|
|
266
296
|
projects: ProjectMeta[],
|
|
267
297
|
tags: ProjectTags | undefined,
|
|
268
298
|
): string[] {
|
|
299
|
+
if (filter.within !== undefined) {
|
|
300
|
+
const { within, ...name } = filter
|
|
301
|
+
const inDir = new Set(matchSelector(within, projects, tags))
|
|
302
|
+
return matchSelector(name, projects, tags).filter((n) => inDir.has(n))
|
|
303
|
+
}
|
|
269
304
|
const out: string[] = []
|
|
270
305
|
if (filter.tag === true) {
|
|
271
306
|
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
|
|
|
@@ -42,6 +42,10 @@ export function isStarterConfig(file: string): boolean {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** A C0 or C1 control, or DEL. */
|
|
46
|
+
// oxlint-disable-next-line no-control-regex
|
|
47
|
+
const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* Escape an arbitrary string into a single-quoted TS literal. Escapes
|
|
47
51
|
* backslash + quote AND raw newlines/CR — a value with an embedded newline
|
|
@@ -55,7 +59,10 @@ export function quoteTsLiteral(s: string): string {
|
|
|
55
59
|
.replaceAll('\\', '\\\\')
|
|
56
60
|
.replaceAll("'", "\\'")
|
|
57
61
|
.replaceAll('\n', '\\n')
|
|
58
|
-
.replaceAll('\r', '\\r')
|
|
62
|
+
.replaceAll('\r', '\\r')
|
|
63
|
+
// Any other control as its escape: a script's raw ESC landed in the
|
|
64
|
+
// file as the byte, and `cat vx.config.ts` retitled the terminal.
|
|
65
|
+
.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)}'`
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
@@ -451,7 +458,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
451
458
|
...plan.notes,
|
|
452
459
|
]
|
|
453
460
|
if (args.json === true) {
|
|
454
|
-
|
|
461
|
+
ui.data(
|
|
462
|
+
'out',
|
|
455
463
|
`${JSON.stringify({
|
|
456
464
|
dry,
|
|
457
465
|
source,
|
|
@@ -472,7 +480,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
472
480
|
|
|
473
481
|
// The lock's own words become a row; a refusal prints above the report.
|
|
474
482
|
const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
|
|
475
|
-
if (lock !== undefined && lock.code !== 0)
|
|
483
|
+
if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
|
|
476
484
|
|
|
477
485
|
const rows: ui.FieldRow[] = [['from', source]]
|
|
478
486
|
if (dry) rows.push(['dry run', 'nothing written'])
|
|
@@ -657,7 +665,7 @@ const EXAMPLE_BODY = `export default {
|
|
|
657
665
|
build: {
|
|
658
666
|
exec: { command: 'tsc -b' },
|
|
659
667
|
dependsOn: ['^build'],
|
|
660
|
-
inputs: { files: ['src/**'] },
|
|
668
|
+
inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] }, cache: true,
|
|
661
669
|
},
|
|
662
670
|
},
|
|
663
671
|
}`
|
|
@@ -746,7 +754,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
746
754
|
// The suggested block sits in the task, commented: deleting `// `
|
|
747
755
|
// after setting the real globs caches it, no copying from the TODO.
|
|
748
756
|
const block =
|
|
749
|
-
t.task['cache']
|
|
757
|
+
t.task['cache'] !== true
|
|
750
758
|
? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
|
|
751
759
|
: undefined
|
|
752
760
|
lines.push(
|
|
@@ -789,11 +797,10 @@ function ownConfigNamed(
|
|
|
789
797
|
return {
|
|
790
798
|
...p,
|
|
791
799
|
tasks: p.tasks.map((t) => {
|
|
792
|
-
const outputs =
|
|
800
|
+
const outputs = t.task?.['outputs'] as { files?: unknown } | undefined
|
|
793
801
|
if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
|
|
794
|
-
const cache = t.task!['cache'] as Record<string, unknown>
|
|
795
802
|
const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
|
|
796
|
-
return { ...t, task: { ...t.task,
|
|
803
|
+
return { ...t, task: { ...t.task, outputs: { ...outputs, files } } }
|
|
797
804
|
}),
|
|
798
805
|
}
|
|
799
806
|
}
|
|
@@ -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
|
|