@vzn/vx 0.0.118 → 0.0.121
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 +18 -16
- package/package.json +5 -5
- package/src/cli/watch-fs.ts +92 -1
- package/src/cli/watch.ts +7 -82
- package/src/workspace/migrate-scripts.ts +91 -27
- package/src/workspace/project-loader.ts +24 -5
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
|
|
|
38
38
|
fully cached run replays the graph in 510 ms.
|
|
39
39
|
<!-- bench:end -->
|
|
40
40
|
|
|
41
|
-
Measured on
|
|
41
|
+
Measured on fourteen real Turborepo, Nx, moon, wireit and lage repos too: [tried on real repos](#tried-on-real-repos).
|
|
42
42
|
|
|
43
43
|
## Install
|
|
44
44
|
|
|
@@ -145,25 +145,27 @@ Turbo and Nx feature is in vx, pinned by a test:
|
|
|
145
145
|
|
|
146
146
|
## Tried on real repos
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
Fourteen public monorepos, each run with its own `turbo.json`, Nx graph, moon, wireit or lage config
|
|
149
149
|
and nothing rewritten, against the tool it ships with. Both tools on the same machine and
|
|
150
150
|
tasks per row, medians; vx first, theirs second. Where the other tool
|
|
151
151
|
wins, the row says so.
|
|
152
152
|
|
|
153
|
-
| Repo
|
|
154
|
-
|
|
|
155
|
-
| [solidjs/solid](https://github.com/solidjs/solid)
|
|
156
|
-
| [withastro/astro](https://github.com/withastro/astro)
|
|
157
|
-
| [payloadcms/payload](https://github.com/payloadcms/payload)
|
|
158
|
-
| [medusajs/medusa](https://github.com/medusajs/medusa)
|
|
159
|
-
| [n8n-io/n8n](https://github.com/n8n-io/n8n)
|
|
160
|
-
| [calcom/cal.com](https://github.com/calcom/cal.com)
|
|
161
|
-
| [TanStack/query](https://github.com/TanStack/query)
|
|
162
|
-
| [strapi/strapi](https://github.com/strapi/strapi)
|
|
163
|
-
| [novuhq/novu](https://github.com/novuhq/novu)
|
|
164
|
-
| [TanStack/router](https://github.com/TanStack/router)
|
|
165
|
-
| [refinedev/refine](https://github.com/refinedev/refine)
|
|
166
|
-
| [kindspells/astro-shield](https://github.com/kindspells/astro-shield)
|
|
153
|
+
| Repo | Against | Cold build | Restore outputs | Nothing to do |
|
|
154
|
+
| ----------------------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
|
|
155
|
+
| [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
|
|
156
|
+
| [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
|
|
157
|
+
| [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
|
|
158
|
+
| [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
|
|
159
|
+
| [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
|
|
160
|
+
| [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
|
|
161
|
+
| [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
|
|
162
|
+
| [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
|
|
163
|
+
| [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
|
|
164
|
+
| [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
|
|
165
|
+
| [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
|
|
166
|
+
| [kindspells/astro-shield](https://github.com/kindspells/astro-shield) | moon | 2.64 s / 3.78 s | 125 ms / 2.46 s | 111 ms / 2.37 s |
|
|
167
|
+
| [FormidableLabs/spectacle](https://github.com/FormidableLabs/spectacle) | wireit | 21.5 s / 22.2 s | 394 ms / 574 ms | 387 ms / 593 ms |
|
|
168
|
+
| [microsoft/lage](https://github.com/microsoft/lage) | lage | 32.4 s / **29.4 s** | 730 ms / 1.70 s | 284 ms / 1.56 s |
|
|
167
169
|
|
|
168
170
|
**Bold** marks the other tool winning. Versions, revisions, scopes and
|
|
169
171
|
harness per repo: [benchmarks](packages/vx/docs/benchmarks.md) (astro
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.121",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.121",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.121",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.121"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface ArmedWatcher {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** How often the fallback re-walks a watched tree. */
|
|
35
|
-
|
|
35
|
+
const POLL_INTERVAL_MS = 250
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Directory names the fallback never descends into WHEN no better filter is
|
|
@@ -246,3 +246,94 @@ export function fsClockNow(dir: string): number {
|
|
|
246
246
|
return Date.now()
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
/** A handle that watches nothing: a slot a drop emptied, an arm not made. */
|
|
251
|
+
export const CLOSED: WatchHandle = { close() {} }
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every watcher `vx watch` arms. Each OS watcher proves delivery or is
|
|
255
|
+
* swapped for the poller in its slot; an OS watch limit (ENOSPC, EMFILE)
|
|
256
|
+
* goes straight to the poller; `VX_WATCH_POLL=1` polls from the start.
|
|
257
|
+
* `skip` is what the poller leaves unsampled, asked on each walk.
|
|
258
|
+
*/
|
|
259
|
+
export class WatcherPool {
|
|
260
|
+
private readonly watchers: WatchHandle[] = []
|
|
261
|
+
private readonly proofs: Promise<void>[] = []
|
|
262
|
+
// `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
|
|
263
|
+
// to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
|
|
264
|
+
// network mount, a container bind — the attempt costs a denied syscall
|
|
265
|
+
// and a two-second wait before the fallback takes over anyway.
|
|
266
|
+
private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
267
|
+
|
|
268
|
+
constructor(private readonly skip: (dir: string, rel: string) => boolean) {
|
|
269
|
+
if (this.forcePoll)
|
|
270
|
+
process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
|
|
274
|
+
return pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, (rel) => this.skip(dir, rel))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
278
|
+
// swapped for a poller in place, and a drop must close what is there.
|
|
279
|
+
arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
|
|
280
|
+
const watchers = this.watchers
|
|
281
|
+
const at = watchers.length
|
|
282
|
+
const handle: WatchHandle = {
|
|
283
|
+
close: () => {
|
|
284
|
+
watchers[at]?.close()
|
|
285
|
+
watchers[at] = CLOSED
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
if (this.forcePoll) {
|
|
289
|
+
watchers.push(this.poll(dir, recursive, onEvent))
|
|
290
|
+
return handle
|
|
291
|
+
}
|
|
292
|
+
let armed: ArmedWatcher
|
|
293
|
+
try {
|
|
294
|
+
armed = armWatcher(dir, recursive, onEvent)
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// The OS's watch limit, not the directory: the loop said "watching"
|
|
297
|
+
// and never fired. The poller needs no watch slot.
|
|
298
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
299
|
+
if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
|
|
300
|
+
watchers.push(this.poll(dir, recursive, onEvent))
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
`vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
|
|
303
|
+
)
|
|
304
|
+
return handle
|
|
305
|
+
}
|
|
306
|
+
watchers.push(armed.watcher)
|
|
307
|
+
this.proofs.push(
|
|
308
|
+
armed.ready.then((ok) => {
|
|
309
|
+
if (ok) return
|
|
310
|
+
armed.watcher.close()
|
|
311
|
+
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
312
|
+
if (watchers[at] !== armed.watcher) return
|
|
313
|
+
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
314
|
+
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
315
|
+
// the poller rather than run a loop that silently never fires.
|
|
316
|
+
watchers[at] = this.poll(dir, recursive, onEvent)
|
|
317
|
+
process.stderr.write(
|
|
318
|
+
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
319
|
+
)
|
|
320
|
+
}),
|
|
321
|
+
)
|
|
322
|
+
return handle
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Settles once every arm so far has proved delivery or fallen back. */
|
|
326
|
+
async proved(): Promise<void> {
|
|
327
|
+
await Promise.all(this.proofs)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
closeAll(): void {
|
|
331
|
+
for (const w of this.watchers) {
|
|
332
|
+
try {
|
|
333
|
+
w.close()
|
|
334
|
+
} catch {
|
|
335
|
+
// ignore
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -41,14 +41,7 @@ import {
|
|
|
41
41
|
makeWatchIgnore,
|
|
42
42
|
shapesWatchedSet,
|
|
43
43
|
} from './watch-filter.js'
|
|
44
|
-
import {
|
|
45
|
-
armWatcher,
|
|
46
|
-
fsClockNow,
|
|
47
|
-
pollWatcher,
|
|
48
|
-
POLL_INTERVAL_MS,
|
|
49
|
-
WATCH_PROBE_TIMEOUT_MS,
|
|
50
|
-
type WatchHandle,
|
|
51
|
-
} from './watch-fs.js'
|
|
44
|
+
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
52
45
|
import { ChangeJudge } from './watch-judge.js'
|
|
53
46
|
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
54
47
|
|
|
@@ -313,8 +306,6 @@ interface Rediscovered {
|
|
|
313
306
|
packageDirs: ReadonlySet<string>
|
|
314
307
|
}
|
|
315
308
|
|
|
316
|
-
const CLOSED: WatchHandle = { close() {} }
|
|
317
|
-
|
|
318
309
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
319
310
|
const { opts, stop, workspaceRoot, projects, cacheDir } = args
|
|
320
311
|
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
@@ -414,73 +405,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
414
405
|
/** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
|
|
415
406
|
let reread = false
|
|
416
407
|
|
|
417
|
-
const watchers: WatchHandle[] = []
|
|
418
|
-
const proofs: Promise<void>[] = []
|
|
419
|
-
// `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
|
|
420
|
-
// to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
|
|
421
|
-
// network mount, a container bind — the attempt costs a denied syscall
|
|
422
|
-
// and a two-second wait before the fallback takes over anyway.
|
|
423
|
-
const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
424
|
-
if (forcePoll)
|
|
425
|
-
process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
|
|
426
408
|
// The poller skips exactly what the event filter would drop: the
|
|
427
409
|
// unconditional segments AND this run's declared output containers. Read
|
|
428
410
|
// through `isIgnoredPath` rather than captured, because `rearm` replaces
|
|
429
411
|
// the filter when the selection changes.
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
(
|
|
433
|
-
isIgnoredPath(dir, rel)
|
|
434
|
-
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
435
|
-
// swapped for a poller in place, and a drop must close what is there.
|
|
436
|
-
const arm = (
|
|
437
|
-
dir: string,
|
|
438
|
-
recursive: boolean,
|
|
439
|
-
onEvent: (filename: string) => void,
|
|
440
|
-
): WatchHandle => {
|
|
441
|
-
const at = watchers.length
|
|
442
|
-
const handle: WatchHandle = {
|
|
443
|
-
close: () => {
|
|
444
|
-
watchers[at]?.close()
|
|
445
|
-
watchers[at] = CLOSED
|
|
446
|
-
},
|
|
447
|
-
}
|
|
448
|
-
if (forcePoll) {
|
|
449
|
-
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
450
|
-
return handle
|
|
451
|
-
}
|
|
452
|
-
let armed: ReturnType<typeof armWatcher>
|
|
453
|
-
try {
|
|
454
|
-
armed = armWatcher(dir, recursive, onEvent)
|
|
455
|
-
} catch (err) {
|
|
456
|
-
// The OS's watch limit, not the directory: the loop said "watching"
|
|
457
|
-
// and never fired. The poller needs no watch slot.
|
|
458
|
-
const code = (err as NodeJS.ErrnoException).code
|
|
459
|
-
if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
|
|
460
|
-
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
461
|
-
process.stderr.write(
|
|
462
|
-
`vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
|
|
463
|
-
)
|
|
464
|
-
return handle
|
|
465
|
-
}
|
|
466
|
-
watchers.push(armed.watcher)
|
|
467
|
-
proofs.push(
|
|
468
|
-
armed.ready.then((ok) => {
|
|
469
|
-
if (ok) return
|
|
470
|
-
armed.watcher.close()
|
|
471
|
-
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
472
|
-
if (watchers[at] !== armed.watcher) return
|
|
473
|
-
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
474
|
-
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
475
|
-
// the poller rather than run a loop that silently never fires.
|
|
476
|
-
watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
|
|
477
|
-
process.stderr.write(
|
|
478
|
-
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
479
|
-
)
|
|
480
|
-
}),
|
|
481
|
-
)
|
|
482
|
-
return handle
|
|
483
|
-
}
|
|
412
|
+
const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel))
|
|
413
|
+
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
|
|
414
|
+
pool.arm(dir, recursive, onEvent)
|
|
484
415
|
|
|
485
416
|
/** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
|
|
486
417
|
const armedAt = fsClockNow(cacheDir)
|
|
@@ -704,7 +635,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
704
635
|
armImports()
|
|
705
636
|
// A new arm proves delivery like the first ones: an edit in the new
|
|
706
637
|
// package right after this cycle is seen, not lost in the gap.
|
|
707
|
-
await
|
|
638
|
+
await pool.proved()
|
|
708
639
|
process.stdout.write(`${watchingLine(next.projects.length)}\n`)
|
|
709
640
|
}
|
|
710
641
|
|
|
@@ -763,19 +694,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
763
694
|
|
|
764
695
|
// "watching" is a promise that an edit from now on is seen; every
|
|
765
696
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
766
|
-
await
|
|
697
|
+
await pool.proved()
|
|
767
698
|
|
|
768
699
|
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
769
700
|
|
|
770
701
|
return await new Promise<number>((resolve) => {
|
|
771
702
|
const cleanup = async (): Promise<void> => {
|
|
772
|
-
|
|
773
|
-
try {
|
|
774
|
-
w.close()
|
|
775
|
-
} catch {
|
|
776
|
-
// ignore
|
|
777
|
-
}
|
|
778
|
-
}
|
|
703
|
+
pool.closeAll()
|
|
779
704
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
780
705
|
// The aborted cycle is tearing its children down; resolve only once
|
|
781
706
|
// it has returned, so the process never exits over a live child.
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
// hook is usually `rimraf dist`); and a script that is nothing but
|
|
24
24
|
// `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
|
|
25
25
|
// dependency instead of a package-manager subprocess it cannot cache.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// Where the manager runs no such hooks (Yarn 2+, D-31; npm or pnpm told
|
|
27
|
+
// not to, D-33) `prebuild` is a script like any other.
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync } from 'node:fs'
|
|
30
30
|
import path from 'node:path'
|
|
@@ -95,39 +95,90 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Whether the package manager that owns `dir`
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
98
|
+
* Whether the package manager that owns `dir` runs `pre` / `post` hooks
|
|
99
|
+
* around a script. The manager is the nearest `packageManager` field or
|
|
100
|
+
* lockfile, and its settings are read beside it, as each reads them for a
|
|
101
|
+
* member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
|
|
102
|
+
* 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
|
|
103
|
+
* `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
|
|
104
|
+
* `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
|
|
105
|
+
* Yarn 1 run them whatever those say. No manager found is npm's default.
|
|
102
106
|
*/
|
|
103
|
-
function
|
|
107
|
+
function runsScriptHooks(dir: string, memo: Map<string, boolean>): boolean {
|
|
104
108
|
const known = memo.get(dir)
|
|
105
109
|
if (known !== undefined) return known
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
const read = (f: string): string | undefined => {
|
|
111
|
+
try {
|
|
112
|
+
return readFileSync(path.join(dir, f), 'utf8')
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let manager: string | undefined
|
|
108
118
|
try {
|
|
109
|
-
const pm = (JSON.parse(
|
|
119
|
+
const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
|
|
110
120
|
.packageManager
|
|
111
|
-
if (typeof pm === 'string')
|
|
121
|
+
if (typeof pm === 'string')
|
|
122
|
+
manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
|
|
112
123
|
} catch {}
|
|
113
|
-
if (
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
else if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
)
|
|
120
|
-
)
|
|
121
|
-
answer = false
|
|
124
|
+
if (manager === undefined) {
|
|
125
|
+
const yarnLock = read('yarn.lock')
|
|
126
|
+
if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
|
|
127
|
+
else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
|
|
128
|
+
else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
|
|
129
|
+
else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
|
|
122
130
|
}
|
|
123
|
-
|
|
131
|
+
let answer: boolean
|
|
132
|
+
if (manager === undefined) {
|
|
124
133
|
const up = path.dirname(dir)
|
|
125
|
-
answer = up === dir ?
|
|
134
|
+
answer = up === dir ? true : runsScriptHooks(up, memo)
|
|
135
|
+
} else {
|
|
136
|
+
const npmrc = read('.npmrc') ?? ''
|
|
137
|
+
const set = (key: string, value: string): boolean =>
|
|
138
|
+
new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
|
|
139
|
+
answer =
|
|
140
|
+
manager === 'berry'
|
|
141
|
+
? false
|
|
142
|
+
: manager === 'npm'
|
|
143
|
+
? !set('ignore-scripts', 'true')
|
|
144
|
+
: manager === 'pnpm'
|
|
145
|
+
? !set('enable-pre-post-scripts', 'false') &&
|
|
146
|
+
!/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
|
|
147
|
+
: true
|
|
126
148
|
}
|
|
127
149
|
memo.set(dir, answer)
|
|
128
150
|
return answer
|
|
129
151
|
}
|
|
130
152
|
|
|
153
|
+
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The `$npm_*` variables a command reads. Every manager sets them for a
|
|
157
|
+
* script (probed: npm, pnpm, bun and yarn printed the version, the name
|
|
158
|
+
* and the script's name) and vx sets none, so a migrated `echo
|
|
159
|
+
* $npm_package_version` printed nothing (D-34). The name and version are
|
|
160
|
+
* read from the manifest at evaluation, so a bump reaches them; the event
|
|
161
|
+
* is the script's own name, which a folded hook does not share.
|
|
162
|
+
*/
|
|
163
|
+
function npmEnv(
|
|
164
|
+
command: string,
|
|
165
|
+
script: string,
|
|
166
|
+
folded: boolean,
|
|
167
|
+
): { define: Record<string, unknown>; unset: string[]; readsManifest: boolean } {
|
|
168
|
+
const define: Record<string, unknown> = {}
|
|
169
|
+
const unset: string[] = []
|
|
170
|
+
let readsManifest = false
|
|
171
|
+
for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
|
|
172
|
+
if (v! in define || unset.includes(v!)) continue
|
|
173
|
+
if (v === 'npm_package_name' || v === 'npm_package_version') {
|
|
174
|
+
define[v] = { raw: v === 'npm_package_name' ? 'pkg.name' : 'pkg.version' }
|
|
175
|
+
readsManifest = true
|
|
176
|
+
} else if (v === 'npm_lifecycle_event' && !folded) define[v] = script
|
|
177
|
+
else unset.push(v!)
|
|
178
|
+
}
|
|
179
|
+
return { define, unset, readsManifest }
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
132
183
|
// package.json is a boundary: `scripts` is whatever the file holds. A
|
|
133
184
|
// string or an array would enumerate its indices as script names.
|
|
@@ -178,10 +229,10 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
|
178
229
|
|
|
179
230
|
export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
180
231
|
const projects: GeneratedProject[] = []
|
|
181
|
-
const
|
|
232
|
+
const hookMemo = new Map<string, boolean>()
|
|
182
233
|
for (const meta of metas) {
|
|
183
234
|
const scripts = scriptsOf(meta)
|
|
184
|
-
const runsHooks =
|
|
235
|
+
const runsHooks = runsScriptHooks(meta.dir, hookMemo)
|
|
185
236
|
const runnable = Object.keys(scripts).filter(
|
|
186
237
|
(n) => typeof scripts[n] === 'string' && scripts[n] !== '',
|
|
187
238
|
)
|
|
@@ -205,6 +256,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
205
256
|
return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
|
|
206
257
|
}
|
|
207
258
|
const tasks: GeneratedTask[] = []
|
|
259
|
+
let readsManifest = false
|
|
208
260
|
for (const name of names) {
|
|
209
261
|
if (!isTask(name)) continue
|
|
210
262
|
|
|
@@ -239,9 +291,18 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
239
291
|
`npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
240
292
|
)
|
|
241
293
|
}
|
|
242
|
-
const
|
|
294
|
+
const exec: Record<string, unknown> = { command }
|
|
295
|
+
const npm = npmEnv(command, name, hooks.length > 0)
|
|
296
|
+
if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
|
|
297
|
+
if (npm.readsManifest) readsManifest = true
|
|
298
|
+
for (const v of npm.unset) {
|
|
299
|
+
todos.push(
|
|
300
|
+
`the script reads $${v}, which the package manager sets and vx does not: define it under exec.env.define or drop it`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
const task: Record<string, unknown> = { exec }
|
|
243
304
|
if (PERSISTENT_TASK_NAMES.has(name)) {
|
|
244
|
-
|
|
305
|
+
exec['persistent'] = {}
|
|
245
306
|
todos.push(PERSISTENT_TODO)
|
|
246
307
|
}
|
|
247
308
|
if (name === 'build') {
|
|
@@ -262,7 +323,10 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
262
323
|
})
|
|
263
324
|
}
|
|
264
325
|
upstreamBuildOnWorker(tasks)
|
|
265
|
-
if (tasks.length > 0)
|
|
326
|
+
if (tasks.length > 0) {
|
|
327
|
+
const importLines = readsManifest ? [MANIFEST_IMPORT] : []
|
|
328
|
+
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
329
|
+
}
|
|
266
330
|
}
|
|
267
331
|
return {
|
|
268
332
|
headerNotes: [
|
|
@@ -345,8 +345,9 @@ export async function loadProjectConfigs(
|
|
|
345
345
|
const hit = cacheKey === null ? undefined : hits.get(cacheKey)
|
|
346
346
|
// Stored AFTER validation, so a hit needs none; the key covers every
|
|
347
347
|
// byte the evaluation could have read.
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
const cached = hit === undefined ? undefined : storedConfig(hit)
|
|
349
|
+
if (cached !== undefined) {
|
|
350
|
+
out.push(cached)
|
|
350
351
|
continue
|
|
351
352
|
}
|
|
352
353
|
// A fast key that missed: the closure is stale or the file changed.
|
|
@@ -365,9 +366,10 @@ export async function loadProjectConfigs(
|
|
|
365
366
|
})
|
|
366
367
|
key = keyed?.key ?? null
|
|
367
368
|
closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
|
|
370
|
+
const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
|
|
371
|
+
if (slowHit !== undefined) {
|
|
372
|
+
out.push(slowHit)
|
|
371
373
|
if (closure !== undefined) learnedClosures.push([configPath, closure])
|
|
372
374
|
continue
|
|
373
375
|
}
|
|
@@ -488,3 +490,20 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
488
490
|
}
|
|
489
491
|
return null
|
|
490
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A stored evaluation, or undefined when the row is not one: a row cut
|
|
496
|
+
* short (a crash mid-write, a bad disk) failed every later run with a
|
|
497
|
+
* `SyntaxError` stack from this loader until the cache was wiped
|
|
498
|
+
* (fuzzed, L-17). As a miss it is evaluated again and the row replaced.
|
|
499
|
+
*/
|
|
500
|
+
function storedConfig(json: string): ProjectConfig | undefined {
|
|
501
|
+
try {
|
|
502
|
+
const v: unknown = JSON.parse(json)
|
|
503
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
504
|
+
? (v as ProjectConfig)
|
|
505
|
+
: undefined
|
|
506
|
+
} catch {
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
}
|