@vzn/vx-migrate 0.0.645 → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nx/nx-map.ts CHANGED
@@ -20,12 +20,14 @@ import { existsSync } from 'node:fs'
20
20
  import path from 'node:path'
21
21
  import { type Duration, type GeneratedProject, type GeneratedTask, UserError } from '@vzn/vx'
22
22
  import type { PackageMeta } from '../packages.js'
23
+ import { refuseTaskNames } from '../plugin-gaps.js'
23
24
  import { withoutTakenBack } from '../glob-grammar.js'
24
25
  import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
25
26
  import { mapRunCommands, shellQuote } from '../nx-command.js'
26
- import { scriptCommand, yarnPnp } from '../script-command.js'
27
+ import { type Owner, runsScriptHooks, scriptCommand, yarnPnp, yarnRun } from '../script-command.js'
27
28
  import {
28
29
  ownFileTakeBacks,
30
+ takingBack,
29
31
  excludeSiblingOutputs,
30
32
  excludeWorkspaceOutputs,
31
33
  resolveSharedOutputs,
@@ -274,6 +276,8 @@ type MapOpts = MapNxOptions & {
274
276
  /** The root TypeScript config Nx folds into every task's hash, if any. */
275
277
  readonly rootTsConfig: string | undefined
276
278
  readonly pnp: boolean
279
+ /** Whether the manager owning a directory runs `pre` / `post` hooks (`runsScriptHooks`). */
280
+ readonly hooks: (dir: string) => boolean
277
281
  /** Tasks per `syncGenerators` list, said once in the notes. */
278
282
  readonly syncTasks: Map<string, number>
279
283
  }
@@ -284,11 +288,20 @@ export interface NxMapping {
284
288
  readonly notes: string[]
285
289
  }
286
290
 
287
- /** A node's tags as vx takes them: a blank one would be refused at load. */
291
+ /** A command that still runs Nx or Lerna (`nx-exec`, `nx-env`, `nx exec`, `lerna run`) or reads what they set. */
292
+ const RUNS_NX = /(?:^|[\s;&|('"/])(?:nx(?:-exec|-env)?|lerna)\s|NX_TASK_TARGET_|LERNA_PACKAGE_NAME/
293
+
294
+ /**
295
+ * A node's tags as vx takes them: a blank one would be refused at load, and
296
+ * `npm:public` / `npm:private` are Nx's own, read off package.json.
297
+ */
288
298
  function nxTags(node: NxNode | undefined): string[] {
289
299
  const tags = node?.data?.tags
290
300
  return Array.isArray(tags)
291
- ? tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
301
+ ? tags.filter(
302
+ (t): t is string =>
303
+ typeof t === 'string' && t.trim() !== '' && t !== 'npm:public' && t !== 'npm:private',
304
+ )
292
305
  : []
293
306
  }
294
307
 
@@ -316,6 +329,7 @@ export async function mapNxWorkspace(
316
329
  const g = graph
317
330
 
318
331
  const { namedInputs, cacheable, globalSync } = await readNxJsonFacts(root)
332
+ const hookMemo = new Map<string, Owner>()
319
333
  const mapOpts: MapOpts = {
320
334
  ...opts,
321
335
  cacheable: new Set([...opts.cacheable, ...cacheable]),
@@ -324,6 +338,7 @@ export async function mapNxWorkspace(
324
338
  existsSync(path.join(root, f)),
325
339
  ),
326
340
  pnp: yarnPnp(root),
341
+ hooks: (dir) => runsScriptHooks(dir, hookMemo) !== null,
327
342
  syncTasks: new Map(),
328
343
  }
329
344
 
@@ -353,6 +368,8 @@ export async function mapNxWorkspace(
353
368
  const relRoot = normRel(node?.data?.root ?? '')
354
369
  if (node?.data?.targets === undefined) continue
355
370
  const dir = relRoot === '' || relRoot === '.' ? root : path.join(root, relRoot)
371
+ const rel = path.relative(root, dir)
372
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue
356
373
  let pkg: PackageMeta['packageJson'] = { name: nodeName }
357
374
  try {
358
375
  pkg = (await Bun.file(path.join(dir, 'package.json')).json()) as PackageMeta['packageJson']
@@ -377,7 +394,7 @@ export async function mapNxWorkspace(
377
394
  nodeNameOf.set(synthetic, nodeName)
378
395
  }
379
396
 
380
- const upstream = planNxUpstream(nodeMap, g.dependencies, namedInputs, metaByNode)
397
+ const upstream = planNxUpstream(nodeMap, g.dependencies, namedInputs, metaByNode, opts.tracked)
381
398
 
382
399
  const taskNameFor: TaskNameFor = (project, target, configuration) => {
383
400
  const t = nodeMap[project]?.data?.targets?.[target]
@@ -481,7 +498,7 @@ export async function mapNxWorkspace(
481
498
  }
482
499
  mapped.push({ meta, tasks })
483
500
  }
484
- // The `nx-input:<name>` twins the `^` inputs above asked for, in every
501
+ // The `inputs:<name>` twins the `^` inputs above asked for, in every
485
502
  // project a graph node owns — one with no targets too.
486
503
  const byMeta = new Map(mapped.map((m) => [m.meta, m]))
487
504
  for (const [nodeName, list] of upstream.inputTasks()) {
@@ -562,7 +579,7 @@ export async function mapNxWorkspace(
562
579
  )
563
580
  followNxGraph(t.task, nodeName, meta.name, nxEdges, metaByNode, emittedIds, reached)
564
581
  }
565
- dropUnheldDeps(t, emitted, emittedIds)
582
+ dropUnheldDeps(t, meta.name, emitted, emittedIds)
566
583
  const reader = dependentReaders.get(t)
567
584
  if (reader !== undefined && !reachesProducer(t, meta.name, emittedIds, reached)) {
568
585
  delete t.task!['cache']
@@ -606,6 +623,7 @@ export async function mapNxWorkspace(
606
623
  `\`syncGenerators\` (${gens}) on ${n} task${n === 1 ? '' : 's'}: ` +
607
624
  `Nx runs them before those targets, and vx does not — ${syncAdvice}`,
608
625
  )
626
+ refuseTaskNames(projects, 'target')
609
627
  return { projects, notes }
610
628
  }
611
629
 
@@ -744,6 +762,7 @@ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
744
762
 
745
763
  function dropUnheldDeps(
746
764
  t: GeneratedTask,
765
+ owner: string,
747
766
  emitted: ReadonlySet<string>,
748
767
  emittedIds: ReadonlySet<string>,
749
768
  ): void {
@@ -757,7 +776,9 @@ function dropUnheldDeps(
757
776
  if (d.startsWith('^')) return emitted.has(d.slice(1))
758
777
  // `pkg#task` on a project no package holds (the root project with no
759
778
  // vx.config) refused the whole run as "no such project".
760
- if (!d.includes('#') || emittedIds.has(d)) return true
779
+ // A same-project target written as no task (an `nx:noop` with nothing
780
+ // to chain) is nothing Nx runs; kept, `vx lock` refused the project.
781
+ if (emittedIds.has(d.includes('#') ? d : `${owner}#${d}`)) return true
761
782
  t.todos.push(
762
783
  `dependsOn ${JSON.stringify(d)}: no vx project runs it — edge dropped, and the key misses it`,
763
784
  )
@@ -813,6 +834,7 @@ function buildTask(
813
834
  todos,
814
835
  dotenv,
815
836
  opts.pnp,
837
+ opts.hooks(meta.dir),
816
838
  meta.packageJson,
817
839
  opts.manifestField,
818
840
  opts.executors,
@@ -827,6 +849,7 @@ function buildTask(
827
849
  rel: projectRel,
828
850
  name: projectName,
829
851
  outputs: nxProjectOutputs(nodeMap[nodeName]?.data?.targets, projectRel, projectName),
852
+ ...(opts.tracked === undefined ? {} : { tracked: opts.tracked }),
830
853
  rootDeps: opts.rootDeps,
831
854
  }
832
855
  expandNxInputs(target.inputs ?? [], upstream.namedOf(nodeName), at, inputs, todos)
@@ -855,7 +878,10 @@ function buildTask(
855
878
  patterns,
856
879
  Object.entries(nodeMap).map(([name, n]) => ({
857
880
  name,
858
- tags: nxTags(n),
881
+ // Every tag Nx matches, its own `npm:*` too: nxTags is what vx writes.
882
+ tags: Array.isArray(n?.data?.tags)
883
+ ? n.data.tags.filter((t): t is string => typeof t === 'string')
884
+ : [],
859
885
  ...(typeof n?.data?.root === 'string' ? { root: n.data.root } : {}),
860
886
  })),
861
887
  ),
@@ -865,6 +891,12 @@ function buildTask(
865
891
  : { node: nodeName, configuration: variant.configuration! },
866
892
  options,
867
893
  )
894
+ // Nx draws no edge from a task to itself (`task.id !== selfTaskId`): a
895
+ // `projects: "*"` or a tag the project carries named it, and vx refused
896
+ // the workspace as a cycle (2026-10-28).
897
+ for (const own of [variant.name, `${meta.name}#${variant.name}`]) {
898
+ for (let i = deps.indexOf(own); i !== -1; i = deps.indexOf(own)) deps.splice(i, 1)
899
+ }
868
900
  // A nested `nx <target>` runs the target's default configuration, whatever this one runs.
869
901
  for (const c of mapped?.nxCalls ?? []) {
870
902
  const edge =
@@ -884,7 +916,7 @@ function buildTask(
884
916
  // vx cleans outputs before a run and Nx never does. With git's file list
885
917
  // known, a wildcard-first output (`**/*.d.ts`) stays cached: core never
886
918
  // cleans a committed file, so what the clean removes is what the task
887
- // writes (owner, 2026-10-03). Without it, the sources cannot be told apart.
919
+ // writes (2026-10-03). Without it, the sources cannot be told apart.
888
920
  const wild =
889
921
  opts.tracked === undefined
890
922
  ? (wildcardOutput(outFiles) ?? wildcardOutput(wsOutFiles))
@@ -898,7 +930,7 @@ function buildTask(
898
930
  // saw nothing the task reads, and a changed document replayed the old
899
931
  // generated client (Growth, 2026-10-16).
900
932
  const orphanReader =
901
- inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#nx-input:'))
933
+ inputs.dependentOutputs.length > 0 && !deps.some((d) => !d.includes('#inputs:'))
902
934
  if (orphanReader && cacheWanted && !persistent) {
903
935
  todos.push(orphanReaderTodo(inputs.dependentOutputs, target.dependsOn))
904
936
  }
@@ -967,18 +999,24 @@ function buildTask(
967
999
  // `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
968
1000
  // unset, it booted Nx's own task runner, which ran the target and its
969
1001
  // dependencies again. `LERNA_PACKAGE_NAME` is the project too: Lerna
970
- // runs on Nx's runner and documents it to scripts. A run-commands `env`
971
- // still wins, as in Nx.
972
- env.define = {
973
- NX_TASK_TARGET_PROJECT: projectName,
974
- NX_TASK_TARGET_TARGET: targetName,
975
- LERNA_PACKAGE_NAME: projectName,
976
- ...(variant.configuration === undefined
977
- ? {}
978
- : { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
1002
+ // runs on Nx's runner and documents it to scripts. Only a command that
1003
+ // still runs Nx or Lerna gets them: a native task names no other runner
1004
+ // (2026-10-22). A run-commands `env` still wins, as in Nx.
1005
+ const define = {
1006
+ ...(RUNS_NX.test(mapped.command)
1007
+ ? {
1008
+ NX_TASK_TARGET_PROJECT: projectName,
1009
+ NX_TASK_TARGET_TARGET: targetName,
1010
+ LERNA_PACKAGE_NAME: projectName,
1011
+ ...(variant.configuration === undefined
1012
+ ? {}
1013
+ : { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
1014
+ }
1015
+ : {}),
979
1016
  ...mapped.env,
980
1017
  }
981
- exec.env = env
1018
+ if (Object.keys(define).length > 0) env.define = define
1019
+ if (Object.keys(env).length > 0) exec.env = env
982
1020
  if (mapped.timeout !== undefined) exec.timeout = mapped.timeout
983
1021
  if (readyWhen !== undefined) {
984
1022
  exec.persistent = { readyWhen }
@@ -1005,8 +1043,8 @@ function buildTask(
1005
1043
  if (inputs.runtimeCmds.length > 0) keyed.workspaceRuntime = inputs.runtimeCmds
1006
1044
  task.inputs = keyed
1007
1045
  if (cacheEnabled) {
1008
- const outputs: Record<string, unknown> = { files: outFiles }
1009
- if (wsOutFiles.length > 0) outputs.workspaceFiles = wsOutFiles
1046
+ const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
1047
+ if (wsOutFiles.length > 0) outputs.workspaceFiles = takingBack(wsOutFiles)
1010
1048
  task.cache = { outputs }
1011
1049
  }
1012
1050
 
@@ -1040,7 +1078,7 @@ function reachesProducer(
1040
1078
  if (!Array.isArray(deps)) return false
1041
1079
  return deps.some((d) => {
1042
1080
  if (typeof d !== 'string' || d.includes('*')) return true
1043
- if (d.includes('#nx-input:')) return false
1081
+ if (d.includes('#inputs:')) return false
1044
1082
  if (d.startsWith('^'))
1045
1083
  return [...reached(project)].some((p) => emittedIds.has(`${p}#${d.slice(1)}`))
1046
1084
  return d.includes('#') || emittedIds.has(`${project}#${d}`)
@@ -1114,6 +1152,7 @@ function mapCommand(
1114
1152
  todos: string[],
1115
1153
  dotenv: readonly string[] | null,
1116
1154
  pnp: boolean,
1155
+ hooks: boolean,
1117
1156
  manifest: { readonly name?: unknown; readonly version?: unknown },
1118
1157
  manifestField: MapNxOptions['manifestField'],
1119
1158
  executors: NxExecutors | undefined,
@@ -1184,10 +1223,11 @@ function mapCommand(
1184
1223
  // (novu's `test:watch: ""`, 2026-09-11); as a command it is a config
1185
1224
  // that refuses to load, so it is the placeholder with its todo.
1186
1225
  if (body !== undefined && body.length > 0) {
1187
- const command = scriptCommand(script, body, scripts, pnp)
1226
+ // Nx runs `<pm> run <script>`: the hooks fold only where the manager runs them.
1227
+ const command = scriptCommand(script, body, scripts, pnp, hooks)
1188
1228
  // `yarn run <name>` sets its own.
1189
1229
  const env =
1190
- command === `yarn run ${script}`
1230
+ command === yarnRun(script)
1191
1231
  ? {}
1192
1232
  : npmScriptEnv(command, script, command !== body, manifest, manifestField, todos)
1193
1233
  return shell(command, undefined, { env, readyWhen: undefined })
@@ -3,7 +3,7 @@
3
3
 
4
4
  import path from 'node:path'
5
5
  import { isLiteralPattern } from '@vzn/vx'
6
- import { literalGlob, minimatchToVx } from '../glob-grammar.js'
6
+ import { gluedGlobstarAsStar, literalGlob, minimatchToVx, nxBareGroups } from '../glob-grammar.js'
7
7
  import { takingBack } from '../shared-outputs.js'
8
8
 
9
9
  interface NxOutputs {
@@ -59,11 +59,19 @@ export function withProjectDir(p: string, projectRel: string, asGlob: boolean):
59
59
  })
60
60
  }
61
61
 
62
- /** A workspace path relative to the project dir, or null when it lies outside. */
62
+ /**
63
+ * A workspace path relative to the project dir, or null when it lies
64
+ * outside; `''` is the project dir itself, which Nx reads as the whole tree
65
+ * (`path_or_everything_under`). As a workspace path, `{projectRoot}` cached
66
+ * a literal core reads as another project's, and `{projectRoot}/` the empty
67
+ * path, which saved and keyed nothing.
68
+ */
63
69
  export function underProject(p: string, projectRel: string): string | null {
64
70
  if (projectRel === '.' || projectRel === '') return p
65
- for (const dir of [masked(projectRel), projectRel])
66
- if (p.startsWith(`${dir}/`)) return p.slice(dir.length + 1)
71
+ for (const dir of [masked(projectRel), projectRel]) {
72
+ if (p === dir) return ''
73
+ if (p.startsWith(`${dir}/`)) return p.slice(dir.length + 1).replace(/\/+$/, '')
74
+ }
67
75
  return null
68
76
  }
69
77
 
@@ -191,7 +199,7 @@ export function mapNxOutputs(
191
199
  const gs = g.slice(gn.length)
192
200
  const own = underProject(gs, projectRel)
193
201
  if (own !== null) {
194
- outFiles.push(gn + withProjectDir(own, projectRel, true))
202
+ outFiles.push(gn + (own === '' ? '**' : withProjectDir(own, projectRel, true)))
195
203
  continue
196
204
  }
197
205
  const m = path.posix.normalize(gs).replace(/^\.\//, '')
@@ -215,7 +223,9 @@ export function mapNxOutputs(
215
223
  */
216
224
  function vxOutputGlobs(o: string): string[] | null {
217
225
  const negated = o.startsWith('!')
218
- const segs = o.slice(negated ? 1 : 0).split('/')
226
+ const bare = nxBareGroups(o.slice(negated ? 1 : 0))
227
+ if (bare === null) return null
228
+ const segs = gluedGlobstarAsStar(bare).split('/')
219
229
  const at = segs.findIndex((seg) => /^!\([^()|{},]+(\|[^()|{},]+)*\)$/.test(seg))
220
230
  if (at !== -1) {
221
231
  if (negated) return null
@@ -2,7 +2,7 @@
2
2
  // dependency's `name` input for every project the graph reaches, whether or
3
3
  // not a task edge exists, so a `test` with `^production` and no `dependsOn`
4
4
  // re-keys on a dependency's source. vx folds upstream KEYS along
5
- // `dependsOn`, so each project gets one `nx-input:<name>` task — a keyed
5
+ // `dependsOn`, so each project gets one `inputs:<name>` task — a keyed
6
6
  // group, keyed on the project's own `name` input — with an edge to its
7
7
  // dependencies' twin. A task that reads `^name` depends on its direct
8
8
  // dependencies' twins and folds the whole closure through them: each
@@ -11,13 +11,14 @@
11
11
  //
12
12
  // The edges are the NX graph's, not vx's package graph (`^` in vx follows
13
13
  // package.json, and an Nx edge from a tsconfig path has no manifest entry),
14
- // so they are explicit `pkg#nx-input:<name>`. A graph node with no vx
14
+ // so they are explicit `pkg#inputs:<name>`. A graph node with no vx
15
15
  // project is transparent: its files join as workspace globs and the walk
16
16
  // goes on through it. Nx allows a project cycle and vx's task graph does
17
17
  // not: inside a cycle a twin carries its peers' files as workspace globs
18
18
  // and edges only out of the cycle.
19
19
  import type { GeneratedTask } from '@vzn/vx'
20
20
  import type { PackageMeta } from '../packages.js'
21
+ import type { TrackedKinds } from '../tracked-outputs.js'
21
22
  import { emptyNxInputs, expandNxInputs, type NxInputs } from './nx-inputs.js'
22
23
  import { nxProjectOutputs } from './nx-outputs.js'
23
24
  import { matchNxProjects } from './nx-deps.js'
@@ -47,8 +48,8 @@ const isFileset = (name: string): boolean =>
47
48
  /** A fileset twin is named by its hash: a glob's `*` is no task name. */
48
49
  const inputTask = (name: string): string =>
49
50
  isFileset(name)
50
- ? `nx-input:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
51
- : `nx-input:${name}`
51
+ ? `inputs:fileset-${Bun.hash.xxHash3(name).toString(16).padStart(16, '0')}`
52
+ : `inputs:${name}`
52
53
 
53
54
  function normRel(p: string): string {
54
55
  const s = p.replace(/\/+$/, '')
@@ -63,7 +64,7 @@ export interface NxUpstream {
63
64
  * needs, with a transparent node's files added to `into`.
64
65
  */
65
66
  resolve(node: string, into: NxInputs, todos: string[]): string[]
66
- /** Each project's `nx-input:<name>` tasks, for every twin an edge reaches. */
67
+ /** Each project's `inputs:<name>` tasks, for every twin an edge reaches. */
67
68
  inputTasks(): Map<string, GeneratedTask[]>
68
69
  }
69
70
 
@@ -72,6 +73,7 @@ export function planNxUpstream(
72
73
  dependencies: unknown,
73
74
  workspaceNamed: Readonly<Record<string, unknown[]>> | null,
74
75
  metaByNode: ReadonlyMap<string, PackageMeta>,
76
+ tracked?: (rel: string) => TrackedKinds,
75
77
  ): NxUpstream {
76
78
  const isProject = (n: string): boolean => typeof nodes[n]?.data?.root === 'string'
77
79
  const projectTags = Object.entries(nodes).map(([name, n]) => ({
@@ -221,6 +223,7 @@ export function planNxUpstream(
221
223
  rel,
222
224
  name: nm,
223
225
  outputs: nxProjectOutputs(nodes[node]?.data?.targets, rel || '.', nm),
226
+ ...(tracked === undefined ? {} : { tracked }),
224
227
  }
225
228
  expandNxInputs([name], namedOf(node), at, into, todos)
226
229
  }
@@ -289,6 +292,7 @@ export function planNxUpstream(
289
292
  into: NxInputs,
290
293
  todos: string[],
291
294
  edges: Set<string>,
295
+ depsOf = false,
292
296
  ): void => {
293
297
  // Nx reads the list as `dependsOn`'s `projects` (`findMatchingProjects`):
294
298
  // `*` patterns, `tag:` and `!` exclusions. Looked up as names, a
@@ -302,6 +306,10 @@ export function planNxUpstream(
302
306
  for (const p of literal)
303
307
  todos.push(`input project ${JSON.stringify(p)} is not a graph node — map manually`)
304
308
  for (const p of matchNxProjects(of, projectTags)) {
309
+ if (depsOf) {
310
+ fold(p, name, new Set(), into, todos, edges)
311
+ continue
312
+ }
305
313
  const u = { name }
306
314
  const meta = metaByNode.get(p)
307
315
  if (meta !== undefined) {
@@ -319,7 +327,7 @@ export function planNxUpstream(
319
327
  const edges = new Set<string>()
320
328
  for (const u of into.upstream) {
321
329
  if (u.of === 'deps') fold(node, u.name, new Set(), into, todos, edges)
322
- else foldProjects(u.name, u.of, into, todos, edges)
330
+ else foldProjects(u.name, u.of, into, todos, edges, u.depsOf)
323
331
  }
324
332
  return [...edges]
325
333
  }
@@ -337,7 +345,7 @@ export function planNxUpstream(
337
345
  fold(node, name, peers, inputs, todos, edges)
338
346
  // A nested `^other` in this project's `name` input.
339
347
  for (const u of inputs.upstream) {
340
- if (u.of !== 'deps') foldProjects(u.name, u.of, inputs, todos, edges)
348
+ if (u.of !== 'deps') foldProjects(u.name, u.of, inputs, todos, edges, u.depsOf)
341
349
  else if (u.name !== name) fold(node, u.name, peers, inputs, todos, edges)
342
350
  }
343
351
  // Nx hashes a fileset that matches nothing as nothing; core warns of
@@ -17,9 +17,12 @@ import {
17
17
  type RemoteOpenContext,
18
18
  type VxPlugin,
19
19
  refuseUnknownOptions,
20
+ UserError,
20
21
  type PluginOptionKinds,
21
22
  } from '@vzn/vx'
22
- import { deadline } from '../remote-deadline.js'
23
+ import { deadline, MAX_TIMER_MS } from '../remote-deadline.js'
24
+ import { checkRemoteUrl } from '../remote-url.js'
25
+ import { refusal } from '../remote-refusal.js'
23
26
  import { OutageBreaker } from '../remote-breaker.js'
24
27
  import { Cooldown, withRetry } from '../remote-retry.js'
25
28
  import {
@@ -31,13 +34,13 @@ import {
31
34
 
32
35
  export interface NxCacheOptions {
33
36
  /** Base URL of the cache server, or `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. */
34
- server?: string
37
+ url?: string
35
38
  /**
36
39
  * Bearer token, or `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN`, then the OS
37
40
  * keychain. `''` (option or variable) is a server that runs open: no
38
41
  * token, nothing asked.
39
42
  */
40
- accessToken?: string
43
+ token?: string
41
44
  /** Per-request deadline (default 30 s). */
42
45
  timeoutMs?: number
43
46
  /** Resends of a request answered 429 / 5xx or never connected (default 1); 0 turns them off. */
@@ -45,9 +48,9 @@ export interface NxCacheOptions {
45
48
  }
46
49
 
47
50
  export interface NxCacheConfig {
48
- server: string
51
+ url: string
49
52
  /** Undefined: the layer's `open` asks the OS keychain; `''`: an open server. */
50
- accessToken?: string
53
+ token?: string
51
54
  timeoutMs: number
52
55
  retries: number
53
56
  }
@@ -57,36 +60,22 @@ export function resolveNxCacheConfig(
57
60
  options: NxCacheOptions,
58
61
  env: Record<string, string | undefined> = Bun.env,
59
62
  ): NxCacheConfig | undefined {
60
- const server = (options.server ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
61
- if (!server) return undefined
62
- // The URL is printed in every refusal line, so a `user:pass@` in it would
63
- // leak to the log. Credentials go in the access token.
64
- if (URL.canParse(server)) {
65
- const u = new URL(server)
66
- if (u.username !== '' || u.password !== '')
67
- throw new Error(
68
- 'vx/nx-cache: server carries credentials (user:pass@); pass them as the accessToken instead',
69
- )
70
- }
71
- const accessToken = options.accessToken ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
72
- const fault = accessToken === undefined ? null : headerValueFault(accessToken)
63
+ // Named by its source: the env's was refused as a `url` option.
64
+ const from =
65
+ options.url !== undefined ? 'nxCache() option "url"' : 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER'
66
+ const url = (options.url ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
67
+ if (!url) return undefined
68
+ checkRemoteUrl(url, from)
69
+ const token = options.token ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
70
+ const fault = token === undefined ? null : headerValueFault(token)
73
71
  if (fault !== null)
74
72
  throw new Error(
75
- `vx/nx-cache: the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
73
+ `nxCache(): the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
76
74
  )
77
75
  const retries = options.retries ?? 1
78
- // A NaN never reaches the bound, and the request would be resent forever.
79
- if (!Number.isInteger(retries) || retries < 0)
80
- throw new Error(`vx/nx-cache: retries must be a whole number ≥ 0, got ${retries}`)
81
- // A zero timeout aborted every request as it started.
82
- const ms = options.timeoutMs
83
- if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0))
84
- throw new Error(
85
- `vx/nx-cache: timeoutMs must be a positive number of ms, got ${JSON.stringify(ms)}`,
86
- )
87
76
  return {
88
- server,
89
- ...(accessToken !== undefined ? { accessToken } : {}),
77
+ url,
78
+ ...(token !== undefined ? { token } : {}),
90
79
  timeoutMs: options.timeoutMs ?? 30_000,
91
80
  retries,
92
81
  }
@@ -98,7 +87,7 @@ export function resolveNxCacheConfig(
98
87
  * the prefetch pass calls `get`) is a GET whose body is cancelled before
99
88
  * it answers, so no probe holds a connection. It kept the response for a
100
89
  * `get` of the same hash, which no caller makes. Bodies stream: `get` returns the `fetch` Response and `put`
101
- * sends the Blob core hands it. `put` treats `409` as success:
90
+ * sends the Blob core hands it. `put` treats any 2xx and `409` as success:
102
91
  * the record is immutable and content-addressed, so "already there" is the
103
92
  * outcome wanted. An auth failure (401/403) throws ONCE — core
104
93
  * reports it — and then turns the layer off for the rest of the process
@@ -129,11 +118,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
129
118
  private readonly fetchImpl: typeof fetch = fetch,
130
119
  private readonly wait: (ms: number) => Promise<void> = Bun.sleep,
131
120
  ) {
132
- this.endpoint = `${config.server}/v1/cache`
133
- this.token = config.accessToken
121
+ this.endpoint = `${config.url}/v1/cache`
122
+ this.token = config.token
134
123
  this.keychainRemote = {
135
124
  key: this.endpoint,
136
- label: remoteLabel(config.server, undefined),
125
+ label: remoteLabel(config.url, undefined),
137
126
  env: 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN',
138
127
  }
139
128
  }
@@ -171,7 +160,7 @@ export class NxRemoteCache implements RemoteCacheLayer {
171
160
  () =>
172
161
  withRetry(
173
162
  () =>
174
- this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
163
+ this.fetchImpl(`${this.config.url}/v1/cache/${hash}`, {
175
164
  method,
176
165
  headers,
177
166
  ...(body === undefined ? {} : { body }),
@@ -187,23 +176,25 @@ export class NxRemoteCache implements RemoteCacheLayer {
187
176
  const first = !this.writesRefused
188
177
  this.writesRefused = true
189
178
  if (!first) return undefined
179
+ // The status and the server's own words, as turboCache() says them.
190
180
  throw new Error(
191
- 'HTTP 403: access forbidden (a read-only token cannot write); uploads off for this run, reads go on',
181
+ `${(await refusal(res)).message}: access forbidden (a read-only token cannot write); uploads off for this run, reads go on`,
192
182
  )
193
183
  }
194
184
  if (res.status === 401 || res.status === 403) {
195
185
  const first = !this.disabled
196
186
  this.disabled = true
197
187
  if (!first) return undefined
188
+ const said = (await refusal(res)).message
198
189
  const why =
199
190
  res.status === 401
200
191
  ? 'missing or invalid token'
201
192
  : 'access forbidden (the token may not read)'
202
193
  if (this.forget === undefined)
203
- throw new Error(`HTTP ${res.status}: ${why}; remote cache off for this run`)
194
+ throw new Error(`${said}: ${why}; remote cache off for this run`)
204
195
  await this.forget()
205
196
  throw new Error(
206
- `HTTP ${res.status}: ${why}; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
197
+ `${said}: ${why}; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
207
198
  )
208
199
  }
209
200
  return res
@@ -220,9 +211,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
220
211
  const res = await this.request('GET', hash)
221
212
  if (res === undefined) return null
222
213
  if (res.status !== 200) {
223
- await res.body?.cancel()
224
- if (res.status === 404) return null
225
- throw new Error(`HTTP ${res.status}`)
214
+ if (res.status === 404) {
215
+ await res.body?.cancel()
216
+ return null
217
+ }
218
+ throw await refusal(res)
226
219
  }
227
220
  return res
228
221
  }
@@ -236,19 +229,21 @@ export class NxRemoteCache implements RemoteCacheLayer {
236
229
  }
237
230
 
238
231
  // Nx's record carries no duration.
239
- async put(hash: string, body: Blob): Promise<void> {
240
- if (this.disabled || this.writesRefused) return
232
+ async put(hash: string, body: Blob): Promise<void | false> {
233
+ // Nothing sent, so no upload is counted.
234
+ if (this.disabled || this.writesRefused) return false
241
235
  const res = await this.request('PUT', hash, body)
242
- if (res === undefined) return
243
- if (res.status === 200 || res.status === 202 || res.status === 409) return
244
- throw new Error(`HTTP ${res.status}`)
236
+ if (res === undefined) return false
237
+ // The spec answers 200; a store behind it may say 201 or 204.
238
+ if (res.ok || res.status === 409) return
239
+ throw await refusal(res)
245
240
  }
246
241
  }
247
242
 
248
243
  /** Each option `NxCacheOptions` names, with its kind: derived from the type, so the two cannot drift. */
249
244
  const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
250
- server: 'string',
251
- accessToken: 'string',
245
+ url: 'string',
246
+ token: 'string',
252
247
  timeoutMs: 'number',
253
248
  retries: 'number',
254
249
  }
@@ -257,13 +252,28 @@ const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
257
252
  * Declare in `.vx/workspace.ts`; the local store stays the floor beneath it:
258
253
  *
259
254
  * ```ts
260
- * plugins: [nxCache({ server: 'https://cache.example.com', accessToken: process.env.CACHE_TOKEN })]
255
+ * plugins: [nxCache({ url: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
261
256
  * ```
262
257
  *
263
- * Declines without a server, so it is safe to leave declared.
258
+ * Declines without a url, so it is safe to leave declared.
264
259
  */
265
260
  export function nxCache(options: NxCacheOptions = {}): VxPlugin {
266
261
  refuseUnknownOptions('nxCache()', options, NX_CACHE_KEYS)
262
+ // At the call: in the `cache` hook a laptop with no server named passed
263
+ // what failed the first CI run. A NaN never reaches the bound, and the
264
+ // request would be resent forever; a zero timeout, or one past the longest
265
+ // timer, aborted every request as it started.
266
+ const { retries, timeoutMs: ms } = options
267
+ if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
268
+ throw new UserError(`nxCache() option "retries" must be a whole number ≥ 0, got ${retries}`)
269
+ if (ms !== undefined && !(ms > 0 && ms <= MAX_TIMER_MS))
270
+ throw new UserError(
271
+ `nxCache() option "timeoutMs" must be a positive number of ms up to ${MAX_TIMER_MS}, got ${ms}`,
272
+ )
273
+ // At the call too, as vxCache() checks its url: in the hook alone, a verb
274
+ // that opens no cache (`vx show`) took a url every run then failed on.
275
+ const url = options.url?.replace(/\/+$/, '')
276
+ if (url) checkRemoteUrl(url, 'nxCache() option "url"')
267
277
  return definePlugin(import.meta, {
268
278
  cache(): RemoteCacheLayer | undefined {
269
279
  const config = resolveNxCacheConfig(options)
package/src/nx-exec.cjs CHANGED
@@ -133,7 +133,7 @@ async function loadGraph(nx, project) {
133
133
  }
134
134
  // A cache written before the project existed (one added since Nx last
135
135
  // ran) is stale: \`nx run\` computes the graph and finds it, so compute.
136
- if (graph === null || graph.nodes[project] === undefined) {
136
+ if (graph === null || !Object.hasOwn(graph.nodes, project)) {
137
137
  graph = unwrapGraph(await pg.createProjectGraphAsync({ exitOnError: false }))
138
138
  }
139
139
  if (graph === null) throw new Error('nx produced a project graph with no nodes')
@@ -168,7 +168,7 @@ async function main(argv) {
168
168
  loadTaskEnv(nx, process.env, args.dotenv, undefined)
169
169
 
170
170
  const { graph, pg } = await loadGraph(nx, args.project)
171
- const node = graph.nodes[args.project]
171
+ const node = Object.hasOwn(graph.nodes, args.project) ? graph.nodes[args.project] : undefined
172
172
  if (node === undefined) {
173
173
  process.stderr.write(
174
174
  `nx-exec: no project ${JSON.stringify(args.project)} in the Nx project graph\n`,