@vzn/vx 0.0.50 → 0.0.51
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 +38 -31
- package/package.json +5 -5
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +18 -6
- package/src/cache/cache.ts +150 -67
- package/src/cache/chained-cache.ts +9 -2
- package/src/cache/file-hashes.ts +32 -3
- package/src/cache/git-inputs.ts +96 -11
- package/src/cache/index.ts +1 -0
- package/src/cache/layer.ts +28 -3
- package/src/cache/layered-cache.ts +7 -2
- package/src/cache/output-index.ts +16 -7
- package/src/cache/run-history.ts +5 -1
- package/src/cache/zstd.ts +40 -8
- package/src/cli/cache.ts +35 -9
- package/src/cli/completions.ts +10 -7
- package/src/cli/help.ts +48 -4
- package/src/cli/index.ts +5 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/plugin-commands.ts +10 -2
- package/src/cli/run.ts +1 -0
- package/src/cli/select.ts +29 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +35 -18
- package/src/config.ts +18 -5
- package/src/exec/executor.ts +3 -2
- package/src/exec/index.ts +1 -1
- package/src/exec/runner.ts +102 -24
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +149 -40
- package/src/exec/sandbox-violations.ts +42 -10
- package/src/graph/scheduler.ts +11 -5
- package/src/graph/task-graph.ts +96 -12
- package/src/orchestrator/admission.ts +72 -17
- package/src/orchestrator/execute-task.ts +39 -25
- package/src/orchestrator/hit-restore.ts +7 -1
- package/src/orchestrator/miss-save.ts +29 -1
- package/src/orchestrator/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +1 -0
- package/src/orchestrator/run.ts +75 -12
- package/src/orchestrator/sandbox-request.ts +19 -10
- package/src/orchestrator/summary.ts +26 -15
- package/src/orchestrator/task-hash.ts +7 -2
- package/src/orchestrator/telemetry.ts +14 -4
- package/src/util/paths.ts +16 -9
- package/src/workspace/affected.ts +201 -38
- package/src/workspace/config-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
package/src/cli/select.ts
CHANGED
|
@@ -240,6 +240,7 @@ export async function resolveFilters(
|
|
|
240
240
|
workspaceGlobOwners: (changed) =>
|
|
241
241
|
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
242
242
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
243
|
+
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
243
244
|
})
|
|
244
245
|
affectedByFilter.set(f, names)
|
|
245
246
|
} catch (err) {
|
|
@@ -325,11 +326,15 @@ export interface PickedTask {
|
|
|
325
326
|
description?: string
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The task the user picks from a numbered menu; null when nothing was
|
|
331
|
+
* picked (said on stderr), `'interrupted'` for a Ctrl-C at the prompt.
|
|
332
|
+
*/
|
|
328
333
|
export async function pickTask(
|
|
329
334
|
cwd: string,
|
|
330
335
|
io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
|
|
331
336
|
load: CliLoadOptions = {},
|
|
332
|
-
): Promise<PickedTask | null> {
|
|
337
|
+
): Promise<PickedTask | null | 'interrupted'> {
|
|
333
338
|
const projects = await loadWorkspaceProjects(cwd)
|
|
334
339
|
// The staged load: a task a `project` plugin gave a config-less package
|
|
335
340
|
// is on the menu, as it is in a run.
|
|
@@ -362,8 +367,30 @@ export async function pickTask(
|
|
|
362
367
|
input: io.input ?? process.stdin,
|
|
363
368
|
output: io.output ?? process.stdout,
|
|
364
369
|
})
|
|
370
|
+
// On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
|
|
371
|
+
// rejects the pending question with an AbortError — which reached the
|
|
372
|
+
// user as `vx: AbortError: Aborted with Ctrl+C` and a stack, exit 1.
|
|
373
|
+
// A SIGINT listener tells the two apart: Ctrl-C is an interrupt (exit 130,
|
|
374
|
+
// as a run's), Ctrl-D an answer that never came.
|
|
375
|
+
const abort = new AbortController()
|
|
376
|
+
let interrupted = false
|
|
377
|
+
rl.on('SIGINT', () => {
|
|
378
|
+
interrupted = true
|
|
379
|
+
abort.abort()
|
|
380
|
+
})
|
|
365
381
|
try {
|
|
366
|
-
|
|
382
|
+
let answer: string
|
|
383
|
+
try {
|
|
384
|
+
answer = (
|
|
385
|
+
await rl.question(`Pick a task [1-${entries.length}]: `, { signal: abort.signal })
|
|
386
|
+
).trim()
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (!(err instanceof Error) || err.name !== 'AbortError') throw err
|
|
389
|
+
if (interrupted) return 'interrupted'
|
|
390
|
+
out.write('\n')
|
|
391
|
+
process.stderr.write(`vx run: no task picked\n`)
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
367
394
|
const n = Number(answer)
|
|
368
395
|
if (!Number.isInteger(n) || n < 1 || n > entries.length) {
|
|
369
396
|
process.stderr.write(`vx run: invalid selection: ${answer}\n`)
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
// Asks the GitHub release API for the asset of this os/arch and the
|
|
3
3
|
// SHA-256 digest it publishes, downloads the asset, verifies the digest,
|
|
4
4
|
// writes next to the current executable, atomic rename over it. The
|
|
5
|
-
// digest is what makes a cut transfer (a proxy that drops a
|
|
6
|
-
// a disk that fills)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// digest is what makes a cut or corrupted transfer (a proxy that drops a
|
|
6
|
+
// connection, a disk that fills) a refusal instead of a binary that does
|
|
7
|
+
// not start: the rename is the last step, and a mismatch replaces
|
|
8
|
+
// nothing. It guards the TRANSFER only: the digest comes from the same
|
|
9
|
+
// release API as the download URL, and GitHub recomputes it when an asset
|
|
10
|
+
// is uploaded, so whoever can replace the asset replaces its digest too
|
|
11
|
+
// (item 1096). Nothing here verifies who built the binary.
|
|
9
12
|
// Named `upgrade` (not `update`) per CLI convention: bun upgrade,
|
|
10
13
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
11
14
|
|
|
12
|
-
import { chmod, rename, rm } from 'node:fs/promises'
|
|
15
|
+
import { chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
|
|
13
16
|
import { seeHelp } from './help.js'
|
|
14
17
|
import { UserError } from '../util/index.js'
|
|
15
18
|
import { VERSION } from '../version.js'
|
|
@@ -70,6 +73,8 @@ function assetName(): string {
|
|
|
70
73
|
|
|
71
74
|
/** What the release API says about the asset this platform installs. */
|
|
72
75
|
export interface ReleaseAsset {
|
|
76
|
+
/** The release's tag, as the API names it (`v0.4.0`). */
|
|
77
|
+
tag: string
|
|
73
78
|
url: string
|
|
74
79
|
/** Lower-case hex SHA-256 of the asset's bytes. */
|
|
75
80
|
sha256: string
|
|
@@ -113,7 +118,16 @@ export function releaseAsset(release: unknown, name: string): ReleaseAsset {
|
|
|
113
118
|
`vx upgrade: release ${tag} publishes no SHA-256 digest for ${name} — nothing to verify the download against, so it is not attempted`,
|
|
114
119
|
)
|
|
115
120
|
}
|
|
116
|
-
return { url, sha256: m[1]!.toLowerCase() }
|
|
121
|
+
return { tag, url, sha256: m[1]!.toLowerCase() }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a release tag names the version this binary is: the upgrade
|
|
126
|
+
* re-downloaded and replaced a binary with itself, printing `X → latest`
|
|
127
|
+
* (item 1098). Tags are `v<version>`; a bare version is accepted too.
|
|
128
|
+
*/
|
|
129
|
+
export function isThisVersion(tag: string, version: string): boolean {
|
|
130
|
+
return tag === `v${version}` || tag === version
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/**
|
|
@@ -134,8 +148,26 @@ async function fetchOrRefuse(url: string, init: RequestInit, what: string): Prom
|
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A response's body, read to the end. The headers can arrive and the
|
|
153
|
+
* connection drop before the body does (a proxy that cuts a transfer):
|
|
154
|
+
* Bun rejects the read with its own `TypeError` (`ECONNRESET`, "The socket
|
|
155
|
+
* connection was closed unexpectedly"), which `fetchOrRefuse` never sees
|
|
156
|
+
* and the CLI printed as an internal error with a stack.
|
|
157
|
+
*/
|
|
158
|
+
async function readOrRefuse<T>(read: () => Promise<T>, url: string, what: string): Promise<T> {
|
|
159
|
+
try {
|
|
160
|
+
return await read()
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
163
|
+
throw new UserError(
|
|
164
|
+
`vx upgrade: could not ${what} from ${new URL(url).host} (${message}) — nothing replaced; check the network or the proxy and re-run`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
137
169
|
/** The release document for `latest` or a tag, from the GitHub API. */
|
|
138
|
-
async function fetchRelease(tag: string | undefined): Promise<unknown> {
|
|
170
|
+
export async function fetchRelease(tag: string | undefined): Promise<unknown> {
|
|
139
171
|
const url =
|
|
140
172
|
tag === undefined
|
|
141
173
|
? `https://api.github.com/repos/${REPO}/releases/latest`
|
|
@@ -149,25 +181,47 @@ async function fetchRelease(tag: string | undefined): Promise<unknown> {
|
|
|
149
181
|
'read the release',
|
|
150
182
|
)
|
|
151
183
|
if (!res.ok) {
|
|
184
|
+
// GitHub answers an unauthenticated address past its hourly budget with
|
|
185
|
+
// 403 (or 429) and says so in the headers; "could not read the release
|
|
186
|
+
// (403)" read as a permissions fault (item 1098).
|
|
187
|
+
const reset = Number(res.headers.get('x-ratelimit-reset'))
|
|
188
|
+
const limited =
|
|
189
|
+
(res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0'
|
|
152
190
|
throw new UserError(
|
|
153
|
-
|
|
191
|
+
limited
|
|
192
|
+
? `vx upgrade: GitHub's API rate limit for this address is spent${Number.isFinite(reset) && reset > 0 ? ` until ${new Date(reset * 1000).toISOString()}` : ''} — re-run after that`
|
|
193
|
+
: `vx upgrade: could not read the release (${res.status}) — ${url}${res.status === 404 && tag !== undefined ? ` (is ${tag} a release tag?)` : ''}`,
|
|
154
194
|
)
|
|
155
195
|
}
|
|
156
|
-
|
|
196
|
+
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
197
|
+
// page served with a 200) is the same refusal as a cut one.
|
|
198
|
+
return await readOrRefuse(() => res.json() as Promise<unknown>, url, 'read the release')
|
|
157
199
|
}
|
|
158
200
|
|
|
159
201
|
/**
|
|
160
202
|
* Download `url`, verify its SHA-256 against `sha256`, and atomically
|
|
161
|
-
* replace `dest` with it
|
|
162
|
-
*
|
|
163
|
-
*
|
|
203
|
+
* replace `dest` with it, keeping `dest`'s mode (and, as root, its owner):
|
|
204
|
+
* a 0750 install came back 0755 and owned by whoever upgraded (item 1097).
|
|
205
|
+
* `starts`, when given, is asked of the replaced binary; false puts the old
|
|
206
|
+
* one back, since the digest proves the bytes, not that this machine can
|
|
207
|
+
* run them (a CPU below the build's target, a `noexec` mount), and the
|
|
208
|
+
* upgrade reported success over a vx that no longer started. Exported for
|
|
209
|
+
* tests (which stub `fetch` and point `dest` at a tmp file); the CLI wires
|
|
210
|
+
* it to the release asset and process.execPath.
|
|
164
211
|
*/
|
|
165
|
-
export async function replaceBinary(
|
|
212
|
+
export async function replaceBinary(
|
|
213
|
+
dest: string,
|
|
214
|
+
url: string,
|
|
215
|
+
sha256: string,
|
|
216
|
+
starts?: (dest: string) => boolean,
|
|
217
|
+
): Promise<void> {
|
|
166
218
|
const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release asset')
|
|
167
219
|
if (!res.ok) {
|
|
168
220
|
throw new UserError(`vx upgrade: download failed (${res.status}) — ${url}`)
|
|
169
221
|
}
|
|
170
|
-
const bytes = new Uint8Array(
|
|
222
|
+
const bytes = new Uint8Array(
|
|
223
|
+
await readOrRefuse(() => res.arrayBuffer(), url, 'download the release asset'),
|
|
224
|
+
)
|
|
171
225
|
if (bytes.byteLength === 0) {
|
|
172
226
|
throw new UserError(`vx upgrade: empty download — ${url}`)
|
|
173
227
|
}
|
|
@@ -178,27 +232,54 @@ export async function replaceBinary(dest: string, url: string, sha256: string):
|
|
|
178
232
|
)
|
|
179
233
|
}
|
|
180
234
|
const tmp = `${dest}.upgrade-${process.pid}`
|
|
235
|
+
const old = `${dest}.previous-${process.pid}`
|
|
236
|
+
let kept = false
|
|
181
237
|
try {
|
|
238
|
+
const was = await stat(dest).catch(() => null)
|
|
182
239
|
await Bun.write(tmp, bytes)
|
|
183
|
-
|
|
240
|
+
// Its mode, executable wherever it is readable: a 0750 install stays
|
|
241
|
+
// group-only, and a binary is never left without its execute bits.
|
|
242
|
+
await chmod(tmp, was === null ? 0o755 : (was.mode & 0o7777) | ((was.mode & 0o444) >> 2))
|
|
243
|
+
if (was !== null && process.getuid?.() === 0) await chown(tmp, was.uid, was.gid)
|
|
244
|
+
// A second name for the binary being replaced, so a new one that does
|
|
245
|
+
// not start can be put back; the rename below stays the one atomic swap.
|
|
246
|
+
if (starts !== undefined && was !== null) {
|
|
247
|
+
await link(dest, old)
|
|
248
|
+
kept = true
|
|
249
|
+
}
|
|
184
250
|
await rename(tmp, dest)
|
|
185
251
|
} catch (err) {
|
|
186
252
|
await rm(tmp, { force: true })
|
|
253
|
+
if (kept) await rm(old, { force: true })
|
|
187
254
|
const msg = err instanceof Error ? err.message : String(err)
|
|
188
255
|
throw new UserError(
|
|
189
256
|
`vx upgrade: could not replace ${dest} (${msg}) — ` +
|
|
190
257
|
`check permissions, or reinstall with npm install -g @vzn/vx`,
|
|
191
258
|
)
|
|
192
259
|
}
|
|
260
|
+
if (starts === undefined || starts(dest)) {
|
|
261
|
+
if (kept) await rm(old, { force: true })
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (kept) await rename(old, dest)
|
|
265
|
+
throw new UserError(
|
|
266
|
+
`vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
|
|
267
|
+
)
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
196
|
-
const tag = args.find((a) => !a.startsWith('-'))
|
|
197
271
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
198
272
|
if (unknown !== undefined) {
|
|
199
273
|
process.stderr.write(`vx upgrade: unknown flag: ${unknown}${seeHelp('upgrade')}\n`)
|
|
200
274
|
return 1
|
|
201
275
|
}
|
|
276
|
+
// One release is installed; a second tag was dropped without a word and
|
|
277
|
+
// the first one went in (`vx upgrade v0.0.22 v0.0.23`).
|
|
278
|
+
const [tag, extra] = args
|
|
279
|
+
if (extra !== undefined) {
|
|
280
|
+
process.stderr.write(`vx upgrade: unexpected argument: ${extra}${seeHelp('upgrade')}\n`)
|
|
281
|
+
return 1
|
|
282
|
+
}
|
|
202
283
|
if (!isCompiledBinary()) {
|
|
203
284
|
throw new UserError(
|
|
204
285
|
'vx upgrade only works for the compiled binary. ' +
|
|
@@ -214,12 +295,19 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
214
295
|
)
|
|
215
296
|
}
|
|
216
297
|
const asset = releaseAsset(await fetchRelease(tag), assetName())
|
|
298
|
+
if (isThisVersion(asset.tag, VERSION)) {
|
|
299
|
+
process.stdout.write(`vx upgrade: already at ${VERSION} (${asset.tag}) — nothing to do\n`)
|
|
300
|
+
return 0
|
|
301
|
+
}
|
|
217
302
|
process.stdout.write(`vx upgrade: ${VERSION} → ${tag ?? 'latest'} (${dest})\n`)
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
303
|
+
// The replaced binary's own version: the new build speaks for itself
|
|
304
|
+
// rather than this process guessing, and one that cannot is rolled back.
|
|
305
|
+
let installed = ''
|
|
306
|
+
await replaceBinary(dest, asset.url, asset.sha256, (bin) => {
|
|
307
|
+
const proc = Bun.spawnSync({ cmd: [bin, '--version'], stdout: 'pipe', stderr: 'pipe' })
|
|
308
|
+
installed = new TextDecoder().decode(proc.stdout).trim()
|
|
309
|
+
return proc.exitCode === 0 && installed.startsWith('vx ')
|
|
310
|
+
})
|
|
311
|
+
process.stdout.write(`vx upgrade: installed ${installed}\n`)
|
|
224
312
|
return 0
|
|
225
313
|
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
xxh3,
|
|
24
24
|
} from '../util/index.js'
|
|
25
25
|
import { asTrees } from '../cache/index.js'
|
|
26
|
-
import { parseRunArgs, resolveRunOptions } from './run.js'
|
|
26
|
+
import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
|
|
27
27
|
import {
|
|
28
28
|
fingerprintClaims,
|
|
29
29
|
forwardedSignal,
|
|
@@ -397,30 +397,37 @@ export function armWatcher(
|
|
|
397
397
|
return { watcher, ready }
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
400
|
+
/**
|
|
401
|
+
* The run flags a watch loop cannot honour, as the refusal line it prints; null
|
|
402
|
+
* when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
|
|
403
|
+
* the help line and the completions, and a test holds the two together.
|
|
404
|
+
*/
|
|
405
|
+
export function watchRefusal(parsed: RunArgs): string | null {
|
|
407
406
|
if (parsed.dry !== undefined || parsed.graph !== undefined) {
|
|
408
|
-
|
|
409
|
-
return 1
|
|
407
|
+
return 'vx watch: --dry / --graph are not supported in watch mode'
|
|
410
408
|
}
|
|
411
409
|
if (parsed.summarize !== undefined || parsed.profile !== undefined) {
|
|
412
|
-
|
|
413
|
-
`vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)\n`,
|
|
414
|
-
)
|
|
415
|
-
return 1
|
|
410
|
+
return 'vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)'
|
|
416
411
|
}
|
|
417
412
|
// All three format ONE run's result and are consumed by `runCmd` alone, so
|
|
418
413
|
// a watch loop silently ignored them. `--verbosity 0` is not rejected: it
|
|
419
414
|
// asks for the output watch already gives.
|
|
420
415
|
if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
416
|
+
return 'vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)'
|
|
417
|
+
}
|
|
418
|
+
return null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
422
|
+
const parsed = parseRunArgs(args)
|
|
423
|
+
if (parsed.error) {
|
|
424
|
+
process.stderr.write(`vx watch: ${parsed.error}\n`)
|
|
425
|
+
return 1
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const refused = watchRefusal(parsed)
|
|
429
|
+
if (refused !== null) {
|
|
430
|
+
process.stderr.write(`${refused}\n`)
|
|
424
431
|
return 1
|
|
425
432
|
}
|
|
426
433
|
if (parsed.tasks.length === 0) {
|
|
@@ -509,8 +516,17 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
509
516
|
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
510
517
|
// not (item 1017).
|
|
511
518
|
let held: HeldPersistent | undefined
|
|
519
|
+
let refusedToStart = false
|
|
512
520
|
try {
|
|
513
|
-
|
|
521
|
+
const initial = await runOrchestrator(opts)
|
|
522
|
+
held = initial.persistent
|
|
523
|
+
// A run that failed having run nothing refused to start: a requested
|
|
524
|
+
// name no project declares (run() says which, with a "did you mean").
|
|
525
|
+
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
526
|
+
// same refusal on every change, since no edit to an input can declare
|
|
527
|
+
// a task. A name only the diff left out (`--affected`) is `ok` and
|
|
528
|
+
// keeps watching.
|
|
529
|
+
refusedToStart = !initial.ok && initial.outcomes.length === 0
|
|
514
530
|
} catch (err) {
|
|
515
531
|
process.stderr.write(
|
|
516
532
|
`vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -520,6 +536,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
520
536
|
await held?.stop(forwardedSignal(stop.signal.reason))
|
|
521
537
|
return 0
|
|
522
538
|
}
|
|
539
|
+
if (refusedToStart) return 1
|
|
523
540
|
|
|
524
541
|
const load: CliLoadOptions = {
|
|
525
542
|
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
package/src/config.ts
CHANGED
|
@@ -190,8 +190,10 @@ export interface TaskConfig {
|
|
|
190
190
|
* write grant costs on Linux, is in `docs/schema.md` under
|
|
191
191
|
* `exec.sandbox`.
|
|
192
192
|
*
|
|
193
|
-
* Paths are project-relative, absolute, or `~`-expanded
|
|
194
|
-
*
|
|
193
|
+
* Paths are project-relative, absolute, or `~`-expanded, and may be
|
|
194
|
+
* globs: macOS matches the pattern in the policy, Linux expands it when
|
|
195
|
+
* the task starts (a mount cannot hold a pattern), so a file created
|
|
196
|
+
* later is not covered there — grant its directory.
|
|
195
197
|
*/
|
|
196
198
|
export interface SandboxConfig {
|
|
197
199
|
/** Capabilities granted on top of the task's own declarations. */
|
|
@@ -219,8 +221,11 @@ export interface SandboxConfig {
|
|
|
219
221
|
* ignore: { write: ['*.bun-build'] } // bun --compile stages a random
|
|
220
222
|
* // temp name in the cwd
|
|
221
223
|
* ```
|
|
224
|
+
*
|
|
225
|
+
* A denial is classed read, write, systemInfo or network, so those four
|
|
226
|
+
* are what can be ignored.
|
|
222
227
|
*/
|
|
223
|
-
ignore?:
|
|
228
|
+
ignore?: SandboxIgnore
|
|
224
229
|
/**
|
|
225
230
|
* Accept a weaker sandbox when vx itself already runs inside one
|
|
226
231
|
* (a container, another sandbox). Without it, nesting fails fast rather
|
|
@@ -231,15 +236,23 @@ export interface SandboxConfig {
|
|
|
231
236
|
weakerNetworkIsolation?: boolean
|
|
232
237
|
}
|
|
233
238
|
|
|
239
|
+
/** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
|
|
240
|
+
export interface SandboxIgnore {
|
|
241
|
+
read?: string[]
|
|
242
|
+
write?: string[]
|
|
243
|
+
systemInfo?: string[]
|
|
244
|
+
network?: string[]
|
|
245
|
+
}
|
|
246
|
+
|
|
234
247
|
export interface SandboxGrants {
|
|
235
248
|
/**
|
|
236
|
-
* Readable
|
|
249
|
+
* Readable paths or globs, added to the baseline — which is
|
|
237
250
|
* `node_modules`, NOT `cache.inputs.files`. A task that reads its own
|
|
238
251
|
* source in the sandbox says so here.
|
|
239
252
|
*/
|
|
240
253
|
read?: string[]
|
|
241
254
|
/**
|
|
242
|
-
* Writable
|
|
255
|
+
* Writable paths or globs. The task can write NOTHING without these:
|
|
243
256
|
* declaring `cache.outputs.files` grants no write, and a task whose
|
|
244
257
|
* only declaration is its outputs writes into a tmpfs that evaporates
|
|
245
258
|
* (vx then warns that `cache.outputs` matched no files).
|
package/src/exec/executor.ts
CHANGED
|
@@ -173,8 +173,9 @@ export interface TaskPlacement {
|
|
|
173
173
|
readonly command: string
|
|
174
174
|
/**
|
|
175
175
|
* Must run on this machine: the task is persistent, depends (transitively)
|
|
176
|
-
* on a persistent or sandboxed task, is sandboxed itself,
|
|
177
|
-
* `exec.remote: false
|
|
176
|
+
* on a persistent or sandboxed task, is sandboxed itself, declares
|
|
177
|
+
* `exec.remote: false`, or folds a runtime probe into its key (this
|
|
178
|
+
* machine's `node -v`). A `remote` executor is never offered such a task.
|
|
178
179
|
*/
|
|
179
180
|
readonly pinnedLocal: boolean
|
|
180
181
|
/** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
|
package/src/exec/index.ts
CHANGED
package/src/exec/runner.ts
CHANGED
|
@@ -82,6 +82,64 @@ export function shellQuote(arg: string): string {
|
|
|
82
82
|
return `'${arg.replace(/'/g, `'\\''`)}'`
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Where a shell comment still open at the end of `command` starts, or -1.
|
|
87
|
+
* A `#` opens one only unquoted and at the start of a word (`a#b`, `$#` and
|
|
88
|
+
* `${#x}` are words), and a newline closes it.
|
|
89
|
+
*/
|
|
90
|
+
function trailingCommentStart(command: string): number {
|
|
91
|
+
let quote = ''
|
|
92
|
+
let comment = -1
|
|
93
|
+
let atWordStart = true
|
|
94
|
+
for (let i = 0; i < command.length; i++) {
|
|
95
|
+
const c = command[i]!
|
|
96
|
+
if (comment >= 0) {
|
|
97
|
+
if (c === '\n') {
|
|
98
|
+
comment = -1
|
|
99
|
+
atWordStart = true
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (quote === "'") {
|
|
104
|
+
if (c === "'") quote = ''
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (c === '\\') {
|
|
108
|
+
i++
|
|
109
|
+
atWordStart = false
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
if (quote === '"') {
|
|
113
|
+
if (c === '"') quote = ''
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (c === "'" || c === '"') {
|
|
117
|
+
quote = c
|
|
118
|
+
atWordStart = false
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (c === '#' && atWordStart) {
|
|
122
|
+
comment = i
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
atWordStart = /[\s;&|()<>]/.test(c)
|
|
126
|
+
}
|
|
127
|
+
return comment
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The command a task runs with the args after `--` appended, shell-quoted.
|
|
132
|
+
* They go before a trailing comment: appended after it, `echo args: # show`
|
|
133
|
+
* ran without them and said nothing (item 1060).
|
|
134
|
+
*/
|
|
135
|
+
export function withForwardArgs(command: string, args: readonly string[] | undefined): string {
|
|
136
|
+
if (!args || args.length === 0) return command
|
|
137
|
+
const quoted = args.map(shellQuote).join(' ')
|
|
138
|
+
const comment = trailingCommentStart(command)
|
|
139
|
+
if (comment < 0) return `${command} ${quoted}`
|
|
140
|
+
return `${command.slice(0, comment).trimEnd()} ${quoted} ${command.slice(comment)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
85
143
|
// Any shell control/expansion character means the wrapping `sh` has real
|
|
86
144
|
// work to do (chaining, pipes, redirects, globbing, variables, subshells,
|
|
87
145
|
// backgrounding) and must stay resident.
|
|
@@ -296,6 +354,15 @@ export async function drainOrAbort(
|
|
|
296
354
|
*/
|
|
297
355
|
const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
298
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
|
|
359
|
+
* cursor), OSC (titles, links) and the two-byte forms. Vite under
|
|
360
|
+
* `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
|
|
361
|
+
* (item 1059). The streamed bytes keep them; only the tested text drops them.
|
|
362
|
+
*/
|
|
363
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
364
|
+
const TERMINAL_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g
|
|
365
|
+
|
|
299
366
|
/**
|
|
300
367
|
* Why a persistent task never became ready — the reason every label and
|
|
301
368
|
* record reads instead of a fabricated exit (item 270). `exitCode` is the
|
|
@@ -420,11 +487,16 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
420
487
|
}
|
|
421
488
|
|
|
422
489
|
// Stream readers. Each stream owns a pending fragment so a regex
|
|
423
|
-
// match isn't missed across chunk boundaries. The
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
490
|
+
// match isn't missed across chunk boundaries. The pattern is tested
|
|
491
|
+
// against each line on its own — without its break and without terminal
|
|
492
|
+
// escapes — so `^` and `$` anchor to a line as a user reads it; a whole
|
|
493
|
+
// fragment holding `booting\nready\n` failed `^ready` and `ready$`
|
|
494
|
+
// alike (item 1059). `\r` ends a line too: a progress bar's frames are
|
|
495
|
+
// lines to a reader. The trailing partial line is tested as well —
|
|
496
|
+
// prompt-style markers ("Listening on :3000" with no newline) would
|
|
497
|
+
// never resolve ready under complete-lines-only matching.
|
|
498
|
+
const readyLine = (re: RegExp, line: string): boolean =>
|
|
499
|
+
re.test(line.includes('\x1b') ? line.replace(TERMINAL_ESCAPE_RE, '') : line)
|
|
428
500
|
const consumeChunks = async (
|
|
429
501
|
stream: ReadableStream<Uint8Array> | number | undefined,
|
|
430
502
|
isStderr: boolean,
|
|
@@ -442,23 +514,32 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
442
514
|
else opts.onStdout?.(chunk)
|
|
443
515
|
if (readyRe && readyAt === undefined) {
|
|
444
516
|
fragment += chunk
|
|
445
|
-
|
|
517
|
+
let start = 0
|
|
518
|
+
for (let i = 0; i < fragment.length; i++) {
|
|
519
|
+
const c = fragment.charCodeAt(i)
|
|
520
|
+
if (c !== 10 && c !== 13) continue
|
|
521
|
+
if (readyLine(readyRe, fragment.slice(start, i))) {
|
|
522
|
+
markReady()
|
|
523
|
+
fragment = ''
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
start = i + 1
|
|
527
|
+
}
|
|
528
|
+
fragment = fragment.slice(start)
|
|
529
|
+
if (readyLine(readyRe, fragment)) {
|
|
446
530
|
markReady()
|
|
447
531
|
fragment = ''
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (fragment.length > READY_MATCH_WINDOW_CHARS) {
|
|
460
|
-
fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
|
|
461
|
-
}
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
// Dropping complete lines assumes the stream HAS line breaks. Output
|
|
535
|
+
// with none is one endless line, so the fragment would become a
|
|
536
|
+
// third unbounded accumulator (measured 464 MiB in 6 s, and the
|
|
537
|
+
// per-chunk `test` over an ever-growing string makes it quadratic
|
|
538
|
+
// in CPU too). Keep the most RECENT window: a match may span the
|
|
539
|
+
// chunk boundary, and no readiness marker is anywhere near this
|
|
540
|
+
// long.
|
|
541
|
+
if (fragment.length > READY_MATCH_WINDOW_CHARS) {
|
|
542
|
+
fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
|
|
462
543
|
}
|
|
463
544
|
}
|
|
464
545
|
}
|
|
@@ -566,10 +647,7 @@ export function spawnFailureText(err: unknown, cwd: string, what = 'task'): stri
|
|
|
566
647
|
|
|
567
648
|
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
568
649
|
const start = Date.now()
|
|
569
|
-
const fullCommand =
|
|
570
|
-
opts.forwardArgs && opts.forwardArgs.length > 0
|
|
571
|
-
? opts.command + ' ' + opts.forwardArgs.map(shellQuote).join(' ')
|
|
572
|
-
: opts.command
|
|
650
|
+
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
573
651
|
|
|
574
652
|
let proc: ReturnType<typeof Bun.spawn>
|
|
575
653
|
try {
|
|
@@ -82,7 +82,7 @@ const warnedSymlinkPunch = new Set<string>()
|
|
|
82
82
|
* `--bind <out>` then `--ro-bind <readPath>`. When the read path is an
|
|
83
83
|
* ancestor of the write path the read-only mount lands ON TOP of the
|
|
84
84
|
* writable one and every write fails with `Read-only file system`
|
|
85
|
-
* (`pushReadDenyDirMounts`, SRT 0.0.75 — its skip only covers the reverse
|
|
85
|
+
* (`pushReadDenyDirMounts`, SRT 0.0.75 and 0.0.76 — its skip only covers the reverse
|
|
86
86
|
* nesting). Verified in a Linux container 2026-09-05:
|
|
87
87
|
*
|
|
88
88
|
* read=[proj] write=[proj/dist] → mkdir: Read-only file system
|