@vzn/vx-migrate 0.0.595 → 0.0.597

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,11 +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
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);
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.
9
10
  // The mappers return a plan; core's migration seam
10
11
  // (`applyMigration`) renders, guards, writes and reports, so what this
11
12
  // package writes reads exactly like what `vx init` writes.
@@ -33,9 +34,11 @@ import {
33
34
  missingPackages,
34
35
  MODE_QUESTION,
35
36
  ownVersion,
37
+ packageManagerOf,
36
38
  parseModeAnswer,
37
39
  } from './adopt.js'
38
40
  import { migrateTurbo } from './migrate-turbo.js'
41
+ import { migrateViteTask } from './migrate-vite-task.js'
39
42
  import { turboConfigFile } from './turbo/turbo-map.js'
40
43
  import {
41
44
  extendWorkspaceFile,
@@ -51,7 +54,7 @@ export interface MigrateArgs {
51
54
  force: boolean
52
55
  /** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
53
56
  mjs: boolean
54
- from?: 'turbo' | 'nx'
57
+ from?: 'turbo' | 'nx' | 'vite-task'
55
58
  /** `--native` / `--keep`; unset asks a terminal and is native elsewhere. */
56
59
  mode?: AdoptionMode
57
60
  /** `--no-install`: leave package.json's dependencies alone. */
@@ -62,7 +65,7 @@ export interface MigrateArgs {
62
65
  }
63
66
 
64
67
  const USAGE =
65
- '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]'
66
69
 
67
70
  export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
68
71
  const out: MigrateArgs = { dry: false, force: false, mjs: false }
@@ -80,10 +83,10 @@ export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
80
83
  out.mode = mode
81
84
  } else if (a === '--from' || a?.startsWith('--from=')) {
82
85
  const v = a === '--from' ? args[++i] : a.slice('--from='.length)
83
- if (v !== 'turbo' && v !== 'nx') {
86
+ if (v !== 'turbo' && v !== 'nx' && v !== 'vite-task') {
84
87
  return {
85
88
  ...out,
86
- 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\`)`,
87
90
  }
88
91
  }
89
92
  out.from = v
@@ -152,16 +155,31 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
152
155
  throw new UserError('--from turbo, but no turbo.json at the workspace root')
153
156
  }
154
157
 
155
- const runner: 'turbo' | 'nx' | undefined =
156
- parsed.from ?? (hasTurbo ? 'turbo' : hasGraph || hasNxJson || lerna ? '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)
157
167
  if (runner === undefined) {
158
168
  throw new UserError(
159
- 'nothing to migrate: no turbo.json and no Nx workspace — ' +
169
+ 'nothing to migrate: no turbo.json, no Nx workspace and no vite-plus — ' +
160
170
  'for package.json scripts, run `vx init`',
161
171
  )
162
172
  }
163
- const mode = parsed.mode ?? (await askMode(runner, lerna ? 'lerna.json' : undefined, parsed.dry))
164
- if (mode === 'keep') return keep(root, runner, hasTurbo, lerna, 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)
165
183
 
166
184
  const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
167
185
  // No workspace file yet: this run writes one, declaring the plugins the
@@ -169,7 +187,10 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
169
187
  const writesWorkspace = workspaceFileAt(root) === undefined
170
188
  let source: string
171
189
  let plan: MigrationPlan
172
- 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') {
173
194
  if (hasGraph) {
174
195
  source = NX_GRAPH_REL
175
196
  plan = await migrateNx(root, metas, format, undefined, writesWorkspace)
@@ -177,6 +198,14 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
177
198
  // Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
178
199
  // when exported. The workspace's own nx exports it, as `nx()` does, into
179
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
+ }
180
209
  const tmp = await mkdtemp(path.join(os.tmpdir(), 'vx-migrate-nx-'))
181
210
  try {
182
211
  const snapshot = path.join(tmp, 'project-graph.json')
@@ -230,9 +259,21 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
230
259
  ],
231
260
  }
232
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
+ )
233
270
  const headerNotes = refused
234
271
  ? []
235
- : 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
+ )
236
277
  return applyMigration({
237
278
  root,
238
279
  metas,
@@ -246,6 +287,17 @@ export async function migrateCmd(args: readonly string[]): Promise<number> {
246
287
  })
247
288
  }
248
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
+
249
301
  /** A terminal is asked which adoption it wants; anything else gets native. */
250
302
  async function askMode(
251
303
  runner: 'turbo' | 'nx',
@@ -392,7 +444,10 @@ const LERNA_RUN = /(?:^|[\s;&|(])lerna\s+run\s/
392
444
  function lernaOnNx(root: string): boolean {
393
445
  const json = (file: string): Record<string, unknown> | undefined => {
394
446
  try {
395
- return JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
447
+ return JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) as Record<
448
+ string,
449
+ unknown
450
+ >
396
451
  } catch {
397
452
  return undefined
398
453
  }
@@ -1,6 +1,7 @@
1
1
  // The resolved Nx graph, exported by the workspace's own nx: `nx()` keys and
2
2
  // keeps it, `vx-migrate --from nx` maps it once.
3
3
 
4
+ import { readFileSync, realpathSync } from 'node:fs'
4
5
  import { mkdir } from 'node:fs/promises'
5
6
  import path from 'node:path'
6
7
 
@@ -10,9 +11,10 @@ import path from 'node:path'
10
11
  * daemon up the export is served from memory, without it Nx computes.
11
12
  */
12
13
  export async function exportGraph(root: string, snapshot: string): Promise<string | null> {
13
- const bin = path.join(root, 'node_modules', '.bin', 'nx')
14
- if (!(await Bun.file(bin).exists())) {
15
- return `no ${path.relative(root, bin)} — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
14
+ const linked = path.join(root, 'node_modules', '.bin', 'nx')
15
+ const bin = (await Bun.file(linked).exists()) ? linked : lernasNx(root)
16
+ if (bin === undefined) {
17
+ return `no ${path.relative(root, linked)} — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
16
18
  }
17
19
  // `discover` runs before the run opens (and makes) the cache dir.
18
20
  await mkdir(path.dirname(snapshot), { recursive: true })
@@ -27,3 +29,19 @@ export async function exportGraph(root: string, snapshot: string): Promise<strin
27
29
  const tail = err.trim().split('\n').slice(-3).join(' ')
28
30
  return `nx graph --file exited ${code}${tail ? `: ${tail}` : ''}`
29
31
  }
32
+
33
+ /**
34
+ * The nx Lerna 6+ runs on, from Lerna's own dependencies: pnpm links only
35
+ * a root's direct dependencies into `node_modules/.bin`, so a Lerna repo
36
+ * on pnpm has no `nx` there, and `lerna run` uses this one.
37
+ */
38
+ function lernasNx(root: string): string | undefined {
39
+ try {
40
+ const lerna = realpathSync(path.join(root, 'node_modules', 'lerna'))
41
+ const manifest = Bun.resolveSync('nx/package.json', lerna)
42
+ const bin = (JSON.parse(readFileSync(manifest, 'utf8')) as { bin?: { nx?: unknown } }).bin?.nx
43
+ return typeof bin === 'string' ? path.join(path.dirname(manifest), bin) : undefined
44
+ } catch {
45
+ return undefined
46
+ }
47
+ }
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'))))),
@@ -521,7 +524,7 @@ async function graphInputKey(
521
524
  if (changed === null) return null
522
525
  // The cache dir holds the snapshot itself: a workspace that does not
523
526
  // ignore it would see the export move the key it was keyed on.
524
- const cacheRel = path.relative(root, cacheDir).split(path.sep).join('/')
527
+ const cacheRel = relPosix(root, cacheDir)
525
528
  const inCache = (p: string): boolean =>
526
529
  cacheRel !== '' &&
527
530
  !cacheRel.startsWith('..') &&
@@ -537,7 +540,7 @@ async function graphInputKey(
537
540
  // stray write at the root (a report, a log) is not, and counting it
538
541
  // re-exported the graph on every run after it.
539
542
  // A `project.json` anywhere: a new one is a project no root lists yet.
540
- const roots = dirs.map((d) => path.relative(root, d).split(path.sep).join('/'))
543
+ const roots = dirs.map((d) => relPosix(root, d))
541
544
  const graphFile = (p: string): boolean =>
542
545
  p === 'project.json' ||
543
546
  p.endsWith('/project.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
 
@@ -70,15 +70,23 @@ export function expandNxInputs(
70
70
  }
71
71
  // Nx's file map skips gitignored files, so it hashes nothing here
72
72
  // either: a changed `dist` file is a cache hit under Nx 23.3 (YL-1).
73
- if (neg === '' && at.outputs?.some((o) => p === o || p.startsWith(`${o}/`))) return
73
+ const lit = withProjectDir(p, at.rel, false)
74
+ if (neg === '' && at.outputs?.some((o) => lit === o || lit.startsWith(`${o}/`))) return
74
75
  const g = minimatchToVx(p, neg !== '')
75
76
  if (g === null) {
76
77
  todos.push(`input ${JSON.stringify(entry)}: glob syntax vx cannot take — map manually`)
77
78
  return
78
79
  }
79
- const own = underProject(g, at.rel)
80
- if (own === null) into.wsFiles.push(neg + g)
81
- else into.files.push(neg + own)
80
+ // Nx globs a `{workspaceRoot}` path over every file of the
81
+ // workspace, a vx project glob only over its project's own: on the
82
+ // root project TanStack Query's `{workspaceRoot}/**/package.json`
83
+ // (sherif's input) left every package's manifest out of the key.
84
+ const own =
85
+ (at.rel === '' || at.rel === '.') && s.startsWith('{workspaceRoot}')
86
+ ? null
87
+ : underProject(g, at.rel)
88
+ if (own === null) into.wsFiles.push(neg + withProjectDir(g, at.rel, true))
89
+ else into.files.push(neg + withProjectDir(own, at.rel, true))
82
90
  return
83
91
  }
84
92
  // Bare string = named-input reference.
@@ -193,7 +201,10 @@ export function expandNxInputs(
193
201
  }
194
202
  // A negation filters only other includeIgnored matches, and a literal
195
203
  // reads no others.
196
- if (!neg) into.runtimeCmds.push(`cat -- ${shellQuote(p)} 2>/dev/null; echo "$?"`)
204
+ if (!neg)
205
+ into.runtimeCmds.push(
206
+ `cat -- ${shellQuote(withProjectDir(p, at.rel, false))} 2>/dev/null; echo "$?"`,
207
+ )
197
208
  }
198
209
  for (const entry of entries) expand(entry, new Set())
199
210
  // Nx matches a project fileset with no positive glob against every project
package/src/nx/nx-map.ts CHANGED
@@ -36,7 +36,8 @@ import {
36
36
  wildcardTodo,
37
37
  } from '../shared-outputs.js'
38
38
  import type { TrackedKinds } from '../tracked-outputs.js'
39
- import { packageScripts, relPosix } from '../paths.js'
39
+ import { isRecord, 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,
@@ -142,9 +143,6 @@ const LEGACY: Readonly<Record<string, string>> = {
142
143
  '@nx/workspace:run-script': 'nx:run-script',
143
144
  }
144
145
 
145
- const isRecord = (v: unknown): v is Record<string, unknown> =>
146
- typeof v === 'object' && v !== null && !Array.isArray(v)
147
-
148
146
  /** Every dependency name the root package.json declares; none without one. */
149
147
  async function rootDependencies(root: string): Promise<ReadonlySet<string>> {
150
148
  const pkg = (await Bun.file(path.join(root, 'package.json'))
@@ -304,7 +302,17 @@ export async function mapNxWorkspace(
304
302
  graph: NxGraph,
305
303
  opts: MapNxOptions,
306
304
  ): Promise<NxMapping> {
307
- const nodeMap = graph.nodes
305
+ const byRel = new Map(metas.map((m) => [normRel(relPosix(root, m.dir)), m.packageJson]))
306
+ const nodeMap = await withLernaOrder(
307
+ root,
308
+ graph.nodes,
309
+ (await readNxJson(root).catch(() => null))?.json,
310
+ async (rel) =>
311
+ (byRel.get(normRel(rel)) as Record<string, unknown> | undefined) ??
312
+ (await Bun.file(path.join(root, rel, 'package.json'))
313
+ .json()
314
+ .catch(() => undefined)),
315
+ )
308
316
  const g = graph
309
317
 
310
318
  const { namedInputs, cacheable, globalSync } = await readNxJsonFacts(root)
@@ -923,7 +931,10 @@ function buildTask(
923
931
  }
924
932
  const exec: Record<string, unknown> = { command: mapped.command }
925
933
  const env: Record<string, unknown> = {}
926
- if (inputs.envNames.length > 0) env.passThrough = inputs.envNames
934
+ // A name that is no shell identifier never reached a task (`sh` drops it)
935
+ // and core refuses it in passThrough; the key still reads it.
936
+ const passThrough = inputs.envNames.filter((n) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(n))
937
+ if (passThrough.length > 0) env.passThrough = passThrough
927
938
  // Nx hands every task its target (`getNxEnvVariablesForTask`), and
928
939
  // `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
929
940
  // 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]
@@ -20,6 +20,7 @@ import type { GeneratedTask, ProjectMeta } from '@vzn/vx'
20
20
  import { emptyNxInputs, expandNxInputs, type NxInputs } from './nx-inputs.js'
21
21
  import { nxProjectOutputs } from './nx-outputs.js'
22
22
  import { matchNxProjects } from './nx-deps.js'
23
+ import { literalGlob } from '../glob-grammar.js'
23
24
 
24
25
  interface GraphNode {
25
26
  name?: string
@@ -192,7 +193,9 @@ export function planNxUpstream(
192
193
  const inputs = emptyNxInputs()
193
194
  for (const g of own.files) {
194
195
  const neg = g.startsWith('!') ? '!' : ''
195
- inputs.wsFiles.push(rel === '' ? g : `${neg}${rel}/${neg === '' ? g : g.slice(1)}`)
196
+ inputs.wsFiles.push(
197
+ rel === '' ? g : `${neg}${literalGlob(rel)}/${neg === '' ? g : g.slice(1)}`,
198
+ )
196
199
  }
197
200
  inputs.wsFiles.push(...own.wsFiles)
198
201
  inputs.envNames.push(...own.envNames)
@@ -336,7 +339,14 @@ export function planNxUpstream(
336
339
  if (u.of !== 'deps') foldProjects(u.name, u.of, inputs, todos, edges)
337
340
  else if (u.name !== name) fold(node, u.name, peers, inputs, todos, edges)
338
341
  }
339
- const cacheInputs: Record<string, unknown> = { files: inputs.files }
342
+ // Nx hashes a fileset that matches nothing as nothing; core warns of
343
+ // an input set that resolves to no file. A project has a package.json
344
+ // or a project.json, and Nx keys each task on both (its manifest, its
345
+ // configuration), so listing them keeps the twin of an absent
346
+ // `tsconfig.spec.json` (or a dependency's unbuilt `*.d.ts`) quiet; the
347
+ // file appearing still re-keys it, and a missing literal is silent.
348
+ const files = [...new Set(['package.json', 'project.json', ...inputs.files])]
349
+ const cacheInputs: Record<string, unknown> = { files }
340
350
  if (inputs.wsFiles.length > 0) cacheInputs.workspaceFiles = inputs.wsFiles
341
351
  if (inputs.envNames.length > 0) cacheInputs.env = inputs.envNames
342
352
  if (inputs.runtimeCmds.length > 0) cacheInputs.workspaceRuntime = inputs.runtimeCmds
@@ -20,7 +20,8 @@ import {
20
20
  refuseUnknownOptions,
21
21
  type PluginOptionKinds,
22
22
  } from '@vzn/vx'
23
- import { deadlineNamed } from '../remote-deadline.js'
23
+ import { deadline } from '../remote-deadline.js'
24
+ import { OutageBreaker } from '../remote-breaker.js'
24
25
  import { withRetry } from '../remote-retry.js'
25
26
  import { headerValueFault } from '../remote-token.js'
26
27
 
@@ -102,6 +103,7 @@ export function resolveNxCacheConfig(
102
103
  */
103
104
  export class NxRemoteCache implements RemoteCacheLayer {
104
105
  private disabled = false
106
+ private readonly breaker = new OutageBreaker()
105
107
  /**
106
108
  * A `403` on a write is the spec's read-only token (a CI's pull-request
107
109
  * token): uploads stop, reads go on. Read as a refused token, it turned
@@ -140,19 +142,19 @@ export class NxRemoteCache implements RemoteCacheLayer {
140
142
  // every hit read as a corrupt artifact (nx#33092).
141
143
  headers['Accept'] = 'application/octet-stream'
142
144
  }
143
- const res = await withRetry(
144
- () =>
145
- this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
146
- method,
147
- headers,
148
- ...(body === undefined ? {} : { body }),
149
- signal: AbortSignal.timeout(this.config.timeoutMs),
150
- }),
151
- this.config.retries,
152
- this.wait,
153
- ).catch((err: unknown) => {
154
- throw deadlineNamed(err, this.config.timeoutMs)
155
- })
145
+ const res = await this.breaker.send(() =>
146
+ withRetry(
147
+ () =>
148
+ this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
149
+ method,
150
+ headers,
151
+ ...(body === undefined ? {} : { body }),
152
+ signal: deadline(this.config.timeoutMs),
153
+ }),
154
+ this.config.retries,
155
+ this.wait,
156
+ ),
157
+ )
156
158
  if (method === 'PUT' && res.status === 403) {
157
159
  const first = !this.writesRefused
158
160
  this.writesRefused = true
@@ -166,7 +168,7 @@ export class NxRemoteCache implements RemoteCacheLayer {
166
168
  this.disabled = true
167
169
  if (!first) return undefined
168
170
  throw new Error(
169
- `HTTP ${res.status}: ${res.status === 401 ? 'missing or invalid token' : 'access forbidden (a read-only token cannot write)'}; remote cache off for this run`,
171
+ `HTTP ${res.status}: ${res.status === 401 ? 'missing or invalid token' : 'access forbidden (the token may not read)'}; remote cache off for this run`,
170
172
  )
171
173
  }
172
174
  return res