@vzn/vx 0.0.101 → 0.0.103

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.
@@ -0,0 +1,145 @@
1
+ // Masking the values of secret-named variables in what vx prints, stores
2
+ // or hands to telemetry (L-11). A task that echoes `$NPM_TOKEN`, or a
3
+ // TS config that interpolates `process.env.API_KEY` into its command,
4
+ // put the value in the terminal, the cached stdout every later hit
5
+ // replays, and the spans a telemetry plugin exports. The masking is
6
+ // GitHub Actions' kind: a known value is replaced wherever it appears.
7
+
8
+ /** A variable whose value is masked, by the words its name holds. */
9
+ const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL/i
10
+
11
+ /**
12
+ * Names that hold such a word but a value that is no secret: where a
13
+ * secret lives (`NPM_TOKEN_FILE`), or git's own config channel, whose
14
+ * `GIT_CONFIG_KEY_<n>` values are config names (`safe.directory`) that
15
+ * masking would blank out of ordinary output.
16
+ */
17
+ const NOT_SECRET = /_(FILE|PATH|DIR)$|^GIT_CONFIG_KEY_\d+$/i
18
+
19
+ /**
20
+ * Values shorter than this are not masked: `KEY=1` would mask every `1`
21
+ * in every line, and a value that short is no secret worth the noise.
22
+ */
23
+ const MIN_SECRET_CHARS = 6
24
+
25
+ export const MASKED = '***'
26
+
27
+ export interface SecretMask {
28
+ /** `text` with every secret value replaced. */
29
+ mask(text: string): string
30
+ /**
31
+ * A mask for a stream of chunks: a value split across two chunks is
32
+ * still caught, by holding back only a tail that could begin one until
33
+ * the next chunk or `end`.
34
+ */
35
+ stream(): { push(chunk: string): string; end(): string }
36
+ }
37
+
38
+ /**
39
+ * The mask for these variables (a process's env, a task's `define`), or
40
+ * null when none is secret-named with a value worth masking: the common
41
+ * run pays one name test per variable and nothing per byte.
42
+ */
43
+ export function secretMask(
44
+ ...sources: ReadonlyArray<Readonly<Record<string, string | undefined>> | undefined>
45
+ ): SecretMask | null {
46
+ const values = new Set<string>()
47
+ for (const source of sources) {
48
+ if (source === undefined) continue
49
+ for (const name in source) {
50
+ const value = source[name]
51
+ if (
52
+ value !== undefined &&
53
+ value.length >= MIN_SECRET_CHARS &&
54
+ SECRET_NAME.test(name) &&
55
+ !NOT_SECRET.test(name)
56
+ ) {
57
+ values.add(value)
58
+ }
59
+ }
60
+ }
61
+ if (values.size === 0) return null
62
+ // Longest first, so a value holding another is replaced whole.
63
+ const sorted = [...values].sort((a, b) => b.length - a.length)
64
+ const pattern = new RegExp(
65
+ sorted.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'),
66
+ 'g',
67
+ )
68
+ const mask = (text: string): string => text.replace(pattern, MASKED)
69
+ /** The longest tail of `text` that is a proper prefix of a value. */
70
+ const partialTail = (text: string): number => {
71
+ let longest = 0
72
+ for (const v of sorted) {
73
+ for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
74
+ if (text.endsWith(v.slice(0, k))) {
75
+ longest = k
76
+ break
77
+ }
78
+ }
79
+ }
80
+ return longest
81
+ }
82
+ return {
83
+ mask,
84
+ stream() {
85
+ let carry = ''
86
+ return {
87
+ push(chunk) {
88
+ const text = carry + chunk
89
+ // Held back: only a tail that could begin a value, so output that
90
+ // cannot be one is never delayed (a `readyWhen` line, a marker).
91
+ let cut = text.length - partialTail(text)
92
+ pattern.lastIndex = 0
93
+ // Never cut through a value that is already whole.
94
+ for (let m = pattern.exec(text); m !== null; m = pattern.exec(text)) {
95
+ if (m.index < cut && m.index + m[0].length > cut) cut = m.index
96
+ }
97
+ carry = text.slice(cut)
98
+ return mask(text.slice(0, cut))
99
+ },
100
+ end() {
101
+ const rest = mask(carry)
102
+ carry = ''
103
+ return rest
104
+ },
105
+ }
106
+ },
107
+ }
108
+ }
109
+
110
+ /** A task's command as vx shows it: its secret-named values masked. */
111
+ export function maskedCommand(command: string, define?: Readonly<Record<string, string>>): string {
112
+ return secretMask(process.env, define)?.mask(command) ?? command
113
+ }
114
+
115
+ /**
116
+ * `mask.stream()` driving `emit`: what it holds back (a tail that could
117
+ * begin a value) is emitted `idleMs` after the last chunk when none
118
+ * follows, so a long task's last characters do not wait for its exit. A
119
+ * value is written whole or in chunks that arrive together; one split by
120
+ * a pause longer than that is not caught.
121
+ */
122
+ export function maskedEmitter(
123
+ mask: SecretMask,
124
+ emit: (text: string) => void,
125
+ idleMs = 25,
126
+ ): { push(chunk: string): void; end(): void } {
127
+ const s = mask.stream()
128
+ let timer: ReturnType<typeof setTimeout> | undefined
129
+ const end = (): void => {
130
+ if (timer !== undefined) clearTimeout(timer)
131
+ timer = undefined
132
+ const rest = s.end()
133
+ if (rest) emit(rest)
134
+ }
135
+ return {
136
+ push(chunk) {
137
+ if (timer !== undefined) clearTimeout(timer)
138
+ const out = s.push(chunk)
139
+ if (out) emit(out)
140
+ timer = setTimeout(end, idleMs)
141
+ timer.unref?.()
142
+ },
143
+ end,
144
+ }
145
+ }
package/src/util/which.ts CHANGED
@@ -46,3 +46,15 @@ export function executablePath(name: string): string {
46
46
  found.set(name, at)
47
47
  return at
48
48
  }
49
+
50
+ /**
51
+ * The argv that runs `script` in the task shell: `sh -c` on Linux and
52
+ * macOS, and Bun's own shell (`bun exec`) on Windows, which ships no `sh`
53
+ * (docs/design/windows-2026-09.md § Decisions 1). Bun's shell reads the
54
+ * same `&&`, `|`, `$(…)` and `VAR=x cmd` syntax that `bun run` uses there.
55
+ */
56
+ export function shellArgv(script: string): string[] {
57
+ return process.platform === 'win32'
58
+ ? [process.execPath, 'exec', script]
59
+ : [executablePath('sh'), '-c', script]
60
+ }
@@ -7,6 +7,7 @@
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
9
  import { realpathSync, statSync } from 'node:fs'
10
+ import { realpath } from 'node:fs/promises'
10
11
  import path from 'node:path'
11
12
  import {
12
13
  asTrees,
@@ -240,7 +241,12 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
240
241
  }
241
242
  }
242
243
 
243
- const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
244
+ const realDirs = new Map(
245
+ await Promise.all(
246
+ args.projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
247
+ ),
248
+ )
249
+ const owned = projectsContaining(args.workspaceRoot, changed, args.projects, realDirs)
244
250
  for (const name of claimedOwned) owned.add(name)
245
251
 
246
252
  // A manifest edit can drop an edge today's graph no longer shows: a
@@ -277,6 +283,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
277
283
  projects: args.projects,
278
284
  changed,
279
285
  skip: owned,
286
+ realDirs,
280
287
  })) {
281
288
  owned.add(name)
282
289
  }
@@ -659,6 +666,7 @@ function projectsContaining(
659
666
  workspaceRoot: string,
660
667
  changedRelPaths: readonly string[],
661
668
  projects: readonly ProjectMeta[],
669
+ realDirs: ReadonlyMap<string, string>,
662
670
  ): Set<string> {
663
671
  // Index projects by their (canonical) dir, then for each changed path walk
664
672
  // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
@@ -676,7 +684,7 @@ function projectsContaining(
676
684
  // too. One realpath per member, and only on an --affected run.
677
685
  const realRoot = realpathOr(workspaceRoot)
678
686
  for (const p of projects) {
679
- const real = realpathOr(p.dir)
687
+ const real = realDirs.get(p.dir) ?? p.dir
680
688
  const rel = path.relative(realRoot, real)
681
689
  if (rel.startsWith('..') || path.isAbsolute(rel)) continue
682
690
  const spelled = path.resolve(workspaceRoot, rel)
@@ -141,8 +141,10 @@ const MAX_CLOSURE_FILES = 32
141
141
  // passes: the gate stops accidental impurity, not a config written to
142
142
  // defeat it. `random` is denied as a word, not as `Math.random`: a
143
143
  // destructured `const { random } = Math` was cached as pure (item 1036).
144
+ // `prompt`, `confirm` and `alert` read the user's terminal, and a cached
145
+ // evaluation replayed the first answer (D-25).
144
146
  const IMPURE_RE =
145
- /\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*|Reflect|getPrototypeOf|setPrototypeOf|getOwnPropertyNames|getOwnPropertyDescriptor|getOwnPropertyDescriptors|__proto__|prototype|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__|random)\b|import\s*\.\s*meta|\bimport\s*\(/
147
+ /\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*|Reflect|getPrototypeOf|setPrototypeOf|getOwnPropertyNames|getOwnPropertyDescriptor|getOwnPropertyDescriptors|__proto__|prototype|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__|random|prompt|confirm|alert)\b|import\s*\.\s*meta|\bimport\s*\(/
146
148
 
147
149
  /** Literal text that names a way to `Function` when used as a computed key. */
148
150
  const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
@@ -18,6 +18,8 @@
18
18
  //
19
19
  // - RELATIVE specifiers only. A bare specifier is a package; it moves when
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
+ // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
+ // from disk (D-27).
21
23
  // - Descend only through files owned by NO project. A config reaching into
22
24
  // another project (say a site's `vx.config.ts` importing
23
25
  // `../core/src/index.ts`) records that edge and STOPS there — following
@@ -25,7 +27,7 @@
25
27
  // closure, and the containment channel already selects the project that
26
28
  // owns it.
27
29
 
28
- import { existsSync } from 'node:fs'
30
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
29
31
  import { realpath } from 'node:fs/promises'
30
32
  import { builtinModules } from 'node:module'
31
33
  import path from 'node:path'
@@ -130,11 +132,158 @@ export function unprovidedBareImports(
130
132
  if (up === dir) break
131
133
  dir = up
132
134
  }
133
- if (!provided && !out.includes(spec)) out.push(spec)
135
+ if (!provided && tsconfigTarget(spec, fromDir) === undefined && !out.includes(spec)) {
136
+ out.push(spec)
137
+ }
134
138
  }
135
139
  return out
136
140
  }
137
141
 
142
+ interface CompilerPaths {
143
+ paths?: Record<string, unknown>
144
+ /** What `paths` targets resolve against: `baseUrl`, else the config's own dir. */
145
+ pathsBase?: string
146
+ baseUrl?: string
147
+ }
148
+
149
+ /**
150
+ * `compilerOptions.paths` and `baseUrl` after `extends`, each resolved
151
+ * against the config that set it. A package `extends` is not followed: its
152
+ * paths are unseen, so a specifier only they map stays refused.
153
+ */
154
+ function compilerPaths(file: string, memo?: TsconfigMemo, depth = 0): CompilerPaths {
155
+ const known = memo?.get(file)
156
+ if (known !== undefined) return known
157
+ let json: unknown
158
+ try {
159
+ json = Bun.JSONC.parse(readFileSync(file, 'utf8'))
160
+ } catch {
161
+ return {}
162
+ }
163
+ if (typeof json !== 'object' || json === null) return {}
164
+ const dir = path.dirname(file)
165
+ const out: CompilerPaths = {}
166
+ const ext = (json as { extends?: unknown }).extends
167
+ for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
168
+ if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
169
+ const f = path.resolve(dir, base)
170
+ Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', memo, depth + 1))
171
+ }
172
+ const opts = (json as { compilerOptions?: unknown }).compilerOptions
173
+ if (typeof opts !== 'object' || opts === null) return out
174
+ const { baseUrl, paths } = opts as { baseUrl?: unknown; paths?: unknown }
175
+ if (typeof baseUrl === 'string') out.baseUrl = path.resolve(dir, baseUrl)
176
+ if (typeof paths === 'object' && paths !== null) {
177
+ out.paths = paths as Record<string, unknown>
178
+ out.pathsBase = out.baseUrl ?? dir
179
+ } else if (out.paths !== undefined && out.baseUrl !== undefined) {
180
+ out.pathsBase = out.baseUrl
181
+ }
182
+ memo?.set(file, out)
183
+ return out
184
+ }
185
+
186
+ /** The file Bun loads for an extensionless or extensioned path, or undefined. */
187
+ function fileAt(target: string): string | undefined {
188
+ for (const f of [
189
+ target,
190
+ ...RESOLVED_EXTENSIONS.map((e) => target + e),
191
+ ...RESOLVED_EXTENSIONS.map((e) => path.join(target, 'index' + e)),
192
+ ]) {
193
+ try {
194
+ if (statSync(f).isFile()) return f
195
+ } catch {}
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ /** Paths per directory and per tsconfig file, memoised across one walk. */
201
+ type TsconfigMemo = Map<string, CompilerPaths>
202
+
203
+ /**
204
+ * The paths of the nearest `tsconfig.json`, else `jsconfig.json`, at or
205
+ * above `dir`: Bun reads that one alone.
206
+ */
207
+ function pathsAt(dir: string, memo?: TsconfigMemo): CompilerPaths {
208
+ const known = memo?.get(dir)
209
+ if (known !== undefined) return known
210
+ const file = ['tsconfig.json', 'jsconfig.json'].map((n) => path.join(dir, n)).find(existsSync)
211
+ const up = path.dirname(dir)
212
+ const cp = file !== undefined ? compilerPaths(file, memo) : up === dir ? {} : pathsAt(up, memo)
213
+ memo?.set(dir, cp)
214
+ return cp
215
+ }
216
+
217
+ /**
218
+ * Whether `source` quotes a bare specifier the nearest tsconfig maps: the
219
+ * textual pass that keeps a config importing only packages unscanned, as
220
+ * D-23's keeps one importing nothing relative (D-27).
221
+ */
222
+ function hasAliasCandidate(source: string, fromDir: string, memo: TsconfigMemo): boolean {
223
+ SPECIFIER.lastIndex = 0
224
+ for (let m = SPECIFIER.exec(source); m !== null; m = SPECIFIER.exec(source)) {
225
+ const spec = m[1] ?? m[2] ?? ''
226
+ if (needsLookup(spec) && tsconfigBases(spec, fromDir, memo).length > 0) return true
227
+ }
228
+ return false
229
+ }
230
+
231
+ /**
232
+ * The paths a bare specifier names through the nearest tsconfig's `paths`
233
+ * (each matching target, in order) or `baseUrl`, before extensions: Bun
234
+ * tries them ahead of `node_modules`.
235
+ */
236
+ function tsconfigBases(spec: string, fromDir: string, memo?: TsconfigMemo): string[] {
237
+ const { paths, pathsBase, baseUrl } = pathsAt(path.resolve(fromDir), memo)
238
+ const out: string[] = []
239
+ for (const [key, targets] of Object.entries(paths ?? {})) {
240
+ const star = key.indexOf('*')
241
+ let hole: string
242
+ if (star === -1) {
243
+ if (spec !== key) continue
244
+ hole = ''
245
+ } else {
246
+ const head = key.slice(0, star)
247
+ const tail = key.slice(star + 1)
248
+ if (spec.length < key.length - 1 || !spec.startsWith(head) || !spec.endsWith(tail)) continue
249
+ hole = spec.slice(head.length, spec.length - tail.length)
250
+ }
251
+ for (const t of Array.isArray(targets) ? targets : []) {
252
+ if (typeof t === 'string') out.push(path.resolve(pathsBase!, t.replace('*', hole)))
253
+ }
254
+ }
255
+ if (baseUrl !== undefined) out.push(path.resolve(baseUrl, spec))
256
+ return out
257
+ }
258
+
259
+ /**
260
+ * The local file a bare specifier names through the nearest tsconfig, as
261
+ * Bun resolves it (D-26): an alias whose target exists loads from disk and
262
+ * never reaches the registry.
263
+ */
264
+ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): string | undefined {
265
+ for (const base of tsconfigBases(spec, fromDir, memo)) {
266
+ const hit = fileAt(base)
267
+ if (hit !== undefined) return hit
268
+ }
269
+ return undefined
270
+ }
271
+
272
+ /** A path as Bun names it: realpath'd when it exists. */
273
+ function realpathOr(file: string): string {
274
+ try {
275
+ return realpathSync(file)
276
+ } catch {
277
+ return file
278
+ }
279
+ }
280
+
281
+ /** A path and, bare of an extension, the files Bun would try for it. */
282
+ function expansions(named: string): string[] {
283
+ if (path.extname(named) !== '') return [named]
284
+ return [named, ...RESOLVED_EXTENSIONS.flatMap((e) => [named + e, path.join(named, 'index' + e)])]
285
+ }
286
+
138
287
  /**
139
288
  * A textual pass before the scan: a relative specifier opens with a quoted
140
289
  * `./` or `../`, or spells one with an escape, and most configs hold
@@ -152,8 +301,14 @@ const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
152
301
  * reason. `import type` is erased by `scanImports` and so contributes no edge,
153
302
  * which is correct: an erased import cannot move a resolved value.
154
303
  */
155
- function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
156
- if (!RELATIVE_CANDIDATE.test(source)) return []
304
+ function scanLocalImports(
305
+ source: string,
306
+ fromDir: string,
307
+ loader: 'ts' | 'js',
308
+ memo: TsconfigMemo,
309
+ ): string[] {
310
+ const aliased = hasAliasCandidate(source, fromDir, memo)
311
+ if (!aliased && !RELATIVE_CANDIDATE.test(source)) return []
157
312
  let specifiers: string[]
158
313
  try {
159
314
  specifiers = scanner(loader)
@@ -164,7 +319,16 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
164
319
  }
165
320
  const out: string[] = []
166
321
  for (const spec of specifiers) {
167
- if (!spec.startsWith('./') && !spec.startsWith('../')) continue
322
+ if (!spec.startsWith('./') && !spec.startsWith('../')) {
323
+ // A tsconfig alias is a local import Bun resolves by path (D-27); a
324
+ // target the change deleted still names its paths, as below.
325
+ if (!aliased || spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
326
+ const bases = tsconfigBases(spec, fromDir, memo)
327
+ const hit = bases.map(fileAt).find((f) => f !== undefined)
328
+ if (hit !== undefined) out.push(realpathOr(hit))
329
+ else out.push(...bases.flatMap(expansions))
330
+ continue
331
+ }
168
332
  try {
169
333
  out.push(Bun.resolveSync(spec, fromDir))
170
334
  } catch {
@@ -174,12 +338,7 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
174
338
  // of an extension, the files Bun would have tried), so the deleted
175
339
  // path in the diff still reaches its importer (item 958). Selection
176
340
  // may widen; it is never hashed.
177
- const named = path.resolve(fromDir, spec)
178
- out.push(named)
179
- if (path.extname(spec) === '') {
180
- for (const ext of RESOLVED_EXTENSIONS)
181
- out.push(named + ext, path.join(named, 'index' + ext))
182
- }
341
+ out.push(...expansions(path.resolve(fromDir, spec)))
183
342
  }
184
343
  }
185
344
  return out
@@ -198,6 +357,12 @@ export interface ConfigImportOwnersArgs {
198
357
  changed: readonly string[]
199
358
  /** Already-selected projects; their configs need no scan. */
200
359
  skip: ReadonlySet<string>
360
+ /**
361
+ * Each project dir's realpath, when the caller already has them: the
362
+ * containment pass needs the same answers, and asking twice cost 5,000
363
+ * realpaths at 5,000 projects (D-24).
364
+ */
365
+ realDirs?: ReadonlyMap<string, string>
201
366
  }
202
367
 
203
368
  /**
@@ -207,11 +372,14 @@ export interface ConfigImportOwnersArgs {
207
372
  * `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
208
373
  * the two matches nothing and fails exactly like "found no imports".
209
374
  */
210
- async function realDirIndex(projects: readonly ProjectMeta[]): Promise<Map<string, string>> {
375
+ async function realDirIndex(
376
+ projects: readonly ProjectMeta[],
377
+ known: ReadonlyMap<string, string> | undefined,
378
+ ): Promise<Map<string, string>> {
211
379
  const out = new Map<string, string>()
212
380
  await Promise.all(
213
381
  projects.map(async (p) => {
214
- out.set(await realpath(p.dir).catch(() => p.dir), p.name)
382
+ out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
215
383
  }),
216
384
  )
217
385
  return out
@@ -249,10 +417,11 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
249
417
  }),
250
418
  )
251
419
  if (roots.size === 0) return selected
252
- const dirToName = await realDirIndex(a.projects)
420
+ const dirToName = await realDirIndex(a.projects, a.realDirs)
253
421
 
254
422
  // target → the files that import it. Reversed up front so one BFS from the
255
423
  // changed set answers every root at once, instead of a walk per root.
424
+ const tsconfigs: TsconfigMemo = new Map()
256
425
  const importedBy = new Map<string, string[]>()
257
426
  const visited = new Set<string>()
258
427
  // A level at a time, its files read together: one awaited read per
@@ -275,7 +444,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
275
444
  if (entry === null) continue
276
445
  const { file, source } = entry
277
446
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
278
- for (const target of scanLocalImports(source, path.dirname(file), loader)) {
447
+ for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
279
448
  if (!target.startsWith(workspaceRoot + path.sep)) continue
280
449
  if (target.split(path.sep).includes('node_modules')) continue
281
450
  const list = importedBy.get(target)