@vzn/vx 0.0.101 → 0.0.102

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/
@@ -25,7 +25,7 @@
25
25
  // closure, and the containment channel already selects the project that
26
26
  // owns it.
27
27
 
28
- import { existsSync } from 'node:fs'
28
+ import { existsSync, readFileSync, statSync } from 'node:fs'
29
29
  import { realpath } from 'node:fs/promises'
30
30
  import { builtinModules } from 'node:module'
31
31
  import path from 'node:path'
@@ -130,11 +130,110 @@ export function unprovidedBareImports(
130
130
  if (up === dir) break
131
131
  dir = up
132
132
  }
133
- if (!provided && !out.includes(spec)) out.push(spec)
133
+ if (!provided && tsconfigTarget(spec, fromDir) === undefined && !out.includes(spec)) {
134
+ out.push(spec)
135
+ }
136
+ }
137
+ return out
138
+ }
139
+
140
+ /** The nearest `tsconfig.json`, else `jsconfig.json`, at or above `dir`: Bun reads that one alone. */
141
+ function nearestTsconfig(dir: string): string | undefined {
142
+ for (let d = path.resolve(dir); ;) {
143
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
144
+ if (existsSync(path.join(d, name))) return path.join(d, name)
145
+ }
146
+ const up = path.dirname(d)
147
+ if (up === d) return undefined
148
+ d = up
149
+ }
150
+ }
151
+
152
+ interface CompilerPaths {
153
+ paths?: Record<string, unknown>
154
+ /** What `paths` targets resolve against: `baseUrl`, else the config's own dir. */
155
+ pathsBase?: string
156
+ baseUrl?: string
157
+ }
158
+
159
+ /**
160
+ * `compilerOptions.paths` and `baseUrl` after `extends`, each resolved
161
+ * against the config that set it. A package `extends` is not followed: its
162
+ * paths are unseen, so a specifier only they map stays refused.
163
+ */
164
+ function compilerPaths(file: string, depth = 0): CompilerPaths {
165
+ let json: unknown
166
+ try {
167
+ json = Bun.JSONC.parse(readFileSync(file, 'utf8'))
168
+ } catch {
169
+ return {}
170
+ }
171
+ if (typeof json !== 'object' || json === null) return {}
172
+ const dir = path.dirname(file)
173
+ const out: CompilerPaths = {}
174
+ const ext = (json as { extends?: unknown }).extends
175
+ for (const base of typeof ext === 'string' ? [ext] : Array.isArray(ext) ? ext : []) {
176
+ if (depth > 8 || typeof base !== 'string' || !base.startsWith('.')) continue
177
+ const f = path.resolve(dir, base)
178
+ Object.assign(out, compilerPaths(f.endsWith('.json') ? f : f + '.json', depth + 1))
179
+ }
180
+ const opts = (json as { compilerOptions?: unknown }).compilerOptions
181
+ if (typeof opts !== 'object' || opts === null) return out
182
+ const { baseUrl, paths } = opts as { baseUrl?: unknown; paths?: unknown }
183
+ if (typeof baseUrl === 'string') out.baseUrl = path.resolve(dir, baseUrl)
184
+ if (typeof paths === 'object' && paths !== null) {
185
+ out.paths = paths as Record<string, unknown>
186
+ out.pathsBase = out.baseUrl ?? dir
187
+ } else if (out.paths !== undefined && out.baseUrl !== undefined) {
188
+ out.pathsBase = out.baseUrl
134
189
  }
135
190
  return out
136
191
  }
137
192
 
193
+ /** The file Bun loads for an extensionless or extensioned path, or undefined. */
194
+ function fileAt(target: string): string | undefined {
195
+ for (const f of [
196
+ target,
197
+ ...RESOLVED_EXTENSIONS.map((e) => target + e),
198
+ ...RESOLVED_EXTENSIONS.map((e) => path.join(target, 'index' + e)),
199
+ ]) {
200
+ try {
201
+ if (statSync(f).isFile()) return f
202
+ } catch {}
203
+ }
204
+ return undefined
205
+ }
206
+
207
+ /**
208
+ * The local file a bare specifier names through the nearest tsconfig's
209
+ * `paths` or `baseUrl`, as Bun resolves it (D-26): an alias whose target
210
+ * exists loads from disk and never reaches the registry.
211
+ */
212
+ function tsconfigTarget(spec: string, fromDir: string): string | undefined {
213
+ const cfg = nearestTsconfig(fromDir)
214
+ if (cfg === undefined) return undefined
215
+ const { paths, pathsBase, baseUrl } = compilerPaths(cfg)
216
+ for (const [key, targets] of Object.entries(paths ?? {})) {
217
+ const star = key.indexOf('*')
218
+ let hole: string
219
+ if (star === -1) {
220
+ if (spec !== key) continue
221
+ hole = ''
222
+ } else {
223
+ const head = key.slice(0, star)
224
+ const tail = key.slice(star + 1)
225
+ if (spec.length < key.length - 1 || !spec.startsWith(head) || !spec.endsWith(tail)) continue
226
+ hole = spec.slice(head.length, spec.length - tail.length)
227
+ }
228
+ for (const t of Array.isArray(targets) ? targets : []) {
229
+ if (typeof t !== 'string') continue
230
+ const hit = fileAt(path.resolve(pathsBase!, t.replace('*', hole)))
231
+ if (hit !== undefined) return hit
232
+ }
233
+ }
234
+ return baseUrl === undefined ? undefined : fileAt(path.resolve(baseUrl, spec))
235
+ }
236
+
138
237
  /**
139
238
  * A textual pass before the scan: a relative specifier opens with a quoted
140
239
  * `./` or `../`, or spells one with an escape, and most configs hold
@@ -198,6 +297,12 @@ export interface ConfigImportOwnersArgs {
198
297
  changed: readonly string[]
199
298
  /** Already-selected projects; their configs need no scan. */
200
299
  skip: ReadonlySet<string>
300
+ /**
301
+ * Each project dir's realpath, when the caller already has them: the
302
+ * containment pass needs the same answers, and asking twice cost 5,000
303
+ * realpaths at 5,000 projects (D-24).
304
+ */
305
+ realDirs?: ReadonlyMap<string, string>
201
306
  }
202
307
 
203
308
  /**
@@ -207,11 +312,14 @@ export interface ConfigImportOwnersArgs {
207
312
  * `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
208
313
  * the two matches nothing and fails exactly like "found no imports".
209
314
  */
210
- async function realDirIndex(projects: readonly ProjectMeta[]): Promise<Map<string, string>> {
315
+ async function realDirIndex(
316
+ projects: readonly ProjectMeta[],
317
+ known: ReadonlyMap<string, string> | undefined,
318
+ ): Promise<Map<string, string>> {
211
319
  const out = new Map<string, string>()
212
320
  await Promise.all(
213
321
  projects.map(async (p) => {
214
- out.set(await realpath(p.dir).catch(() => p.dir), p.name)
322
+ out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
215
323
  }),
216
324
  )
217
325
  return out
@@ -249,7 +357,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
249
357
  }),
250
358
  )
251
359
  if (roots.size === 0) return selected
252
- const dirToName = await realDirIndex(a.projects)
360
+ const dirToName = await realDirIndex(a.projects, a.realDirs)
253
361
 
254
362
  // target → the files that import it. Reversed up front so one BFS from the
255
363
  // changed set answers every root at once, instead of a walk per root.