@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/src/adopt.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // which kind of adoption (native configs, or the runner's config read live),
3
3
  // and installing vx with the repo's own package manager (solidjs/solid,
4
4
  // 2026-10-04: the configs were written with no vx installed to run them).
5
- // The repo's own scripts are never edited (owner, 2026-10-06).
5
+ // The repo's own scripts are never edited (2026-10-06).
6
6
 
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
@@ -63,6 +63,32 @@ export function packageManagerOf(root: string, userAgent?: string): PackageManag
63
63
  )
64
64
  }
65
65
 
66
+ /**
67
+ * pnpm's `-w` where pnpm-workspace.yaml makes `root` a workspace: pnpm
68
+ * refuses it in a single-package repo ("--workspace-root may only be used
69
+ * inside a workspace").
70
+ */
71
+ function pnpmRoot(root: string): string[] {
72
+ return existsSync(path.join(root, 'pnpm-workspace.yaml')) ? ['-w'] : []
73
+ }
74
+
75
+ /**
76
+ * Yarn 2+: its lockfile carries `__metadata`, and before the first install
77
+ * there is none, so `packageManager` names it (`-W` was refused there).
78
+ */
79
+ function berry(root: string): boolean {
80
+ const lock = readText(path.join(root, 'yarn.lock'))
81
+ if (lock !== '') return lock.includes('__metadata:')
82
+ try {
83
+ const pm = (
84
+ JSON.parse(readText(path.join(root, 'package.json')) || '{}') as { packageManager?: unknown }
85
+ ).packageManager
86
+ return typeof pm === 'string' && /^yarn@([2-9]|\d{2,})/.test(pm)
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
66
92
  /** argv that adds `packages` as root dev dependencies. */
67
93
  export function installArgv(
68
94
  root: string,
@@ -71,11 +97,11 @@ export function installArgv(
71
97
  ): string[] {
72
98
  switch (pm) {
73
99
  case 'pnpm':
74
- return ['pnpm', 'add', '-D', '-w', ...packages]
100
+ return ['pnpm', 'add', '-D', ...pnpmRoot(root), ...packages]
75
101
  case 'yarn':
76
102
  // Yarn 1 refuses `add` at a workspace root without `-W`; Berry has no
77
- // such flag. A Berry lockfile carries `__metadata`.
78
- return readText(path.join(root, 'yarn.lock')).includes('__metadata:')
103
+ // such flag.
104
+ return berry(root)
79
105
  ? ['yarn', 'add', '-D', ...packages]
80
106
  : ['yarn', 'add', '-D', '-W', ...packages]
81
107
  case 'bun':
@@ -104,7 +130,7 @@ export function ownVersion(): string | null {
104
130
  * (at `version`, given one). Either alone is not adopted: a reset
105
131
  * package.json leaves node_modules behind, and a listing is not an
106
132
  * install; on solidjs/solid vx-migrate 0.0.512 skipped @vzn/vx that
107
- * package.json did not list (owner, 2026-10-06).
133
+ * package.json did not list (2026-10-06).
108
134
  */
109
135
  export function missingPackages(
110
136
  root: string,
@@ -129,11 +155,9 @@ export function removeArgv(
129
155
  ): string[] {
130
156
  switch (pm) {
131
157
  case 'pnpm':
132
- return ['pnpm', 'remove', '-w', ...packages]
158
+ return ['pnpm', 'remove', ...pnpmRoot(root), ...packages]
133
159
  case 'yarn':
134
- return readText(path.join(root, 'yarn.lock')).includes('__metadata:')
135
- ? ['yarn', 'remove', ...packages]
136
- : ['yarn', 'remove', '-W', ...packages]
160
+ return berry(root) ? ['yarn', 'remove', ...packages] : ['yarn', 'remove', '-W', ...packages]
137
161
  case 'bun':
138
162
  return ['bun', 'remove', ...packages]
139
163
  case 'npm':
@@ -156,7 +180,7 @@ export function listedPackages(root: string, packages: readonly string[]): strin
156
180
  * The `@vzn/vx-*` plugins the root lists from the registry (a `workspace:`
157
181
  * or `file:` link is the repo's own; a platform binary comes with core).
158
182
  * Every @vzn package releases together: an install moves them with core,
159
- * as `vx upgrade` does (M, 2026-10-16: "always install latest and upgrade all").
183
+ * as `vx upgrade` does (2026-10-16: "always install latest and upgrade all").
160
184
  */
161
185
  export function listedPlugins(root: string): string[] {
162
186
  const pj = JSON.parse(readText(path.join(root, 'package.json')) || '{}') as Record<
@@ -179,7 +203,7 @@ export function listedPlugins(root: string): string[] {
179
203
  * The `@vzn/vx*` packages a workspace file imports, parsed, not matched (a
180
204
  * formatter's quotes hid a match). Each is installed with core: a file an
181
205
  * earlier init wrote imports @vzn/vx-lockfile that package.json need not
182
- * list, and the lock could not find it (M, 2026-10-16).
206
+ * list, and the lock could not find it (2026-10-16).
183
207
  */
184
208
  export function importedPackages(text: string): string[] {
185
209
  const out = new Set<string>()
@@ -279,9 +303,26 @@ async function runManager(
279
303
  if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
280
304
  if (code !== 0) {
281
305
  process.stderr.write(said)
306
+ const missing = notServed(said)
282
307
  throw new UserError(
283
- `${what} failed (${line} exited ${code}${failed}); run it, then vx-migrate again${code > 0 ? ranHint : ''}`,
308
+ missing === undefined
309
+ ? `${what} failed (${line} exited ${code}${failed}); run it, then vx lock${code > 0 ? ranHint : ''}`
310
+ : `${what} failed: the npm registry does not serve ${missing} yet (404), as a release can take minutes to reach it; run vx init again`,
284
311
  )
285
312
  }
286
313
  return line
287
314
  }
315
+
316
+ /**
317
+ * The package a manager's output says the registry answered 404 for, as
318
+ * `name@version` (core's `vx init` reads its installs the same way): a
319
+ * release's platform tarball stays unserved for minutes after publish.
320
+ */
321
+ export function notServed(output: string): string | undefined {
322
+ for (const line of output.split('\n')) {
323
+ if (!line.includes('404')) continue
324
+ const m = /https?:\/\/\S+?\/((?:@[^/\s]+\/)?[^/\s]+)\/-\/[^/\s]+?-(\d[^/\s]*?)\.tgz/.exec(line)
325
+ if (m !== null) return `${decodeURIComponent(m[1]!)}@${m[2]}`
326
+ }
327
+ return undefined
328
+ }
@@ -6,7 +6,7 @@
6
6
  // skeleton until item 592; what differs is only how the mapping is made.
7
7
 
8
8
  import { definePlugin, type ProjectHookContext, type VxPlugin } from '@vzn/vx'
9
- import { type AdoptionMapping, cachedMapping } from './mapping-cache.js'
9
+ import { type AdoptionMapping, cachedMapping, codeId } from './mapping-cache.js'
10
10
  import { warnGaps } from './plugin-gaps.js'
11
11
  import { headStamp, spareTrackedOutputs, trackedFiles } from './tracked-outputs.js'
12
12
 
@@ -76,18 +76,16 @@ export function adoptionPlugin(
76
76
  async project(config, ctx) {
77
77
  if (mappedFor !== ctx.projects) {
78
78
  mappedFor = ctx.projects
79
+ // The key's other reads overlap the plugin's own.
80
+ void codeId()
81
+ const stamp = headStamp(ctx.workspaceRoot)
79
82
  mapping = mapRun(ctx).then(async (r) =>
80
83
  r.spareTracked === true
81
- ? cachedMapping(
82
- ctx.cacheDir,
83
- r.name,
84
- [...r.reads, await headStamp(ctx.workspaceRoot)],
85
- async () => {
86
- let read: Promise<readonly string[] | null> | undefined
87
- const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
88
- return spare(ctx, await r.map(tracked), await tracked())
89
- },
90
- )
84
+ ? cachedMapping(ctx.cacheDir, r.name, [...r.reads, await stamp], async () => {
85
+ let read: Promise<readonly string[] | null> | undefined
86
+ const tracked = () => (read ??= trackedFiles(ctx.workspaceRoot))
87
+ return spare(ctx, await r.map(tracked), await tracked())
88
+ })
91
89
  : cachedMapping(ctx.cacheDir, r.name, r.reads, () =>
92
90
  r.map(() => trackedFiles(ctx.workspaceRoot)),
93
91
  ),
package/src/bin.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // 2026-10-16).
11
11
 
12
12
  import path from 'node:path'
13
+ import * as core from '@vzn/vx'
13
14
  import { isUserError, UserError, VERSION } from '@vzn/vx'
14
15
  import { ownVersion } from './adopt.js'
15
16
 
@@ -26,8 +27,27 @@ try {
26
27
  const { migrateCmd } = await import('./migrate.js')
27
28
  process.exitCode = await migrateCmd(process.argv.slice(2))
28
29
  } catch (err) {
30
+ // Asked for JSON (`vx init --dry --format json` hands a Turbo or Nx repo
31
+ // here), a refusal is core's error document on stdout too
32
+ // (schemas/error.json): stdout was empty.
33
+ const args = process.argv.slice(2)
34
+ if (args.some((a, i) => a === '--format=json' || (a === '--format' && args[i + 1] === 'json'))) {
35
+ const message = err instanceof Error ? err.message : String(err)
36
+ const own = isUserError(err) ? (err as { code?: unknown }).code : 'VX_E_INTERNAL'
37
+ const code = typeof own === 'string' ? own : 'VX_E_REFUSED'
38
+ const docs = code.startsWith('VX_E_')
39
+ ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
40
+ : {}
41
+ process.stdout.write(
42
+ `${JSON.stringify({ ok: false, error: { code, message: `vx-migrate: ${message}`, ...docs } })}\n`,
43
+ )
44
+ }
29
45
  if (isUserError(err)) {
30
- process.stderr.write(`vx-migrate: ${err.message}\n`)
46
+ // `ui` by namespace: a core older than it has none, and its refusal
47
+ // still reaches the reader.
48
+ const ui = (core as Partial<typeof core>).ui
49
+ if (ui === undefined) process.stderr.write(`vx-migrate: ${err.message}\n`)
50
+ else ui.say('error', `vx-migrate: ${err.message}`)
31
51
  process.exitCode = 1
32
52
  } else {
33
53
  throw err
@@ -27,6 +27,51 @@ export function minimatchToVx(glob: string, negated: boolean): string | null {
27
27
  return out.includes('\0') || /[?@+*!]\(/.test(out) ? null : out
28
28
  }
29
29
 
30
+ // A group no extglob kind opens and no backslash escapes.
31
+ const BARE_GROUP = /(?<![\\?@+*!])\(([^()]*)\)/g
32
+
33
+ /**
34
+ * An Nx glob's bare groups in vx's grammar, or null when one has no safe
35
+ * form. Nx's native glob reads `(a|b)` as `{a,b}` and `(a)` as `a`, so
36
+ * `dist/*.(js|map)` is the two extensions there; vx and minimatch read the
37
+ * parentheses as literal text, and the glob matched nothing. Turbo's
38
+ * grammar has no such group, so this is the Nx adapter's alone.
39
+ */
40
+ export function nxBareGroups(glob: string): string | null {
41
+ if (!glob.includes('(')) return glob
42
+ let unsafe = false
43
+ const out = glob.replace(BARE_GROUP, (_whole, body: string) => {
44
+ const alts = body.split('|')
45
+ if (alts.some((a) => /[{},]/.test(a))) unsafe = true
46
+ return alts.length === 1 ? alts[0]! : `{${alts.join(',')}}`
47
+ })
48
+ return unsafe ? null : out
49
+ }
50
+
51
+ /** A segment (or brace arm) holding `**` and other text: `**.ts`, `a**`. */
52
+ const glued = (seg: string): boolean => seg.includes('**') && /[^*]/.test(seg)
53
+
54
+ /** Does `glob` glue `**` to other text, which core refuses? */
55
+ export const hasGluedGlobstar = (glob: string): boolean =>
56
+ glob
57
+ .replace(/^!/, '')
58
+ .split(/[/{},]/)
59
+ .some(glued)
60
+
61
+ /**
62
+ * A glued `**` as minimatch and Nx's matcher (globset, literal separator)
63
+ * read it: `*`, one level.
64
+ */
65
+ export function gluedGlobstarAsStar(glob: string): string {
66
+ const neg = glob.startsWith('!') ? '!' : ''
67
+ return (
68
+ neg +
69
+ glob
70
+ .slice(neg.length)
71
+ .replace(/[^/{},]+/g, (seg) => (glued(seg) ? seg.replace(/\*{2,}/g, '*') : seg))
72
+ )
73
+ }
74
+
30
75
  /**
31
76
  * `list` without a literal that one of its `!` entries takes back, and
32
77
  * without a repeat. Turbo, Nx and vx all subtract an exclusion wherever it
@@ -53,6 +98,43 @@ export function withoutTakenBack<T>(entries: readonly T[]): T[] {
53
98
  })
54
99
  }
55
100
 
101
+ /**
102
+ * Could a path match both globs? False only when, before either reaches a
103
+ * wildcard, a segment of one differs from the other's; a literal also
104
+ * matches its directory's files, as a `!` entry takes them back.
105
+ */
106
+ function mayOverlap(a: string, b: string): boolean {
107
+ const wild = /[*?{}[\]\\]/
108
+ const sa = a.replace(/^\.\//, '').split('/')
109
+ const sb = b.replace(/^\.\//, '').split('/')
110
+ for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
111
+ if (wild.test(sa[i]!) || wild.test(sb[i]!)) return true
112
+ if (sa[i] !== sb[i]) return false
113
+ }
114
+ return true
115
+ }
116
+
117
+ /**
118
+ * Two lists core reads as one, each with its own `!` entries: Turbo
119
+ * subtracts a task's exclusions from the task's own inputs only, and a
120
+ * `globalDependencies` exclusion from the global hash only. One list in
121
+ * vx subtracts every `!` from every entry, so each list loses an exclusion
122
+ * that may reach a positive entry of the other: it keys more files (a
123
+ * missed hit), where kept it keyed fewer (a stale one).
124
+ */
125
+ export function apartTakeBacks<T>(a: readonly T[], b: readonly T[]): [T[], T[]] {
126
+ const positives = (l: readonly T[]) =>
127
+ l.flatMap((e) => (typeof e === 'string' && !e.startsWith('!') ? [e] : []))
128
+ const keep = (l: readonly T[], other: readonly string[]) =>
129
+ l.filter(
130
+ (e) =>
131
+ typeof e !== 'string' ||
132
+ !e.startsWith('!') ||
133
+ !other.some((p) => mayOverlap(e.slice(1), p)),
134
+ )
135
+ return [keep(a, positives(b)), keep(b, positives(a))]
136
+ }
137
+
56
138
  /**
57
139
  * A path from the file system as a task glob that names only itself: a dir
58
140
  * named `my{app}` read as a brace matched `myapp` instead, and `app/[id]` a
package/src/index.ts CHANGED
@@ -2,14 +2,40 @@
2
2
  // runs unchanged) and the two remote caches speaking Turbo's and Nx's
3
3
  // wire. The CLI (`bunx @vzn/vx-migrate`) is `migrate.ts`; mappers and
4
4
  // cache clients are internal (1.0 freezes what this file exports).
5
- export { turbo, type TurboPluginOptions } from './turbo/index.js'
6
- export {
7
- nx,
8
- type NxExecutors,
9
- type NxExecutorTarget,
10
- type NxExecutorTranslation,
11
- type NxExecutorTranslator,
12
- type NxPluginOptions,
5
+ //
6
+ // Each factory loads its module on first call: a workspace evaluates this
7
+ // file on every run, and a Turbo repo paid ~4 ms per run transpiling nx()
8
+ // and both cache clients it never calls (solid, 2026-10-22).
9
+ import type { VxPlugin } from '@vzn/vx'
10
+ import type * as Turbo from './turbo/index.js'
11
+ import type { TurboPluginOptions } from './turbo/index.js'
12
+ import type { NxPluginOptions } from './nx/index.js'
13
+ import type { TurboCacheOptions } from './turbo-cache/index.js'
14
+ import type { NxCacheOptions } from './nx-cache/index.js'
15
+ import type * as Nx from './nx/index.js'
16
+ import type * as TurboCache from './turbo-cache/index.js'
17
+ import type * as NxCache from './nx-cache/index.js'
18
+
19
+ export type { TurboPluginOptions } from './turbo/index.js'
20
+ export type {
21
+ NxExecutors,
22
+ NxExecutorTarget,
23
+ NxExecutorTranslation,
24
+ NxExecutorTranslator,
25
+ NxPluginOptions,
13
26
  } from './nx/index.js'
14
- export { turboCache, type TurboCacheOptions } from './turbo-cache/index.js'
15
- export { nxCache, type NxCacheOptions } from './nx-cache/index.js'
27
+ export type { TurboCacheOptions } from './turbo-cache/index.js'
28
+ export type { NxCacheOptions } from './nx-cache/index.js'
29
+
30
+ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
31
+ return (require('./turbo/index.js') as typeof Turbo).turbo(options)
32
+ }
33
+ export function nx(options: NxPluginOptions = {}): VxPlugin {
34
+ return (require('./nx/index.js') as typeof Nx).nx(options)
35
+ }
36
+ export function turboCache(options: TurboCacheOptions = {}): VxPlugin {
37
+ return (require('./turbo-cache/index.js') as typeof TurboCache).turboCache(options)
38
+ }
39
+ export function nxCache(options: NxCacheOptions = {}): VxPlugin {
40
+ return (require('./nx-cache/index.js') as typeof NxCache).nxCache(options)
41
+ }
@@ -8,6 +8,7 @@
8
8
  // package's sources and the core helpers the mappers call: a new
9
9
  // `@vzn/vx-migrate` or `@vzn/vx` maps afresh.
10
10
 
11
+ import { statSync } from 'node:fs'
11
12
  import { mkdir, rename } from 'node:fs/promises'
12
13
  import path from 'node:path'
13
14
  import { foldScriptHooks, isLiteralPattern, outputsOverlap, VERSION } from '@vzn/vx'
@@ -30,8 +31,8 @@ export interface AdoptionMapping {
30
31
 
31
32
  let code: Promise<string> | undefined
32
33
 
33
- /** This package's sources and the core helpers the mappers call, hashed once per process. */
34
- function codeId(): Promise<string> {
34
+ /** This package's sources (by size and mtime) and the core helpers the mappers call, once per process. */
35
+ export function codeId(): Promise<string> {
35
36
  code ??= (async () => {
36
37
  const parts: string[] = [
37
38
  VERSION,
@@ -40,7 +41,13 @@ function codeId(): Promise<string> {
40
41
  const files: string[] = []
41
42
  for await (const f of new Bun.Glob('**/*.{ts,cjs}').scan(import.meta.dir)) files.push(f)
42
43
  files.sort()
43
- for (const f of files) parts.push(f, await Bun.file(path.join(import.meta.dir, f)).text())
44
+ // Size and mtime, not bytes: reading the 650 kB of sources cost ~10 ms
45
+ // on every run, more than the mapping hit it guards saves on a small
46
+ // repo (solid, 2026-10-22). An edit moves both.
47
+ for (const f of files) {
48
+ const s = statSync(path.join(import.meta.dir, f))
49
+ parts.push(f, `${s.size}:${s.mtimeMs}`)
50
+ }
44
51
  return hex(parts.join('\0'))
45
52
  })()
46
53
  return code
@@ -71,11 +78,12 @@ export async function cachedMapping(
71
78
  parts: readonly string[],
72
79
  map: () => Promise<AdoptionMapping>,
73
80
  ): Promise<AdoptionMapping> {
74
- const key = hex([await codeId(), ...parts].join('\0'))
75
81
  const file = path.join(cacheDir, `vx-migrate-${name}-mapping.json`)
76
- const held = (await Bun.file(file)
82
+ const reading = Bun.file(file)
77
83
  .json()
78
- .catch(() => null)) as Held | null
84
+ .catch(() => null) as Promise<Held | null>
85
+ const key = hex([await codeId(), ...parts].join('\0'))
86
+ const held = await reading
79
87
  if (held?.key === key) {
80
88
  return {
81
89
  byName: new Map(held.byName),
package/src/migrate-nx.ts CHANGED
@@ -3,7 +3,12 @@
3
3
 
4
4
  import { stat } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { type GeneratedProject, type MigrationFormat, type MigrationPlan } from '@vzn/vx'
6
+ import {
7
+ type GeneratedProject,
8
+ type MigrationFormat,
9
+ type MigrationPlan,
10
+ quoteTsLiteral,
11
+ } from '@vzn/vx'
7
12
  import type { PackageMeta } from './packages.js'
8
13
  import { mapNxWorkspace, parseNxGraph, readNxJson } from './nx/nx-map.js'
9
14
  import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
@@ -172,8 +177,6 @@ export interface NxWorkspaceField {
172
177
  readonly note: string
173
178
  }
174
179
 
175
- const quoted = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
176
-
177
180
  /**
178
181
  * nx.json's run-wide settings `nx()` applies live: each a field of the
179
182
  * written `.vx/workspace.ts`, or a migrated repo that ran one task at a time
@@ -202,7 +205,7 @@ export function nxWorkspaceFields(json: Record<string, unknown> | undefined): Nx
202
205
  const branch = base.trim().replace(/^origin\//, '')
203
206
  out.push({
204
207
  field: 'baseBranch',
205
- source: quoted(branch),
208
+ source: quoteTsLiteral(branch),
206
209
  note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`baseBranch: ${JSON.stringify(branch)}\` to .vx/workspace.ts`,
207
210
  })
208
211
  }
@@ -39,6 +39,7 @@ import {
39
39
  } from '@vzn/vx'
40
40
  import type { PackageMeta } from './packages.js'
41
41
  import { relPosix } from './paths.js'
42
+ import { type Owner, ownerOf, runsScriptHooks } from './script-command.js'
42
43
 
43
44
  /**
44
45
  * Task names that conventionally never exit. Every mapper guesses the same
@@ -128,19 +129,24 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
128
129
  /**
129
130
  * The script a command delegates to when it is NOTHING but a package-manager
130
131
  * invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
131
- * x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
132
- * make it a real command again, which is left verbatim.
132
+ * x`, plus npm's bare `npm test` / `npm start` / `npm stop`. npm's aliases
133
+ * (`run-script`, `rum`, `urn`; `t`, `tst`) and pnpm's (`run-script`; `t`,
134
+ * `tst`) are the same commands: read verbatim, `npm run-script build` built
135
+ * again beside the `build` task. Flags, arguments or a chain make it a real
136
+ * command again, which is left verbatim.
133
137
  */
134
138
  export function delegatedScript(command: string): string | null {
135
139
  const m =
136
- /^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
140
+ /^(?:(?:npm (?:run|run-script|rum|urn)|(pnpm|pn|yarn|bun)(?: (run|run-script))?) ([^\s&|;<>()$`'"\\]+)|npm (test|t|tst|start|stop))$/.exec(
137
141
  command.trim(),
138
142
  )
139
143
  if (m === null) return null
140
- if (m[4] !== undefined) return m[4]
144
+ if (m[4] !== undefined) return m[4] === 't' || m[4] === 'tst' ? 'test' : m[4]
141
145
  const [, alias, run, name] = m
142
146
  // `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
143
147
  const manager = alias === 'pn' ? 'pnpm' : alias
148
+ if (run === 'run-script' && manager !== 'pnpm') return null
149
+ if (manager === 'pnpm' && run === undefined && (name === 't' || name === 'tst')) return 'test'
144
150
  // Bare, the manager's own command wins over a script of that name:
145
151
  // `bun test` is Bun's test runner and `bun build` its bundler, never the
146
152
  // `test` / `build` script, and a group over the script ran the wrong
@@ -177,104 +183,6 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
177
183
  ),
178
184
  }
179
185
 
180
- /**
181
- * The package manager that owns `dir`, when it runs `pre` / `post` hooks
182
- * around a script; null when it runs none. The manager is the nearest `packageManager` field or
183
- * lockfile, and its settings are read beside it, as each reads them for a
184
- * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
185
- * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
186
- * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
187
- * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
188
- * Yarn 1 run them whatever those say. No manager found is npm's default.
189
- */
190
- function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
191
- const { manager, at } = ownerOf(dir, memo)
192
- const read = (f: string): string | undefined => {
193
- try {
194
- return readFileSync(path.join(at, f), 'utf8')
195
- } catch {
196
- return undefined
197
- }
198
- }
199
- const npmrc = read('.npmrc') ?? ''
200
- const set = (key: string, value: string): boolean =>
201
- new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
202
- const runs =
203
- manager === 'berry'
204
- ? false
205
- : manager === 'npm'
206
- ? !set('ignore-scripts', 'true')
207
- : manager === 'pnpm'
208
- ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
209
- : true
210
- return runs ? manager : null
211
- }
212
-
213
- /**
214
- * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
215
- * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
216
- * which pnpm 10 honours, and folded hooks pnpm does not run.
217
- */
218
- function pnpmSkipsHooks(yaml: string | undefined): boolean {
219
- if (yaml === undefined) return false
220
- try {
221
- const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
222
- return parsed?.enablePrePostScripts === false
223
- } catch {
224
- return false
225
- }
226
- }
227
-
228
- /** The package manager that owns a directory, and the directory that says so. */
229
- interface Owner {
230
- manager: string
231
- at: string
232
- }
233
-
234
- /**
235
- * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
236
- * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
237
- */
238
- function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
239
- const known = memo.get(dir)
240
- if (known !== undefined) return known
241
- const read = (f: string): string | undefined => {
242
- try {
243
- return readFileSync(path.join(dir, f), 'utf8')
244
- } catch {
245
- return undefined
246
- }
247
- }
248
- let manager: string | undefined
249
- try {
250
- // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
251
- // JSON.parse threw on one and the field went unread.
252
- const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
253
- const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
254
- // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
255
- // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
256
- // claim nothing had checked (D-96).
257
- if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
258
- manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
259
- } catch {}
260
- if (manager === undefined) {
261
- const yarnLock = read('yarn.lock')
262
- if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
263
- else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
264
- else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
265
- else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
266
- }
267
- const up = path.dirname(dir)
268
- const owner =
269
- manager !== undefined
270
- ? { manager, at: dir }
271
- : up === dir
272
- ? { manager: 'npm', at: dir }
273
- : ownerOf(up, memo)
274
- memo.set(dir, owner)
275
- return owner
276
- }
277
-
278
186
  /**
279
187
  * Yarn 2+ runs a script in its own shell, where `run <script>` is `yarn
280
188
  * run <script>`; vx's shell has no `run`, and 23 of berry's scripts
@@ -12,12 +12,8 @@ import {
12
12
  quoteTsLiteral as quote,
13
13
  } from '@vzn/vx'
14
14
  import type { PackageMeta } from './packages.js'
15
- import {
16
- mapTurboWorkspace,
17
- rootTaskProject,
18
- type TurboGlobal,
19
- type TurboMappedProject,
20
- } from './turbo/turbo-map.js'
15
+ import { mapTurboWorkspace, type TurboGlobal, type TurboMappedProject } from './turbo/turbo-map.js'
16
+ import { rootTaskProject } from './turbo/turbo-reads.js'
21
17
  import { relPosix } from './paths.js'
22
18
  import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
23
19
  import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'