@vzn/vx-migrate 0.0.635 → 0.0.637
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 +294 -42
- package/package.json +3 -3
- package/src/adopt.ts +23 -8
- package/src/manifest-graph.ts +65 -0
- package/src/manifests.ts +52 -0
- package/src/mapping-cache.ts +2 -8
- package/src/migrate-nx.ts +17 -16
- package/src/migrate-scripts.ts +1261 -0
- package/src/migrate-turbo.ts +6 -7
- package/src/migrate-vite-task.ts +5 -4
- package/src/migrate.ts +376 -139
- package/src/nx/index.ts +77 -20
- package/src/nx/nx-boundaries.ts +191 -0
- package/src/nx/nx-deps.ts +3 -3
- package/src/nx/nx-map.ts +37 -41
- package/src/nx/nx-upstream.ts +3 -2
- package/src/nx-cache/index.ts +5 -11
- package/src/package-fs.ts +152 -0
- package/src/packages.ts +806 -0
- package/src/paths.ts +2 -2
- package/src/remote-cache.ts +118 -0
- package/src/shared-outputs.ts +63 -19
- package/src/tracked-outputs.ts +54 -35
- package/src/turbo/index.ts +32 -20
- package/src/turbo/turbo-map.ts +96 -56
- package/src/turbo-cache/index.ts +14 -20
- package/src/vite-task/vite-task-map.ts +13 -12
- package/src/workspace-notes.ts +12 -6
- package/src/workspace-plugins.ts +41 -11
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
// package.json scripts → vx tasks: the source for a workspace that comes
|
|
2
|
+
// from nowhere (no turbo.json, no nx). `vx init` is this mapper.
|
|
3
|
+
//
|
|
4
|
+
// Scripts carry a command and nothing else, so the mapping is honest about
|
|
5
|
+
// what it cannot know: every task gets `exec.command` verbatim; `build`
|
|
6
|
+
// gets the conventional `dependsOn: ['^build']` and `test` / `typecheck`
|
|
7
|
+
// wait for `build` when the package has one (`lint` does not); a dev-server
|
|
8
|
+
// shaped script becomes persistent. Caching is opt-in and needs declared
|
|
9
|
+
// inputs AND outputs, which a script cannot tell us — so NO task gets a
|
|
10
|
+
// cache block; `build` carries a TODO showing the block to add. Until
|
|
11
|
+
// 2026-09-04 `build` was emitted with whole-project inputs and EMPTY
|
|
12
|
+
// outputs "to fill in": that is not an uncached task but a no-output one —
|
|
13
|
+
// it hits on unchanged inputs and skips the build with nothing to restore,
|
|
14
|
+
// so a deleted `dist` stayed deleted under a green `up-to-date` run
|
|
15
|
+
// (reproduced on the init walkthrough). A block that guessed `dist/**`
|
|
16
|
+
// would restore the wrong tree for every package that writes somewhere
|
|
17
|
+
// else, and a wrong restore is the worst failure vx has.
|
|
18
|
+
//
|
|
19
|
+
// Two npm conventions are mapped rather than copied, because copying them
|
|
20
|
+
// loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
|
|
21
|
+
// without being named, are folded into `x`'s command in that order (a
|
|
22
|
+
// standalone `prebuild` task is one `vx run build` never runs — and the
|
|
23
|
+
// hook is usually `rimraf dist`); and a script that is nothing but
|
|
24
|
+
// `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
|
|
25
|
+
// dependency instead of a package-manager subprocess it cannot cache.
|
|
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
|
+
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
import {
|
|
32
|
+
foldScriptHooks,
|
|
33
|
+
type GeneratedProject,
|
|
34
|
+
type GeneratedTask,
|
|
35
|
+
type MigrationPlan,
|
|
36
|
+
PERSISTENT_TODO,
|
|
37
|
+
pruneOrphanPersistentNotes,
|
|
38
|
+
taskNameProblem,
|
|
39
|
+
} from '@vzn/vx'
|
|
40
|
+
import type { PackageMeta } from './packages.js'
|
|
41
|
+
import { relPosix } from './paths.js'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Task names that conventionally never exit. Every mapper guesses the same
|
|
45
|
+
* way from a name — a source that SAYS a task is long-running (turbo's
|
|
46
|
+
* `persistent: true`, an Nx dev-server executor) is believed instead.
|
|
47
|
+
*/
|
|
48
|
+
const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
|
|
49
|
+
'dev',
|
|
50
|
+
'start',
|
|
51
|
+
'serve',
|
|
52
|
+
'watch',
|
|
53
|
+
'preview',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where a framework's build writes by default, for the cache TODO: the
|
|
58
|
+
* hint said `dist/**` to every build, `next build` included, which writes
|
|
59
|
+
* `.next`. Each tool's documented default; anything else keeps `dist/**`.
|
|
60
|
+
*/
|
|
61
|
+
const BUILD_OUTPUTS: readonly [RegExp, readonly string[]][] = [
|
|
62
|
+
[/\snext build\s/, ['.next/**', '!.next/cache/**']],
|
|
63
|
+
[/\snux[ti] build\s/, ['.output/**']],
|
|
64
|
+
[/\s(?:remix|react-router|react-scripts|docusaurus) build\s/, ['build/**']],
|
|
65
|
+
[/\sgatsby build\s/, ['public/**']],
|
|
66
|
+
[/\s(?:storybook build|build-storybook)\s/, ['storybook-static/**']],
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/** A directory a TODO may name: relative, plain, no hidden or tool dir. */
|
|
70
|
+
const OUTPUT_DIR = /^(?:\.\/)?((?![./])[\w@-][\w@./-]*?)\/?$/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory the command says it writes, when no tool above names one:
|
|
74
|
+
* an out-dir flag (`tsc --outDir lib`, `babel -d lib`), else the
|
|
75
|
+
* directories it cleans first (`del-cli distribution`, `rimraf dist
|
|
76
|
+
* types`). ky's build writes `distribution/`, and the TODO said `dist/**`
|
|
77
|
+
* (D-90). A file (`tsconfig.tsbuildinfo`) and `node_modules` are no output.
|
|
78
|
+
*/
|
|
79
|
+
function namedOutputs(command: string): string[] | undefined {
|
|
80
|
+
const dir = (w: string | undefined): string | undefined => {
|
|
81
|
+
const m = w === undefined ? null : OUTPUT_DIR.exec(w.replace(/^(["'])(.*)\1$/, '$2'))
|
|
82
|
+
if (m === null) return undefined
|
|
83
|
+
const d = m[1]!
|
|
84
|
+
return /\.[a-z]+$/i.test(d) || d.split('/').includes('node_modules') ? undefined : d
|
|
85
|
+
}
|
|
86
|
+
const flag = /\s(?:--outDir|--out-dir|--outdir|-d)[\s=](\S+)/.exec(` ${command} `)
|
|
87
|
+
const out = dir(flag?.[1])
|
|
88
|
+
if (out !== undefined) return [`${out}/**`]
|
|
89
|
+
for (const segment of command.split(/[;&|()]+/)) {
|
|
90
|
+
const words = segment.trim().split(/\s+/)
|
|
91
|
+
const at =
|
|
92
|
+
words[0] === 'rimraf' || words[0] === 'del-cli' || words[0] === 'del'
|
|
93
|
+
? 1
|
|
94
|
+
: words[0] === 'rm' || (words[0] === 'shx' && words[1] === 'rm')
|
|
95
|
+
? words.indexOf('rm') + 1
|
|
96
|
+
: -1
|
|
97
|
+
if (at < 0) continue
|
|
98
|
+
const args = words.slice(at).filter((w) => !w.startsWith('-'))
|
|
99
|
+
// A dir the command makes again (kit's `rm -rf ./missing_ids && mkdir
|
|
100
|
+
// ./missing_ids && vite build`) is a scratch dir it prepares, not output.
|
|
101
|
+
const made = new Set([...command.matchAll(/\bmkdir\s+(?:-p\s+)?(\S+)/g)].map((m) => dir(m[1])))
|
|
102
|
+
const dirs = args
|
|
103
|
+
.map(dir)
|
|
104
|
+
.filter((d): d is string => d !== undefined)
|
|
105
|
+
.filter((d) => !made.has(d))
|
|
106
|
+
// Every cleaned path a directory vx can name, or the guess is not this.
|
|
107
|
+
return dirs.length > 0 && dirs.length === args.filter((w) => !/\.[a-z]+$/i.test(w)).length
|
|
108
|
+
? dirs.map((d) => `${d}/**`)
|
|
109
|
+
: undefined
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The cache block the task that builds `command` should declare, as a TODO on it. */
|
|
115
|
+
export function cacheTodo(command: string): string {
|
|
116
|
+
// Padded, separators as spaces: `next build && …` and `(next build)` match.
|
|
117
|
+
const words = ` ${command.replace(/[;&|()]/g, ' ')} `
|
|
118
|
+
const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
|
|
119
|
+
const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
|
|
120
|
+
return `cache: add \`cache: { inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// `lint` is not here: a linter reads sources, and an edge to `build`
|
|
124
|
+
// serialises the two for nothing (the init walkthrough, 2026-09-04).
|
|
125
|
+
const AFTER_BUILD = new Set(['test', 'typecheck', 'check', 'e2e'])
|
|
126
|
+
const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The script a command delegates to when it is NOTHING but a package-manager
|
|
130
|
+
* 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.
|
|
133
|
+
*/
|
|
134
|
+
export function delegatedScript(command: string): string | null {
|
|
135
|
+
const m =
|
|
136
|
+
/^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
|
|
137
|
+
command.trim(),
|
|
138
|
+
)
|
|
139
|
+
if (m === null) return null
|
|
140
|
+
if (m[4] !== undefined) return m[4]
|
|
141
|
+
const [, alias, run, name] = m
|
|
142
|
+
// `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
|
|
143
|
+
const manager = alias === 'pn' ? 'pnpm' : alias
|
|
144
|
+
// Bare, the manager's own command wins over a script of that name:
|
|
145
|
+
// `bun test` is Bun's test runner and `bun build` its bundler, never the
|
|
146
|
+
// `test` / `build` script, and a group over the script ran the wrong
|
|
147
|
+
// thing (item 908). `pnpm test` and `yarn test` do run the script.
|
|
148
|
+
if (manager !== undefined && run === undefined && OWN_COMMANDS[manager]!.has(name!)) return null
|
|
149
|
+
return name!
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
|
|
153
|
+
// Listing a name that runs the script only keeps the command verbatim, so
|
|
154
|
+
// each set leans wide; missing one of the manager's own made a group over
|
|
155
|
+
// a script the manager never runs (D-32: `pnpm docs` opened npm's docs page,
|
|
156
|
+
// `bun deploy` answered "reserved", `yarn check` verified dependencies).
|
|
157
|
+
const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
158
|
+
// `bun --help`, Bun 1.4.2, the short aliases, and the names it reserves
|
|
159
|
+
// or runs itself (probed: `bun lint` runs the script).
|
|
160
|
+
bun: new Set(
|
|
161
|
+
'run test x repl exec install i add a remove rm update audit dedupe prune outdated link unlink publish patch pm info why build init create c upgrade feedback list deploy config login logout whoami help'.split(
|
|
162
|
+
' ',
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
// pnpm 10.33.0's `commandNames`, and the npm commands it passes through
|
|
166
|
+
// to npm whatever the scripts say.
|
|
167
|
+
pnpm: new Set(
|
|
168
|
+
'add approve-builds audit bin c cache cat-file cat-index ci clean-install completion config create dedupe deploy dislink dlx doctor env exec fetch find-hash get i ic ignored-builds import init install install-clean install-test it la licenses link list ll ln ls m multi outdated pack patch patch-commit patch-remove prune publish rb rebuild recursive remove restart rm root run run-script self-update server set setup store un uni uninstall unlink up update upgrade why access adduser bugs deprecate dist-tag docs edit find home info issues login logout owner ping prefix profile pkg repo s se search set-script show star stars team token unpublish unstar v version view whoami xmas help'.split(
|
|
169
|
+
' ',
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
// `yarn help`, Yarn 1.22.22 and Yarn 4.5.0: one set for both.
|
|
173
|
+
yarn: new Set(
|
|
174
|
+
'access add audit autoclean bin cache check config constraints create dedupe dlx exec explain files generate-lock-entry global help import info init install licenses link list login logout node npm outdated owner pack patch patch-commit plugin policies publish rebuild remove run search set stage tag team unlink unplug up upgrade upgrade-interactive version versions why workspace workspaces'.split(
|
|
175
|
+
' ',
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
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
|
+
/**
|
|
279
|
+
* Yarn 2+ runs a script in its own shell, where `run <script>` is `yarn
|
|
280
|
+
* run <script>`; vx's shell has no `run`, and 23 of berry's scripts
|
|
281
|
+
* (`run build:zip:worker`, `run test:unit packages/…`) mapped to tasks
|
|
282
|
+
* that failed "command not found" (D-92). Spelled out, at the head of
|
|
283
|
+
* each `&&` / `||` / `;` / `|` segment.
|
|
284
|
+
*/
|
|
285
|
+
function berryRun(command: string): string {
|
|
286
|
+
return command.replace(/(^|&&|\|\||[;|(])(\s*)run(?=\s)/g, '$1$2yarn run')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The `$npm_*` variables a command reads. Every manager sets them for a
|
|
293
|
+
* script (probed: npm, pnpm, bun and yarn printed the version, the name
|
|
294
|
+
* and the script's name) and vx sets none, so a migrated `echo
|
|
295
|
+
* $npm_package_version` printed nothing (D-34). The name and version are
|
|
296
|
+
* read from the manifest at evaluation, so a bump reaches them; the event
|
|
297
|
+
* is the script's own name, which a folded hook does not share.
|
|
298
|
+
*/
|
|
299
|
+
function npmEnv(
|
|
300
|
+
command: string,
|
|
301
|
+
script: string,
|
|
302
|
+
folded: boolean,
|
|
303
|
+
): { define: Record<string, unknown>; unset: string[]; readsManifest: boolean } {
|
|
304
|
+
const define: Record<string, unknown> = {}
|
|
305
|
+
const unset: string[] = []
|
|
306
|
+
let readsManifest = false
|
|
307
|
+
for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
|
|
308
|
+
if (v! in define || unset.includes(v!)) continue
|
|
309
|
+
if (v === 'npm_package_name' || v === 'npm_package_version') {
|
|
310
|
+
define[v] = { raw: v === 'npm_package_name' ? 'pkg.name' : 'pkg.version' }
|
|
311
|
+
readsManifest = true
|
|
312
|
+
} else if (v === 'npm_lifecycle_event' && !folded) define[v] = script
|
|
313
|
+
else unset.push(v!)
|
|
314
|
+
}
|
|
315
|
+
return { define, unset, readsManifest }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A script that never exits by design (D-40): a `watch` segment in its
|
|
320
|
+
* name (`build:watch`, `watch-css`), a `--watch` flag, `tsc -w` /
|
|
321
|
+
* `rollup -w`, or nodemon. Run as a plain task it held its dependents
|
|
322
|
+
* forever. A bare `-w` elsewhere is not read: `npm run x -w pkg` is a
|
|
323
|
+
* workspace.
|
|
324
|
+
*/
|
|
325
|
+
function isWatcher(name: string, command: string): boolean {
|
|
326
|
+
if (name.split(/[:\-_.]/).includes('watch')) return true
|
|
327
|
+
return (
|
|
328
|
+
/(?:^|\s)--watch(?:All)?(?:[=\s]|$)/.test(command) ||
|
|
329
|
+
/\b(?:tsc|rollup)\b[^&|;]*\s-w(?:\s|$)/.test(command) ||
|
|
330
|
+
/(?:^|[\s&|;])nodemon\b/.test(command)
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A server run alone (`serve website`, `http-server`), or a tool's own server verb. */
|
|
335
|
+
const SERVERS = new Set(['serve', 'http-server', 'live-server', 'sirv', 'webpack-dev-server'])
|
|
336
|
+
const SERVER_TOOLS = new Set([
|
|
337
|
+
'vite',
|
|
338
|
+
'next',
|
|
339
|
+
'astro',
|
|
340
|
+
'nuxt',
|
|
341
|
+
'nuxi',
|
|
342
|
+
'remix',
|
|
343
|
+
'react-router',
|
|
344
|
+
'docusaurus',
|
|
345
|
+
'storybook',
|
|
346
|
+
'netlify',
|
|
347
|
+
'wrangler',
|
|
348
|
+
'webpack',
|
|
349
|
+
'expo',
|
|
350
|
+
'react-native',
|
|
351
|
+
])
|
|
352
|
+
const SERVER_VERBS = new Set(['dev', 'serve', 'start', 'preview'])
|
|
353
|
+
const LAUNCHERS = new Set(['cross-env', 'npx', 'bunx', 'exec', 'dlx'])
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* A command that runs a server (D-91): docusaurus's `serve website` and
|
|
357
|
+
* `netlify dev` mapped as one-shot tasks, so a dependent waited forever and
|
|
358
|
+
* a server that exits on stdin EOF ended at once. Read per `&&` segment,
|
|
359
|
+
* past env assignments and launchers; a quoted string is another tool's
|
|
360
|
+
* argument (`start-server-and-test 'vite preview' …` exits).
|
|
361
|
+
*/
|
|
362
|
+
function servesCommand(command: string): boolean {
|
|
363
|
+
for (const segment of foreground(command.replace(/"[^"]*"|'[^']*'/g, ' '))) {
|
|
364
|
+
const words = segment
|
|
365
|
+
.trim()
|
|
366
|
+
.split(/\s+/)
|
|
367
|
+
.filter((w) => w !== '')
|
|
368
|
+
let i = 0
|
|
369
|
+
while (
|
|
370
|
+
i < words.length &&
|
|
371
|
+
(/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) ||
|
|
372
|
+
LAUNCHERS.has(words[i]!) ||
|
|
373
|
+
words[i]!.startsWith('-') ||
|
|
374
|
+
(/^(pnpm|npm|yarn)$/.test(words[i]!) && LAUNCHERS.has(words[i + 1] ?? '')))
|
|
375
|
+
) {
|
|
376
|
+
// A launcher's `--package <name>` / `-p <name>` names what to install,
|
|
377
|
+
// not the program: docusaurus's `pnpm dlx --package netlify-cli netlify
|
|
378
|
+
// dev` read `netlify-cli` as the program (D-112).
|
|
379
|
+
i += words[i] === '--package' || words[i] === '-p' ? 2 : 1
|
|
380
|
+
}
|
|
381
|
+
const [program, verb] = [words[i], words[i + 1]]
|
|
382
|
+
if (program === undefined) continue
|
|
383
|
+
if (SERVERS.has(program)) return true
|
|
384
|
+
if (SERVER_TOOLS.has(program) && verb !== undefined && SERVER_VERBS.has(verb)) return true
|
|
385
|
+
if (program === 'vite' && (verb === undefined || verb.startsWith('-'))) return true
|
|
386
|
+
}
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Whether script `name` never exits: by name, as a watcher, as a server, or
|
|
392
|
+
* running such a script of its own package by name (docusaurus's
|
|
393
|
+
* `start:baseUrl`: `cross-env BASE_URL=… pnpm start`) or through a runner.
|
|
394
|
+
*/
|
|
395
|
+
function isPersistent(
|
|
396
|
+
name: string,
|
|
397
|
+
scripts: Readonly<Record<string, unknown>>,
|
|
398
|
+
seen: Set<string> = new Set(),
|
|
399
|
+
): boolean {
|
|
400
|
+
const own = scripts[name]
|
|
401
|
+
if (typeof own !== 'string' || seen.has(name)) return false
|
|
402
|
+
seen.add(name)
|
|
403
|
+
if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
|
|
404
|
+
// Through a runner too: `concurrently "npm:web" "npm:api"` and `run-p web
|
|
405
|
+
// api` over two servers mapped as one-shot tasks (D-113).
|
|
406
|
+
const text = Object.entries(scripts).filter(
|
|
407
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
408
|
+
)
|
|
409
|
+
return foreground(own).some((segment) =>
|
|
410
|
+
scriptRefs(segment, text).some((ref) => isPersistent(ref, scripts, seen)),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The `&&` / `;` / `|` segments of a command, less those it backgrounds with a single `&`. */
|
|
415
|
+
function foreground(command: string): string[] {
|
|
416
|
+
return command
|
|
417
|
+
.split(/&&|\|\||[;|()]/)
|
|
418
|
+
.flatMap((part) => part.split('&').slice(-1))
|
|
419
|
+
.filter((segment) => segment.trim() !== '')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function scriptsOf(meta: PackageMeta): Record<string, unknown> {
|
|
423
|
+
// package.json is a boundary: `scripts` is whatever the file holds. A
|
|
424
|
+
// string or an array would enumerate its indices as script names.
|
|
425
|
+
const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
|
|
426
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
|
|
427
|
+
return raw as Record<string, unknown>
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* A `build` that only delegates (`build: pnpm run compile`) is a group over
|
|
432
|
+
* its target, and the `^build` edge every `build` carries belongs on the
|
|
433
|
+
* task that does the work: the group ran `compile` with no edge at all, so
|
|
434
|
+
* a package compiled before the ones it imports had built (item 907). On
|
|
435
|
+
* the group itself it would not hold — the group waits on both, `compile`
|
|
436
|
+
* on neither. Followed through a chain of groups to the first command,
|
|
437
|
+
* which takes the cache TODO too: a group has no command to cache, and the
|
|
438
|
+
* TODO a delegating `build` never got was the one the header promised
|
|
439
|
+
* (item 1045).
|
|
440
|
+
*/
|
|
441
|
+
function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
442
|
+
const byName = new Map(tasks.map((t) => [t.name, t]))
|
|
443
|
+
const seen = new Set<string>()
|
|
444
|
+
let at = byName.get('build')
|
|
445
|
+
while (at?.task !== undefined && at.task !== null && !('exec' in at.task)) {
|
|
446
|
+
if (seen.has(at.name)) return
|
|
447
|
+
seen.add(at.name)
|
|
448
|
+
const deps = at.task['dependsOn']
|
|
449
|
+
at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
|
|
450
|
+
}
|
|
451
|
+
if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
|
|
452
|
+
// The worker IS `build`'s work, and so is every group on the way to it:
|
|
453
|
+
// a `typecheck` behind `build: npm run typecheck` waited for `build`,
|
|
454
|
+
// and the run refused the cycle (D-16).
|
|
455
|
+
for (const name of [...seen, at.name]) {
|
|
456
|
+
const t = byName.get(name)!.task!
|
|
457
|
+
if (name !== 'build' && Array.isArray(t['dependsOn'])) {
|
|
458
|
+
t['dependsOn'] = (t['dependsOn'] as string[]).filter((d) => d !== 'build')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
|
|
462
|
+
if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
|
|
463
|
+
const todo = cacheTodo(String((at.task['exec'] as { command?: unknown }).command))
|
|
464
|
+
if (!at.todos.includes(todo)) at.todos.push(todo)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* A root script that runs the members rather than checking the repo:
|
|
469
|
+
* pnpm's `-r` / `--filter` / `-C`, Yarn's `--cwd` (excalidraw's
|
|
470
|
+
* `build:common`) and `yarn workspace <name>` (cal.com's `prisma`), npm's
|
|
471
|
+
* `--prefix`, the workspace flags, Bun's `--filter`,
|
|
472
|
+
* Vite+'s `vp run` (tiptap), and the other runners, vx itself included. Mapped, it ran every member's
|
|
473
|
+
* task again beside the member's own (D-45).
|
|
474
|
+
*/
|
|
475
|
+
const RUNS_MEMBERS =
|
|
476
|
+
/(^|[\s;&|(])(turbo|nx|lerna|ultra|wireit|nps|moon|rush|vx)(\s|$)|\bworkspaces?\s+(foreach|run)\b|\byarn\s+workspace\s|\bvp\s+run\s/
|
|
477
|
+
|
|
478
|
+
/** A workspace flag, read only where a package manager takes it (`pmRunsMembers`). */
|
|
479
|
+
const MEMBER_FLAG =
|
|
480
|
+
/^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace|-w|-C|--dir|--cwd|--prefix|--if-present|--parallel|--stream)(=|$)/
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The workspace flags are a package manager's: on the program it runs they
|
|
484
|
+
* mean something else, and berry's `bench` (`yarn node -r ./setup.ts …`,
|
|
485
|
+
* node's `--require`) was left out as running the members (D-81). With no
|
|
486
|
+
* manager named, `node <bin> run` is one (npm/cli's own npm), and not
|
|
487
|
+
* `docker run -w` (D-83).
|
|
488
|
+
*/
|
|
489
|
+
function pmRunsMembers(script: string): boolean {
|
|
490
|
+
for (const segment of script.split(/&&|\|\||[;|()]/)) {
|
|
491
|
+
const words = segment.trim().split(/\s+/)
|
|
492
|
+
let i = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
|
|
493
|
+
// npm/cli runs itself: `node . run test --workspaces`.
|
|
494
|
+
if (i < 0) i = words.findIndex((w, j) => w === 'run' && words[j - 2] === 'node')
|
|
495
|
+
if (i < 0) continue
|
|
496
|
+
// `exec`, `dlx` and `x` take the manager's flags up to the program name.
|
|
497
|
+
let program = false
|
|
498
|
+
for (i++; i < words.length; i++) {
|
|
499
|
+
const w = words[i]!
|
|
500
|
+
if (MEMBER_FLAG.test(w)) return true
|
|
501
|
+
if (program && !w.startsWith('-')) break
|
|
502
|
+
if (w === 'node') break
|
|
503
|
+
if (w === 'exec' || w === 'dlx' || w === 'x') program = true
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return false
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// A bare target may escape a space or paren (`cd packages/my\ app`).
|
|
510
|
+
const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|(?:\\.|[^\s;&|()\\])+)/g
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* A `cd` into a member's directory, or one holding members, runs that
|
|
514
|
+
* member's work (kit's `cd packages/kit && vitest run`); bun's
|
|
515
|
+
* `typecheck` (`cd test && bun run typecheck`) enters no member and was
|
|
516
|
+
* left out (D-83). A target vx cannot read (`$DIR`, `~`) still counts.
|
|
517
|
+
*/
|
|
518
|
+
function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
|
|
519
|
+
for (const m of script.matchAll(CD)) {
|
|
520
|
+
const quoted = /^(["'])(.*)\1$/.exec(m[1]!)
|
|
521
|
+
const target = quoted ? quoted[2]! : m[1]!.replace(/\\(.)/g, '$1')
|
|
522
|
+
if (/[$`~*?]/.test(target) || target === '-') return true
|
|
523
|
+
const dir = path.resolve(rootDir, target)
|
|
524
|
+
const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
|
|
525
|
+
if (memberDirs.some((d) => inside(d, dir) || inside(dir, d))) return true
|
|
526
|
+
}
|
|
527
|
+
return false
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The manager a root's files name, or undefined: `pnpm-workspace.yaml` is
|
|
532
|
+
* pnpm's whatever the lockfile, and the npm `ownerOf` falls back to at the
|
|
533
|
+
* file-system root with no lockfile anywhere is no claim.
|
|
534
|
+
*/
|
|
535
|
+
function rootManager(dir: string, memo: Map<string, Owner>): string | undefined {
|
|
536
|
+
if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return 'pnpm'
|
|
537
|
+
const owner = ownerOf(dir, memo)
|
|
538
|
+
const guessed =
|
|
539
|
+
owner.at === path.parse(owner.at).root && !existsSync(path.join(owner.at, 'package-lock.json'))
|
|
540
|
+
return guessed ? undefined : owner.manager
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* How the repo's own manager runs the members, for the root note: an npm
|
|
545
|
+
* repo read "(`pnpm -r`, `--filter`, a runner)" (insomnia).
|
|
546
|
+
*/
|
|
547
|
+
function membersExample(manager: string | undefined): string {
|
|
548
|
+
switch (manager) {
|
|
549
|
+
case 'npm':
|
|
550
|
+
return '`--workspaces`, `-w`'
|
|
551
|
+
case 'yarn':
|
|
552
|
+
return '`yarn workspaces run`, `yarn workspace`'
|
|
553
|
+
case 'berry':
|
|
554
|
+
return '`yarn workspaces foreach`, `yarn workspace`'
|
|
555
|
+
case 'bun':
|
|
556
|
+
return '`bun --filter`'
|
|
557
|
+
default:
|
|
558
|
+
return '`pnpm -r`, `--filter`'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The hooks a package's build hides in when it has no `build` script:
|
|
564
|
+
* react-navigation's twelve packages build in `prepack: bob build`, their
|
|
565
|
+
* root's `build` (`lerna run prepack`) runs the members and is left out,
|
|
566
|
+
* and the repo mapped with no build at all.
|
|
567
|
+
*/
|
|
568
|
+
const LIFECYCLE_BUILD_HOOKS = ['prepack', 'prepublishOnly', 'prepublish', 'prepare']
|
|
569
|
+
const BUILDER =
|
|
570
|
+
/(?:^|[\s;&|(])(?:bob build|tsc|tsup|tsdown|rollup|vite build|babel|unbuild|esbuild|webpack|microbundle|pkgroll|bunchee|preconstruct build)(?:\s|$)/
|
|
571
|
+
|
|
572
|
+
/** The workspace flags that move a package manager off the package it runs in. */
|
|
573
|
+
const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
|
|
574
|
+
const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The part of a member's script that runs another member's work: pinia's
|
|
578
|
+
* online-playground `build` is `pnpm -C ../pinia run build && vite build`,
|
|
579
|
+
* and mapped verbatim it built pinia again beside pinia's own task, writing
|
|
580
|
+
* the dist its siblings read. `yarn workspace <name>`, a runner, `pnpm -r`
|
|
581
|
+
* / `--filter`, and a `cd` / `-C` / `--cwd` / `--prefix` whose innermost
|
|
582
|
+
* member is another one count; a fixture inside the member, or the root,
|
|
583
|
+
* does not.
|
|
584
|
+
*/
|
|
585
|
+
function siblingRun(script: string, dir: string, others: readonly string[]): string | undefined {
|
|
586
|
+
const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
|
|
587
|
+
const intoOther = (target: string): boolean => {
|
|
588
|
+
const t = path.resolve(dir, target.replace(/^(["'])(.*)\1$/, '$2'))
|
|
589
|
+
if (/[$`~*?]/.test(target)) return false
|
|
590
|
+
const owner = [dir, ...others]
|
|
591
|
+
.filter((d) => inside(t, d))
|
|
592
|
+
.sort((x, y) => y.length - x.length)[0]
|
|
593
|
+
return owner !== undefined && owner !== dir
|
|
594
|
+
}
|
|
595
|
+
for (const segment of script.split(/&&|\|\||[;|()]/)) {
|
|
596
|
+
const s = segment.trim()
|
|
597
|
+
if (RUNS_MEMBERS.test(` ${s}`)) return s
|
|
598
|
+
for (const m of s.matchAll(CD)) if (intoOther(m[1]!)) return s
|
|
599
|
+
const words = s.split(/\s+/)
|
|
600
|
+
const at = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
|
|
601
|
+
if (at < 0) continue
|
|
602
|
+
for (let i = at + 1; i < words.length; i++) {
|
|
603
|
+
const w = words[i]!
|
|
604
|
+
if (ELSEWHERE_FLAG.test(w)) return s
|
|
605
|
+
const flag = DIR_FLAG.exec(w)
|
|
606
|
+
if (flag === null) continue
|
|
607
|
+
const target = flag[2] ?? words[++i]
|
|
608
|
+
if (target !== undefined && intoOther(target)) return s
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return undefined
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The package globs a `lerna.json` beside a lone root lists (Lerna's
|
|
616
|
+
* default when it names none), or undefined: Lerna-classic repos list
|
|
617
|
+
* their packages there, not in `workspaces`, and vx saw the root alone
|
|
618
|
+
* (D-111).
|
|
619
|
+
*/
|
|
620
|
+
function lernaPackages(dir: string): string[] | undefined {
|
|
621
|
+
let json: unknown
|
|
622
|
+
try {
|
|
623
|
+
json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
624
|
+
} catch {
|
|
625
|
+
return undefined
|
|
626
|
+
}
|
|
627
|
+
const listed = (json as { packages?: unknown } | null)?.packages
|
|
628
|
+
return Array.isArray(listed) && listed.every((g) => typeof g === 'string') && listed.length > 0
|
|
629
|
+
? listed
|
|
630
|
+
: ['packages/*']
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function lernaNote(globs: readonly string[]): string {
|
|
634
|
+
const list = globs.map((g) => JSON.stringify(g)).join(', ')
|
|
635
|
+
return `lerna.json lists the packages (${list}), but package.json declares no \`workspaces\`, so vx sees the root alone: add \`"workspaces": [${list}]\` to package.json and run \`vx init\` again`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Yarn 2+ installs with Plug'n'Play unless `.yarnrc.yml` names another
|
|
640
|
+
* linker: a package's bins live in `.pnp.cjs`, and a task's `json5` exited
|
|
641
|
+
* 127 under vx, which runs no `yarn` in front of a command (probed on Yarn
|
|
642
|
+
* 4.5).
|
|
643
|
+
*/
|
|
644
|
+
function usesPnp(dir: string): boolean {
|
|
645
|
+
let rc = ''
|
|
646
|
+
try {
|
|
647
|
+
rc = readFileSync(path.join(dir, '.yarnrc.yml'), 'utf8')
|
|
648
|
+
} catch {}
|
|
649
|
+
const linker = /^nodeLinker:\s*["']?([\w-]+)/m.exec(rc)?.[1]
|
|
650
|
+
return linker === undefined || linker === 'pnp'
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const PNP_NOTE =
|
|
654
|
+
"Yarn Plug'n'Play installs this repo: a package's bins live in `.pnp.cjs`, not `node_modules/.bin`, so a task's `tsc` is not found under vx — set `nodeLinker: node-modules` in `.yarnrc.yml` and run `yarn install`, or write each command as `yarn exec '<command>'`"
|
|
655
|
+
|
|
656
|
+
/** A runner of this package's scripts by name: `run-p build:*`. */
|
|
657
|
+
const RUNS_OWN = /(?:^|\s)(?:run-s|run-p|npm-run-all)(?:\s|$)/
|
|
658
|
+
|
|
659
|
+
/** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
|
|
660
|
+
const RUNS_SCRIPT =
|
|
661
|
+
/(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The scripts a command runs by name: a package manager's (`pnpm x`, `npm
|
|
665
|
+
* run x`), `run-s` / `run-p` / `npm-run-all` (`build:*` is one segment,
|
|
666
|
+
* `build:**` any), and `concurrently "npm:x"`. A root `ci: run-s build:all
|
|
667
|
+
* lint` over `build:all: pnpm -r build` mapped as a root task (D-95).
|
|
668
|
+
*/
|
|
669
|
+
function scriptRefs(command: string, scripts: readonly (readonly [string, string])[]): string[] {
|
|
670
|
+
const refs = [...command.matchAll(RUNS_SCRIPT)].map((m) => m[1]!)
|
|
671
|
+
for (const m of command.matchAll(/["']?\b(?:npm|pnpm|yarn|bun):([^\s"']+)/g)) refs.push(m[1]!)
|
|
672
|
+
for (const segment of command.split(/&&|\|\||[;|()]/)) {
|
|
673
|
+
const words = segment.trim().split(/\s+/)
|
|
674
|
+
const at = words.findIndex((w) => /^(run-s|run-p|npm-run-all)$/.test(w))
|
|
675
|
+
if (at < 0) continue
|
|
676
|
+
for (const w of words.slice(at + 1)) {
|
|
677
|
+
if (w.startsWith('-')) continue
|
|
678
|
+
const name = w.replace(/^(["'])(.*)\1$/, '$2')
|
|
679
|
+
if (!name.includes('*')) {
|
|
680
|
+
refs.push(name)
|
|
681
|
+
continue
|
|
682
|
+
}
|
|
683
|
+
const re = new RegExp(
|
|
684
|
+
`^${name
|
|
685
|
+
.split(/(\*\*|\*)/)
|
|
686
|
+
.map((part) =>
|
|
687
|
+
part === '**'
|
|
688
|
+
? '.*'
|
|
689
|
+
: part === '*'
|
|
690
|
+
? '[^:]*'
|
|
691
|
+
: part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'),
|
|
692
|
+
)
|
|
693
|
+
.join('')}$`,
|
|
694
|
+
)
|
|
695
|
+
for (const [n] of scripts) if (re.test(n)) refs.push(n)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return refs
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The scripts that run the members, directly or through another of these
|
|
703
|
+
* scripts: vite's `ci-docs` (`pnpm build && pnpm docs-build`) runs each
|
|
704
|
+
* member's build through the root's own `build` (`pnpm -r … run build`).
|
|
705
|
+
*/
|
|
706
|
+
function runningMembers(
|
|
707
|
+
scripts: Readonly<Record<string, unknown>>,
|
|
708
|
+
rootDir: string,
|
|
709
|
+
memberDirs: readonly string[],
|
|
710
|
+
): Set<string> {
|
|
711
|
+
const text = Object.entries(scripts).filter(
|
|
712
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
713
|
+
)
|
|
714
|
+
const out = new Set(
|
|
715
|
+
text
|
|
716
|
+
.filter(
|
|
717
|
+
([, v]) =>
|
|
718
|
+
RUNS_MEMBERS.test(` ${v}`) || pmRunsMembers(v) || cdsToMembers(v, rootDir, memberDirs),
|
|
719
|
+
)
|
|
720
|
+
.map(([n]) => n),
|
|
721
|
+
)
|
|
722
|
+
for (let grew = true; grew;) {
|
|
723
|
+
grew = false
|
|
724
|
+
for (const [n, v] of text) {
|
|
725
|
+
if (out.has(n)) continue
|
|
726
|
+
if (scriptRefs(v, text).some((r) => out.has(r))) {
|
|
727
|
+
out.add(n)
|
|
728
|
+
grew = true
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return out
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* `outside`: the root manifest when the root is no member (pnpm's and
|
|
737
|
+
* Yarn's default), whose own scripts (`lint: eslint .`) went unmapped
|
|
738
|
+
* without a word; `outsideDir` is where it sits.
|
|
739
|
+
*/
|
|
740
|
+
export function migrateScripts(
|
|
741
|
+
metas: readonly PackageMeta[],
|
|
742
|
+
outside?: Readonly<Record<string, unknown>>,
|
|
743
|
+
outsideDir?: string,
|
|
744
|
+
): MigrationPlan {
|
|
745
|
+
const projects: GeneratedProject[] = []
|
|
746
|
+
const hookMemo = new Map<string, Owner>()
|
|
747
|
+
// The workspace root among members: many of its scripts run the
|
|
748
|
+
// workspace (`npm run build --workspaces`, `pnpm -r build`), and a root
|
|
749
|
+
// task made of one ran every member's build again under `--all`. Since
|
|
750
|
+
// D-39 a hand-written root config makes the root a project, and
|
|
751
|
+
// `--force` replaced it so (D-45). A lone package is its repo's project
|
|
752
|
+
// and maps.
|
|
753
|
+
const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
|
|
754
|
+
const notes: string[] = []
|
|
755
|
+
const lifecycleBuilds: [string, string, string][] = []
|
|
756
|
+
// The rest check the whole repo (`lint: oxlint .`, `test: vitest`):
|
|
757
|
+
// `vx run lint` found no project in remix, wagmi or element-plus. Such a
|
|
758
|
+
// script maps onto the root, when the root has a name (vx skips a
|
|
759
|
+
// nameless one's config), no config of its own, and no member declares
|
|
760
|
+
// the task, so `--all` never runs one check twice.
|
|
761
|
+
const outsideName =
|
|
762
|
+
typeof outside?.['name'] === 'string' && outside['name'] !== '' ? outside['name'] : undefined
|
|
763
|
+
// insomnia's root is named as its `packages/insomnia`: a root config made
|
|
764
|
+
// it a project, and every later run was refused for the duplicate (D-129).
|
|
765
|
+
const clash = outsideName === undefined ? undefined : metas.find((m) => m.name === outsideName)
|
|
766
|
+
// A root with a config of its own is kept as written, and a run of `vx
|
|
767
|
+
// init` after the one that mapped it keeps it like a member's (X-27).
|
|
768
|
+
const rootKept = root !== undefined && root.configPath !== null
|
|
769
|
+
const rootMeta: PackageMeta | undefined =
|
|
770
|
+
root !== undefined
|
|
771
|
+
? rootKept
|
|
772
|
+
? undefined
|
|
773
|
+
: root
|
|
774
|
+
: outsideName !== undefined && outsideDir !== undefined && clash === undefined
|
|
775
|
+
? { name: outsideName, dir: outsideDir, packageJson: outside as never, configPath: null }
|
|
776
|
+
: undefined
|
|
777
|
+
const memberTasks = new Set(
|
|
778
|
+
metas
|
|
779
|
+
.filter((m) => m !== root)
|
|
780
|
+
.flatMap((m) => Object.entries(scriptsOf(m)))
|
|
781
|
+
.filter(([, v]) => typeof v === 'string' && v !== '')
|
|
782
|
+
.map(([n]) => n),
|
|
783
|
+
)
|
|
784
|
+
// The root scripts left out, by why: jest's root `build` (every package's)
|
|
785
|
+
// went unnamed beside the website's, and `vx run build --all` built the
|
|
786
|
+
// website alone (D-85). A left-out script's `pre` / `post` hook goes with
|
|
787
|
+
// it where the manager runs hooks: kept, it became a task of its own.
|
|
788
|
+
const leftOut = (meta: PackageMeta): { runs: string[]; shared: string[]; out: Set<string> } => {
|
|
789
|
+
const scripts = scriptsOf(meta)
|
|
790
|
+
const runs = runningMembers(
|
|
791
|
+
scripts,
|
|
792
|
+
meta.dir,
|
|
793
|
+
metas.filter((m) => m !== root).map((m) => m.dir),
|
|
794
|
+
)
|
|
795
|
+
const hooks = runsScriptHooks(meta.dir, hookMemo) !== null
|
|
796
|
+
const hookOf = (n: string): string | undefined => {
|
|
797
|
+
const base = hooks ? /^(?:pre|post)(.+)$/.exec(n)?.[1] : undefined
|
|
798
|
+
return base !== undefined && typeof scripts[base] === 'string' ? base : undefined
|
|
799
|
+
}
|
|
800
|
+
// A hook rides in its script's command, so it is judged with it.
|
|
801
|
+
const named = Object.keys(scripts).filter(
|
|
802
|
+
(n) => typeof scripts[n] === 'string' && !LIFECYCLE.test(n) && hookOf(n) === undefined,
|
|
803
|
+
)
|
|
804
|
+
const left = {
|
|
805
|
+
runs: named.filter((n) => runs.has(n)),
|
|
806
|
+
shared: named.filter((n) => memberTasks.has(n) && !runs.has(n)),
|
|
807
|
+
}
|
|
808
|
+
const outNames = new Set([...left.runs, ...left.shared])
|
|
809
|
+
const out = new Set(
|
|
810
|
+
Object.keys(scripts).filter((n) => outNames.has(n) || outNames.has(hookOf(n) ?? '')),
|
|
811
|
+
)
|
|
812
|
+
return { ...left, out }
|
|
813
|
+
}
|
|
814
|
+
const rootScripts = (meta: PackageMeta): Record<string, unknown> => {
|
|
815
|
+
const scripts = scriptsOf(meta)
|
|
816
|
+
const { out } = leftOut(meta)
|
|
817
|
+
return Object.fromEntries(
|
|
818
|
+
Object.entries(scripts).filter(([n, v]) => typeof v === 'string' && !out.has(n)),
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
const mapsOf = (meta: PackageMeta | undefined): number =>
|
|
822
|
+
meta === undefined ? 0 : Object.keys(rootScripts(meta)).filter((n) => !LIFECYCLE.test(n)).length
|
|
823
|
+
const rootMapped = mapsOf(rootMeta)
|
|
824
|
+
const listed = (names: readonly string[]): string =>
|
|
825
|
+
names.slice(0, 8).join(', ') + (names.length > 8 ? ` and ${names.length - 8} more` : '')
|
|
826
|
+
const outsideScripts = outside?.['scripts']
|
|
827
|
+
const outsideRuns =
|
|
828
|
+
typeof outsideScripts === 'object' &&
|
|
829
|
+
outsideScripts !== null &&
|
|
830
|
+
Object.values(outsideScripts).some((v) => typeof v === 'string' && v !== '')
|
|
831
|
+
const unnamedMaps = (): string[] => {
|
|
832
|
+
const meta: PackageMeta = {
|
|
833
|
+
name: 'package.json',
|
|
834
|
+
dir: outsideDir!,
|
|
835
|
+
packageJson: outside as never,
|
|
836
|
+
configPath: null,
|
|
837
|
+
}
|
|
838
|
+
const scripts = scriptsOf(meta)
|
|
839
|
+
return Object.keys(rootScripts(meta)).filter((n) => {
|
|
840
|
+
// A hook rides in its script's command and is no script to name.
|
|
841
|
+
const base = /^(?:pre|post)(.+)$/.exec(n)?.[1]
|
|
842
|
+
return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
// Why a root mapped nothing: `eslint .` beside a member's `lint` runs
|
|
846
|
+
// nothing of the workspace, and the note said it did.
|
|
847
|
+
const rootLeftOut = (): string => {
|
|
848
|
+
const judged = rootKept ? root : rootMeta
|
|
849
|
+
if (judged === undefined) return 'run the workspace'
|
|
850
|
+
const scripts = scriptsOf(judged)
|
|
851
|
+
const named = Object.keys(scripts).filter(
|
|
852
|
+
(n) => typeof scripts[n] === 'string' && scripts[n] !== '' && !LIFECYCLE.test(n),
|
|
853
|
+
)
|
|
854
|
+
const runs = runningMembers(
|
|
855
|
+
scripts,
|
|
856
|
+
judged.dir,
|
|
857
|
+
metas.filter((m) => m !== root).map((m) => m.dir),
|
|
858
|
+
)
|
|
859
|
+
const shared = named.filter((n) => memberTasks.has(n) && !runs.has(n))
|
|
860
|
+
if (shared.length === 0) return 'run the workspace'
|
|
861
|
+
const list = `${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ', …' : ''}`
|
|
862
|
+
return `${shared.length < named.length ? 'run the workspace or ' : ''}share a member's task name (${list})`
|
|
863
|
+
}
|
|
864
|
+
const rootName =
|
|
865
|
+
root?.name ??
|
|
866
|
+
(outsideRuns
|
|
867
|
+
? typeof outside?.['name'] === 'string' && outside['name'] !== ''
|
|
868
|
+
? outside['name']
|
|
869
|
+
: 'package.json'
|
|
870
|
+
: undefined)
|
|
871
|
+
if (rootName !== undefined && rootMapped > 0) {
|
|
872
|
+
const { runs, shared } = leftOut(rootMeta!)
|
|
873
|
+
notes.push(
|
|
874
|
+
`${rootName} (the workspace root): its scripts that check the whole repo are its tasks` +
|
|
875
|
+
(runs.length > 0
|
|
876
|
+
? `; left out as running the members (${membersExample(rootMeta && rootManager(rootMeta.dir, hookMemo))}, a runner): ${listed(runs)}`
|
|
877
|
+
: '') +
|
|
878
|
+
(shared.length > 0
|
|
879
|
+
? `; left out as a member's task name, so \`--all\` never runs one twice: ${listed(shared)} — one that does other work maps by hand under a name of its own`
|
|
880
|
+
: ''),
|
|
881
|
+
)
|
|
882
|
+
} else if (clash !== undefined && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
883
|
+
const would = unnamedMaps()
|
|
884
|
+
notes.push(
|
|
885
|
+
`${clash.name} (the workspace root) not mapped: ${relPosix(outsideDir, clash.dir)} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
|
|
886
|
+
)
|
|
887
|
+
} else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
888
|
+
// react's nameless root: "its scripts run the workspace" was not why,
|
|
889
|
+
// and 30 of its scripts (`build`, `lint`, `test`) map once it has a
|
|
890
|
+
// name (D-87).
|
|
891
|
+
const would = unnamedMaps()
|
|
892
|
+
notes.push(
|
|
893
|
+
`package.json (the workspace root) not mapped: it has no "name", and vx names a project by it; give it one and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
|
|
894
|
+
)
|
|
895
|
+
} else if (rootName !== undefined && !(rootKept && mapsOf(root) > 0)) {
|
|
896
|
+
// A nameless root's vx.config is skipped (vx names projects by their
|
|
897
|
+
// manifest's name), so the hand-written one needs a name first (vuejs/core).
|
|
898
|
+
notes.push(
|
|
899
|
+
`${rootName} (the workspace root) not mapped: its scripts ${rootLeftOut()}; declare its own tasks in its vx.config by hand` +
|
|
900
|
+
(rootName === 'package.json' ? ', after giving its package.json a "name"' : ''),
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
const mapped = metas.filter((m) => m !== root)
|
|
904
|
+
if (rootMeta !== undefined && rootMapped > 0) mapped.push(rootMeta)
|
|
905
|
+
for (const meta of mapped) {
|
|
906
|
+
const scripts = meta === rootMeta ? rootScripts(meta) : scriptsOf(meta)
|
|
907
|
+
const hooksBy = runsScriptHooks(meta.dir, hookMemo)
|
|
908
|
+
const runsHooks = hooksBy !== null
|
|
909
|
+
const berry = ownerOf(meta.dir, hookMemo).manager === 'berry'
|
|
910
|
+
const runnable = Object.keys(scripts).filter(
|
|
911
|
+
(n) => typeof scripts[n] === 'string' && scripts[n] !== '',
|
|
912
|
+
)
|
|
913
|
+
// A script whose name no task may carry (`lint#fix`, `^up`) was written
|
|
914
|
+
// as one, and the next run refused the whole config (item 1033). It is
|
|
915
|
+
// left out, with a TODO, and a script delegating to it keeps its
|
|
916
|
+
// command rather than naming it as an edge.
|
|
917
|
+
const refused = runnable.flatMap((n) => {
|
|
918
|
+
const why = taskNameProblem(n)
|
|
919
|
+
return why === null ? [] : [[n, why] as const]
|
|
920
|
+
})
|
|
921
|
+
const names = runnable.filter((n) => taskNameProblem(n) === null)
|
|
922
|
+
if (meta !== rootMeta && !('build' in scripts)) {
|
|
923
|
+
const hook = LIFECYCLE_BUILD_HOOKS.find(
|
|
924
|
+
(h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
|
|
925
|
+
)
|
|
926
|
+
if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
|
|
927
|
+
}
|
|
928
|
+
if (runnable.length === 0) continue
|
|
929
|
+
const hasBuild = names.includes('build')
|
|
930
|
+
const has = (n: string): boolean => names.includes(n)
|
|
931
|
+
// Lifecycle hooks are npm's, not tasks anyone runs by name, and a hook
|
|
932
|
+
// of a script that exists rides inside that script's command.
|
|
933
|
+
const isTask = (n: string): boolean => {
|
|
934
|
+
if (!has(n) || LIFECYCLE.test(n)) return false
|
|
935
|
+
const hookOf = runsHooks ? /^(pre|post)(.+)$/.exec(n) : null
|
|
936
|
+
return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
|
|
937
|
+
}
|
|
938
|
+
const tasks: GeneratedTask[] = []
|
|
939
|
+
let readsManifest = false
|
|
940
|
+
const text = Object.entries(scripts).filter(
|
|
941
|
+
(e): e is [string, string] => typeof e[1] === 'string',
|
|
942
|
+
)
|
|
943
|
+
for (const name of names) {
|
|
944
|
+
if (!isTask(name)) continue
|
|
945
|
+
|
|
946
|
+
const todos: string[] = []
|
|
947
|
+
const own = berry ? berryRun(scripts[name] as string) : (scripts[name] as string)
|
|
948
|
+
const delegate = delegatedScript(own)
|
|
949
|
+
// npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
|
|
950
|
+
// package manager and never ride inside a task — `pack` stays alone.
|
|
951
|
+
const hook = (h: string): boolean => runsHooks && has(h) && !LIFECYCLE.test(h)
|
|
952
|
+
const hooks = [`pre${name}`, `post${name}`].filter(hook)
|
|
953
|
+
// A group over a script that is no task (`setup: npm run prepare`)
|
|
954
|
+
// named a task nothing defines, and the run `vx init` suggested
|
|
955
|
+
// refused the config (D-12).
|
|
956
|
+
if (delegate !== null && isTask(delegate) && delegate !== name && hooks.length === 0) {
|
|
957
|
+
// A group: no exec, the graph runs the target. `npm test` calling
|
|
958
|
+
// `vitest` through `npm run test:unit` is two tasks, not a subprocess.
|
|
959
|
+
const task: Record<string, unknown> = { dependsOn: [delegate] }
|
|
960
|
+
if (AFTER_BUILD.has(name) && hasBuild && delegate !== 'build') {
|
|
961
|
+
task['dependsOn'] = [delegate, 'build']
|
|
962
|
+
}
|
|
963
|
+
tasks.push({ name, todos, task })
|
|
964
|
+
continue
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const command = foldScriptHooks(
|
|
968
|
+
hook(`pre${name}`) ? (scripts[`pre${name}`] as string) : undefined,
|
|
969
|
+
own,
|
|
970
|
+
hook(`post${name}`) ? (scripts[`post${name}`] as string) : undefined,
|
|
971
|
+
)
|
|
972
|
+
if (hooks.length > 0) {
|
|
973
|
+
todos.push(
|
|
974
|
+
`${hooksBy} ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
const sibling =
|
|
978
|
+
meta === rootMeta
|
|
979
|
+
? undefined
|
|
980
|
+
: siblingRun(
|
|
981
|
+
own,
|
|
982
|
+
meta.dir,
|
|
983
|
+
metas.filter((m) => m !== root && m !== meta).map((m) => m.dir),
|
|
984
|
+
)
|
|
985
|
+
if (sibling !== undefined) {
|
|
986
|
+
todos.push(
|
|
987
|
+
`\`${sibling}\` runs another member's work outside the graph, again beside that member's own task: name that task under dependsOn (\`<member>#<task>\`) and drop it from the command`,
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
// A chain of this package's own scripts (`check: pnpm run build &&
|
|
991
|
+
// pnpm run lint`) ran each again inside the command, beside the task
|
|
992
|
+
// of that name: `vx run check` built twice. The chain's order may
|
|
993
|
+
// matter, so it is named rather than turned into a group.
|
|
994
|
+
// Through `run-s` / `run-p` / `npm-run-all` too (`build: run-p
|
|
995
|
+
// build:*`), each segment naming every task it runs (M-48). A
|
|
996
|
+
// persistent one has PERSISTENT_TODO.
|
|
997
|
+
const persistent = isPersistent(name, scripts)
|
|
998
|
+
let ranTasks = 0
|
|
999
|
+
const ownRuns = own
|
|
1000
|
+
.split(/&&|\|\||;/)
|
|
1001
|
+
.map((part) => part.trim())
|
|
1002
|
+
.filter((part) => {
|
|
1003
|
+
const d = delegatedScript(part)
|
|
1004
|
+
if (d !== null) {
|
|
1005
|
+
if (d === name || !isTask(d)) return false
|
|
1006
|
+
ranTasks++
|
|
1007
|
+
return true
|
|
1008
|
+
}
|
|
1009
|
+
if (persistent || !RUNS_OWN.test(part)) return false
|
|
1010
|
+
const refs = new Set(scriptRefs(part, text).filter((r) => r !== name && isTask(r)))
|
|
1011
|
+
ranTasks += refs.size
|
|
1012
|
+
return refs.size > 0
|
|
1013
|
+
})
|
|
1014
|
+
if (ownRuns.length > 0) {
|
|
1015
|
+
const list = ownRuns.map((r) => `\`${r}\``).join(', ')
|
|
1016
|
+
todos.push(
|
|
1017
|
+
`${list} ${ownRuns.length === 1 ? 'runs' : 'run'} this package's own ${
|
|
1018
|
+
ranTasks === 1
|
|
1019
|
+
? 'task again inside the command, beside that task: name it'
|
|
1020
|
+
: 'tasks again inside the command, beside those tasks: name them'
|
|
1021
|
+
} under dependsOn and drop ${ownRuns.length === 1 ? 'it' : 'them'} from the command`,
|
|
1022
|
+
)
|
|
1023
|
+
}
|
|
1024
|
+
const exec: Record<string, unknown> = { command }
|
|
1025
|
+
const npm = npmEnv(command, name, hooks.length > 0)
|
|
1026
|
+
if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
|
|
1027
|
+
if (npm.readsManifest) readsManifest = true
|
|
1028
|
+
for (const v of npm.unset) {
|
|
1029
|
+
todos.push(
|
|
1030
|
+
`the script reads $${v}, which the package manager sets and vx does not: define it under exec.env.define or drop it`,
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
const task: Record<string, unknown> = { exec }
|
|
1034
|
+
if (persistent) {
|
|
1035
|
+
exec['persistent'] = {}
|
|
1036
|
+
todos.push(PERSISTENT_TODO)
|
|
1037
|
+
}
|
|
1038
|
+
if (name === 'build') {
|
|
1039
|
+
task['dependsOn'] = ['^build']
|
|
1040
|
+
todos.push(cacheTodo(own))
|
|
1041
|
+
} else if (AFTER_BUILD.has(name) && hasBuild) {
|
|
1042
|
+
task['dependsOn'] = ['build']
|
|
1043
|
+
}
|
|
1044
|
+
tasks.push({ name, todos, task })
|
|
1045
|
+
}
|
|
1046
|
+
for (const [name, why] of refused) {
|
|
1047
|
+
tasks.push({
|
|
1048
|
+
name,
|
|
1049
|
+
todos: [
|
|
1050
|
+
`script ${JSON.stringify(name)} not migrated: its name ${why} — rename the script, or add it by hand under another name`,
|
|
1051
|
+
],
|
|
1052
|
+
task: null,
|
|
1053
|
+
})
|
|
1054
|
+
}
|
|
1055
|
+
upstreamBuildOnWorker(tasks)
|
|
1056
|
+
if (tasks.length > 0) {
|
|
1057
|
+
const importLines = readsManifest ? [MANIFEST_IMPORT] : []
|
|
1058
|
+
projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const owner = metas[0] === undefined ? undefined : ownerOf(metas[0].dir, hookMemo)
|
|
1062
|
+
if (owner?.manager === 'berry' && usesPnp(owner.at)) notes.push(PNP_NOTE)
|
|
1063
|
+
|
|
1064
|
+
if (lifecycleBuilds.length > 0) {
|
|
1065
|
+
const [name, hook, command] = lifecycleBuilds[0]!
|
|
1066
|
+
const more = lifecycleBuilds.length - 1
|
|
1067
|
+
notes.push(
|
|
1068
|
+
`${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
|
|
1069
|
+
)
|
|
1070
|
+
}
|
|
1071
|
+
const lerna = metas.length === 1 ? lernaPackages(metas[0]!.dir) : undefined
|
|
1072
|
+
if (lerna !== undefined) notes.push(lernaNote(lerna))
|
|
1073
|
+
rootBinBuilds(projects, metas, root?.packageJson ?? outside)
|
|
1074
|
+
breakBuildCycles(projects, metas)
|
|
1075
|
+
pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
|
|
1076
|
+
return {
|
|
1077
|
+
headerNotes: [
|
|
1078
|
+
// Where to add one is the TODO's to say, when there is one: an Nx repo
|
|
1079
|
+
// whose only script is `codegen` read "`build` carries a TODO" over
|
|
1080
|
+
// "0 TODOs" (the first-five-minutes walk, 2026-09-28).
|
|
1081
|
+
'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block',
|
|
1082
|
+
],
|
|
1083
|
+
projects,
|
|
1084
|
+
extraFiles: [],
|
|
1085
|
+
notes,
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
1090
|
+
|
|
1091
|
+
const depsOf = (manifest: object | undefined): Set<string> =>
|
|
1092
|
+
new Set(
|
|
1093
|
+
DEP_FIELDS.flatMap((f) => {
|
|
1094
|
+
const deps = (manifest as Record<string, unknown> | undefined)?.[f]
|
|
1095
|
+
return typeof deps === 'object' && deps !== null ? Object.keys(deps) : []
|
|
1096
|
+
}),
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
/** The bin names a manifest declares: `bin: './cli.js'` is named after the package. */
|
|
1100
|
+
function binsOf(manifest: object, name: string): string[] {
|
|
1101
|
+
const bin = (manifest as Record<string, unknown>)['bin']
|
|
1102
|
+
if (typeof bin === 'string') return [name.replace(/^@[^/]+\//, '')]
|
|
1103
|
+
return typeof bin === 'object' && bin !== null ? Object.keys(bin) : []
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* A member's script that runs a workspace package's bin it gets only
|
|
1108
|
+
* through the root's dependencies (vite's playgrounds run `vite` from the
|
|
1109
|
+
* root's `vite: workspace:*`) has no `^build` edge to that package: 64 of
|
|
1110
|
+
* 70 playground builds ran before vite's own (Growth's trial 4, F9). Each
|
|
1111
|
+
* such task waits on that package's build too.
|
|
1112
|
+
*/
|
|
1113
|
+
function rootBinBuilds(
|
|
1114
|
+
projects: GeneratedProject[],
|
|
1115
|
+
metas: readonly PackageMeta[],
|
|
1116
|
+
rootManifest: object | undefined,
|
|
1117
|
+
): void {
|
|
1118
|
+
const fromRoot = depsOf(rootManifest)
|
|
1119
|
+
if (fromRoot.size === 0) return
|
|
1120
|
+
const builds = new Set(
|
|
1121
|
+
projects
|
|
1122
|
+
.filter((p) => p.tasks.some((t) => t.name === 'build' && t.task !== null))
|
|
1123
|
+
.map((p) => p.name),
|
|
1124
|
+
)
|
|
1125
|
+
const owner = new Map<string, string>()
|
|
1126
|
+
for (const m of metas)
|
|
1127
|
+
if (fromRoot.has(m.name) && builds.has(m.name))
|
|
1128
|
+
for (const b of binsOf(m.packageJson, m.name)) owner.set(b, m.name)
|
|
1129
|
+
if (owner.size === 0) return
|
|
1130
|
+
const metaOf = new Map(metas.map((m) => [m.name, m]))
|
|
1131
|
+
for (const p of projects) {
|
|
1132
|
+
const own = depsOf(metaOf.get(p.name)?.packageJson)
|
|
1133
|
+
for (const t of p.tasks) {
|
|
1134
|
+
const command = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
|
|
1135
|
+
if (typeof command !== 'string') continue
|
|
1136
|
+
const needs = new Set(
|
|
1137
|
+
command
|
|
1138
|
+
.split(/[\s;&|()]+/)
|
|
1139
|
+
.map((w) => owner.get(w))
|
|
1140
|
+
.filter((n): n is string => n !== undefined && n !== p.name && !own.has(n)),
|
|
1141
|
+
)
|
|
1142
|
+
if (needs.size === 0) continue
|
|
1143
|
+
const deps = Array.isArray(t.task!['dependsOn']) ? (t.task!['dependsOn'] as string[]) : []
|
|
1144
|
+
t.task!['dependsOn'] = [...deps, ...[...needs].sort().map((n) => `${n}#build`)]
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* pnpm sorts a dependency cycle away and builds anyway; vx's `^build`
|
|
1151
|
+
* refuses it, so the config init wrote failed its first run (nuxt:
|
|
1152
|
+
* `@nuxt/nitro-server` devDepends on `nuxt`, which depends on it; vitest's
|
|
1153
|
+
* browser packages). A package in a cycle of builds waits, instead of on
|
|
1154
|
+
* `^build`, on each build outside its cycle that `^build` reaches (through
|
|
1155
|
+
* packages with none, as core walks it), with a TODO to order the cycle.
|
|
1156
|
+
*/
|
|
1157
|
+
function breakBuildCycles(projects: GeneratedProject[], metas: readonly PackageMeta[]): void {
|
|
1158
|
+
const builds = new Set(
|
|
1159
|
+
projects
|
|
1160
|
+
.filter((p) => p.tasks.some((t) => t.name === 'build' && t.task !== null))
|
|
1161
|
+
.map((p) => p.name),
|
|
1162
|
+
)
|
|
1163
|
+
if (builds.size < 2) return
|
|
1164
|
+
// The edges the lockfile plugin init declares will give `^build`: the
|
|
1165
|
+
// manager installs what a manifest names, so a key naming a workspace
|
|
1166
|
+
// package is one. Core's graph reads no manifest; this mapper reads
|
|
1167
|
+
// package.json throughout.
|
|
1168
|
+
const names = new Set(metas.map((m) => m.name))
|
|
1169
|
+
const direct = new Map<string, string[]>()
|
|
1170
|
+
for (const m of metas) {
|
|
1171
|
+
const own = new Set<string>()
|
|
1172
|
+
for (const field of [
|
|
1173
|
+
m.packageJson.dependencies,
|
|
1174
|
+
m.packageJson.devDependencies,
|
|
1175
|
+
m.packageJson.optionalDependencies,
|
|
1176
|
+
]) {
|
|
1177
|
+
if (field === null || typeof field !== 'object') continue
|
|
1178
|
+
for (const key of Object.keys(field)) if (key !== m.name && names.has(key)) own.add(key)
|
|
1179
|
+
}
|
|
1180
|
+
direct.set(m.name, [...own])
|
|
1181
|
+
}
|
|
1182
|
+
const directDeps = (name: string): readonly string[] => direct.get(name) ?? []
|
|
1183
|
+
const holders = new Map<string, string[]>()
|
|
1184
|
+
for (const name of builds) {
|
|
1185
|
+
const found = new Set<string>()
|
|
1186
|
+
const visited = new Set([name])
|
|
1187
|
+
const frontier = [...directDeps(name)]
|
|
1188
|
+
while (frontier.length > 0) {
|
|
1189
|
+
const at = frontier.pop()!
|
|
1190
|
+
if (visited.has(at)) continue
|
|
1191
|
+
visited.add(at)
|
|
1192
|
+
if (builds.has(at)) found.add(at)
|
|
1193
|
+
else frontier.push(...directDeps(at))
|
|
1194
|
+
}
|
|
1195
|
+
holders.set(name, [...found].sort())
|
|
1196
|
+
}
|
|
1197
|
+
for (const cycle of cyclesOf(holders)) {
|
|
1198
|
+
const members = new Set(cycle)
|
|
1199
|
+
for (const p of projects) {
|
|
1200
|
+
if (!members.has(p.name)) continue
|
|
1201
|
+
const at = p.tasks.find(
|
|
1202
|
+
(t) =>
|
|
1203
|
+
Array.isArray(t.task?.['dependsOn']) &&
|
|
1204
|
+
(t.task['dependsOn'] as string[]).includes('^build'),
|
|
1205
|
+
)
|
|
1206
|
+
if (at === undefined) continue
|
|
1207
|
+
const outside = holders
|
|
1208
|
+
.get(p.name)!
|
|
1209
|
+
.filter((h) => !members.has(h))
|
|
1210
|
+
.map((h) => `${h}#build`)
|
|
1211
|
+
at.task!['dependsOn'] = (at.task!['dependsOn'] as string[]).flatMap((d) =>
|
|
1212
|
+
d === '^build' ? outside : [d],
|
|
1213
|
+
)
|
|
1214
|
+
at.todos.push(
|
|
1215
|
+
`its package is in a dependency cycle (${cycle.join(', ')}), where \`^build\` would refuse the run — it waits on the builds outside the cycle; order the ones inside it by hand`,
|
|
1216
|
+
)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** The strongly connected components of more than one node (Tarjan), each sorted. */
|
|
1222
|
+
function cyclesOf(edges: ReadonlyMap<string, readonly string[]>): string[][] {
|
|
1223
|
+
const index = new Map<string, number>()
|
|
1224
|
+
const low = new Map<string, number>()
|
|
1225
|
+
const stack: string[] = []
|
|
1226
|
+
const onStack = new Set<string>()
|
|
1227
|
+
const out: string[][] = []
|
|
1228
|
+
const visit = (v: string): void => {
|
|
1229
|
+
index.set(v, index.size)
|
|
1230
|
+
low.set(v, index.get(v)!)
|
|
1231
|
+
stack.push(v)
|
|
1232
|
+
onStack.add(v)
|
|
1233
|
+
for (const w of edges.get(v) ?? []) {
|
|
1234
|
+
if (!index.has(w)) {
|
|
1235
|
+
visit(w)
|
|
1236
|
+
low.set(v, Math.min(low.get(v)!, low.get(w)!))
|
|
1237
|
+
} else if (onStack.has(w)) {
|
|
1238
|
+
low.set(v, Math.min(low.get(v)!, index.get(w)!))
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (low.get(v) === index.get(v)) {
|
|
1242
|
+
const comp: string[] = []
|
|
1243
|
+
let w: string
|
|
1244
|
+
do {
|
|
1245
|
+
w = stack.pop()!
|
|
1246
|
+
onStack.delete(w)
|
|
1247
|
+
comp.push(w)
|
|
1248
|
+
} while (w !== v)
|
|
1249
|
+
if (comp.length > 1) out.push(comp.sort())
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
for (const v of [...edges.keys()].sort()) if (!index.has(v)) visit(v)
|
|
1253
|
+
return out
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** The member whose directory holds every other member's, if one does. */
|
|
1257
|
+
function workspaceRootOf(metas: readonly PackageMeta[]): PackageMeta | undefined {
|
|
1258
|
+
const outer = metas.reduce((a, b) => (b.dir.length < a.dir.length ? b : a))
|
|
1259
|
+
const prefix = outer.dir.endsWith(path.sep) ? outer.dir : outer.dir + path.sep
|
|
1260
|
+
return metas.every((m) => m === outer || m.dir.startsWith(prefix)) ? outer : undefined
|
|
1261
|
+
}
|