@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/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 nx.json → Nx (the resolved graph, exported by nx if absent);
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 nx (package.json scripts: \`vx init\`)`,
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 ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson ? 'nx' : undefined)
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 and no Nx workspace — ' +
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
- const mode = parsed.mode ?? (await askMode(runner, parsed.dry))
159
- if (mode === 'keep') return keep(root, runner, hasTurbo, parsed)
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 === 'nx') {
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
- // No workspace file yet: write one declaring the plugins the repo calls
205
- // for, and drop the note that told the user to declare the lockfile one.
206
- const plugins = workspaceFileAt(root) === undefined ? workspacePlugins(root) : []
207
- if (plugins.length > 0) {
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
- { relPath: `vx.workspace.${format}`, contents: renderWorkspaceFile(plugins, format) },
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(root, ['@vzn/vx', ...plugins.map((p) => p.pkg)], parsed)
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(runner: 'turbo' | 'nx', dry: boolean): Promise<AdoptionMode> {
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'))))),
@@ -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
+ }
@@ -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
- if (neg === '' && at.outputs?.some((o) => p === o || p.startsWith(`${o}/`))) {
72
- todos.push(
73
- `input ${JSON.stringify(entry)} is an output of the project's own targets: git does not ` +
74
- 'list it, so vx cannot key on it — dropped; the task that writes it keys its dependants through dependsOn',
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) into.runtimeCmds.push(`cat -- ${shellQuote(p)} 2>/dev/null; echo "$?"`)
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 nodeMap = graph.nodes
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
- if (inputs.envNames.length > 0) env.passThrough = inputs.envNames
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
@@ -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 { minimatchToVx } from '../glob-grammar.js'
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
- return p.startsWith(`${projectRel}/`) ? p.slice(projectRel.length + 1) : null
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
- const at = (p: string): string => path.posix.join(root, p)
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(at).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`,
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([path.posix.join('dist', root), at('dist')]),
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
- if (projectRel === '.') outFiles.push(g)
162
- else if (gs.startsWith(`${projectRel}/`)) outFiles.push(gn + gs.slice(projectRel.length + 1))
163
- else wsOutFiles.push(gn + path.posix.normalize(gs).replace(/^\.\//, ''))
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
- for (const f of [...outFiles.map((f) => path.posix.join(rel, f)), ...wsOutFiles]) {
260
+ const literal = (f: string): string | null => {
221
261
  const lit = f.replace(/\/\*\*(\/\*)?$/, '')
222
- if (!lit.startsWith('!') && !/[*?[{]/.test(lit)) out.add(lit)
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]