@vzn/vx-migrate 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 +77 -73
- package/package.json +3 -3
- package/src/adopt.ts +53 -12
- package/src/adoption-plugin.ts +9 -11
- package/src/bin.ts +21 -1
- package/src/glob-grammar.ts +82 -0
- package/src/index.ts +36 -10
- package/src/mapping-cache.ts +14 -6
- package/src/migrate-nx.ts +7 -4
- package/src/migrate-scripts.ts +10 -102
- package/src/migrate-turbo.ts +2 -6
- package/src/migrate.ts +57 -38
- package/src/nx/index.ts +19 -5
- package/src/nx/nx-inputs.ts +44 -9
- package/src/nx/nx-map.ts +65 -25
- package/src/nx/nx-outputs.ts +16 -6
- package/src/nx/nx-upstream.ts +15 -7
- package/src/nx-cache/index.ts +60 -50
- package/src/nx-exec.cjs +2 -2
- package/src/package-fs.ts +6 -1
- package/src/packages.ts +9 -7
- package/src/plugin-gaps.ts +32 -6
- package/src/remote-cache.ts +5 -1
- package/src/remote-deadline.ts +7 -1
- package/src/remote-refusal.ts +31 -0
- package/src/remote-retry.ts +9 -4
- package/src/remote-url.ts +28 -0
- package/src/script-command.ts +110 -4
- package/src/shared-outputs.ts +24 -12
- package/src/tracked-outputs.ts +11 -7
- package/src/turbo/index.ts +33 -17
- package/src/turbo/turbo-map.ts +97 -192
- package/src/turbo/turbo-reads.ts +183 -0
- package/src/turbo-cache/index.ts +261 -102
- package/src/turbo-cache/oidc.ts +52 -26
- package/src/turbo-cache/scope.ts +5 -3
- package/src/vite-task/vite-task-map.ts +11 -6
- package/src/workspace-plugins.ts +5 -2
package/src/package-fs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What packages.ts needs of the file system and of globs, as core has
|
|
2
2
|
// them: core's copies are not on the façade, and these left core with the
|
|
3
|
-
// package-manager discovery (
|
|
3
|
+
// package-manager discovery (2026-10-14).
|
|
4
4
|
|
|
5
5
|
import { readlinkSync, realpathSync } from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
@@ -123,6 +123,11 @@ export async function gitTop(dir: string): Promise<string | null> {
|
|
|
123
123
|
if (await file.exists()) {
|
|
124
124
|
const gitdir = await file.text().catch(() => '')
|
|
125
125
|
if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
|
|
126
|
+
// A submodule's own worktree points there too, and is a top-level:
|
|
127
|
+
// git writes `commondir` in a worktree's gitdir alone.
|
|
128
|
+
const to = gitdir.replace(/^gitdir:\s*/, '').trim()
|
|
129
|
+
if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
|
|
130
|
+
return d || '/'
|
|
126
131
|
}
|
|
127
132
|
if (d === '') return null
|
|
128
133
|
}
|
package/src/packages.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Package-manager workspaces: the members `package.json#workspaces` or
|
|
2
2
|
// `pnpm-workspace.yaml` list, and the root a migration writes
|
|
3
|
-
// `.vx/workspace.ts` at. Core reads neither (
|
|
3
|
+
// `.vx/workspace.ts` at. Core reads neither (2026-10-14): a project is a
|
|
4
4
|
// directory holding a vx.config.
|
|
5
5
|
import { constants, existsSync, type Dirent } from 'node:fs'
|
|
6
6
|
import { access, readdir, realpath, stat } from 'node:fs/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import { PROJECT_CONFIG_FILENAMES, UserError } from '@vzn/vx'
|
|
8
|
+
import { PROJECT_CONFIG_FILENAMES, ui, UserError } from '@vzn/vx'
|
|
9
9
|
import {
|
|
10
10
|
BUN_GLOB_WILDCARDS,
|
|
11
11
|
EXTGLOB,
|
|
@@ -75,7 +75,7 @@ const decoder = new TextDecoder()
|
|
|
75
75
|
* Inside git the walk never goes above the git top-level ({@link gitTop}),
|
|
76
76
|
* so a manifest above the checkout is never even read: a `package.json`
|
|
77
77
|
* planted in a shared directory above it claimed it, and its workspace
|
|
78
|
-
* config ran as you (
|
|
78
|
+
* config ran as you (2026-10-13: "find the root based on git").
|
|
79
79
|
*
|
|
80
80
|
* Outside git the walk stops above `start` at a manifest another user
|
|
81
81
|
* owns (root aside), the same case without a top-level to stop at
|
|
@@ -641,8 +641,9 @@ async function readManifest(root: string, dir: string, file: string): Promise<st
|
|
|
641
641
|
() => false,
|
|
642
642
|
)
|
|
643
643
|
if (searchable) unreadable(err, file)
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
ui.say(
|
|
645
|
+
'warn',
|
|
646
|
+
`vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it`,
|
|
646
647
|
)
|
|
647
648
|
return null
|
|
648
649
|
}
|
|
@@ -768,8 +769,9 @@ export async function discoverPackages(
|
|
|
768
769
|
shared.sort()
|
|
769
770
|
const shown =
|
|
770
771
|
shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
|
|
771
|
-
|
|
772
|
-
|
|
772
|
+
ui.say(
|
|
773
|
+
'warn',
|
|
774
|
+
`vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}`,
|
|
773
775
|
)
|
|
774
776
|
}
|
|
775
777
|
|
package/src/plugin-gaps.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// What a mapping could not say, reported once by `vx lock`, the pass that
|
|
2
|
-
// checks configs (a run checks nothing,
|
|
2
|
+
// checks configs (a run checks nothing, 2026-10-15). What this machine
|
|
3
3
|
// lacks to run the mapping (a bin, a graph export) is said on runs too. One line per DISTINCT gap, not one per task: n8n has a
|
|
4
4
|
// `dev` and a `watch` in most of its 84 packages, and astro's `build`
|
|
5
5
|
// carries the same `!vendor/**` output in every package — the per-task
|
|
6
6
|
// form was a hundred identical lines before the first frame (2026-09-11).
|
|
7
7
|
// Shared by `turbo()` and `nx()` since the second one existed.
|
|
8
8
|
|
|
9
|
+
import { taskNameProblem } from '@vzn/vx'
|
|
10
|
+
|
|
9
11
|
/** What both mappers' projects have in common: a name and tasks with todos. */
|
|
10
12
|
interface MappedProject {
|
|
11
13
|
readonly name: string
|
|
@@ -39,25 +41,49 @@ export function collectGaps(
|
|
|
39
41
|
return { notes, todos, live }
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* At `vx lock` every gap; on a run only the live notes. Each says `[vx]`
|
|
46
|
+
* first, as every plugin warning does, then the package's short name.
|
|
47
|
+
*/
|
|
43
48
|
export function warnGaps(
|
|
44
49
|
warn: (line: string) => void,
|
|
45
50
|
pluginName: string,
|
|
46
51
|
gaps: Gaps,
|
|
47
52
|
lock: boolean,
|
|
48
53
|
): void {
|
|
49
|
-
|
|
54
|
+
const tag = `[vx] ${pluginName.replace(/^@vzn\//, '')}:`
|
|
55
|
+
for (const note of gaps.live) warn(`${tag} ${note}`)
|
|
50
56
|
if (!lock) return
|
|
51
|
-
for (const note of gaps.notes) warn(
|
|
57
|
+
for (const note of gaps.notes) warn(`${tag} ${note}`)
|
|
52
58
|
for (const [todo, ids] of gaps.todos) {
|
|
53
59
|
if (ids.length === 1) {
|
|
54
|
-
warn(
|
|
60
|
+
warn(`${tag} ${ids[0]}: ${todo}`)
|
|
55
61
|
continue
|
|
56
62
|
}
|
|
57
63
|
const names = [...new Set(ids.map((id) => id.slice(id.indexOf('#') + 1)))]
|
|
58
64
|
const packages = new Set(ids.map((id) => id.slice(0, id.indexOf('#'))))
|
|
59
65
|
warn(
|
|
60
|
-
|
|
66
|
+
`${tag} ${ids.length} tasks (${names.join(', ')} across ${packages.size} package${packages.size === 1 ? '' : 's'}): ${todo}`,
|
|
61
67
|
)
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A task whose name the schema refuses (`lint#fix`, `^up`) becomes a TODO,
|
|
73
|
+
* as the scripts migrator does (item 1033): written as a task, core
|
|
74
|
+
* refused the whole config on the next command.
|
|
75
|
+
*/
|
|
76
|
+
export function refuseTaskNames(
|
|
77
|
+
projects: readonly { tasks: { name: string; todos: string[]; task: object | null }[] }[],
|
|
78
|
+
what: string,
|
|
79
|
+
): void {
|
|
80
|
+
for (const p of projects)
|
|
81
|
+
for (const t of p.tasks) {
|
|
82
|
+
const why = t.task === null ? null : taskNameProblem(t.name)
|
|
83
|
+
if (why === null) continue
|
|
84
|
+
t.task = null
|
|
85
|
+
t.todos = [
|
|
86
|
+
`${what} ${JSON.stringify(t.name)} not migrated: its name ${why} — rename it, or add it by hand under another name`,
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/remote-cache.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The remote cache a Turbo or Nx repo already uses, as the repo shows it,
|
|
2
2
|
// so the workspace file a migration writes keeps sharing it: native or
|
|
3
|
-
// keep, from `vx init` or `bunx @vzn/vx-migrate` (
|
|
3
|
+
// keep, from `vx init` or `bunx @vzn/vx-migrate` (2026-10-14). Nx
|
|
4
4
|
// Cloud's wire is Nx's own, so it is named, never declared.
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
@@ -28,6 +28,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
28
28
|
rc = (
|
|
29
29
|
Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
|
|
30
30
|
)?.remoteCache
|
|
31
|
+
// quiet: a local config file: none read is none declared
|
|
31
32
|
} catch {
|
|
32
33
|
continue
|
|
33
34
|
}
|
|
@@ -42,6 +43,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
42
43
|
let linked: unknown
|
|
43
44
|
try {
|
|
44
45
|
linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
|
|
46
|
+
// quiet: a local config file: none read is none linked
|
|
45
47
|
} catch {}
|
|
46
48
|
if (
|
|
47
49
|
typeof linked === 'object' &&
|
|
@@ -63,6 +65,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
63
65
|
let text: string
|
|
64
66
|
try {
|
|
65
67
|
text = readFileSync(path.join(root, f), 'utf8')
|
|
68
|
+
// quiet: a local config file: none read is none declared
|
|
66
69
|
} catch {
|
|
67
70
|
continue
|
|
68
71
|
}
|
|
@@ -84,6 +87,7 @@ function usesNxCloud(root: string): boolean {
|
|
|
84
87
|
} | null
|
|
85
88
|
try {
|
|
86
89
|
json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
|
|
90
|
+
// quiet: a local config file: none read is none declared
|
|
87
91
|
} catch {
|
|
88
92
|
return false
|
|
89
93
|
}
|
package/src/remote-deadline.ts
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export function deadline(timeoutMs: number): AbortSignal {
|
|
13
13
|
const controller = new AbortController()
|
|
14
|
+
// A timer past 2^31-1 ms fires after 1 ms: a 30-day
|
|
15
|
+
// TURBO_REMOTE_CACHE_TIMEOUT aborted every request as it started. Turbo
|
|
16
|
+
// defines that value, so it is held here; an option is refused at the call.
|
|
14
17
|
setTimeout(
|
|
15
18
|
() => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
|
|
16
|
-
timeoutMs,
|
|
19
|
+
Math.min(timeoutMs, MAX_TIMER_MS),
|
|
17
20
|
).unref()
|
|
18
21
|
return controller.signal
|
|
19
22
|
}
|
|
23
|
+
|
|
24
|
+
/** The longest delay a timer holds. */
|
|
25
|
+
export const MAX_TIMER_MS = 2 ** 31 - 1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** How long the body's first chunk is waited for: a refusal is not held on a body that never comes. */
|
|
2
|
+
const BODY_WAIT_MS = 1_000
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A remote's refusal, named by its status and its own words: "HTTP 500"
|
|
6
|
+
* alone said nothing, and a batch query Vercel refused with 400 "Too many
|
|
7
|
+
* hashes. Limit is 100." read as no answer and was never seen (main
|
|
8
|
+
* f71b377, 2026-10-23). One line of the body, bounded: it is remote text.
|
|
9
|
+
* Shared by `turboCache()` and `nxCache()`.
|
|
10
|
+
*/
|
|
11
|
+
export async function refusal(res: Response, what = ''): Promise<Error> {
|
|
12
|
+
let said = ''
|
|
13
|
+
try {
|
|
14
|
+
const reader = res.body?.getReader()
|
|
15
|
+
if (reader !== undefined) {
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
const first = await Promise.race([
|
|
18
|
+
reader.read(),
|
|
19
|
+
new Promise<undefined>((r) => {
|
|
20
|
+
timer = setTimeout(() => r(undefined), BODY_WAIT_MS)
|
|
21
|
+
}),
|
|
22
|
+
]).finally(() => clearTimeout(timer))
|
|
23
|
+
// quiet: read no further; a cancel that fails has nothing left to free
|
|
24
|
+
void reader.cancel().catch(() => undefined)
|
|
25
|
+
said = new TextDecoder().decode(first?.value?.subarray(0, 512)).replace(/\s+/g, ' ').trim()
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// quiet: the status alone still names the refusal
|
|
29
|
+
}
|
|
30
|
+
return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
|
|
31
|
+
}
|
package/src/remote-retry.ts
CHANGED
|
@@ -24,7 +24,8 @@ export class Cooldown {
|
|
|
24
24
|
* One request with Turbo's retry rule (`turborepo-api-client/src/retry.rs`):
|
|
25
25
|
* a 5xx other than 501, or a request that never connected, is sent again
|
|
26
26
|
* after 2 s, `retries` times. A 429 opens the remote's `cooldown` for its
|
|
27
|
-
* `Retry-After` (capped at 10 s a wait, 2 s without one
|
|
27
|
+
* `Retry-After` (capped at 10 s a wait, 2 s without one or for a `0`
|
|
28
|
+
* after the first), and the request
|
|
28
29
|
* is resent once it ends, as often as fits in 20 s from its first 429: a
|
|
29
30
|
* limit longer than one capped wait lost every hit before (Growth's
|
|
30
31
|
* parity run, 2026-10-16). A spent deadline is not retried, unlike
|
|
@@ -55,8 +56,12 @@ export async function withRetry(
|
|
|
55
56
|
continue
|
|
56
57
|
}
|
|
57
58
|
if (res.status === 429) {
|
|
59
|
+
// `Retry-After: 0` resends at once, but only once in a row: each
|
|
60
|
+
// answered so sent again at once, hundreds a second for 20 s.
|
|
61
|
+
const asked = retryAfterMs(res)
|
|
62
|
+
const delay =
|
|
63
|
+
asked === undefined || (asked === 0 && firstLimit !== undefined) ? BACKOFF_MS : asked
|
|
58
64
|
firstLimit ??= now()
|
|
59
|
-
const delay = retryAfterMs(res) ?? BACKOFF_MS
|
|
60
65
|
if (retries === 0 || now() + delay - firstLimit > RATE_LIMIT_BUDGET_MS) return res
|
|
61
66
|
cooldown.extend(delay)
|
|
62
67
|
await res.body?.cancel()
|
|
@@ -77,10 +82,10 @@ function retryable(status: number): boolean {
|
|
|
77
82
|
return status >= 500 && status !== 501
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
/** A refused port or an unresolved host, as Bun's `fetch` codes them (
|
|
85
|
+
/** A refused port or an unresolved host, as Bun's `fetch` codes them (1.4.4 took errno names). */
|
|
81
86
|
function neverConnected(err: unknown): boolean {
|
|
82
87
|
const code = (err as { code?: unknown } | null)?.code
|
|
83
|
-
return code === '
|
|
88
|
+
return code === 'ECONNREFUSED' || code === 'ENOTFOUND'
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/** `Retry-After` as seconds or an HTTP date, capped as Turbo caps it. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { UserError } from '@vzn/vx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A remote cache's URL, refused as `from` names it (an option, a variable,
|
|
5
|
+
* a config file). Shared by `turboCache()` and `nxCache()`, at the call for
|
|
6
|
+
* the option and when the cache opens for every source.
|
|
7
|
+
*
|
|
8
|
+
* Plain http:// to any host is taken, where vxCache() takes it only to
|
|
9
|
+
* loopback: Turbo and Nx both send their token over it, and their
|
|
10
|
+
* self-hosted servers document such URLs (`TURBO_API=http://cache:3000`
|
|
11
|
+
* beside a CI service container; nx-cache-server's
|
|
12
|
+
* `NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://your-server:3000`). These
|
|
13
|
+
* plugins run such a repo unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function checkRemoteUrl(url: string, from: string): void {
|
|
16
|
+
// With no scheme every request failed on its own, as a bare "Invalid URL".
|
|
17
|
+
if (!/^https?:\/\//.test(url))
|
|
18
|
+
throw new UserError(`${from} must be an http(s) URL, got ${JSON.stringify(url)}`)
|
|
19
|
+
// The URL is printed in every refusal line, so a `user:pass@` in it would
|
|
20
|
+
// leak to the log. Credentials go in the token.
|
|
21
|
+
if (URL.canParse(url)) {
|
|
22
|
+
const u = new URL(url)
|
|
23
|
+
if (u.username !== '' || u.password !== '')
|
|
24
|
+
throw new UserError(
|
|
25
|
+
`${from} carries credentials (user:pass@); pass them as the token instead`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/script-command.ts
CHANGED
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
// the shims `yarn run` writes, so inlined, `node -e "require('left-pad')"`
|
|
21
21
|
// was MODULE_NOT_FOUND (2026-09-28). Every script runs as `yarn run <name>`.
|
|
22
22
|
|
|
23
|
-
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
import { foldScriptHooks } from '@vzn/vx'
|
|
26
|
+
import { shellQuote } from './nx-command.js'
|
|
26
27
|
|
|
27
28
|
const YARN_RUN_BUILTIN = /(^|&&|\|\||;|\(|\|)\s*run\s/
|
|
28
29
|
const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
|
|
@@ -36,15 +37,22 @@ export function scriptCommand(
|
|
|
36
37
|
body: string,
|
|
37
38
|
scripts: Readonly<Record<string, unknown>> = {},
|
|
38
39
|
pnp = false,
|
|
40
|
+
/** `runsScriptHooks`: false where the manager runs no `pre` / `post` hook. */
|
|
41
|
+
hooks = true,
|
|
39
42
|
): string {
|
|
40
|
-
if (pnp) return
|
|
43
|
+
if (pnp) return yarnRun(name)
|
|
41
44
|
const hook = (h: string): string | undefined =>
|
|
42
|
-
!LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
|
|
45
|
+
hooks && !LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
|
|
43
46
|
const parts = [hook(`pre${name}`), body, hook(`post${name}`)]
|
|
44
|
-
if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return
|
|
47
|
+
if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return yarnRun(name)
|
|
45
48
|
return foldScriptHooks(parts[0], body, parts[2])
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/** `yarn run <name>`, the name one shell word: `a b` ran script `a` with an argument. */
|
|
52
|
+
export function yarnRun(name: string): string {
|
|
53
|
+
return `yarn run ${shellQuote(name)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
/**
|
|
49
57
|
* Yarn ≥ 2 links Plug'n'Play unless `.yarnrc.yml` names another `nodeLinker`.
|
|
50
58
|
* Read once per mapping; synchronous so every mapper, the synchronous ones
|
|
@@ -67,3 +75,101 @@ export async function yarnrcText(root: string): Promise<string> {
|
|
|
67
75
|
.text()
|
|
68
76
|
.catch(() => '\0absent')
|
|
69
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The package manager that owns `dir`, when it runs `pre` / `post` hooks
|
|
81
|
+
* around a script; null when it runs none. The manager is the nearest `packageManager` field or
|
|
82
|
+
* lockfile, and its settings are read beside it, as each reads them for a
|
|
83
|
+
* member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
|
|
84
|
+
* 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
|
|
85
|
+
* `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
|
|
86
|
+
* `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
|
|
87
|
+
* Yarn 1 run them whatever those say. No manager found is npm's default.
|
|
88
|
+
*/
|
|
89
|
+
export function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
|
|
90
|
+
const { manager, at } = ownerOf(dir, memo)
|
|
91
|
+
const read = (f: string): string | undefined => {
|
|
92
|
+
try {
|
|
93
|
+
return readFileSync(path.join(at, f), 'utf8')
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const npmrc = read('.npmrc') ?? ''
|
|
99
|
+
const set = (key: string, value: string): boolean =>
|
|
100
|
+
new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
|
|
101
|
+
const runs =
|
|
102
|
+
manager === 'berry'
|
|
103
|
+
? false
|
|
104
|
+
: manager === 'npm'
|
|
105
|
+
? !set('ignore-scripts', 'true')
|
|
106
|
+
: manager === 'pnpm'
|
|
107
|
+
? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
|
|
108
|
+
: true
|
|
109
|
+
return runs ? manager : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
|
|
114
|
+
* it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
|
|
115
|
+
* which pnpm 10 honours, and folded hooks pnpm does not run.
|
|
116
|
+
*/
|
|
117
|
+
function pnpmSkipsHooks(yaml: string | undefined): boolean {
|
|
118
|
+
if (yaml === undefined) return false
|
|
119
|
+
try {
|
|
120
|
+
const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
|
|
121
|
+
return parsed?.enablePrePostScripts === false
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The package manager that owns a directory, and the directory that says so. */
|
|
128
|
+
export interface Owner {
|
|
129
|
+
manager: string
|
|
130
|
+
at: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The nearest `packageManager` field or lockfile from `dir` up: `berry` for
|
|
135
|
+
* Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
|
|
136
|
+
*/
|
|
137
|
+
export function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
|
|
138
|
+
const known = memo.get(dir)
|
|
139
|
+
if (known !== undefined) return known
|
|
140
|
+
const read = (f: string): string | undefined => {
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(path.join(dir, f), 'utf8')
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let manager: string | undefined
|
|
148
|
+
try {
|
|
149
|
+
// A byte-order mark is stripped, as npm, pnpm and discovery strip it:
|
|
150
|
+
// JSON.parse threw on one and the field went unread.
|
|
151
|
+
const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
|
|
152
|
+
const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
|
|
153
|
+
// A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
|
|
154
|
+
// hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
|
|
155
|
+
// claim nothing had checked (D-96).
|
|
156
|
+
if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
|
|
157
|
+
manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
|
|
158
|
+
} catch {}
|
|
159
|
+
if (manager === undefined) {
|
|
160
|
+
const yarnLock = read('yarn.lock')
|
|
161
|
+
if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
|
|
162
|
+
else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
|
|
163
|
+
else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
|
|
164
|
+
else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
|
|
165
|
+
}
|
|
166
|
+
const up = path.dirname(dir)
|
|
167
|
+
const owner =
|
|
168
|
+
manager !== undefined
|
|
169
|
+
? { manager, at: dir }
|
|
170
|
+
: up === dir
|
|
171
|
+
? { manager: 'npm', at: dir }
|
|
172
|
+
: ownerOf(up, memo)
|
|
173
|
+
memo.set(dir, owner)
|
|
174
|
+
return owner
|
|
175
|
+
}
|
package/src/shared-outputs.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// file exists to prevent (item 445).
|
|
24
24
|
|
|
25
25
|
import { isLiteralPattern, outputsOverlap, type GeneratedTask } from '@vzn/vx'
|
|
26
|
+
import { withoutTakenBack } from './glob-grammar.js'
|
|
26
27
|
import { relPosix } from './paths.js'
|
|
27
28
|
import type { TrackedKinds } from './tracked-outputs.js'
|
|
28
29
|
|
|
@@ -192,18 +193,24 @@ function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/**
|
|
195
|
-
* Can the keeper's `ga` give `gb`'s tree back? Only when
|
|
196
|
-
* directory at any depth (`**\/dist`, `**\/dist/**`) and
|
|
197
|
-
* head names no such directory:
|
|
198
|
-
*
|
|
199
|
-
* beside types' `types
|
|
200
|
-
* vx the slower of the two.
|
|
196
|
+
* Can the keeper's `ga` give `gb`'s tree back? Only when one of the two is
|
|
197
|
+
* a directory at any depth (`**\/dist`, `**\/dist/**`) and the other's
|
|
198
|
+
* literal head names no such directory: they meet only where one tree
|
|
199
|
+
* nests in the other, which neither task writes. solid's build outputs
|
|
200
|
+
* `dist/**` and `**\/dist` beside types' `types/**` and `**\/types`; Turbo
|
|
201
|
+
* caches both, and uncaching types made vx the slower of the two.
|
|
201
202
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
203
|
+
const DIR_ANYWHERE = /^\*\*\/([^*?[\]{}()!/\\]+)(?:\/\*\*)?$/
|
|
204
|
+
|
|
205
|
+
const separable = (ga: string, gb: string): boolean => apart(ga, gb) || apart(gb, ga)
|
|
206
|
+
|
|
207
|
+
function apart(dir: string, other: string): boolean {
|
|
208
|
+
const name = DIR_ANYWHERE.exec(dir)?.[1]
|
|
204
209
|
if (name === undefined) return false
|
|
210
|
+
const otherName = DIR_ANYWHERE.exec(other)?.[1]
|
|
211
|
+
if (otherName !== undefined) return otherName !== name
|
|
205
212
|
const head: string[] = []
|
|
206
|
-
for (const seg of
|
|
213
|
+
for (const seg of other.replace(/^(\.\/)+/, '').split('/')) {
|
|
207
214
|
if (!isLiteralPattern(seg) || seg === '') break
|
|
208
215
|
head.push(seg)
|
|
209
216
|
}
|
|
@@ -348,7 +355,7 @@ function prefixIndex(): {
|
|
|
348
355
|
}
|
|
349
356
|
|
|
350
357
|
/**
|
|
351
|
-
* A keyed group (`cache`, no `exec`: the `
|
|
358
|
+
* A keyed group (`cache`, no `exec`: the `inputs:<name>` twins, Turbo's
|
|
352
359
|
* transit nodes) runs nothing, so core lets its key read what a task
|
|
353
360
|
* writes (`detectInputOverlaps` exempts it). Uncaching one left `{}`, a
|
|
354
361
|
* task core refuses, and every verb failed.
|
|
@@ -606,7 +613,12 @@ export function excludeWorkspaceOutputs(
|
|
|
606
613
|
}
|
|
607
614
|
}
|
|
608
615
|
|
|
609
|
-
/**
|
|
616
|
+
/**
|
|
617
|
+
* A `!` output with no positive beside it takes back nothing, and core
|
|
618
|
+
* refuses a list of them alone; a literal one takes back was never saved,
|
|
619
|
+
* and core refuses it too (trpc's `package.json`, taken back).
|
|
620
|
+
*/
|
|
610
621
|
export function takingBack(globs: readonly string[]): string[] {
|
|
611
|
-
|
|
622
|
+
const kept = withoutTakenBack(globs)
|
|
623
|
+
return kept.some((g) => !g.startsWith('!')) ? kept : []
|
|
612
624
|
}
|
package/src/tracked-outputs.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
|
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
|
|
13
13
|
import { relPosix } from './paths.js'
|
|
14
|
-
import
|
|
14
|
+
import type * as SharedOutputs from './shared-outputs.js'
|
|
15
15
|
|
|
16
16
|
/** The files git tracks under `root`, root-relative; null outside a repo or without git. */
|
|
17
17
|
export async function trackedFiles(root: string): Promise<string[] | null> {
|
|
@@ -135,11 +135,13 @@ export async function headStamp(root: string): Promise<string> {
|
|
|
135
135
|
const m = /^gitdir: (.*)$/m.exec(await readFile(dotGit, 'utf8').catch(() => ''))
|
|
136
136
|
if (m !== null) gitDir = path.resolve(dir, m[1]!.trim())
|
|
137
137
|
}
|
|
138
|
-
const head = await
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
const [head, log, index, common] = await Promise.all([
|
|
139
|
+
readFile(path.join(gitDir, 'HEAD'), 'utf8').catch(() => ''),
|
|
140
|
+
lstat(path.join(gitDir, 'logs', 'HEAD')).catch(() => null),
|
|
141
|
+
lstat(path.join(gitDir, 'index')).catch(() => null),
|
|
142
|
+
// A worktree's own stack holds its HEAD; the common one, its branches.
|
|
143
|
+
readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => null),
|
|
144
|
+
])
|
|
143
145
|
const tables = await Promise.all(
|
|
144
146
|
[gitDir, ...(common === null ? [] : [path.resolve(gitDir, common.trim())])].map((d) =>
|
|
145
147
|
readFile(path.join(d, 'reftable', 'tables.list'), 'utf8').catch(() => ''),
|
|
@@ -220,7 +222,7 @@ interface Outputs {
|
|
|
220
222
|
/**
|
|
221
223
|
* Fit a mapped task's outputs to the committed files they cover, in place,
|
|
222
224
|
* and return one `[pkg#task, todo]` per task this leaves uncached. Core
|
|
223
|
-
* never cleans a committed file (
|
|
225
|
+
* never cleans a committed file (2026-10-14), so
|
|
224
226
|
* the outputs keep them; what is left is the readers' `!` that hides one.
|
|
225
227
|
* `tracked` is root-relative.
|
|
226
228
|
*/
|
|
@@ -236,6 +238,8 @@ export function spareTrackedOutputs(
|
|
|
236
238
|
}[],
|
|
237
239
|
tracked: readonly string[],
|
|
238
240
|
): [string, string][] {
|
|
241
|
+
// Loaded here, on a remap only: a kept mapping's run never reads it.
|
|
242
|
+
const { isKeyedGroup, unkey } = require('./shared-outputs.js') as typeof SharedOutputs
|
|
239
243
|
const todos: [string, string][] = []
|
|
240
244
|
const trackedSet = new Set(tracked)
|
|
241
245
|
/** Drop the `!` entries readers carry for a committed file no output names any more. */
|