@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/README.md +77 -73
- package/package.json +3 -3
- package/src/adopt.ts +53 -12
- package/src/adoption-plugin.ts +9 -11
- package/src/bin.ts +21 -1
- package/src/glob-grammar.ts +82 -0
- package/src/index.ts +36 -10
- package/src/mapping-cache.ts +14 -6
- package/src/migrate-nx.ts +7 -4
- package/src/migrate-scripts.ts +10 -102
- package/src/migrate-turbo.ts +2 -6
- package/src/migrate.ts +57 -38
- package/src/nx/index.ts +19 -5
- package/src/nx/nx-inputs.ts +44 -9
- package/src/nx/nx-map.ts +65 -25
- package/src/nx/nx-outputs.ts +16 -6
- package/src/nx/nx-upstream.ts +15 -7
- package/src/nx-cache/index.ts +60 -50
- package/src/nx-exec.cjs +2 -2
- package/src/package-fs.ts +6 -1
- package/src/packages.ts +9 -7
- package/src/plugin-gaps.ts +32 -6
- package/src/remote-cache.ts +5 -1
- package/src/remote-deadline.ts +7 -1
- package/src/remote-refusal.ts +31 -0
- package/src/remote-retry.ts +9 -4
- package/src/remote-url.ts +28 -0
- package/src/script-command.ts +110 -4
- package/src/shared-outputs.ts +24 -12
- package/src/tracked-outputs.ts +11 -7
- package/src/turbo/index.ts +33 -17
- package/src/turbo/turbo-map.ts +97 -192
- package/src/turbo/turbo-reads.ts +183 -0
- package/src/turbo-cache/index.ts +261 -102
- package/src/turbo-cache/oidc.ts +52 -26
- package/src/turbo-cache/scope.ts +5 -3
- package/src/vite-task/vite-task-map.ts +11 -6
- package/src/workspace-plugins.ts +5 -2
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
|
|
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(
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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('#
|
|
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.
|
|
971
|
-
// still
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
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('#
|
|
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
|
-
|
|
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 ===
|
|
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 })
|
package/src/nx/nx-outputs.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
package/src/nx/nx-upstream.ts
CHANGED
|
@@ -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 `
|
|
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#
|
|
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
|
-
? `
|
|
51
|
-
: `
|
|
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 `
|
|
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
|
package/src/nx-cache/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
url: string
|
|
49
52
|
/** Undefined: the layer's `open` asks the OS keychain; `''`: an open server. */
|
|
50
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
89
|
-
...(
|
|
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.
|
|
133
|
-
this.token = config.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
194
|
+
throw new Error(`${said}: ${why}; remote cache off for this run`)
|
|
204
195
|
await this.forget()
|
|
205
196
|
throw new Error(
|
|
206
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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({
|
|
255
|
+
* plugins: [nxCache({ url: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
|
|
261
256
|
* ```
|
|
262
257
|
*
|
|
263
|
-
* Declines without a
|
|
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
|
|
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`,
|