@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- 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 +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- 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 +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- 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 +326 -65
- 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 +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- 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 +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- 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 +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- 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 +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- 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 +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- 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 +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- 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 +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- 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 +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -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 +65 -4
- 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 +282 -0
- 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 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
package/src/util/paths.ts
CHANGED
|
@@ -117,13 +117,44 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
117
117
|
return [...new Set(out)]
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The directories whose snapshot covers every path the positive `globs`
|
|
122
|
+
* can match, or `null` when one reaches outside the project. A
|
|
123
|
+
* whole-subtree glob or a literal is its own tree
|
|
124
|
+
* ({@link wholeSubtreePrefixes}); any other glob is the tree under its
|
|
125
|
+
* static prefix, and a root-anchored one (`**\/dist/**`, Turbo's shape) is
|
|
126
|
+
* the whole project, `''`. Sound for the same reason: a file added or
|
|
127
|
+
* removed anywhere under a recorded directory bumps a recorded mtime. The
|
|
128
|
+
* snapshot's file list is then held to the entry's rows through the globs
|
|
129
|
+
* themselves, so a source file beside the outputs is no stray.
|
|
130
|
+
*/
|
|
131
|
+
export function outputDirPrefixes(globs: readonly string[]): string[] | null {
|
|
132
|
+
if (globs.length === 0) return null
|
|
133
|
+
const dirs: string[] = []
|
|
134
|
+
for (const g of globs) {
|
|
135
|
+
const whole = wholeSubtreePrefixes([g])?.[0]
|
|
136
|
+
if (whole !== undefined) {
|
|
137
|
+
dirs.push(whole)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const prefix = staticPrefix(g)
|
|
141
|
+
if (prefix === '.') return ['']
|
|
142
|
+
if (prefix.startsWith('/') || prefix.startsWith('!') || prefix.split('/').includes('..'))
|
|
143
|
+
return null
|
|
144
|
+
dirs.push(prefix)
|
|
145
|
+
}
|
|
146
|
+
const sorted = [...new Set(dirs)].sort()
|
|
147
|
+
return sorted.filter((d, i) => !sorted.slice(0, i).some((a) => d.startsWith(`${a}/`)))
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
/**
|
|
121
151
|
* The spellings a reader, Turbo and `.gitignore` all accept but a matcher
|
|
122
152
|
* fed the raw string turns into NOTHING — and a task keyed on nothing
|
|
123
153
|
* replays old outputs as a green hit (2026-09-10, probed one by one):
|
|
124
154
|
* a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
|
|
125
155
|
* `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
|
|
126
|
-
* `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
|
|
127
158
|
* after an optional `!`; a bare `.` is the empty entry the schema refuses.
|
|
128
159
|
*/
|
|
129
160
|
export function normalizeGlob(glob: string): string {
|
|
@@ -143,6 +174,9 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
|
|
|
143
174
|
const neg = glob.startsWith('!')
|
|
144
175
|
let g = neg ? glob.slice(1) : glob
|
|
145
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)
|
|
146
180
|
if (g === '.') g = ''
|
|
147
181
|
if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
|
|
148
182
|
return neg ? `!${g}` : g
|
|
@@ -176,7 +210,7 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
176
210
|
* class written out nine times — a spelling that drifts is how two guards
|
|
177
211
|
* come to answer differently for one pattern.
|
|
178
212
|
*
|
|
179
|
-
* A task glob is a standard glob (
|
|
213
|
+
* A task glob is a standard glob (2026-10-18): `[...]` is a character
|
|
180
214
|
* class and `\[id\]` names the route directory `[id]`. An escape counts as
|
|
181
215
|
* a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
|
|
182
216
|
* escape, never compared as a string; `asTrees` still reads an escaped-only
|
|
@@ -194,8 +228,9 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
|
|
|
194
228
|
|
|
195
229
|
/**
|
|
196
230
|
* An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm, yarn,
|
|
197
|
-
* Nx and Turbo read it; `Bun.Glob`
|
|
198
|
-
* 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
|
|
199
234
|
* no file, so an edit to `src/x.ts` replayed a stale hit, and a member
|
|
200
235
|
* glob `packages/!(x)` took in x (turborepo#3766). Every glob vx hands
|
|
201
236
|
* `Bun.Glob` from a config refuses it.
|
|
@@ -215,8 +250,8 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
215
250
|
* list across two thousand tasks, and the graph's overlap checks compiled
|
|
216
251
|
* each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
|
|
217
252
|
* comes from a config, so the set is bounded by what the configs say. Kept
|
|
218
|
-
* per `Bun.Glob` in force:
|
|
219
|
-
*
|
|
253
|
+
* per `Bun.Glob` in force: a test swaps it, and a glob built by one is not
|
|
254
|
+
* the other's.
|
|
220
255
|
*/
|
|
221
256
|
const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
|
|
222
257
|
const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
|
|
@@ -242,7 +277,7 @@ function compileTaskGlob(pattern: string): Bun.Glob {
|
|
|
242
277
|
// A path with a slash on the end names no file vx lists; `Bun.Glob` reads
|
|
243
278
|
// it by rules of its own (`src/**` takes `src/`, never `src`). `scan`
|
|
244
279
|
// stays `Bun.Glob`'s. Set on the instance, not by a subclass, so the glob
|
|
245
|
-
// is built by whatever `Bun.Glob` is at the call
|
|
280
|
+
// is built by whatever `Bun.Glob` is at the call.
|
|
246
281
|
glob.match = (rel: string): boolean =>
|
|
247
282
|
rel.endsWith('/') ? native.match.call(glob, rel) : compiled.test(rel)
|
|
248
283
|
return glob
|
|
@@ -333,20 +368,29 @@ export function stripTrailingSlash(p: string): string {
|
|
|
333
368
|
* may not import `cache`, and a second copy is how the two would
|
|
334
369
|
* disagree — which is exactly what item 441 measured about
|
|
335
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.
|
|
336
376
|
*/
|
|
337
377
|
export function asTrees(patterns: readonly string[]): string[] {
|
|
338
378
|
const out: string[] = []
|
|
339
379
|
for (const raw of patterns) {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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}/**`)
|
|
344
390
|
}
|
|
345
|
-
const lit = stripTrailingSlash(p)
|
|
346
|
-
if (lit.length === 0) continue
|
|
347
|
-
out.push(lit, `${lit}/**`)
|
|
348
391
|
}
|
|
349
|
-
|
|
392
|
+
// Two alternatives can fold to one entry (`{./src,src}`).
|
|
393
|
+
return [...new Set(out)]
|
|
350
394
|
}
|
|
351
395
|
|
|
352
396
|
/**
|
|
@@ -408,6 +452,23 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
408
452
|
*/
|
|
409
453
|
const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
|
|
410
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
|
+
|
|
411
472
|
/**
|
|
412
473
|
* `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
|
|
413
474
|
* those globs cross into other projects, and every project's `vx.lock.json`
|
|
@@ -457,7 +518,7 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
457
518
|
return expandBraces(pattern, true)
|
|
458
519
|
}
|
|
459
520
|
|
|
460
|
-
/** `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. */
|
|
461
522
|
export function braceAlternatives(pattern: string): string[] {
|
|
462
523
|
return expandBraces(pattern, false)
|
|
463
524
|
}
|
|
@@ -476,20 +537,53 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
|
476
537
|
}
|
|
477
538
|
if (close === -1) return [pattern]
|
|
478
539
|
const body = pattern.slice(open + 1, close)
|
|
479
|
-
if (
|
|
540
|
+
if (slashOnly && !body.includes('/')) continue
|
|
541
|
+
const range = cuts.length === 0 ? braceRange(body) : null
|
|
542
|
+
if (cuts.length === 0 && range === null) continue
|
|
480
543
|
const head = pattern.slice(0, open)
|
|
481
544
|
const tail = pattern.slice(close + 1)
|
|
482
545
|
const bounds = [open, ...cuts, close]
|
|
546
|
+
const alts = range ?? bounds.slice(1).map((end, k) => pattern.slice(bounds[k]! + 1, end))
|
|
483
547
|
const out: string[] = []
|
|
484
|
-
for (
|
|
485
|
-
const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
|
|
486
|
-
out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
487
|
-
}
|
|
548
|
+
for (const alt of alts) out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
488
549
|
return [...new Set(out)]
|
|
489
550
|
}
|
|
490
551
|
return [pattern]
|
|
491
552
|
}
|
|
492
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
|
+
|
|
493
587
|
/**
|
|
494
588
|
* Whether a path git lists as UNTRACKED is an installed dependency: it sits
|
|
495
589
|
* under a `node_modules` directory. Such a file is never an input, and the
|
|
@@ -511,3 +605,9 @@ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
|
|
|
511
605
|
export function printable(s: string): string {
|
|
512
606
|
return s.replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
513
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
|
@@ -40,13 +40,28 @@ export const path = (text: string): Inline => ({ style: 'bold', text })
|
|
|
40
40
|
export const cmd = (text: string): Inline => ({ style: 'bold', text })
|
|
41
41
|
/** A secondary fact. */
|
|
42
42
|
export const dim = (text: string): Inline => ({ style: 'dim', text })
|
|
43
|
+
/** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
|
|
44
|
+
export const place = (text: string): Inline => (text === 'local' ? text : id(text))
|
|
43
45
|
/** Text in a tone's color. */
|
|
44
46
|
export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
|
|
45
47
|
|
|
46
|
-
|
|
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
|
+
|
|
59
|
+
/** One inline as text, colored for `c`. */
|
|
60
|
+
export function inline(x: Inline, c: ColorSupport): string {
|
|
61
|
+
if (typeof x === 'string') return plainText(x)
|
|
48
62
|
if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
|
|
49
|
-
const { style
|
|
63
|
+
const { style } = x as { style: Style; text: string }
|
|
64
|
+
const text = plainText((x as { text: string }).text)
|
|
50
65
|
switch (style) {
|
|
51
66
|
case 'dim':
|
|
52
67
|
return paint('', text, c, { dim: true })
|
|
@@ -78,9 +93,55 @@ export function status(t: Tone, verb: string, headline: Inline): Block {
|
|
|
78
93
|
])
|
|
79
94
|
}
|
|
80
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
|
+
|
|
81
133
|
/** Indented short lines: what is wrong, or what happened. */
|
|
82
134
|
export function text(...items: Inline[]): Block {
|
|
83
|
-
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)))
|
|
84
145
|
}
|
|
85
146
|
|
|
86
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') {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { lstatSync, readlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, open, rename, rm } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { UserError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Refuses a `file` that is a symlink: a repo can carry `vx.config.ts ->
|
|
8
|
+
* /anywhere`, and a write through it lands outside the workspace. Called
|
|
9
|
+
* before the first of a set of writes, so a refusal leaves none half-made.
|
|
10
|
+
*/
|
|
11
|
+
export function refuseSymlink(file: string): void {
|
|
12
|
+
let link: string
|
|
13
|
+
try {
|
|
14
|
+
if (!lstatSync(file).isSymbolicLink()) return
|
|
15
|
+
link = readlinkSync(file)
|
|
16
|
+
} catch {
|
|
17
|
+
// quiet: nothing there to write through
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
throw new UserError(
|
|
21
|
+
`refusing to write ${file}: it is a symlink to ${link}; remove the link and run again`,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Writes `text` to `file`, never through a link: a temp beside it, made
|
|
27
|
+
* with O_EXCL, renamed over it. A rename replaces a link put there after
|
|
28
|
+
* the check, never its target.
|
|
29
|
+
*/
|
|
30
|
+
export async function writeNoFollow(file: string, text: string): Promise<void> {
|
|
31
|
+
refuseSymlink(file)
|
|
32
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
33
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
34
|
+
try {
|
|
35
|
+
const fh = await open(tmp, 'wx')
|
|
36
|
+
try {
|
|
37
|
+
await fh.writeFile(text)
|
|
38
|
+
} finally {
|
|
39
|
+
await fh.close()
|
|
40
|
+
}
|
|
41
|
+
await rename(tmp, file)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
44
|
+
// The refusal names the file the user knows, as `writeLockfile`'s does.
|
|
45
|
+
const e = err as NodeJS.ErrnoException
|
|
46
|
+
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
47
|
+
if (e.path === tmp) e.path = file
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A `.gitignore` that hides its whole directory from git, itself included. */
|
|
53
|
+
export const IGNORE_ALL = '*\n'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hides `dir`, a directory vx made, from git: an `IGNORE_ALL` `.gitignore`
|
|
57
|
+
* in it (the Cargo / Nx convention), written only when absent, so a
|
|
58
|
+
* user's own file wins. Never trusts a `.vx/.gitignore` to name it: an
|
|
59
|
+
* older init's, a user's own, or a cache moved elsewhere names none.
|
|
60
|
+
*/
|
|
61
|
+
export function ignoreDir(dir: string): void {
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(path.join(dir, '.gitignore'), IGNORE_ALL, { flag: 'wx' })
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
66
|
+
}
|
|
67
|
+
}
|