@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/util/index.ts
CHANGED
|
@@ -19,11 +19,14 @@ export {
|
|
|
19
19
|
gitSpawnRefusal,
|
|
20
20
|
isExecutableMissing,
|
|
21
21
|
isOutOfFds,
|
|
22
|
+
gitFailure,
|
|
22
23
|
notAWorkTree,
|
|
23
24
|
OUT_OF_FDS_HINT,
|
|
25
|
+
thrownMessage,
|
|
24
26
|
wantsJson,
|
|
25
27
|
} from './errors.js'
|
|
26
28
|
export { configErrorFrame, configRefusal } from './config-frame.js'
|
|
29
|
+
export { envFlagOn } from './env-flag.js'
|
|
27
30
|
export {
|
|
28
31
|
detectColors,
|
|
29
32
|
dumbTerminal,
|
|
@@ -34,11 +37,11 @@ export {
|
|
|
34
37
|
WARN,
|
|
35
38
|
type ColorSupport,
|
|
36
39
|
} from './colors.js'
|
|
37
|
-
export { barWidth, gradientRule } from './report.js'
|
|
40
|
+
export { barWidth, gradientRule, ruleWidth } from './report.js'
|
|
38
41
|
import * as ui from './ui.js'
|
|
39
42
|
export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
|
|
40
43
|
export { ui }
|
|
41
|
-
export { xxh3, xxh3hex } from './hash.js'
|
|
44
|
+
export { keysSorted, xxh3, xxh3hex } from './hash.js'
|
|
42
45
|
export {
|
|
43
46
|
beginRun,
|
|
44
47
|
mark,
|
|
@@ -48,7 +51,7 @@ export {
|
|
|
48
51
|
stageTimes,
|
|
49
52
|
type StageTime,
|
|
50
53
|
} from './timing.js'
|
|
51
|
-
export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
54
|
+
export { clampInt, formatElapsed, isMeasure, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
52
55
|
export {
|
|
53
56
|
asTrees,
|
|
54
57
|
BUN_GLOB_WILDCARDS,
|
|
@@ -63,6 +66,7 @@ export {
|
|
|
63
66
|
outputNever,
|
|
64
67
|
restorableOutput,
|
|
65
68
|
printable,
|
|
69
|
+
hasControl,
|
|
66
70
|
relPosix,
|
|
67
71
|
DOT_VX_GITATTRIBUTES,
|
|
68
72
|
DOT_VX_GITIGNORE,
|
|
@@ -76,6 +80,9 @@ export {
|
|
|
76
80
|
wholeSubtreePrefixes,
|
|
77
81
|
slashBraceExpansions,
|
|
78
82
|
braceAlternatives,
|
|
83
|
+
braceRange,
|
|
84
|
+
BRACE_RANGE_LIMIT,
|
|
85
|
+
writableUnder,
|
|
79
86
|
} from './paths.js'
|
|
80
87
|
export {
|
|
81
88
|
claimExitForSignal,
|
|
@@ -102,7 +109,7 @@ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.
|
|
|
102
109
|
export { executablePath, shellArgv, taskShell } from './which.js'
|
|
103
110
|
export { procfsIsOwn } from './procfs.js'
|
|
104
111
|
export { hangupIgnored } from './hangup.js'
|
|
105
|
-
export { ESSENTIAL_ENV } from './essential-env.js'
|
|
112
|
+
export { ESSENTIAL_ENV, withoutWorkspaceBins } from './essential-env.js'
|
|
106
113
|
export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
|
|
107
114
|
export {
|
|
108
115
|
checkIgnore,
|
|
@@ -112,9 +119,11 @@ export {
|
|
|
112
119
|
wholeIgnored,
|
|
113
120
|
} from './git-ignored.js'
|
|
114
121
|
export { realPath, realpathOf } from './real-path.js'
|
|
122
|
+
export { IGNORE_ALL, ignoreDir, refuseSymlink, writeNoFollow } from './write-file.js'
|
|
115
123
|
export {
|
|
124
|
+
heldSecrets,
|
|
116
125
|
maskedCommand,
|
|
117
|
-
|
|
126
|
+
maskedStreams,
|
|
118
127
|
maskedLine,
|
|
119
128
|
MASKED,
|
|
120
129
|
secretMask,
|
|
@@ -131,3 +140,10 @@ export {
|
|
|
131
140
|
type SecretUnavailableReason,
|
|
132
141
|
} from './secrets.js'
|
|
133
142
|
export { terminalOf, type SecretTerminal } from './hidden-prompt.js'
|
|
143
|
+
export {
|
|
144
|
+
DependencySpecError,
|
|
145
|
+
compileTaskPattern,
|
|
146
|
+
isTaskPattern,
|
|
147
|
+
parseDependencySpec,
|
|
148
|
+
type DependencySpec,
|
|
149
|
+
} from './dependency-spec.js'
|
package/src/util/num.ts
CHANGED
|
@@ -21,6 +21,14 @@ export function clampInt(n: number, min: number, max: number): number {
|
|
|
21
21
|
return Math.min(max, Math.max(min, Math.floor(n)))
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A timing or need SQLite may keep: finite and not below zero. Infinity
|
|
26
|
+
* stored as REAL reads back through `group_concat` as `Inf`, a NaN forecast.
|
|
27
|
+
*/
|
|
28
|
+
export function isMeasure(v: number): boolean {
|
|
29
|
+
return Number.isFinite(v) && v >= 0
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/**
|
|
25
33
|
* Parse a PLAIN decimal integer from user input; `null` for anything else.
|
|
26
34
|
*
|
package/src/util/paths.ts
CHANGED
|
@@ -153,7 +153,8 @@ export function outputDirPrefixes(globs: readonly string[]): string[] | null {
|
|
|
153
153
|
* replays old outputs as a green hit (2026-09-10, probed one by one):
|
|
154
154
|
* a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
|
|
155
155
|
* `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
|
|
156
|
-
* `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job)
|
|
156
|
+
* `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job), and a
|
|
157
|
+
* trailing `/.`, read as a trailing `/`. Applied
|
|
157
158
|
* after an optional `!`; a bare `.` is the empty entry the schema refuses.
|
|
158
159
|
*/
|
|
159
160
|
export function normalizeGlob(glob: string): string {
|
|
@@ -173,6 +174,9 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
|
|
|
173
174
|
const neg = glob.startsWith('!')
|
|
174
175
|
let g = neg ? glob.slice(1) : glob
|
|
175
176
|
g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
|
|
177
|
+
// A trailing `/.` is the directory, as a trailing `/` is: `*/.` matched
|
|
178
|
+
// no file and `!gen/.` took nothing back (2026-10-28).
|
|
179
|
+
if (g.endsWith('/.')) g = g.slice(0, -1)
|
|
176
180
|
if (g === '.') g = ''
|
|
177
181
|
if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
|
|
178
182
|
return neg ? `!${g}` : g
|
|
@@ -206,7 +210,7 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
206
210
|
* class written out nine times — a spelling that drifts is how two guards
|
|
207
211
|
* come to answer differently for one pattern.
|
|
208
212
|
*
|
|
209
|
-
* A task glob is a standard glob (
|
|
213
|
+
* A task glob is a standard glob (2026-10-18): `[...]` is a character
|
|
210
214
|
* class and `\[id\]` names the route directory `[id]`. An escape counts as
|
|
211
215
|
* a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
|
|
212
216
|
* escape, never compared as a string; `asTrees` still reads an escaped-only
|
|
@@ -224,8 +228,9 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
|
|
|
224
228
|
|
|
225
229
|
/**
|
|
226
230
|
* An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm, yarn,
|
|
227
|
-
* Nx and Turbo read it; `Bun.Glob`
|
|
228
|
-
* literal text or as a plain wildcard.
|
|
231
|
+
* Nx and Turbo read it; `Bun.Glob` had none before 1.4.4, and vx's own
|
|
232
|
+
* matchers still read the group as literal text or as a plain wildcard.
|
|
233
|
+
* A task input `src/@(x|y).ts` keyed
|
|
229
234
|
* no file, so an edit to `src/x.ts` replayed a stale hit, and a member
|
|
230
235
|
* glob `packages/!(x)` took in x (turborepo#3766). Every glob vx hands
|
|
231
236
|
* `Bun.Glob` from a config refuses it.
|
|
@@ -245,8 +250,8 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
245
250
|
* list across two thousand tasks, and the graph's overlap checks compiled
|
|
246
251
|
* each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
|
|
247
252
|
* comes from a config, so the set is bounded by what the configs say. Kept
|
|
248
|
-
* per `Bun.Glob` in force:
|
|
249
|
-
*
|
|
253
|
+
* per `Bun.Glob` in force: a test swaps it, and a glob built by one is not
|
|
254
|
+
* the other's.
|
|
250
255
|
*/
|
|
251
256
|
const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
|
|
252
257
|
const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
|
|
@@ -272,7 +277,7 @@ function compileTaskGlob(pattern: string): Bun.Glob {
|
|
|
272
277
|
// A path with a slash on the end names no file vx lists; `Bun.Glob` reads
|
|
273
278
|
// it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
|
|
274
279
|
// stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
|
|
275
|
-
// is built by whatever `Bun.Glob` is at the call
|
|
280
|
+
// is built by whatever `Bun.Glob` is at the call.
|
|
276
281
|
glob.match = (rel: string): boolean =>
|
|
277
282
|
rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
|
|
278
283
|
return glob
|
|
@@ -363,20 +368,29 @@ export function stripTrailingSlash(p: string): string {
|
|
|
363
368
|
* may not import `cache`, and a second copy is how the two would
|
|
364
369
|
* disagree — which is exactly what item 441 measured about
|
|
365
370
|
* `staticPrefix`.
|
|
371
|
+
*
|
|
372
|
+
* A brace is expanded first and each alternative read alone: as one
|
|
373
|
+
* pattern, `{src,lib}` never became the two trees and `{./src,lib}/**`
|
|
374
|
+
* kept a `./` no git path holds, so an edit under either was a stale hit
|
|
375
|
+
* and the same output glob saved an empty artifact.
|
|
366
376
|
*/
|
|
367
377
|
export function asTrees(patterns: readonly string[]): string[] {
|
|
368
378
|
const out: string[] = []
|
|
369
379
|
for (const raw of patterns) {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
380
|
+
const alternatives = raw.includes('{') ? braceAlternatives(raw) : [raw]
|
|
381
|
+
for (const alt of alternatives) {
|
|
382
|
+
const p = normalizeGlob(alt)
|
|
383
|
+
if (!namesOnePath(p)) {
|
|
384
|
+
out.push(p)
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
const lit = stripTrailingSlash(p)
|
|
388
|
+
if (lit.length === 0) continue
|
|
389
|
+
out.push(lit, `${lit}/**`)
|
|
374
390
|
}
|
|
375
|
-
const lit = stripTrailingSlash(p)
|
|
376
|
-
if (lit.length === 0) continue
|
|
377
|
-
out.push(lit, `${lit}/**`)
|
|
378
391
|
}
|
|
379
|
-
|
|
392
|
+
// Two alternatives can fold to one entry (`{./src,src}`).
|
|
393
|
+
return [...new Set(out)]
|
|
380
394
|
}
|
|
381
395
|
|
|
382
396
|
/**
|
|
@@ -438,6 +452,23 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
438
452
|
*/
|
|
439
453
|
const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
|
|
440
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Whether vx may write or delete at `real`, a path a link on the way
|
|
457
|
+
* resolved: under `realBase` and not in its `.git`, in any case (a
|
|
458
|
+
* case-insensitive disk reads `.GIT` as `.git`). Names are held to
|
|
459
|
+
* `OUTPUT_NEVER` lexically; this holds where a link lands
|
|
460
|
+
* (vercel/turborepo#14808). Only the base's own: a deeper `.git` (a
|
|
461
|
+
* submodule under `gen/**`, a build's `dist/.git`) is an output an entry
|
|
462
|
+
* may name, and refused only once it existed on disk, a restore failed by
|
|
463
|
+
* what the last run left.
|
|
464
|
+
*/
|
|
465
|
+
export function writableUnder(real: string, realBase: string): boolean {
|
|
466
|
+
if (real === realBase) return true
|
|
467
|
+
if (!real.startsWith(realBase + path.sep)) return false
|
|
468
|
+
const top = real.slice(realBase.length + 1).split(path.sep, 1)[0]!
|
|
469
|
+
return top.toLowerCase() !== '.git'
|
|
470
|
+
}
|
|
471
|
+
|
|
441
472
|
/**
|
|
442
473
|
* `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
|
|
443
474
|
* those globs cross into other projects, and every project's `vx.lock.json`
|
|
@@ -487,7 +518,7 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
487
518
|
return expandBraces(pattern, true)
|
|
488
519
|
}
|
|
489
520
|
|
|
490
|
-
/** `pattern` with every brace group of two or more alternatives expanded. */
|
|
521
|
+
/** `pattern` with every brace group of two or more alternatives, or a range, expanded. */
|
|
491
522
|
export function braceAlternatives(pattern: string): string[] {
|
|
492
523
|
return expandBraces(pattern, false)
|
|
493
524
|
}
|
|
@@ -506,20 +537,53 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
|
506
537
|
}
|
|
507
538
|
if (close === -1) return [pattern]
|
|
508
539
|
const body = pattern.slice(open + 1, close)
|
|
509
|
-
if (
|
|
540
|
+
if (slashOnly && !body.includes('/')) continue
|
|
541
|
+
const range = cuts.length === 0 ? braceRange(body) : null
|
|
542
|
+
if (cuts.length === 0 && range === null) continue
|
|
510
543
|
const head = pattern.slice(0, open)
|
|
511
544
|
const tail = pattern.slice(close + 1)
|
|
512
545
|
const bounds = [open, ...cuts, close]
|
|
546
|
+
const alts = range ?? bounds.slice(1).map((end, k) => pattern.slice(bounds[k]! + 1, end))
|
|
513
547
|
const out: string[] = []
|
|
514
|
-
for (
|
|
515
|
-
const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
|
|
516
|
-
out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
517
|
-
}
|
|
548
|
+
for (const alt of alts) out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
518
549
|
return [...new Set(out)]
|
|
519
550
|
}
|
|
520
551
|
return [pattern]
|
|
521
552
|
}
|
|
522
553
|
|
|
554
|
+
const NUMERIC_RANGE = /^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/
|
|
555
|
+
const LETTER_RANGE = /^(?:([a-z])\.\.([a-z])|([A-Z])\.\.([A-Z]))(?:\.\.(-?\d+))?$/
|
|
556
|
+
/** The most names a brace range may name: each is a glob vx compiles. */
|
|
557
|
+
export const BRACE_RANGE_LIMIT = 1024
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A brace range's names, as `Bun.Glob` reads them since 1.4.4 (bash's
|
|
561
|
+
* rule): `1..3`, `3..1`, `01..03` padded, `a..c`, `1..10..3` stepped; null
|
|
562
|
+
* for any other body, a mixed-case one (`A..c` spans `[`, `\` and `]`) and
|
|
563
|
+
* one past `BRACE_RANGE_LIMIT`. vx expands it like a comma brace, so every matcher
|
|
564
|
+
* names the files `Bun.Glob` does.
|
|
565
|
+
*/
|
|
566
|
+
export function braceRange(body: string): string[] | null {
|
|
567
|
+
const num = NUMERIC_RANGE.exec(body)
|
|
568
|
+
const letter = num === null ? LETTER_RANGE.exec(body) : null
|
|
569
|
+
const m =
|
|
570
|
+
num ?? (letter && [letter[0], letter[1] ?? letter[3], letter[2] ?? letter[4], letter[5]])
|
|
571
|
+
if (m === null) return null
|
|
572
|
+
const from = num ? Number(m[1]) : m[1]!.charCodeAt(0)
|
|
573
|
+
const to = num ? Number(m[2]) : m[2]!.charCodeAt(0)
|
|
574
|
+
const step = Math.abs(Number(m[3] ?? 1)) || 1
|
|
575
|
+
if (Math.abs(to - from) / step >= BRACE_RANGE_LIMIT) return null
|
|
576
|
+
const padded = num !== null && [m[1]!, m[2]!].some((n) => /^-?0\d/.test(n))
|
|
577
|
+
const width = padded ? Math.max(m[1]!.length, m[2]!.length) : 0
|
|
578
|
+
const out: string[] = []
|
|
579
|
+
for (let i = from; from <= to ? i <= to : i >= to; i += from <= to ? step : -step) {
|
|
580
|
+
if (!num) out.push(String.fromCharCode(i))
|
|
581
|
+
else if (i < 0) out.push(`-${String(-i).padStart(width - 1, '0')}`)
|
|
582
|
+
else out.push(String(i).padStart(width, '0'))
|
|
583
|
+
}
|
|
584
|
+
return out
|
|
585
|
+
}
|
|
586
|
+
|
|
523
587
|
/**
|
|
524
588
|
* Whether a path git lists as UNTRACKED is an installed dependency: it sits
|
|
525
589
|
* under a `node_modules` directory. Such a file is never an input, and the
|
|
@@ -541,3 +605,9 @@ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
|
|
|
541
605
|
export function printable(s: string): string {
|
|
542
606
|
return s.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
543
607
|
}
|
|
608
|
+
|
|
609
|
+
/** Whether `s` holds a C0/C1 control or DEL, which `printable` would escape. */
|
|
610
|
+
export function hasControl(s: string): boolean {
|
|
611
|
+
// oxlint-disable-next-line no-control-regex
|
|
612
|
+
return /[\x00-\x1f\x7f-\x9f]/.test(s)
|
|
613
|
+
}
|
package/src/util/report.ts
CHANGED
|
@@ -14,10 +14,15 @@ export function barWidth(colors: ColorSupport): number {
|
|
|
14
14
|
return columns === undefined ? 50 : Math.max(10, Math.min(50, columns - LABEL))
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** A rule's width, flush with the bars: the footer's and a task frame's section rules. */
|
|
18
|
+
export function ruleWidth(colors: ColorSupport): number {
|
|
19
|
+
return LABEL + barWidth(colors)
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/** `─ <mark> ` + dashes, flush with the bars. Shared by the run summary and every report. */
|
|
18
23
|
export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
19
24
|
const head = `─ `
|
|
20
|
-
const width =
|
|
25
|
+
const width = ruleWidth(colors)
|
|
21
26
|
const dashes = '─'.repeat(Math.max(8, width - 3 - mark.length))
|
|
22
27
|
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
23
28
|
const chunks = 8
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -23,6 +23,7 @@ const NOT_SECRET = /_(FILE|PATH|DIR)$|^GIT_CONFIG_KEY_\d+$/i
|
|
|
23
23
|
*/
|
|
24
24
|
const secretNames = new Map<string, boolean>()
|
|
25
25
|
|
|
26
|
+
/** Whether vx masks a variable's value by its name alone (`NPM_TOKEN`, not `NPM_TOKEN_FILE`). */
|
|
26
27
|
export function secretNamed(name: string): boolean {
|
|
27
28
|
let secret = secretNames.get(name)
|
|
28
29
|
if (secret === undefined) {
|
|
@@ -46,9 +47,28 @@ const MIN_SECRET_CHARS = 6
|
|
|
46
47
|
|
|
47
48
|
export const MASKED = '***'
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Secrets this process holds that no environment names: a plugin's
|
|
52
|
+
* keychain answers (`ctx.secret`). A server's reply that echoed one
|
|
53
|
+
* printed it whole in the warning, beside masked task output.
|
|
54
|
+
*/
|
|
55
|
+
const held = new Set<string>()
|
|
56
|
+
|
|
57
|
+
/** `value` is masked wherever this process's secrets are, from now on. */
|
|
58
|
+
export function holdSecret(value: string): void {
|
|
59
|
+
held.add(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How many values `holdSecret` holds: a mask built with fewer is stale. */
|
|
63
|
+
export function heldSecrets(): number {
|
|
64
|
+
return held.size
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
export interface SecretMask {
|
|
50
68
|
/** `text` with every secret value replaced. */
|
|
51
69
|
mask(text: string): string
|
|
70
|
+
/** Where `mask` replaces a value in `text`: `[start, end)` pairs, in order. */
|
|
71
|
+
spans(text: string): Array<[start: number, end: number]>
|
|
52
72
|
/**
|
|
53
73
|
* A mask for a stream of chunks: a value split across two chunks is
|
|
54
74
|
* still caught, by holding back only a tail that could begin one until
|
|
@@ -101,6 +121,8 @@ export function secretMask(
|
|
|
101
121
|
}
|
|
102
122
|
for (const name of named) add(source[name])
|
|
103
123
|
}
|
|
124
|
+
// This process's secrets: the environment's and the held ones.
|
|
125
|
+
if (sources.includes(process.env)) for (const value of held) add(value)
|
|
104
126
|
if (values.size === 0) return null
|
|
105
127
|
// Longest first, so a value holding another is replaced whole.
|
|
106
128
|
const sorted = [...values].sort((a, b) => b.length - a.length)
|
|
@@ -137,6 +159,13 @@ export function secretMask(
|
|
|
137
159
|
}
|
|
138
160
|
return {
|
|
139
161
|
mask,
|
|
162
|
+
spans(text) {
|
|
163
|
+
const out: Array<[number, number]> = []
|
|
164
|
+
pattern.lastIndex = 0
|
|
165
|
+
for (let m = pattern.exec(text); m !== null; m = pattern.exec(text))
|
|
166
|
+
out.push([m.index, m.index + m[0].length])
|
|
167
|
+
return out
|
|
168
|
+
},
|
|
140
169
|
maskCut(head, tail) {
|
|
141
170
|
let h = head.length - partialTail(head)
|
|
142
171
|
pattern.lastIndex = 0
|
|
@@ -202,7 +231,7 @@ export interface TaskEnvSecrets {
|
|
|
202
231
|
* value is written whole or in chunks that arrive together; one split by
|
|
203
232
|
* a pause longer than that is not caught.
|
|
204
233
|
*/
|
|
205
|
-
|
|
234
|
+
function maskedEmitter(
|
|
206
235
|
mask: SecretMask,
|
|
207
236
|
emit: (text: string) => void,
|
|
208
237
|
idleMs = 25,
|
|
@@ -226,3 +255,35 @@ export function maskedEmitter(
|
|
|
226
255
|
end,
|
|
227
256
|
}
|
|
228
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A task's two streams, each through `maskedEmitter`, in the order the
|
|
261
|
+
* task wrote them. Each held its own tail, so a stdout tail that could
|
|
262
|
+
* begin a value waited out the idle bound while stderr printed, and the
|
|
263
|
+
* run log, live and cached, put the stderr line first. A chunk on one
|
|
264
|
+
* stream now emits the other's held tail before it: a value split by a
|
|
265
|
+
* write to the other stream is not caught, as one split by a pause.
|
|
266
|
+
*/
|
|
267
|
+
export function maskedStreams(
|
|
268
|
+
mask: SecretMask,
|
|
269
|
+
emitOut: (text: string) => void,
|
|
270
|
+
emitErr: (text: string) => void,
|
|
271
|
+
idleMs = 25,
|
|
272
|
+
): { out(chunk: string): void; err(chunk: string): void; end(): void } {
|
|
273
|
+
const out = maskedEmitter(mask, emitOut, idleMs)
|
|
274
|
+
const err = maskedEmitter(mask, emitErr, idleMs)
|
|
275
|
+
return {
|
|
276
|
+
out(chunk) {
|
|
277
|
+
err.end()
|
|
278
|
+
out.push(chunk)
|
|
279
|
+
},
|
|
280
|
+
err(chunk) {
|
|
281
|
+
out.end()
|
|
282
|
+
err.push(chunk)
|
|
283
|
+
},
|
|
284
|
+
end() {
|
|
285
|
+
out.end()
|
|
286
|
+
err.end()
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
}
|
package/src/util/secrets.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// A plugin's secrets, kept in the OS keychain (`Bun.secrets`) under the
|
|
2
2
|
// plugin's own name, and asked for on the terminal when a plugin offers a
|
|
3
|
-
// prompt and someone can answer (
|
|
3
|
+
// prompt and someone can answer (2026-10-17). Core reads no
|
|
4
4
|
// environment here: a variable, an option or a config file is the
|
|
5
5
|
// plugin's to check first, so the keychain is always the last source.
|
|
6
6
|
|
|
7
7
|
import { type SecretTerminal, terminalOf } from './hidden-prompt.js'
|
|
8
|
+
import { holdSecret } from './secret-mask.js'
|
|
8
9
|
|
|
9
10
|
/** What `ctx.secret` is asked: the entry's name, and the question to ask on a terminal. */
|
|
10
11
|
export interface SecretRequest {
|
|
@@ -114,7 +115,10 @@ export class SecretBroker {
|
|
|
114
115
|
let storeDown = false
|
|
115
116
|
try {
|
|
116
117
|
const stored = await this.store.get(entry)
|
|
117
|
-
if (stored)
|
|
118
|
+
if (stored) {
|
|
119
|
+
holdSecret(stored)
|
|
120
|
+
return stored
|
|
121
|
+
}
|
|
118
122
|
} catch {
|
|
119
123
|
// No keychain (headless Linux without libsecret).
|
|
120
124
|
storeDown = true
|
|
@@ -124,6 +128,7 @@ export class SecretBroker {
|
|
|
124
128
|
const asked = this.prompting.then(() => this.ask(req.key, req.prompt!))
|
|
125
129
|
this.prompting = asked.catch(() => undefined)
|
|
126
130
|
const answer = await asked
|
|
131
|
+
holdSecret(answer)
|
|
127
132
|
try {
|
|
128
133
|
await this.store.set({ ...entry, value: answer })
|
|
129
134
|
} catch (err) {
|
package/src/util/size.ts
CHANGED
|
@@ -24,11 +24,13 @@ export function parseSize(input: string): number | null {
|
|
|
24
24
|
/**
|
|
25
25
|
* Parse `<n><unit>` where unit is ms/s/m/h/d, case-insensitively (`parseSize`
|
|
26
26
|
* has always been case-insensitive; this matched only lowercase, so `30D`
|
|
27
|
-
* was rejected while `1GB` and `1gb` both worked).
|
|
27
|
+
* was rejected while `1GB` and `1gb` both worked). Minutes are `m` only:
|
|
28
|
+
* `M` is a month to systemd, and `--older-than 1M` read as a minute
|
|
29
|
+
* empties the cache.
|
|
28
30
|
*/
|
|
29
31
|
export function parseDuration(input: string): number | null {
|
|
30
32
|
const m = input.match(/^(\d+)(ms|[smhd])$/i)
|
|
31
|
-
if (!m) return null
|
|
33
|
+
if (!m || m[2] === 'M') return null
|
|
32
34
|
const n = parseDecimalInt(m[1]!)
|
|
33
35
|
if (n === null) return null
|
|
34
36
|
// Lowercase before the switch: with the /i flag an uppercase `M` would
|
|
@@ -55,8 +57,14 @@ export function formatBytes(n: number): string {
|
|
|
55
57
|
if (n < 1024) return `${n} B`
|
|
56
58
|
const units = ['KB', 'MB', 'GB', 'TB']
|
|
57
59
|
let v = n / 1024
|
|
60
|
+
// The tier is the printed value's: 1 MiB less a byte printed `1024 KB`,
|
|
61
|
+
// and 10 KiB less one `10.0 KB`.
|
|
62
|
+
const shown = (x: number): string => {
|
|
63
|
+
const one = x.toFixed(1)
|
|
64
|
+
return Number(one) < 10 ? one : x.toFixed(0)
|
|
65
|
+
}
|
|
58
66
|
for (const u of units) {
|
|
59
|
-
if (v < 1024) return `${
|
|
67
|
+
if (Number(shown(v)) < 1024) return `${shown(v)} ${u}`
|
|
60
68
|
v /= 1024
|
|
61
69
|
}
|
|
62
70
|
return `${v.toFixed(0)} PB`
|
package/src/util/tail.ts
CHANGED
|
@@ -46,7 +46,12 @@ export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS
|
|
|
46
46
|
// A single chunk over the cap is the one place we copy.
|
|
47
47
|
if (t.chars > limit) {
|
|
48
48
|
const only = t.chunks[0]!
|
|
49
|
-
|
|
49
|
+
let start = only.length - limit
|
|
50
|
+
// A cut between a surrogate pair opened the frame on a lone low
|
|
51
|
+
// surrogate, which the terminal's UTF-8 encode prints as U+FFFD.
|
|
52
|
+
const first = only.charCodeAt(start)
|
|
53
|
+
if (first >= 0xdc00 && first <= 0xdfff) start++
|
|
54
|
+
const keep = only.slice(start)
|
|
50
55
|
t.dropped += only.length - keep.length
|
|
51
56
|
t.chunks[0] = keep
|
|
52
57
|
t.chars = keep.length
|
package/src/util/timing.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// prints (a push per stage, ~15 a run): a telemetry sink draws them as
|
|
10
10
|
// spans (`stageTimes`). The per-call `span`s stay off unless enabled.
|
|
11
11
|
|
|
12
|
+
import { detectColors, paint } from './colors.js'
|
|
12
13
|
const enabled = process.env.VX_DEBUG_TIMING === 'true' || process.env.VX_DEBUG_TIMING === '1'
|
|
13
14
|
let t0 = Bun.nanoseconds()
|
|
14
15
|
// The wall clock at t0, read then: `performance.timeOrigin` plus the
|
|
@@ -117,5 +118,16 @@ export function printTimings(): void {
|
|
|
117
118
|
' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
|
|
118
119
|
)
|
|
119
120
|
}
|
|
120
|
-
process.stderr
|
|
121
|
+
const c = detectColors(process.stderr)
|
|
122
|
+
process.stderr.write(
|
|
123
|
+
`${lines
|
|
124
|
+
.map((l) =>
|
|
125
|
+
l.startsWith('[vx timing]')
|
|
126
|
+
? `${paint('#06b6d4', '[vx timing]', c, { bold: true })}${paint('', l.slice(11), c, { dim: true })}`
|
|
127
|
+
: l.startsWith(' (')
|
|
128
|
+
? paint('', l, c, { dim: true })
|
|
129
|
+
: l,
|
|
130
|
+
)
|
|
131
|
+
.join('\n')}\n`,
|
|
132
|
+
)
|
|
121
133
|
}
|
package/src/util/ui.ts
CHANGED
|
@@ -45,11 +45,23 @@ export const place = (text: string): Inline => (text === 'local' ? text : id(tex
|
|
|
45
45
|
/** Text in a tone's color. */
|
|
46
46
|
export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
|
|
47
47
|
|
|
48
|
+
// oxlint-disable-next-line no-control-regex
|
|
49
|
+
const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `s` with every control byte but a tab and a line break as `\xNN`: a
|
|
53
|
+
* notice quotes paths and names, anyone's bytes, and one holding `ESC ]0;`
|
|
54
|
+
* retitled the terminal. The colors are the components' own, added after.
|
|
55
|
+
*/
|
|
56
|
+
const plainText = (s: string): string =>
|
|
57
|
+
s.replace(CONTROL, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
58
|
+
|
|
48
59
|
/** One inline as text, colored for `c`. */
|
|
49
60
|
export function inline(x: Inline, c: ColorSupport): string {
|
|
50
|
-
if (typeof x === 'string') return x
|
|
61
|
+
if (typeof x === 'string') return plainText(x)
|
|
51
62
|
if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
|
|
52
|
-
const { style
|
|
63
|
+
const { style } = x as { style: Style; text: string }
|
|
64
|
+
const text = plainText((x as { text: string }).text)
|
|
53
65
|
switch (style) {
|
|
54
66
|
case 'dim':
|
|
55
67
|
return paint('', text, c, { dim: true })
|
|
@@ -81,9 +93,55 @@ export function status(t: Tone, verb: string, headline: Inline): Block {
|
|
|
81
93
|
])
|
|
82
94
|
}
|
|
83
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A line vx says mid-run (`[vx] cache save failed: …`, `vx watch: …`), its
|
|
98
|
+
* words as they always read and its tag in the tone's hue; a line with no
|
|
99
|
+
* tag is painted whole. One string, no newline: the run's own streams write it.
|
|
100
|
+
*/
|
|
101
|
+
export function tagged(t: Tone, line: string, c: ColorSupport): string {
|
|
102
|
+
const body = line.replace(/\n+$/, '')
|
|
103
|
+
const tag = /^(?:\[vx\]|vx(?: [a-z][\w-]*){0,2}:|[\w@/.-]+: )/.exec(body)?.[0].trimEnd()
|
|
104
|
+
return tag === undefined
|
|
105
|
+
? paint(HUE[t], body, c)
|
|
106
|
+
: `${paint(HUE[t], tag, c, { bold: true })}${body.slice(tag.length)}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `tagged` on a stream (stderr unless told), colored for it. */
|
|
110
|
+
export function say(t: Tone, line: string, stream: 'out' | 'err' = 'err'): void {
|
|
111
|
+
const s = stream === 'out' ? process.stdout : process.stderr
|
|
112
|
+
s.write(`${tagged(t, line, detectColors(s))}\n`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A file or excerpt shown whole (`vx init --dry`, `vx docs`): its title rule
|
|
117
|
+
* bold, its body behind a dim gutter. Uncolored it is the bare text, so a
|
|
118
|
+
* pipe gets the bytes it would copy.
|
|
119
|
+
*/
|
|
120
|
+
export function excerpt(title: string, body: string): Block {
|
|
121
|
+
return block((c) => {
|
|
122
|
+
if (!c.enabled) return [`── ${title} ──`, ...body.split('\n')]
|
|
123
|
+
const gutter = paint(INFO, '│', c, { dim: true })
|
|
124
|
+
return [
|
|
125
|
+
paint(INFO, `── ${title} ──`, c, { bold: true }),
|
|
126
|
+
...body
|
|
127
|
+
.split('\n')
|
|
128
|
+
.map((l, i, all) => (i === all.length - 1 && l === '' ? l : `${gutter} ${l}`)),
|
|
129
|
+
]
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
84
133
|
/** Indented short lines: what is wrong, or what happened. */
|
|
85
134
|
export function text(...items: Inline[]): Block {
|
|
86
|
-
return block((c) =>
|
|
135
|
+
return block((c) =>
|
|
136
|
+
items.flatMap((i) =>
|
|
137
|
+
lines(typeof i === 'string' ? prose(i) : i, c).map((l) => `${INDENT}${l}`),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Plain words dim, each `command` in them bold: no line of vx reads plain white. */
|
|
143
|
+
function prose(text: string): Inline {
|
|
144
|
+
return text.split(/(`[^`\n]+`)/).map((p, i) => (i % 2 === 1 ? cmd(p) : p === '' ? p : dim(p)))
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
/** An indented dim line: why it matters. */
|
package/src/util/which.ts
CHANGED
|
@@ -53,7 +53,7 @@ let dashMissedOn: string | undefined
|
|
|
53
53
|
* The shell vx runs commands in: `dash` on macOS when vx's PATH has it,
|
|
54
54
|
* else `sh` on vx's PATH. macOS's `/bin/sh` is a stub that execs bash, two
|
|
55
55
|
* images for one shell, and bash where Linux runs dash, so a command read
|
|
56
|
-
* one way on a Mac and another on Linux CI (
|
|
56
|
+
* one way on a Mac and another on Linux CI (2026-10-04).
|
|
57
57
|
*/
|
|
58
58
|
export function taskShell(): string {
|
|
59
59
|
if (process.platform === 'darwin') {
|