@vzn/vx-migrate 0.0.594 → 0.0.596
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 +48 -21
- package/package.json +2 -2
- package/src/adopt.ts +1 -1
- package/src/glob-grammar.ts +20 -6
- package/src/migrate-nx.ts +37 -17
- package/src/migrate-turbo.ts +1 -30
- package/src/migrate-vite-task.ts +40 -0
- package/src/migrate.ts +179 -24
- package/src/nx/index.ts +4 -1
- package/src/nx/lerna.ts +60 -0
- package/src/nx/nx-inputs.ts +11 -11
- package/src/nx/nx-map.ts +16 -2
- package/src/nx/nx-outputs.ts +62 -12
- package/src/nx/nx-upstream.ts +4 -1
- package/src/nx-cache/index.ts +20 -14
- package/src/nx-env.cjs +4 -1
- package/src/nx-exec.cjs +4 -1
- package/src/remote-breaker.ts +60 -0
- package/src/shared-outputs.ts +3 -1
- package/src/spelled-env.ts +36 -0
- package/src/tracked-outputs.ts +36 -2
- package/src/turbo/turbo-map.ts +24 -20
- package/src/turbo-cache/index.ts +111 -28
- package/src/vite-task/vite-task-map.ts +558 -0
- package/src/workspace-plugins.ts +2 -0
package/src/migrate.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// `vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry]
|
|
1
|
+
// `vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry]
|
|
2
2
|
// [--force] [--mjs]` — one vx.config.ts per workspace package from an
|
|
3
3
|
// existing Turbo or Nx setup (`--keep`: the workspace file that reads it
|
|
4
4
|
// live instead, as `vx init` writes it), and vx installed with the repo's
|
|
5
5
|
// manager.
|
|
6
6
|
// Without either mode flag a terminal is asked; anything else is native. Source auto-detect: turbo.json → Turbo;
|
|
7
|
-
// .nx/workspace-data/project-graph.json or
|
|
7
|
+
// .nx/workspace-data/project-graph.json, nx.json or a Lerna-on-Nx lerna.json → Nx (the resolved
|
|
8
|
+
// graph, exported by nx if absent); `vite-plus` in the root package.json → Vite Task, last:
|
|
9
|
+
// vite-plus is a whole toolchain, so beside turbo.json or Nx it is not the task runner.
|
|
8
10
|
// The mappers return a plan; core's migration seam
|
|
9
11
|
// (`applyMigration`) renders, guards, writes and reports, so what this
|
|
10
12
|
// package writes reads exactly like what `vx init` writes.
|
|
@@ -23,17 +25,20 @@ import {
|
|
|
23
25
|
type MigrationPlan,
|
|
24
26
|
UserError,
|
|
25
27
|
} from '@vzn/vx'
|
|
26
|
-
import { migrateNx, NX_GRAPH_REL } from './migrate-nx.js'
|
|
28
|
+
import { migrateNx, NX_GRAPH_REL, nxWorkspaceFields } from './migrate-nx.js'
|
|
27
29
|
import { exportGraph } from './nx/export-graph.js'
|
|
30
|
+
import { readNxJson } from './nx/nx-map.js'
|
|
28
31
|
import {
|
|
29
32
|
type AdoptionMode,
|
|
30
33
|
install,
|
|
31
34
|
missingPackages,
|
|
32
35
|
MODE_QUESTION,
|
|
33
36
|
ownVersion,
|
|
37
|
+
packageManagerOf,
|
|
34
38
|
parseModeAnswer,
|
|
35
39
|
} from './adopt.js'
|
|
36
40
|
import { migrateTurbo } from './migrate-turbo.js'
|
|
41
|
+
import { migrateViteTask } from './migrate-vite-task.js'
|
|
37
42
|
import { turboConfigFile } from './turbo/turbo-map.js'
|
|
38
43
|
import {
|
|
39
44
|
extendWorkspaceFile,
|
|
@@ -41,6 +46,7 @@ import {
|
|
|
41
46
|
renderWorkspaceFile,
|
|
42
47
|
workspaceFileAt,
|
|
43
48
|
workspacePlugins,
|
|
49
|
+
type WorkspacePlugin,
|
|
44
50
|
} from './workspace-plugins.js'
|
|
45
51
|
|
|
46
52
|
export interface MigrateArgs {
|
|
@@ -48,7 +54,7 @@ export interface MigrateArgs {
|
|
|
48
54
|
force: boolean
|
|
49
55
|
/** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
|
|
50
56
|
mjs: boolean
|
|
51
|
-
from?: 'turbo' | 'nx'
|
|
57
|
+
from?: 'turbo' | 'nx' | 'vite-task'
|
|
52
58
|
/** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
|
|
53
59
|
mode?: AdoptionMode
|
|
54
60
|
/** `--no-install`: leave package.json's dependencies alone. */
|
|
@@ -59,7 +65,7 @@ export interface MigrateArgs {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
const USAGE =
|
|
62
|
-
'usage: vx-migrate [--from turbo|nx] [--native|--keep] [--no-install] [--dry] [--force] [--mjs]'
|
|
68
|
+
'usage: vx-migrate [--from turbo|nx|vite-task] [--native|--keep] [--no-install] [--dry] [--force] [--mjs]'
|
|
63
69
|
|
|
64
70
|
export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
65
71
|
const out: MigrateArgs = { dry: false, force: false, mjs: false }
|
|
@@ -77,10 +83,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
|
77
83
|
out.mode = mode
|
|
78
84
|
} else if (a === '--from' || a?.startsWith('--from=')) {
|
|
79
85
|
const v = a === '--from' ? args[++i] : a.slice('--from='.length)
|
|
80
|
-
if (v !== 'turbo' && v !== 'nx') {
|
|
86
|
+
if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task') {
|
|
81
87
|
return {
|
|
82
88
|
...out,
|
|
83
|
-
error: `--from must be turbo or
|
|
89
|
+
error: `--from must be turbo, nx or vite-task (package.json scripts: \`vx init\`)`,
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
92
|
out.from = v
|
|
@@ -124,13 +130,15 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
124
130
|
const hasTurbo = turboFile !== null
|
|
125
131
|
const hasGraph = await Bun.file(path.join(root, NX_GRAPH_REL)).exists()
|
|
126
132
|
const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
|
|
133
|
+
// A Turbo repo's lerna.json publishes; Turbo runs its tasks.
|
|
134
|
+
const lerna = !hasTurbo && !hasGraph && !hasNxJson && lernaOnNx(root)
|
|
127
135
|
|
|
128
136
|
// Evaluating teams routinely have two runners checked in — never
|
|
129
137
|
// ask anyone to delete anything; --from disambiguates.
|
|
130
138
|
const found = (
|
|
131
139
|
[
|
|
132
140
|
['turbo', hasTurbo, 'turbo.json'],
|
|
133
|
-
['nx', hasGraph || hasNxJson, 'an nx workspace'],
|
|
141
|
+
['nx', hasGraph || hasNxJson || lerna, 'an nx workspace'],
|
|
134
142
|
] as const
|
|
135
143
|
).filter(([, present]) => present)
|
|
136
144
|
if (parsed.from === undefined && found.length > 1) {
|
|
@@ -147,28 +155,57 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
147
155
|
throw new UserError('--from turbo, but no turbo.json at the workspace root')
|
|
148
156
|
}
|
|
149
157
|
|
|
150
|
-
const runner: 'turbo' | 'nx' | undefined =
|
|
151
|
-
parsed.from ??
|
|
158
|
+
const runner: 'turbo' | 'nx' | 'vite-task' | undefined =
|
|
159
|
+
parsed.from ??
|
|
160
|
+
(hasTurbo
|
|
161
|
+
? 'turbo'
|
|
162
|
+
: hasGraph || hasNxJson || lerna
|
|
163
|
+
? 'nx'
|
|
164
|
+
: (await hasVitePlus(root))
|
|
165
|
+
? 'vite-task'
|
|
166
|
+
: undefined)
|
|
152
167
|
if (runner === undefined) {
|
|
153
168
|
throw new UserError(
|
|
154
|
-
'nothing to migrate: no turbo.json
|
|
169
|
+
'nothing to migrate: no turbo.json, no Nx workspace and no vite-plus — ' +
|
|
155
170
|
'for package.json scripts, run `vx init`',
|
|
156
171
|
)
|
|
157
172
|
}
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
if (runner === 'vite-task' && parsed.mode === 'keep') {
|
|
174
|
+
throw new UserError(
|
|
175
|
+
'--keep: no plugin runs a Vite Task config live; drop --keep to write vx.config files',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
const mode =
|
|
179
|
+
runner === 'vite-task'
|
|
180
|
+
? 'native'
|
|
181
|
+
: (parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry)))
|
|
182
|
+
if (mode === 'keep' && runner !== 'vite-task') return keep(root, runner, hasTurbo, lerna, parsed)
|
|
160
183
|
|
|
161
184
|
const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
|
|
185
|
+
// No workspace file yet: this run writes one, declaring the plugins the
|
|
186
|
+
// repo calls for and the run settings nx.json holds.
|
|
187
|
+
const writesWorkspace = workspaceFileAt(root) === undefined
|
|
162
188
|
let source: string
|
|
163
189
|
let plan: MigrationPlan
|
|
164
|
-
if (runner === '
|
|
190
|
+
if (runner === 'vite-task') {
|
|
191
|
+
source = 'vite.config run.tasks'
|
|
192
|
+
plan = await migrateViteTask(root, metas, format)
|
|
193
|
+
} else if (runner === 'nx') {
|
|
165
194
|
if (hasGraph) {
|
|
166
195
|
source = NX_GRAPH_REL
|
|
167
|
-
plan = await migrateNx(root, metas, format)
|
|
196
|
+
plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
|
|
168
197
|
} else {
|
|
169
198
|
// Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
|
|
170
199
|
// when exported. The workspace's own nx exports it, as `nx()` does, into
|
|
171
200
|
// a temp file; the user ran that step by hand until 2026-10-01.
|
|
201
|
+
// A fresh clone: the export's own reason names the plugin's `graph`
|
|
202
|
+
// option, which the CLI does not take.
|
|
203
|
+
if (!existsSync(path.join(root, 'node_modules', '.bin', 'nx'))) {
|
|
204
|
+
throw new UserError(
|
|
205
|
+
`nx is not installed here (no node_modules/.bin/nx), and vx-migrate reads the graph it exports — ` +
|
|
206
|
+
`run \`${packageManagerOf(root, process.env['npm_config_user_agent'])} install\`, then vx-migrate again`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
172
209
|
const tmp = await mkdtemp(path.join(os.tmpdir(), 'vx-migrate-nx-'))
|
|
173
210
|
try {
|
|
174
211
|
const snapshot = path.join(tmp, 'project-graph.json')
|
|
@@ -180,7 +217,7 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
180
217
|
)
|
|
181
218
|
}
|
|
182
219
|
source = 'nx graph'
|
|
183
|
-
plan = await migrateNx(root, metas, format, snapshot)
|
|
220
|
+
plan = await migrateNx(root, metas, format, snapshot, writesWorkspace)
|
|
184
221
|
} finally {
|
|
185
222
|
await rm(tmp, { recursive: true, force: true })
|
|
186
223
|
}
|
|
@@ -201,22 +238,42 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
201
238
|
existsSync(path.join(p.dir, configName))),
|
|
202
239
|
) ||
|
|
203
240
|
plan.extraFiles.some((f) => existsSync(path.join(root, f.relPath))))
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
241
|
+
// Drop the note that told the user to declare the lockfile plugin.
|
|
242
|
+
const plugins = writesWorkspace ? workspacePlugins(root) : []
|
|
243
|
+
if (writesWorkspace) {
|
|
244
|
+
const fields =
|
|
245
|
+
runner === 'nx'
|
|
246
|
+
? nxWorkspaceFields((await readNxJson(root).catch(() => null))?.json).flatMap((f) =>
|
|
247
|
+
f.source === undefined ? [] : [{ field: f.field, source: f.source }],
|
|
248
|
+
)
|
|
249
|
+
: []
|
|
208
250
|
plan = {
|
|
209
251
|
...plan,
|
|
210
252
|
headerNotes: plan.headerNotes.filter((n) => !n.includes('from @vzn/vx-lockfile')),
|
|
211
253
|
extraFiles: [
|
|
212
254
|
...plan.extraFiles,
|
|
213
|
-
{
|
|
255
|
+
{
|
|
256
|
+
relPath: `vx.workspace.${format}`,
|
|
257
|
+
contents: renderWorkspaceFile(plugins, format, fields),
|
|
258
|
+
},
|
|
214
259
|
],
|
|
215
260
|
}
|
|
216
261
|
}
|
|
262
|
+
// An executor target is an `nx-exec` line and a `.env` one an `nx-env`
|
|
263
|
+
// line: both bins are this package's, and `bunx` leaves none behind.
|
|
264
|
+
const runsBins = plan.projects.some((p) =>
|
|
265
|
+
p.tasks.some((t) => {
|
|
266
|
+
const cmd = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
|
|
267
|
+
return typeof cmd === 'string' && /^nx-(exec|env) /.test(cmd)
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
217
270
|
const headerNotes = refused
|
|
218
271
|
? []
|
|
219
|
-
: await prepareRepo(
|
|
272
|
+
: await prepareRepo(
|
|
273
|
+
root,
|
|
274
|
+
['@vzn/vx', ...(runsBins ? ['@vzn/vx-migrate'] : []), ...plugins.map((p) => p.pkg)],
|
|
275
|
+
parsed,
|
|
276
|
+
)
|
|
220
277
|
return applyMigration({
|
|
221
278
|
root,
|
|
222
279
|
metas,
|
|
@@ -230,10 +287,25 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
|
230
287
|
})
|
|
231
288
|
}
|
|
232
289
|
|
|
290
|
+
/** vite-plus (`vp run`) in the root package.json's dependencies. */
|
|
291
|
+
async function hasVitePlus(root: string): Promise<boolean> {
|
|
292
|
+
const pkg = (await Bun.file(path.join(root, 'package.json'))
|
|
293
|
+
.json()
|
|
294
|
+
.catch(() => ({}))) as Record<string, unknown>
|
|
295
|
+
return ['dependencies', 'devDependencies'].some((f) => {
|
|
296
|
+
const deps = pkg[f]
|
|
297
|
+
return typeof deps === 'object' && deps !== null && Object.hasOwn(deps, 'vite-plus')
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
233
301
|
/** A terminal is asked which adoption it wants; anything else gets native. */
|
|
234
|
-
async function askMode(
|
|
302
|
+
async function askMode(
|
|
303
|
+
runner: 'turbo' | 'nx',
|
|
304
|
+
file: string | undefined,
|
|
305
|
+
dry: boolean,
|
|
306
|
+
): Promise<AdoptionMode> {
|
|
235
307
|
if (dry || !process.stdin.isTTY || !process.stdout.isTTY) return 'native'
|
|
236
|
-
const source = runner === 'turbo' ? 'turbo.json' : 'nx.json'
|
|
308
|
+
const source = file ?? (runner === 'turbo' ? 'turbo.json' : 'nx.json')
|
|
237
309
|
for (;;) {
|
|
238
310
|
const mode = parseModeAnswer(prompt(MODE_QUESTION(runner, source)))
|
|
239
311
|
if (mode !== undefined) return mode
|
|
@@ -268,6 +340,7 @@ async function keep(
|
|
|
268
340
|
root: string,
|
|
269
341
|
runner: 'turbo' | 'nx',
|
|
270
342
|
hasTurbo: boolean,
|
|
343
|
+
lerna: boolean,
|
|
271
344
|
args: MigrateArgs,
|
|
272
345
|
): Promise<number> {
|
|
273
346
|
// `vx init` reads turbo.json first.
|
|
@@ -291,6 +364,9 @@ async function keep(
|
|
|
291
364
|
)) {
|
|
292
365
|
process.stdout.write(`vx-migrate: ${note}\n`)
|
|
293
366
|
}
|
|
367
|
+
// `vx init` adopts nx() by nx.json, and a Lerna repo may have none: it
|
|
368
|
+
// would map the scripts instead, so the file is written here.
|
|
369
|
+
if (lerna) return keepLerna(root, plugins, args)
|
|
294
370
|
// A resolve that misses from a directory with no node_modules is an
|
|
295
371
|
// auto-install under Bun: ask the root only where it has the package.
|
|
296
372
|
const installed = path.join(root, 'node_modules', '@vzn', 'vx')
|
|
@@ -316,3 +392,82 @@ async function keep(
|
|
|
316
392
|
}
|
|
317
393
|
return code
|
|
318
394
|
}
|
|
395
|
+
|
|
396
|
+
/** `--keep` in a Lerna repo with no nx.json: the workspace file declaring `nx()`. */
|
|
397
|
+
async function keepLerna(
|
|
398
|
+
root: string,
|
|
399
|
+
plugins: readonly WorkspacePlugin[],
|
|
400
|
+
args: MigrateArgs,
|
|
401
|
+
): Promise<number> {
|
|
402
|
+
const format = args.mjs ? 'mjs' : 'ts'
|
|
403
|
+
const name = `vx.workspace.${format}`
|
|
404
|
+
const existing = workspaceFileAt(root)
|
|
405
|
+
const all = [{ pkg: '@vzn/vx-migrate', factory: 'nx' }, ...plugins]
|
|
406
|
+
if (existing !== undefined && !args.force) {
|
|
407
|
+
const text = readFileSync(path.join(root, existing), 'utf8')
|
|
408
|
+
const extended = extendWorkspaceFile(text, all)
|
|
409
|
+
if (extended === null) {
|
|
410
|
+
throw new UserError(
|
|
411
|
+
`${existing} exists; add nx() from @vzn/vx-migrate to its plugins, or --force replaces it`,
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
if (!args.dry && extended !== text) await Bun.write(path.join(root, existing), extended)
|
|
415
|
+
const names = undeclared(text, all).map((p) => `${p.factory}()`)
|
|
416
|
+
if (names.length > 0)
|
|
417
|
+
process.stdout.write(
|
|
418
|
+
`vx-migrate: ${args.dry ? 'would declare' : 'declared'} ${names.join(', ')} in ${existing}\n`,
|
|
419
|
+
)
|
|
420
|
+
return 0
|
|
421
|
+
}
|
|
422
|
+
const text = renderWorkspaceFile(all, format)
|
|
423
|
+
if (args.dry) {
|
|
424
|
+
process.stdout.write(`── ${name} ──\n${text}\nvx-migrate: would write ${name} (dry run)\n`)
|
|
425
|
+
return 0
|
|
426
|
+
}
|
|
427
|
+
if (existing !== undefined && existing !== name) await rm(path.join(root, existing))
|
|
428
|
+
await Bun.write(path.join(root, name), text)
|
|
429
|
+
process.stdout.write(`vx-migrate: lerna.json found — wrote ${name} declaring nx()\n`)
|
|
430
|
+
return 0
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Lerna 6+ runs `lerna run` on Nx's task runner over the graph `nx graph`
|
|
435
|
+
* exports, nx.json or not, unless lerna.json says `useNx: false`; Lerna 5
|
|
436
|
+
* ran its own unless it said `useNx: true`. Lerna is the installed one,
|
|
437
|
+
* else the root manifest's range; with neither, the lerna.json is another
|
|
438
|
+
* tool's (lerna-lite reads it too, and runs no Nx). A repo whose root
|
|
439
|
+
* scripts never `lerna run` runs its tasks some other way and publishes
|
|
440
|
+
* with Lerna (webdriverio: `run-s`, `pnpm -r`): its scripts are the source.
|
|
441
|
+
*/
|
|
442
|
+
const LERNA_RUN = /(?:^|[\s;&|(])lerna\s+run\s/
|
|
443
|
+
|
|
444
|
+
function lernaOnNx(root: string): boolean {
|
|
445
|
+
const json = (file: string): Record<string, unknown> | undefined => {
|
|
446
|
+
try {
|
|
447
|
+
return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) as Record<
|
|
448
|
+
string,
|
|
449
|
+
unknown
|
|
450
|
+
>
|
|
451
|
+
} catch {
|
|
452
|
+
return undefined
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const config = json(path.join(root, 'lerna.json'))
|
|
456
|
+
if (config === undefined) return false
|
|
457
|
+
const installed = json(path.join(root, 'node_modules', 'lerna', 'package.json'))?.['version']
|
|
458
|
+
const manifest = json(path.join(root, 'package.json'))
|
|
459
|
+
const declared = ['devDependencies', 'dependencies']
|
|
460
|
+
.map((k) => (manifest?.[k] as Record<string, unknown> | undefined)?.['lerna'])
|
|
461
|
+
.find((v): v is string => typeof v === 'string')
|
|
462
|
+
const version = typeof installed === 'string' ? installed : declared
|
|
463
|
+
if (version === undefined) return false
|
|
464
|
+
const scripts = manifest?.['scripts']
|
|
465
|
+
const runs =
|
|
466
|
+
scripts !== null &&
|
|
467
|
+
typeof scripts === 'object' &&
|
|
468
|
+
Object.values(scripts).some((v) => typeof v === 'string' && LERNA_RUN.test(v))
|
|
469
|
+
if (!runs) return false
|
|
470
|
+
if (typeof config['useNx'] === 'boolean') return config['useNx']
|
|
471
|
+
const major = /\d+/.exec(version)?.[0]
|
|
472
|
+
return major === undefined || Number(major) >= 6
|
|
473
|
+
}
|
package/src/nx/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readNxJson,
|
|
43
43
|
} from './nx-map.js'
|
|
44
44
|
import { exportGraph } from './export-graph.js'
|
|
45
|
+
import { lernaJsonText } from './lerna.js'
|
|
45
46
|
export type {
|
|
46
47
|
NxExecutors,
|
|
47
48
|
NxExecutorTarget,
|
|
@@ -295,7 +296,7 @@ const textOf = (file: string): Promise<string> =>
|
|
|
295
296
|
.catch(() => '\0absent')
|
|
296
297
|
|
|
297
298
|
/**
|
|
298
|
-
* Everything the mapping reads: the graph, nx.json and its `extends` chain, every package manifest
|
|
299
|
+
* Everything the mapping reads: the graph, nx.json and its `extends` chain, lerna.json, every package manifest
|
|
299
300
|
* and the package.json of each graph node no package matches (its
|
|
300
301
|
* synthetic project's name), the `.env` names in every project dir,
|
|
301
302
|
* NX_LOAD_DOT_ENV_FILES, whether the bins the tasks run are installed,
|
|
@@ -326,6 +327,8 @@ async function nxReads(
|
|
|
326
327
|
return [
|
|
327
328
|
graphText,
|
|
328
329
|
...(await Promise.all(chain.map(textOf))),
|
|
330
|
+
// Its presence orders each target after its dependencies' (`lerna run`).
|
|
331
|
+
String(await lernaJsonText(root)),
|
|
329
332
|
// A config file added beside mapped tasks changes what an output may cover.
|
|
330
333
|
JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
|
|
331
334
|
...(await Promise.all(unmatched.map((r) => textOf(path.join(root, r, 'package.json'))))),
|
package/src/nx/lerna.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `lerna run <x>` orders each package's <x> after its dependencies' <x>.
|
|
2
|
+
// Lerna 6+ runs it on Nx's task runner and passes that order as the target
|
|
3
|
+
// dependency `^<x>` — and drops every other one — unless the repo
|
|
4
|
+
// configures Nx tasks itself: nx.json `targetDefaults` (or the legacy
|
|
5
|
+
// `targetDependencies`), or an `nx` key in the package.json of a package
|
|
6
|
+
// that has <x> (lerna's `prepNxOptions`, 9.0.7). The exported graph holds
|
|
7
|
+
// none of this, so a Lerna repo mapped from it ran every build at once.
|
|
8
|
+
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
interface Target {
|
|
12
|
+
dependsOn?: unknown[]
|
|
13
|
+
}
|
|
14
|
+
interface Node {
|
|
15
|
+
data?: { root?: string; targets?: Record<string, Target> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The text of `root/lerna.json`, or null when there is none. */
|
|
19
|
+
export async function lernaJsonText(root: string): Promise<string | null> {
|
|
20
|
+
return Bun.file(path.join(root, 'lerna.json'))
|
|
21
|
+
.text()
|
|
22
|
+
.catch(() => null)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The graph's nodes with Lerna's order applied, or `nodes` itself when the
|
|
27
|
+
* repo has no lerna.json or configures Nx's task dependencies.
|
|
28
|
+
* `manifest(rel)`: the package.json a root-relative node root holds.
|
|
29
|
+
*/
|
|
30
|
+
export async function withLernaOrder<N extends Node>(
|
|
31
|
+
root: string,
|
|
32
|
+
nodes: Record<string, N>,
|
|
33
|
+
nxJson: Record<string, unknown> | undefined,
|
|
34
|
+
manifest: (rel: string) => Promise<Record<string, unknown> | undefined>,
|
|
35
|
+
): Promise<Record<string, N>> {
|
|
36
|
+
if ((await lernaJsonText(root)) === null) return nodes
|
|
37
|
+
const keys = (v: unknown): number =>
|
|
38
|
+
v !== null && typeof v === 'object' ? Object.keys(v).length : 0
|
|
39
|
+
if (keys(nxJson?.['targetDependencies'] || nxJson?.['targetDefaults']) > 0) return nodes
|
|
40
|
+
const configured = new Set<string>()
|
|
41
|
+
for (const node of Object.values(nodes)) {
|
|
42
|
+
const targets = node.data?.targets
|
|
43
|
+
if (targets === undefined) continue
|
|
44
|
+
if ((await manifest(node.data?.root ?? '.'))?.['nx'] === undefined) continue
|
|
45
|
+
for (const name of Object.keys(targets)) configured.add(name)
|
|
46
|
+
}
|
|
47
|
+
const out: Record<string, N> = {}
|
|
48
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
49
|
+
const targets = node.data?.targets
|
|
50
|
+
if (targets === undefined) {
|
|
51
|
+
out[id] = node
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const ordered: Record<string, Target> = {}
|
|
55
|
+
for (const [name, t] of Object.entries(targets))
|
|
56
|
+
ordered[name] = configured.has(name) ? t : { ...t, dependsOn: [`^${name}`] }
|
|
57
|
+
out[id] = { ...node, data: { ...node.data, targets: ordered } }
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
package/src/nx/nx-inputs.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// (`dependentTasksOutputFiles`) is nothing; `externalDependencies` is a todo saying
|
|
8
8
|
// so. Extracted from `buildTask` in item 606.
|
|
9
9
|
|
|
10
|
-
import { nxWorkspacePath, underProject } from './nx-outputs.js'
|
|
10
|
+
import { nxWorkspacePath, underProject, withProjectDir } from './nx-outputs.js'
|
|
11
11
|
import { minimatchToVx } from '../glob-grammar.js'
|
|
12
12
|
import { shellQuote } from '../nx-command.js'
|
|
13
13
|
|
|
@@ -68,21 +68,18 @@ export function expandNxInputs(
|
|
|
68
68
|
todos.push(`input ${JSON.stringify(entry)} uses a token vx does not support`)
|
|
69
69
|
return
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
return
|
|
77
|
-
}
|
|
71
|
+
// Nx's file map skips gitignored files, so it hashes nothing here
|
|
72
|
+
// either: a changed `dist` file is a cache hit under Nx 23.3 (YL-1).
|
|
73
|
+
const lit = withProjectDir(p, at.rel, false)
|
|
74
|
+
if (neg === '' && at.outputs?.some((o) => lit === o || lit.startsWith(`${o}/`))) return
|
|
78
75
|
const g = minimatchToVx(p, neg !== '')
|
|
79
76
|
if (g === null) {
|
|
80
77
|
todos.push(`input ${JSON.stringify(entry)}: glob syntax vx cannot take — map manually`)
|
|
81
78
|
return
|
|
82
79
|
}
|
|
83
80
|
const own = underProject(g, at.rel)
|
|
84
|
-
if (own === null) into.wsFiles.push(neg + g)
|
|
85
|
-
else into.files.push(neg + own)
|
|
81
|
+
if (own === null) into.wsFiles.push(neg + withProjectDir(g, at.rel, true))
|
|
82
|
+
else into.files.push(neg + withProjectDir(own, at.rel, true))
|
|
86
83
|
return
|
|
87
84
|
}
|
|
88
85
|
// Bare string = named-input reference.
|
|
@@ -197,7 +194,10 @@ export function expandNxInputs(
|
|
|
197
194
|
}
|
|
198
195
|
// A negation filters only other includeIgnored matches, and a literal
|
|
199
196
|
// reads no others.
|
|
200
|
-
if (!neg)
|
|
197
|
+
if (!neg)
|
|
198
|
+
into.runtimeCmds.push(
|
|
199
|
+
`cat -- ${shellQuote(withProjectDir(p, at.rel, false))} 2>/dev/null; echo "$?"`,
|
|
200
|
+
)
|
|
201
201
|
}
|
|
202
202
|
for (const entry of entries) expand(entry, new Set())
|
|
203
203
|
// Nx matches a project fileset with no positive glob against every project
|
package/src/nx/nx-map.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../shared-outputs.js'
|
|
38
38
|
import type { TrackedKinds } from '../tracked-outputs.js'
|
|
39
39
|
import { packageScripts, relPosix } from '../paths.js'
|
|
40
|
+
import { withLernaOrder } from './lerna.js'
|
|
40
41
|
import { mapNxDeps, matchNxProjects, type TaskNameFor } from './nx-deps.js'
|
|
41
42
|
import {
|
|
42
43
|
dotenvCandidates,
|
|
@@ -304,7 +305,17 @@ export async function mapNxWorkspace(
|
|
|
304
305
|
graph: NxGraph,
|
|
305
306
|
opts: MapNxOptions,
|
|
306
307
|
): Promise<NxMapping> {
|
|
307
|
-
const
|
|
308
|
+
const byRel = new Map(metas.map((m) => [normRel(relPosix(root, m.dir)), m.packageJson]))
|
|
309
|
+
const nodeMap = await withLernaOrder(
|
|
310
|
+
root,
|
|
311
|
+
graph.nodes,
|
|
312
|
+
(await readNxJson(root).catch(() => null))?.json,
|
|
313
|
+
async (rel) =>
|
|
314
|
+
(byRel.get(normRel(rel)) as Record<string, unknown> | undefined) ??
|
|
315
|
+
(await Bun.file(path.join(root, rel, 'package.json'))
|
|
316
|
+
.json()
|
|
317
|
+
.catch(() => undefined)),
|
|
318
|
+
)
|
|
308
319
|
const g = graph
|
|
309
320
|
|
|
310
321
|
const { namedInputs, cacheable, globalSync } = await readNxJsonFacts(root)
|
|
@@ -923,7 +934,10 @@ function buildTask(
|
|
|
923
934
|
}
|
|
924
935
|
const exec: Record<string, unknown> = { command: mapped.command }
|
|
925
936
|
const env: Record<string, unknown> = {}
|
|
926
|
-
|
|
937
|
+
// A name that is no shell identifier never reached a task (`sh` drops it)
|
|
938
|
+
// and core refuses it in passThrough; the key still reads it.
|
|
939
|
+
const passThrough = inputs.envNames.filter((n) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(n))
|
|
940
|
+
if (passThrough.length > 0) env.passThrough = passThrough
|
|
927
941
|
// Nx hands every task its target (`getNxEnvVariablesForTask`), and
|
|
928
942
|
// `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
|
|
929
943
|
// unset, it booted Nx's own task runner, which ran the target and its
|
package/src/nx/nx-outputs.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// workspace-root globs. Extracted from `buildTask` in item 606.
|
|
3
3
|
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import { isLiteralPattern } from '@vzn/vx'
|
|
6
|
+
import { literalGlob, minimatchToVx } from '../glob-grammar.js'
|
|
6
7
|
import { takingBack } from '../shared-outputs.js'
|
|
7
8
|
|
|
8
9
|
interface NxOutputs {
|
|
@@ -18,12 +19,13 @@ interface NxOutputs {
|
|
|
18
19
|
* became the literal glob `coverage/{projectRoot}/**`, saved nothing, and a
|
|
19
20
|
* hit restored nothing. Null when a token is left or the path leaves the
|
|
20
21
|
* workspace; a brace set is no token (item 914 — 912 read one as a gap).
|
|
22
|
+
* The project dir comes back masked: {@link withProjectDir} writes it in.
|
|
21
23
|
*/
|
|
22
24
|
export function nxWorkspacePath(s: string, projectRel: string, projectName: string): string | null {
|
|
23
25
|
const rel = projectRel === '.' ? '' : projectRel
|
|
24
26
|
const p = s
|
|
25
27
|
.replaceAll('{workspaceRoot}', '')
|
|
26
|
-
.replaceAll('{projectRoot}', rel)
|
|
28
|
+
.replaceAll('{projectRoot}', masked(rel))
|
|
27
29
|
.replaceAll('{projectName}', projectName)
|
|
28
30
|
// A token left over is a gap; a brace set (`*.{ts,tsx}`) is a glob.
|
|
29
31
|
if (/\{[\w.]+\}/.test(p)) return null
|
|
@@ -31,10 +33,38 @@ export function nxWorkspacePath(s: string, projectRel: string, projectName: stri
|
|
|
31
33
|
return n === '.' || n === '..' || n.startsWith('../') ? null : n
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// A project dir is a path, never a glob: `packages/a{b}` read as a token
|
|
37
|
+
// dropped every `{projectRoot}` path, and `packages/[x]` translated as a
|
|
38
|
+
// class reached `packages/x`. Each dir segment stands in as `\uE000<i>\uE001`
|
|
39
|
+
// until the glob is vx's, so `..` still pops one.
|
|
40
|
+
const MASKED = /\uE000(\d+)\uE001/g
|
|
41
|
+
|
|
42
|
+
function masked(rel: string): string {
|
|
43
|
+
return rel === ''
|
|
44
|
+
? ''
|
|
45
|
+
: rel
|
|
46
|
+
.split('/')
|
|
47
|
+
.map((_, i) => `\uE000${i}\uE001`)
|
|
48
|
+
.join('/')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `p` with the masked project dir written back: as a path, or escaped for a glob. */
|
|
52
|
+
export function withProjectDir(p: string, projectRel: string, asGlob: boolean): string {
|
|
53
|
+
const segs = projectRel.split('/')
|
|
54
|
+
return p.replace(MASKED, (_, i: string, at: number) => {
|
|
55
|
+
const seg = segs[Number(i)]!
|
|
56
|
+
if (!asGlob) return seg
|
|
57
|
+
// Only a glob's head reads `!` as a negation.
|
|
58
|
+
return at === 0 ? literalGlob(seg) : literalGlob(`/${seg}`).slice(1)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
/** A workspace path relative to the project dir, or null when it lies outside. */
|
|
35
63
|
export function underProject(p: string, projectRel: string): string | null {
|
|
36
64
|
if (projectRel === '.' || projectRel === '') return p
|
|
37
|
-
|
|
65
|
+
for (const dir of [masked(projectRel), projectRel])
|
|
66
|
+
if (p.startsWith(`${dir}/`)) return p.slice(dir.length + 1)
|
|
67
|
+
return null
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
/**
|
|
@@ -64,15 +94,16 @@ export function nxDefaultOutputs(
|
|
|
64
94
|
}
|
|
65
95
|
if (targetName !== 'build' && targetName !== 'prepare') return []
|
|
66
96
|
const root = projectRel === '.' ? '' : projectRel
|
|
67
|
-
|
|
97
|
+
// As tokens, so the dir stays a path whatever its name holds.
|
|
98
|
+
const at = (p: string): string => (root === '' ? p : `{projectRoot}/${p}`)
|
|
68
99
|
const held = ['build', 'public'].filter((d) => tops === undefined || tops.has(d))
|
|
69
100
|
if (held.length > 0)
|
|
70
101
|
todos.push(
|
|
71
|
-
`no outputs declared: Nx also caches ${held.map(
|
|
102
|
+
`no outputs declared: Nx also caches ${held.map((d) => path.posix.join(root, d)).join(' and ')} for this target — vx cleans an output before the run, so add ${held.length === 1 ? 'it' : 'them'} to the outputs by hand only if ${held.length === 1 ? 'it holds' : 'they hold'} nothing committed`,
|
|
72
103
|
)
|
|
73
104
|
// The root project's two are one path.
|
|
74
105
|
return [
|
|
75
|
-
...new Set([
|
|
106
|
+
...new Set([root === '' ? 'dist' : '{workspaceRoot}/dist/{projectRoot}', at('dist')]),
|
|
76
107
|
...['build', 'public'].filter((d) => !held.includes(d)).map(at),
|
|
77
108
|
]
|
|
78
109
|
}
|
|
@@ -145,7 +176,7 @@ export function mapNxOutputs(
|
|
|
145
176
|
const norm = path.posix.normalize(s.replace(/^\/+/, ''))
|
|
146
177
|
if (norm === '..' || norm.startsWith('../')) {
|
|
147
178
|
todos.push(
|
|
148
|
-
`output ${JSON.stringify(o)} resolves to ${JSON.stringify(norm)}, outside the workspace — ` +
|
|
179
|
+
`output ${JSON.stringify(o)} resolves to ${JSON.stringify(withProjectDir(norm, projectRel, false))}, outside the workspace — ` +
|
|
149
180
|
'vx caches only inside it; dropped',
|
|
150
181
|
)
|
|
151
182
|
continue
|
|
@@ -158,9 +189,18 @@ export function mapNxOutputs(
|
|
|
158
189
|
for (const g of globs) {
|
|
159
190
|
const gn = g.startsWith('!') ? '!' : ''
|
|
160
191
|
const gs = g.slice(gn.length)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
const own = underProject(gs, projectRel)
|
|
193
|
+
if (own !== null) {
|
|
194
|
+
outFiles.push(gn + withProjectDir(own, projectRel, true))
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
const m = path.posix.normalize(gs).replace(/^\.\//, '')
|
|
198
|
+
const w = withProjectDir(m, projectRel, true)
|
|
199
|
+
wsOutFiles.push(gn + w)
|
|
200
|
+
// An escaped brace makes a literal a glob, which core matches as the
|
|
201
|
+
// path alone (`asTrees` adds the tree only to a literal).
|
|
202
|
+
if (w !== withProjectDir(m, projectRel, false) && isLiteralPattern(m.replace(MASKED, '')))
|
|
203
|
+
wsOutFiles.push(`${gn}${w}/**`)
|
|
164
204
|
}
|
|
165
205
|
}
|
|
166
206
|
return { outFiles: takingBack(outFiles), wsOutFiles: takingBack(wsOutFiles) }
|
|
@@ -217,9 +257,19 @@ export function nxProjectOutputs(
|
|
|
217
257
|
projectName,
|
|
218
258
|
scratch,
|
|
219
259
|
)
|
|
220
|
-
|
|
260
|
+
const literal = (f: string): string | null => {
|
|
221
261
|
const lit = f.replace(/\/\*\*(\/\*)?$/, '')
|
|
222
|
-
|
|
262
|
+
return lit.startsWith('!') || /[*?[]|(?<!\\)[{}]/.test(lit)
|
|
263
|
+
? null
|
|
264
|
+
: lit.replace(/\\([{}!])/g, '$1')
|
|
265
|
+
}
|
|
266
|
+
for (const f of outFiles) {
|
|
267
|
+
const lit = literal(f)
|
|
268
|
+
if (lit !== null) out.add(path.posix.join(rel, lit))
|
|
269
|
+
}
|
|
270
|
+
for (const f of wsOutFiles) {
|
|
271
|
+
const lit = literal(f)
|
|
272
|
+
if (lit !== null) out.add(lit)
|
|
223
273
|
}
|
|
224
274
|
}
|
|
225
275
|
const r = [...out]
|