@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/README.md +45 -20
- package/package.json +2 -2
- package/src/adopt.ts +1 -1
- package/src/adoption-plugin.ts +2 -3
- package/src/migrate-nx.ts +3 -2
- package/src/migrate-turbo.ts +9 -81
- package/src/migrate-vite-task.ts +40 -0
- package/src/migrate.ts +69 -14
- package/src/nx/export-graph.ts +21 -3
- package/src/nx/index.ts +6 -3
- package/src/nx/lerna.ts +60 -0
- package/src/nx/nx-inputs.ts +17 -6
- package/src/nx/nx-map.ts +17 -6
- package/src/nx/nx-outputs.ts +62 -12
- package/src/nx/nx-upstream.ts +12 -2
- package/src/nx-cache/index.ts +17 -15
- package/src/paths.ts +4 -1
- package/src/remote-breaker.ts +60 -0
- package/src/remote-deadline.ts +13 -5
- package/src/shared-outputs.ts +3 -1
- package/src/spelled-env.ts +36 -0
- package/src/tracked-outputs.ts +3 -2
- package/src/turbo/turbo-map.ts +28 -9
- package/src/turbo-cache/index.ts +90 -27
- package/src/vite-task/vite-task-map.ts +558 -0
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
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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 === '
|
|
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(
|
|
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<
|
|
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
|
}
|
package/src/nx/export-graph.ts
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
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) =>
|
|
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') ||
|
package/src/nx/lerna.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `lerna run <x>` orders each package's <x> after its dependencies' <x>.
|
|
2
|
+
// Lerna 6+ runs it on Nx's task runner and passes that order as the target
|
|
3
|
+
// dependency `^<x>` — and drops every other one — unless the repo
|
|
4
|
+
// configures Nx tasks itself: nx.json `targetDefaults` (or the legacy
|
|
5
|
+
// `targetDependencies`), or an `nx` key in the package.json of a package
|
|
6
|
+
// that has <x> (lerna's `prepNxOptions`, 9.0.7). The exported graph holds
|
|
7
|
+
// none of this, so a Lerna repo mapped from it ran every build at once.
|
|
8
|
+
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
interface Target {
|
|
12
|
+
dependsOn?: unknown[]
|
|
13
|
+
}
|
|
14
|
+
interface Node {
|
|
15
|
+
data?: { root?: string; targets?: Record<string, Target> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The text of `root/lerna.json`, or null when there is none. */
|
|
19
|
+
export async function lernaJsonText(root: string): Promise<string | null> {
|
|
20
|
+
return Bun.file(path.join(root, 'lerna.json'))
|
|
21
|
+
.text()
|
|
22
|
+
.catch(() => null)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The graph's nodes with Lerna's order applied, or `nodes` itself when the
|
|
27
|
+
* repo has no lerna.json or configures Nx's task dependencies.
|
|
28
|
+
* `manifest(rel)`: the package.json a root-relative node root holds.
|
|
29
|
+
*/
|
|
30
|
+
export async function withLernaOrder<N extends Node>(
|
|
31
|
+
root: string,
|
|
32
|
+
nodes: Record<string, N>,
|
|
33
|
+
nxJson: Record<string, unknown> | undefined,
|
|
34
|
+
manifest: (rel: string) => Promise<Record<string, unknown> | undefined>,
|
|
35
|
+
): Promise<Record<string, N>> {
|
|
36
|
+
if ((await lernaJsonText(root)) === null) return nodes
|
|
37
|
+
const keys = (v: unknown): number =>
|
|
38
|
+
v !== null && typeof v === 'object' ? Object.keys(v).length : 0
|
|
39
|
+
if (keys(nxJson?.['targetDependencies'] || nxJson?.['targetDefaults']) > 0) return nodes
|
|
40
|
+
const configured = new Set<string>()
|
|
41
|
+
for (const node of Object.values(nodes)) {
|
|
42
|
+
const targets = node.data?.targets
|
|
43
|
+
if (targets === undefined) continue
|
|
44
|
+
if ((await manifest(node.data?.root ?? '.'))?.['nx'] === undefined) continue
|
|
45
|
+
for (const name of Object.keys(targets)) configured.add(name)
|
|
46
|
+
}
|
|
47
|
+
const out: Record<string, N> = {}
|
|
48
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
49
|
+
const targets = node.data?.targets
|
|
50
|
+
if (targets === undefined) {
|
|
51
|
+
out[id] = node
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const ordered: Record<string, Target> = {}
|
|
55
|
+
for (const [name, t] of Object.entries(targets))
|
|
56
|
+
ordered[name] = configured.has(name) ? t : { ...t, dependsOn: [`^${name}`] }
|
|
57
|
+
out[id] = { ...node, data: { ...node.data, targets: ordered } }
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
package/src/nx/nx-inputs.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// (`dependentTasksOutputFiles`) is nothing; `externalDependencies` is a todo saying
|
|
8
8
|
// so. Extracted from `buildTask` in item 606.
|
|
9
9
|
|
|
10
|
-
import { nxWorkspacePath, underProject } from './nx-outputs.js'
|
|
10
|
+
import { nxWorkspacePath, underProject, withProjectDir } from './nx-outputs.js'
|
|
11
11
|
import { minimatchToVx } from '../glob-grammar.js'
|
|
12
12
|
import { shellQuote } from '../nx-command.js'
|
|
13
13
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
package/src/nx/nx-outputs.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// workspace-root globs. Extracted from `buildTask` in item 606.
|
|
3
3
|
|
|
4
4
|
import path from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import { isLiteralPattern } from '@vzn/vx'
|
|
6
|
+
import { literalGlob, minimatchToVx } from '../glob-grammar.js'
|
|
6
7
|
import { takingBack } from '../shared-outputs.js'
|
|
7
8
|
|
|
8
9
|
interface NxOutputs {
|
|
@@ -18,12 +19,13 @@ interface NxOutputs {
|
|
|
18
19
|
* became the literal glob `coverage/{projectRoot}/**`, saved nothing, and a
|
|
19
20
|
* hit restored nothing. Null when a token is left or the path leaves the
|
|
20
21
|
* workspace; a brace set is no token (item 914 — 912 read one as a gap).
|
|
22
|
+
* The project dir comes back masked: {@link withProjectDir} writes it in.
|
|
21
23
|
*/
|
|
22
24
|
export function nxWorkspacePath(s: string, projectRel: string, projectName: string): string | null {
|
|
23
25
|
const rel = projectRel === '.' ? '' : projectRel
|
|
24
26
|
const p = s
|
|
25
27
|
.replaceAll('{workspaceRoot}', '')
|
|
26
|
-
.replaceAll('{projectRoot}', rel)
|
|
28
|
+
.replaceAll('{projectRoot}', masked(rel))
|
|
27
29
|
.replaceAll('{projectName}', projectName)
|
|
28
30
|
// A token left over is a gap; a brace set (`*.{ts,tsx}`) is a glob.
|
|
29
31
|
if (/\{[\w.]+\}/.test(p)) return null
|
|
@@ -31,10 +33,38 @@ export function nxWorkspacePath(s: string, projectRel: string, projectName: stri
|
|
|
31
33
|
return n === '.' || n === '..' || n.startsWith('../') ? null : n
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// A project dir is a path, never a glob: `packages/a{b}` read as a token
|
|
37
|
+
// dropped every `{projectRoot}` path, and `packages/[x]` translated as a
|
|
38
|
+
// class reached `packages/x`. Each dir segment stands in as `\uE000<i>\uE001`
|
|
39
|
+
// until the glob is vx's, so `..` still pops one.
|
|
40
|
+
const MASKED = /\uE000(\d+)\uE001/g
|
|
41
|
+
|
|
42
|
+
function masked(rel: string): string {
|
|
43
|
+
return rel === ''
|
|
44
|
+
? ''
|
|
45
|
+
: rel
|
|
46
|
+
.split('/')
|
|
47
|
+
.map((_, i) => `\uE000${i}\uE001`)
|
|
48
|
+
.join('/')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `p` with the masked project dir written back: as a path, or escaped for a glob. */
|
|
52
|
+
export function withProjectDir(p: string, projectRel: string, asGlob: boolean): string {
|
|
53
|
+
const segs = projectRel.split('/')
|
|
54
|
+
return p.replace(MASKED, (_, i: string, at: number) => {
|
|
55
|
+
const seg = segs[Number(i)]!
|
|
56
|
+
if (!asGlob) return seg
|
|
57
|
+
// Only a glob's head reads `!` as a negation.
|
|
58
|
+
return at === 0 ? literalGlob(seg) : literalGlob(`/${seg}`).slice(1)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
/** A workspace path relative to the project dir, or null when it lies outside. */
|
|
35
63
|
export function underProject(p: string, projectRel: string): string | null {
|
|
36
64
|
if (projectRel === '.' || projectRel === '') return p
|
|
37
|
-
|
|
65
|
+
for (const dir of [masked(projectRel), projectRel])
|
|
66
|
+
if (p.startsWith(`${dir}/`)) return p.slice(dir.length + 1)
|
|
67
|
+
return null
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
/**
|
|
@@ -64,15 +94,16 @@ export function nxDefaultOutputs(
|
|
|
64
94
|
}
|
|
65
95
|
if (targetName !== 'build' && targetName !== 'prepare') return []
|
|
66
96
|
const root = projectRel === '.' ? '' : projectRel
|
|
67
|
-
|
|
97
|
+
// As tokens, so the dir stays a path whatever its name holds.
|
|
98
|
+
const at = (p: string): string => (root === '' ? p : `{projectRoot}/${p}`)
|
|
68
99
|
const held = ['build', 'public'].filter((d) => tops === undefined || tops.has(d))
|
|
69
100
|
if (held.length > 0)
|
|
70
101
|
todos.push(
|
|
71
|
-
`no outputs declared: Nx also caches ${held.map(
|
|
102
|
+
`no outputs declared: Nx also caches ${held.map((d) => path.posix.join(root, d)).join(' and ')} for this target — vx cleans an output before the run, so add ${held.length === 1 ? 'it' : 'them'} to the outputs by hand only if ${held.length === 1 ? 'it holds' : 'they hold'} nothing committed`,
|
|
72
103
|
)
|
|
73
104
|
// The root project's two are one path.
|
|
74
105
|
return [
|
|
75
|
-
...new Set([
|
|
106
|
+
...new Set([root === '' ? 'dist' : '{workspaceRoot}/dist/{projectRoot}', at('dist')]),
|
|
76
107
|
...['build', 'public'].filter((d) => !held.includes(d)).map(at),
|
|
77
108
|
]
|
|
78
109
|
}
|
|
@@ -145,7 +176,7 @@ export function mapNxOutputs(
|
|
|
145
176
|
const norm = path.posix.normalize(s.replace(/^\/+/, ''))
|
|
146
177
|
if (norm === '..' || norm.startsWith('../')) {
|
|
147
178
|
todos.push(
|
|
148
|
-
`output ${JSON.stringify(o)} resolves to ${JSON.stringify(norm)}, outside the workspace — ` +
|
|
179
|
+
`output ${JSON.stringify(o)} resolves to ${JSON.stringify(withProjectDir(norm, projectRel, false))}, outside the workspace — ` +
|
|
149
180
|
'vx caches only inside it; dropped',
|
|
150
181
|
)
|
|
151
182
|
continue
|
|
@@ -158,9 +189,18 @@ export function mapNxOutputs(
|
|
|
158
189
|
for (const g of globs) {
|
|
159
190
|
const gn = g.startsWith('!') ? '!' : ''
|
|
160
191
|
const gs = g.slice(gn.length)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
const own = underProject(gs, projectRel)
|
|
193
|
+
if (own !== null) {
|
|
194
|
+
outFiles.push(gn + withProjectDir(own, projectRel, true))
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
const m = path.posix.normalize(gs).replace(/^\.\//, '')
|
|
198
|
+
const w = withProjectDir(m, projectRel, true)
|
|
199
|
+
wsOutFiles.push(gn + w)
|
|
200
|
+
// An escaped brace makes a literal a glob, which core matches as the
|
|
201
|
+
// path alone (`asTrees` adds the tree only to a literal).
|
|
202
|
+
if (w !== withProjectDir(m, projectRel, false) && isLiteralPattern(m.replace(MASKED, '')))
|
|
203
|
+
wsOutFiles.push(`${gn}${w}/**`)
|
|
164
204
|
}
|
|
165
205
|
}
|
|
166
206
|
return { outFiles: takingBack(outFiles), wsOutFiles: takingBack(wsOutFiles) }
|
|
@@ -217,9 +257,19 @@ export function nxProjectOutputs(
|
|
|
217
257
|
projectName,
|
|
218
258
|
scratch,
|
|
219
259
|
)
|
|
220
|
-
|
|
260
|
+
const literal = (f: string): string | null => {
|
|
221
261
|
const lit = f.replace(/\/\*\*(\/\*)?$/, '')
|
|
222
|
-
|
|
262
|
+
return lit.startsWith('!') || /[*?[]|(?<!\\)[{}]/.test(lit)
|
|
263
|
+
? null
|
|
264
|
+
: lit.replace(/\\([{}!])/g, '$1')
|
|
265
|
+
}
|
|
266
|
+
for (const f of outFiles) {
|
|
267
|
+
const lit = literal(f)
|
|
268
|
+
if (lit !== null) out.add(path.posix.join(rel, lit))
|
|
269
|
+
}
|
|
270
|
+
for (const f of wsOutFiles) {
|
|
271
|
+
const lit = literal(f)
|
|
272
|
+
if (lit !== null) out.add(lit)
|
|
223
273
|
}
|
|
224
274
|
}
|
|
225
275
|
const r = [...out]
|
package/src/nx/nx-upstream.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
package/src/nx-cache/index.ts
CHANGED
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
refuseUnknownOptions,
|
|
21
21
|
type PluginOptionKinds,
|
|
22
22
|
} from '@vzn/vx'
|
|
23
|
-
import {
|
|
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
|
|
144
|
-
(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
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
|