@vzn/vx 0.0.50 → 0.0.52
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 +4 -0
- package/src/cli/select.ts +32 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +38 -20
- 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/index.ts +1 -8
- 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/options.ts +8 -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 +28 -10
- 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-cache.ts +0 -2
- 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/run.ts
CHANGED
|
@@ -461,6 +461,7 @@ export async function resolveRunOptions(
|
|
|
461
461
|
const anchoredTasks = tasks.filter((t) => t.includes('#'))
|
|
462
462
|
let projects: string[] | undefined
|
|
463
463
|
let staged: RunOptions['staged']
|
|
464
|
+
let discovered: RunOptions['discovered']
|
|
464
465
|
let selectedByDiff = false
|
|
465
466
|
if (bareTasks.length === 0) {
|
|
466
467
|
projects = undefined
|
|
@@ -485,6 +486,7 @@ export async function resolveRunOptions(
|
|
|
485
486
|
// The graph walk staged every config; the run reuses those entries
|
|
486
487
|
// instead of evaluating and staging them a second time.
|
|
487
488
|
staged = resolved.staged
|
|
489
|
+
discovered = resolved.discovered
|
|
488
490
|
}
|
|
489
491
|
} else if (parsed.all) {
|
|
490
492
|
projects = undefined
|
|
@@ -519,6 +521,7 @@ export async function resolveRunOptions(
|
|
|
519
521
|
if (projects !== undefined) opts.projects = projects
|
|
520
522
|
if (selectedByDiff) opts.selectedByDiff = true
|
|
521
523
|
if (staged !== undefined) opts.staged = staged
|
|
524
|
+
if (discovered !== undefined) opts.discovered = discovered
|
|
522
525
|
if (parsed.retries !== undefined) opts.retries = parsed.retries
|
|
523
526
|
if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
|
|
524
527
|
if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
|
|
@@ -556,6 +559,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
556
559
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
557
560
|
},
|
|
558
561
|
)
|
|
562
|
+
if (picked === 'interrupted') return 130
|
|
559
563
|
if (!picked) return 1
|
|
560
564
|
tasks = [`${picked.project}#${picked.task}`]
|
|
561
565
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -152,6 +152,8 @@ export type FilterResolution =
|
|
|
152
152
|
byDiff: boolean
|
|
153
153
|
/** The staged load the graph walk needed, for the run to reuse (`RunOptions.staged`). */
|
|
154
154
|
staged?: ReadonlyMap<string, ProjectEntry>
|
|
155
|
+
/** The discovery this pass made, for the run to reuse (`RunOptions.discovered`). */
|
|
156
|
+
discovered: { root: string; projects: ProjectMeta[] }
|
|
155
157
|
}
|
|
156
158
|
| { error: string }
|
|
157
159
|
| { empty: string }
|
|
@@ -240,6 +242,7 @@ export async function resolveFilters(
|
|
|
240
242
|
workspaceGlobOwners: (changed) =>
|
|
241
243
|
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
242
244
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
245
|
+
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
243
246
|
})
|
|
244
247
|
affectedByFilter.set(f, names)
|
|
245
248
|
} catch (err) {
|
|
@@ -316,6 +319,7 @@ export async function resolveFilters(
|
|
|
316
319
|
names: [...selected].sort(),
|
|
317
320
|
byDiff: parsed.some((f) => !f.negate && f.gitSince !== undefined),
|
|
318
321
|
...(staged !== undefined ? { staged } : {}),
|
|
322
|
+
discovered: { root, projects },
|
|
319
323
|
}
|
|
320
324
|
}
|
|
321
325
|
|
|
@@ -325,11 +329,15 @@ export interface PickedTask {
|
|
|
325
329
|
description?: string
|
|
326
330
|
}
|
|
327
331
|
|
|
332
|
+
/**
|
|
333
|
+
* The task the user picks from a numbered menu; null when nothing was
|
|
334
|
+
* picked (said on stderr), `'interrupted'` for a Ctrl-C at the prompt.
|
|
335
|
+
*/
|
|
328
336
|
export async function pickTask(
|
|
329
337
|
cwd: string,
|
|
330
338
|
io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
|
|
331
339
|
load: CliLoadOptions = {},
|
|
332
|
-
): Promise<PickedTask | null> {
|
|
340
|
+
): Promise<PickedTask | null | 'interrupted'> {
|
|
333
341
|
const projects = await loadWorkspaceProjects(cwd)
|
|
334
342
|
// The staged load: a task a `project` plugin gave a config-less package
|
|
335
343
|
// is on the menu, as it is in a run.
|
|
@@ -362,8 +370,30 @@ export async function pickTask(
|
|
|
362
370
|
input: io.input ?? process.stdin,
|
|
363
371
|
output: io.output ?? process.stdout,
|
|
364
372
|
})
|
|
373
|
+
// On a terminal readline takes Ctrl-C and Ctrl-D itself, raw, and
|
|
374
|
+
// rejects the pending question with an AbortError — which reached the
|
|
375
|
+
// user as `vx: AbortError: Aborted with Ctrl+C` and a stack, exit 1.
|
|
376
|
+
// A SIGINT listener tells the two apart: Ctrl-C is an interrupt (exit 130,
|
|
377
|
+
// as a run's), Ctrl-D an answer that never came.
|
|
378
|
+
const abort = new AbortController()
|
|
379
|
+
let interrupted = false
|
|
380
|
+
rl.on('SIGINT', () => {
|
|
381
|
+
interrupted = true
|
|
382
|
+
abort.abort()
|
|
383
|
+
})
|
|
365
384
|
try {
|
|
366
|
-
|
|
385
|
+
let answer: string
|
|
386
|
+
try {
|
|
387
|
+
answer = (
|
|
388
|
+
await rl.question(`Pick a task [1-${entries.length}]: `, { signal: abort.signal })
|
|
389
|
+
).trim()
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (!(err instanceof Error) || err.name !== 'AbortError') throw err
|
|
392
|
+
if (interrupted) return 'interrupted'
|
|
393
|
+
out.write('\n')
|
|
394
|
+
process.stderr.write(`vx run: no task picked\n`)
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
367
397
|
const n = Number(answer)
|
|
368
398
|
if (!Number.isInteger(n) || n < 1 || n > entries.length) {
|
|
369
399
|
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) {
|
|
@@ -459,9 +466,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
459
466
|
// run()'s process.argv fallback put the bin's absolute path there, so
|
|
460
467
|
// `vx last --list` showed `$ /…/bin.ts watch build` beside `$ vx run build`.
|
|
461
468
|
opts.command = ['vx', 'watch', ...args].join(' ')
|
|
462
|
-
// A staged load from the selection pass
|
|
463
|
-
// every cycle after an edit must
|
|
469
|
+
// A staged load and a discovery from the selection pass are one run's
|
|
470
|
+
// worth; every cycle after an edit must load and list live.
|
|
464
471
|
delete opts.staged
|
|
472
|
+
delete opts.discovered
|
|
465
473
|
process.once('SIGINT', () => {
|
|
466
474
|
process.stdout.write('\nvx watch: stopped\n')
|
|
467
475
|
stop.abort('SIGINT')
|
|
@@ -509,8 +517,17 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
509
517
|
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
510
518
|
// not (item 1017).
|
|
511
519
|
let held: HeldPersistent | undefined
|
|
520
|
+
let refusedToStart = false
|
|
512
521
|
try {
|
|
513
|
-
|
|
522
|
+
const initial = await runOrchestrator(opts)
|
|
523
|
+
held = initial.persistent
|
|
524
|
+
// A run that failed having run nothing refused to start: a requested
|
|
525
|
+
// name no project declares (run() says which, with a "did you mean").
|
|
526
|
+
// `vx run` exits 1 on it; `vx watch buidl` watched on, re-running the
|
|
527
|
+
// same refusal on every change, since no edit to an input can declare
|
|
528
|
+
// a task. A name only the diff left out (`--affected`) is `ok` and
|
|
529
|
+
// keeps watching.
|
|
530
|
+
refusedToStart = !initial.ok && initial.outcomes.length === 0
|
|
514
531
|
} catch (err) {
|
|
515
532
|
process.stderr.write(
|
|
516
533
|
`vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
@@ -520,6 +537,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
520
537
|
await held?.stop(forwardedSignal(stop.signal.reason))
|
|
521
538
|
return 0
|
|
522
539
|
}
|
|
540
|
+
if (refusedToStart) return 1
|
|
523
541
|
|
|
524
542
|
const load: CliLoadOptions = {
|
|
525
543
|
...(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 {
|