@vzn/vx-migrate 0.0.0 → 0.0.485
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/LICENSE +21 -0
- package/README.md +306 -0
- package/index.ts +4 -0
- package/package.json +44 -6
- package/src/adoption-plugin.ts +125 -0
- package/src/bin.ts +18 -0
- package/src/dotenv-probe.ts +86 -0
- package/src/glob-grammar.ts +63 -0
- package/src/index.ts +15 -0
- package/src/mapping-cache.ts +101 -0
- package/src/migrate-nx.ts +154 -0
- package/src/migrate-turbo.ts +400 -0
- package/src/migrate.ts +165 -0
- package/src/nx/export-graph.ts +29 -0
- package/src/nx/index.ts +638 -0
- package/src/nx/nx-deps.ts +272 -0
- package/src/nx/nx-dotenv.ts +106 -0
- package/src/nx/nx-inputs.ts +210 -0
- package/src/nx/nx-map.ts +1430 -0
- package/src/nx/nx-outputs.ts +231 -0
- package/src/nx/nx-upstream.ts +356 -0
- package/src/nx-cache/index.ts +236 -0
- package/src/nx-command.ts +458 -0
- package/src/nx-dotenv.cjs +41 -0
- package/src/nx-env.cjs +152 -0
- package/src/nx-exec.cjs +294 -0
- package/src/paths.ts +36 -0
- package/src/plugin-gaps.ts +48 -0
- package/src/remote-deadline.ts +11 -0
- package/src/remote-retry.ts +52 -0
- package/src/remote-token.ts +18 -0
- package/src/script-command.ts +69 -0
- package/src/shared-outputs.ts +373 -0
- package/src/tracked-outputs.ts +248 -0
- package/src/turbo/index.ts +291 -0
- package/src/turbo/turbo-map.ts +1976 -0
- package/src/turbo-cache/index.ts +661 -0
- package/src/workspace-notes.ts +54 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Nx's per-task `.env` loading for the two bins that run Nx targets —
|
|
2
|
+
// `nx-exec` (an executor, in its own process) and `nx-env` (a shell line) —
|
|
3
|
+
// through Nx's own `loadAndExpandDotEnvFile` / `unloadDotEnvFile`
|
|
4
|
+
// (tasks-runner/task-env.ts), so the parsing, `${VAR}` expansion and
|
|
5
|
+
// precedence are the workspace's Nx version's, not a copy's.
|
|
6
|
+
//
|
|
7
|
+
// Which files: the mapper lists the ones that exist, in Nx's order
|
|
8
|
+
// (`getEnvPathsForTask`), relative to the task's working directory. What
|
|
9
|
+
// Nx does with them: the first file to define a name wins, and a name the
|
|
10
|
+
// environment already holds wins over every file — which under vx is the
|
|
11
|
+
// isolated task env, so the target's `env` (exec.env.define) stays on top
|
|
12
|
+
// as in Nx. Nx first UNLOADS the root files it loaded into its own process
|
|
13
|
+
// at start-up; nothing loaded them here, so there is nothing to unload.
|
|
14
|
+
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Loads `files` into `env`, then run-commands' `envFile`, which Nx loads
|
|
21
|
+
* over the result the same way (a name already set wins) and whose
|
|
22
|
+
* unreadable file fails the task.
|
|
23
|
+
*/
|
|
24
|
+
function loadTaskEnv(nx, env, files, envFile) {
|
|
25
|
+
if (files.length === 0 && envFile === undefined) return
|
|
26
|
+
const { loadAndExpandDotEnvFile, unloadDotEnvFile } = nx('nx/src/tasks-runner/task-env')
|
|
27
|
+
if (files.length > 0) {
|
|
28
|
+
loadAndExpandDotEnvFile(
|
|
29
|
+
files.map((f) => path.resolve(f)),
|
|
30
|
+
env,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
if (envFile !== undefined) {
|
|
34
|
+
const file = path.resolve(envFile)
|
|
35
|
+
unloadDotEnvFile(file, env)
|
|
36
|
+
const result = loadAndExpandDotEnvFile(file, env)
|
|
37
|
+
if (result && result.error) throw result.error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { loadTaskEnv }
|
package/src/nx-env.cjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `nx-env [--dotenv <file>]... [--envFile <file>] [--ready-when <string>]... -- <command> [args…]`
|
|
3
|
+
//
|
|
4
|
+
// A shell line with the `.env` files Nx gives the task it came from: what
|
|
5
|
+
// `nx()` and the migrator wrap an `nx:run-commands` or `nx:run-script` line
|
|
6
|
+
// in when the task has any (nx-dotenv.cjs has the rules). The command runs
|
|
7
|
+
// as `sh -c`, with whatever follows it appended quoted — exactly what vx
|
|
8
|
+
// appends to a line after `vx run … --` — so the line is the same one it
|
|
9
|
+
// would be unwrapped.
|
|
10
|
+
//
|
|
11
|
+
// `--ready-when`, repeated: a run-commands target's several `readyWhen`
|
|
12
|
+
// strings. Nx is ready once every one has appeared in the output, stdout
|
|
13
|
+
// or stderr; vx's `readyWhen` is one pattern matched per line. So the
|
|
14
|
+
// child's streams pass through unchanged, separately, and once all the
|
|
15
|
+
// strings have been seen this prints one line, READY, which is the task's
|
|
16
|
+
// `readyWhen`. (Nx's `isReady` marks at most one string per output chunk,
|
|
17
|
+
// so two arriving together in a last chunk never made it ready; here each
|
|
18
|
+
// counts.)
|
|
19
|
+
//
|
|
20
|
+
// Node and CommonJS for the reason `nx-exec` is: the loading is Nx's own,
|
|
21
|
+
// resolved from the working directory up to the workspace's `nx`.
|
|
22
|
+
|
|
23
|
+
'use strict'
|
|
24
|
+
|
|
25
|
+
const { spawn, spawnSync } = require('node:child_process')
|
|
26
|
+
const { createRequire } = require('node:module')
|
|
27
|
+
const path = require('node:path')
|
|
28
|
+
const { loadTaskEnv } = require('./nx-dotenv.cjs')
|
|
29
|
+
|
|
30
|
+
// `--envFile`, Nx's own option name, and not `--env-file`: Node 22 takes
|
|
31
|
+
// `--env-file` from anywhere on its command line, the script's arguments
|
|
32
|
+
// included, and exits 9 when the file is missing.
|
|
33
|
+
const USAGE =
|
|
34
|
+
'usage: nx-env [--dotenv <file>]... [--envFile <file>] [--ready-when <string>]... -- <command> [args…]'
|
|
35
|
+
|
|
36
|
+
/** The line printed once every `--ready-when` string has been seen. */
|
|
37
|
+
const READY = 'nx-env: ready'
|
|
38
|
+
|
|
39
|
+
/** vx's own quoting for an appended argument (runner.ts `shellQuote`). */
|
|
40
|
+
function shellQuote(arg) {
|
|
41
|
+
if (arg === '') return "''"
|
|
42
|
+
if (/^[A-Za-z0-9_\-.,/=:@%+]+$/.test(arg)) return arg
|
|
43
|
+
return `'${arg.replace(/'/g, "'\\''")}'`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const out = { dotenv: [], envFile: undefined, readyWhen: [], command: undefined, args: [] }
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i]
|
|
50
|
+
if (a === '--') {
|
|
51
|
+
out.command = argv[i + 1]
|
|
52
|
+
out.args = argv.slice(i + 2)
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
if (a === '--help' || a === '-h') return { help: true }
|
|
56
|
+
const value = argv[i + 1]
|
|
57
|
+
if ((a === '--dotenv' || a === '--envFile' || a === '--ready-when') && value !== undefined) {
|
|
58
|
+
if (a === '--dotenv') out.dotenv.push(value)
|
|
59
|
+
else if (a === '--ready-when') out.readyWhen.push(value)
|
|
60
|
+
else out.envFile = value
|
|
61
|
+
i++
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
return { error: `unexpected ${JSON.stringify(a)}\n${USAGE}` }
|
|
65
|
+
}
|
|
66
|
+
if (out.command === undefined) return { error: `no command after --\n${USAGE}` }
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Runs `line` with the streams piped through, printing READY once every
|
|
72
|
+
* string has been seen in them; resolves with the shell's exit.
|
|
73
|
+
*/
|
|
74
|
+
function runWatched(line, env, strings) {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const child = spawn('sh', ['-c', line], { stdio: ['inherit', 'pipe', 'pipe'], env })
|
|
77
|
+
const missing = new Set(strings)
|
|
78
|
+
const longest = Math.max(...strings.map((s) => s.length))
|
|
79
|
+
const watch = (stream, out) => {
|
|
80
|
+
// A string split across two chunks is still seen: the tail carries over.
|
|
81
|
+
let tail = ''
|
|
82
|
+
stream.on('data', (chunk) => {
|
|
83
|
+
out.write(chunk)
|
|
84
|
+
if (missing.size === 0) return
|
|
85
|
+
const text = tail + chunk.toString()
|
|
86
|
+
for (const s of [...missing]) if (text.includes(s)) missing.delete(s)
|
|
87
|
+
tail = text.slice(-longest)
|
|
88
|
+
if (missing.size === 0) process.stdout.write(`${READY}\n`)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
watch(child.stdout, process.stdout)
|
|
92
|
+
watch(child.stderr, process.stderr)
|
|
93
|
+
child.on('error', (err) => {
|
|
94
|
+
process.stderr.write(`nx-env: ${err.message}\n`)
|
|
95
|
+
resolve(1)
|
|
96
|
+
})
|
|
97
|
+
child.on('close', (code, signal) => {
|
|
98
|
+
if (signal) resolve(128 + (require('node:os').constants.signals[signal] ?? 2))
|
|
99
|
+
else resolve(code ?? 1)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main(argv) {
|
|
105
|
+
const args = parseArgs(argv)
|
|
106
|
+
if (args.help) {
|
|
107
|
+
process.stdout.write(`${USAGE}\n`)
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
if (args.error) {
|
|
111
|
+
process.stderr.write(`nx-env: ${args.error}\n`)
|
|
112
|
+
return 2
|
|
113
|
+
}
|
|
114
|
+
const req = createRequire(path.join(process.cwd(), 'package.json'))
|
|
115
|
+
try {
|
|
116
|
+
req.resolve('nx/package.json')
|
|
117
|
+
} catch {
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
`nx-env: cannot resolve \`nx\` from ${process.cwd()} — is it installed in this workspace?\n`,
|
|
120
|
+
)
|
|
121
|
+
return 1
|
|
122
|
+
}
|
|
123
|
+
const env = { ...process.env }
|
|
124
|
+
try {
|
|
125
|
+
loadTaskEnv((spec) => req(spec), env, args.dotenv, args.envFile)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
process.stderr.write(`nx-env: ${err && err.message ? err.message : String(err)}\n`)
|
|
128
|
+
return 1
|
|
129
|
+
}
|
|
130
|
+
const line = [args.command, ...args.args.map(shellQuote)].join(' ')
|
|
131
|
+
// The task's process group gets vx's signals, the shell included; this
|
|
132
|
+
// process waits for the shell and reports its end rather than dying first.
|
|
133
|
+
for (const s of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(s, () => {})
|
|
134
|
+
if (args.readyWhen.length > 0) return runWatched(line, env, args.readyWhen)
|
|
135
|
+
const r = spawnSync('sh', ['-c', line], { stdio: 'inherit', env })
|
|
136
|
+
if (r.error) {
|
|
137
|
+
process.stderr.write(`nx-env: ${r.error.message}\n`)
|
|
138
|
+
return 1
|
|
139
|
+
}
|
|
140
|
+
if (r.signal) return 128 + (require('node:os').constants.signals[r.signal] ?? 2)
|
|
141
|
+
return r.status ?? 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main(process.argv.slice(2)).then(
|
|
145
|
+
(code) => {
|
|
146
|
+
process.exitCode = code
|
|
147
|
+
},
|
|
148
|
+
(err) => {
|
|
149
|
+
process.stderr.write(`nx-env: ${err && err.message ? err.message : String(err)}\n`)
|
|
150
|
+
process.exitCode = 1
|
|
151
|
+
},
|
|
152
|
+
)
|
package/src/nx-exec.cjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `nx-exec <executor> --project <p> --target <t> [--configuration <c>] --options '<json>'
|
|
3
|
+
// [--dotenv <file>]... [overrides…]`
|
|
4
|
+
//
|
|
5
|
+
// One Nx executor as one process, through Nx's own public `runExecutor`
|
|
6
|
+
// (design: docs/design/nx-unchanged-2026-09.md). The command line carries
|
|
7
|
+
// the executor and its options, so vx's key sees them and `vx show` prints
|
|
8
|
+
// what runs; the workspace's cached project graph only supplies the
|
|
9
|
+
// `ExecutorContext` executors read (project root, dependencies). The
|
|
10
|
+
// target in the in-memory graph is REPLACED by the command line, so what
|
|
11
|
+
// runs is what the command says, never what project.json says today.
|
|
12
|
+
//
|
|
13
|
+
// Node, not Bun, and CommonJS on purpose: the executor runs in this
|
|
14
|
+
// process, and executors are Node programs (`@nx/nx-<platform>` native
|
|
15
|
+
// binding, jest workers). `nx` resolves from the working directory — the
|
|
16
|
+
// project dir vx runs every task in — up to the workspace's node_modules.
|
|
17
|
+
// Deep `nx/src` imports over the `@nx/devkit` barrel: 50 ms less per
|
|
18
|
+
// task, and a workspace with only `nx` and custom executors has no devkit.
|
|
19
|
+
|
|
20
|
+
'use strict'
|
|
21
|
+
|
|
22
|
+
const { createRequire, enableCompileCache } = require('node:module')
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
const { loadTaskEnv } = require('./nx-dotenv.cjs')
|
|
25
|
+
|
|
26
|
+
// Nx's module graph is ~150 ms of every executed task; Node's on-disk
|
|
27
|
+
// compile cache (22.1+, a no-op below) takes ~30 of them back on the
|
|
28
|
+
// second task, under os.tmpdir() by default, with nothing in a key.
|
|
29
|
+
if (typeof enableCompileCache === 'function') {
|
|
30
|
+
try {
|
|
31
|
+
enableCompileCache()
|
|
32
|
+
} catch {
|
|
33
|
+
// A read-only temp dir just means no cache.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const USAGE =
|
|
38
|
+
"usage: nx-exec <executor> --project <name> --target <name> [--configuration <name>] [--options '<json>'] [--dotenv <file>]... [overrides…]"
|
|
39
|
+
|
|
40
|
+
/** The flags that are nx-exec's own, each taken once but `--dotenv`; everything else is Nx's. */
|
|
41
|
+
const OWN = new Set(['project', 'target', 'configuration', 'options', 'dotenv'])
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parsed argv, or `{ error }` — a usage error is exit 2, before Nx is loaded.
|
|
45
|
+
* Every token that is not the executor or the first of an own flag is an
|
|
46
|
+
* OVERRIDE, kept verbatim for Nx to parse: what `vx run … -- --otp=123`
|
|
47
|
+
* appends to the line is what `nx run p:t --otp=123` gives the executor,
|
|
48
|
+
* where it used to be a usage error (nx#12165).
|
|
49
|
+
*/
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const out = {
|
|
52
|
+
executor: undefined,
|
|
53
|
+
project: undefined,
|
|
54
|
+
target: undefined,
|
|
55
|
+
configuration: undefined,
|
|
56
|
+
options: {},
|
|
57
|
+
dotenv: [],
|
|
58
|
+
overrides: [],
|
|
59
|
+
}
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
for (let i = 0; i < argv.length; i++) {
|
|
62
|
+
const a = argv[i]
|
|
63
|
+
if (a === '--help' || a === '-h') return { help: true }
|
|
64
|
+
if (!a.startsWith('--')) {
|
|
65
|
+
if (out.executor === undefined) out.executor = a
|
|
66
|
+
else out.overrides.push(a)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
const eq = a.indexOf('=')
|
|
70
|
+
const name = eq === -1 ? a.slice(2) : a.slice(2, eq)
|
|
71
|
+
if (!OWN.has(name) || seen.has(name)) {
|
|
72
|
+
out.overrides.push(a)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (name !== 'dotenv') seen.add(name)
|
|
76
|
+
const value = eq === -1 ? argv[++i] : a.slice(eq + 1)
|
|
77
|
+
if (value === undefined) return { error: `--${name} needs a value\n${USAGE}` }
|
|
78
|
+
if (name === 'dotenv') out.dotenv.push(value)
|
|
79
|
+
else if (name === 'options') {
|
|
80
|
+
try {
|
|
81
|
+
out.options = JSON.parse(value)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { error: `--options is not JSON: ${err.message}` }
|
|
84
|
+
}
|
|
85
|
+
if (typeof out.options !== 'object' || out.options === null || Array.isArray(out.options)) {
|
|
86
|
+
return { error: '--options must be a JSON object' }
|
|
87
|
+
}
|
|
88
|
+
} else out[name] = value
|
|
89
|
+
}
|
|
90
|
+
if (out.executor === undefined) return { error: `expected one executor (got 0)\n${USAGE}` }
|
|
91
|
+
if (!out.executor.includes(':')) {
|
|
92
|
+
return { error: `executor must be <package>:<name>, got ${JSON.stringify(out.executor)}` }
|
|
93
|
+
}
|
|
94
|
+
for (const f of ['project', 'target']) {
|
|
95
|
+
if (out[f] === undefined || out[f] === '') return { error: `--${f} is required\n${USAGE}` }
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Both shapes a graph file takes: Nx's cache (`{ nodes, … }`) and `nx graph --file` (`{ graph: { nodes, … } }`). */
|
|
101
|
+
function unwrapGraph(g) {
|
|
102
|
+
const inner = g && typeof g === 'object' && g.graph && typeof g.graph === 'object' ? g.graph : g
|
|
103
|
+
if (
|
|
104
|
+
!inner ||
|
|
105
|
+
typeof inner !== 'object' ||
|
|
106
|
+
typeof inner.nodes !== 'object' ||
|
|
107
|
+
inner.nodes === null
|
|
108
|
+
) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
if (inner.externalNodes === undefined) inner.externalNodes = {}
|
|
112
|
+
if (inner.dependencies === undefined) inner.dependencies = {}
|
|
113
|
+
return inner
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The cached graph, or a fresh one when the cache is missing, unreadable or
|
|
118
|
+
* lacks the project.
|
|
119
|
+
* The daemon is never dialed: NX_DAEMON is off for this process before `nx`
|
|
120
|
+
* loads, so the fallback computes in-process — slow, bounded, and it writes
|
|
121
|
+
* Nx's cache for the next task.
|
|
122
|
+
*/
|
|
123
|
+
async function loadGraph(nx, project) {
|
|
124
|
+
const pg = nx('nx/src/project-graph/project-graph')
|
|
125
|
+
let graph = null
|
|
126
|
+
try {
|
|
127
|
+
graph = unwrapGraph(pg.readCachedProjectGraph())
|
|
128
|
+
} catch {
|
|
129
|
+
// No cache, or a shape the reader did not understand: compute below.
|
|
130
|
+
}
|
|
131
|
+
// A cache written before the project existed (one added since Nx last
|
|
132
|
+
// ran) is stale: \`nx run\` computes the graph and finds it, so compute.
|
|
133
|
+
if (graph === null || graph.nodes[project] === undefined) {
|
|
134
|
+
graph = unwrapGraph(await pg.createProjectGraphAsync({ exitOnError: false }))
|
|
135
|
+
}
|
|
136
|
+
if (graph === null) throw new Error('nx produced a project graph with no nodes')
|
|
137
|
+
return { graph, pg }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function main(argv) {
|
|
141
|
+
const args = parseArgs(argv)
|
|
142
|
+
if (args.help) {
|
|
143
|
+
process.stdout.write(`${USAGE}\n`)
|
|
144
|
+
return 0
|
|
145
|
+
}
|
|
146
|
+
if (args.error) {
|
|
147
|
+
process.stderr.write(`nx-exec: ${args.error}\n`)
|
|
148
|
+
return 2
|
|
149
|
+
}
|
|
150
|
+
if (process.env.NX_DAEMON === undefined) process.env.NX_DAEMON = 'false'
|
|
151
|
+
|
|
152
|
+
const req = createRequire(path.join(process.cwd(), 'package.json'))
|
|
153
|
+
let nx
|
|
154
|
+
try {
|
|
155
|
+
req.resolve('nx/package.json')
|
|
156
|
+
nx = (spec) => req(spec)
|
|
157
|
+
} catch {
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
`nx-exec: cannot resolve \`nx\` from ${process.cwd()} — is it installed in this workspace?\n`,
|
|
160
|
+
)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
// Before anything reads the environment: the executor runs in this
|
|
164
|
+
// process, so the task's `.env` files (nx-dotenv.cjs) are its env.
|
|
165
|
+
loadTaskEnv(nx, process.env, args.dotenv, undefined)
|
|
166
|
+
|
|
167
|
+
const { graph, pg } = await loadGraph(nx, args.project)
|
|
168
|
+
const node = graph.nodes[args.project]
|
|
169
|
+
if (node === undefined) {
|
|
170
|
+
process.stderr.write(
|
|
171
|
+
`nx-exec: no project ${JSON.stringify(args.project)} in the Nx project graph\n`,
|
|
172
|
+
)
|
|
173
|
+
return 1
|
|
174
|
+
}
|
|
175
|
+
const target = { executor: args.executor, options: args.options }
|
|
176
|
+
// The options are already the configuration's, flattened by whoever wrote
|
|
177
|
+
// the command; the name is passed so an executor reading
|
|
178
|
+
// `context.configurationName` sees it, and Nx's merge finds it declared.
|
|
179
|
+
if (args.configuration !== undefined) target.configurations = { [args.configuration]: {} }
|
|
180
|
+
node.data = { ...node.data, targets: { ...node.data?.targets, [args.target]: target } }
|
|
181
|
+
|
|
182
|
+
const { readNxJson } = nx('nx/src/config/nx-json')
|
|
183
|
+
const { runExecutor } = nx('nx/src/command-line/run/run')
|
|
184
|
+
const { workspaceRoot } = nx('nx/src/utils/workspace-root')
|
|
185
|
+
// Nx forks an executor in the directory Nx was started from, the
|
|
186
|
+
// workspace root in CI (`forked-process-task-runner`); vx starts a task in
|
|
187
|
+
// its project dir. An executor that resolves against `process.cwd()`
|
|
188
|
+
// (@nx/js's ts transformers, prettier's config) read the project dir.
|
|
189
|
+
// The `.env` files above are already loaded from it.
|
|
190
|
+
process.chdir(workspaceRoot)
|
|
191
|
+
const context = {
|
|
192
|
+
root: workspaceRoot,
|
|
193
|
+
cwd: workspaceRoot,
|
|
194
|
+
isVerbose: process.env.NX_VERBOSE_LOGGING === 'true',
|
|
195
|
+
projectName: args.project,
|
|
196
|
+
targetName: args.target,
|
|
197
|
+
configurationName: args.configuration,
|
|
198
|
+
projectGraph: graph,
|
|
199
|
+
projectsConfigurations: pg.readProjectsConfigurationFromProjectGraph(graph),
|
|
200
|
+
nxJsonConfiguration: readNxJson(workspaceRoot),
|
|
201
|
+
}
|
|
202
|
+
// An executor yields once per result and a server yields for as long as it
|
|
203
|
+
// runs; the exit is the LAST result's, as `nx run` reports it.
|
|
204
|
+
const description = { project: args.project, target: args.target }
|
|
205
|
+
if (args.configuration !== undefined) description.configuration = args.configuration
|
|
206
|
+
// Parsed by Nx itself, as `nx run` parses what follows the target. Loaded
|
|
207
|
+
// only when there is something to parse: the module pulls in git helpers.
|
|
208
|
+
// `runExecutor` derives the unparsed list from the parsed overrides, so the
|
|
209
|
+
// parser's own copy is dropped — kept, it was serialized back as a flag,
|
|
210
|
+
// `--__overrides_unparsed__=--otp=123`, and handed to the executor. The
|
|
211
|
+
// derived list puts positional words first, where `nx run` keeps them in
|
|
212
|
+
// the order typed.
|
|
213
|
+
let overrides = {}
|
|
214
|
+
if (args.overrides.length > 0) {
|
|
215
|
+
const { createOverrides } = nx('nx/src/utils/command-line-utils')
|
|
216
|
+
const { __overrides_unparsed__: _raw, ...parsed } = createOverrides(args.overrides)
|
|
217
|
+
overrides = parsed
|
|
218
|
+
}
|
|
219
|
+
// As `nx run` reads it (`getLastValueFromAsyncIterableIterator`): the
|
|
220
|
+
// generator's RETURN value when it has one, else the last yield. A
|
|
221
|
+
// `for await` drops the return: an executor that only returns
|
|
222
|
+
// `{ success: true }` exited 1, and a server that yields success and
|
|
223
|
+
// returns failure when it dies (@nx/web:file-server) exited 0.
|
|
224
|
+
const results = await runExecutor(description, overrides, context)
|
|
225
|
+
const iterator = (results[Symbol.asyncIterator] || results[Symbol.iterator]).call(results)
|
|
226
|
+
let last
|
|
227
|
+
for (;;) {
|
|
228
|
+
const step = await iterator.next()
|
|
229
|
+
if (step.done) {
|
|
230
|
+
if (step.value !== undefined) last = step.value
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
last = step.value
|
|
234
|
+
}
|
|
235
|
+
const ok = last !== null && typeof last === 'object' && last.success === true
|
|
236
|
+
return ok ? 0 : 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Does `pkg` resolve from the working directory (the workspace root, once Nx runs)? */
|
|
240
|
+
function installed(pkg) {
|
|
241
|
+
try {
|
|
242
|
+
createRequire(path.join(process.cwd(), 'package.json')).resolve(`${pkg}/package.json`)
|
|
243
|
+
return true
|
|
244
|
+
} catch {
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Exit once both streams have drained. An executor may leave a worker pool
|
|
251
|
+
* or a timer alive after its last result (Nx's own runner calls
|
|
252
|
+
* `process.exit` for the same reason); exiting in the write callbacks is
|
|
253
|
+
* what keeps a piped stdout whole on the platforms where pipes are async.
|
|
254
|
+
*/
|
|
255
|
+
function finish(code) {
|
|
256
|
+
process.exitCode = code
|
|
257
|
+
process.stdout.write('', () => process.stderr.write('', () => process.exit(code)))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// As `nx run` reports a thrown error (`handleErrors`): the message, and
|
|
261
|
+
// the stack only under verbose logging. A missing executor package or an
|
|
262
|
+
// executor's own throw printed a stack of Nx internals every time.
|
|
263
|
+
main(process.argv.slice(2)).then(finish, (err) => {
|
|
264
|
+
const message = err && err.message ? err.message : String(err)
|
|
265
|
+
const verbose = process.env.NX_VERBOSE_LOGGING === 'true'
|
|
266
|
+
// Nx's "Unable to resolve <pkg>:<executor>." with a package Node cannot
|
|
267
|
+
// find from the workspace root: not installed. Nx's own second line
|
|
268
|
+
// varies (Node's "Cannot find module" and its require stack, or the
|
|
269
|
+
// local-plugin lookup's "unable to find tsconfig.base.json"); either,
|
|
270
|
+
// with a package Node cannot resolve, is the refusal. A local plugin
|
|
271
|
+
// Nx found and failed on says something else, and keeps Nx's message.
|
|
272
|
+
const unresolved = /^Unable to resolve (.+):([^:\n]+)\.\n([^\n]*)/.exec(message)
|
|
273
|
+
const notFound =
|
|
274
|
+
unresolved !== null &&
|
|
275
|
+
(unresolved[3].startsWith(`Cannot find module '${unresolved[1]}/package.json'`) ||
|
|
276
|
+
unresolved[3].startsWith('unable to find tsconfig'))
|
|
277
|
+
if (!verbose && notFound && !installed(unresolved[1])) {
|
|
278
|
+
process.stderr.write(
|
|
279
|
+
`nx-exec: executor package ${JSON.stringify(unresolved[1])} is not installed in this workspace ` +
|
|
280
|
+
`(${unresolved[1]}:${unresolved[2]}) — add it to devDependencies, or write the task as the command ` +
|
|
281
|
+
'the executor runs\n',
|
|
282
|
+
)
|
|
283
|
+
finish(1)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
const more =
|
|
287
|
+
err && err.stack
|
|
288
|
+
? verbose
|
|
289
|
+
? `\n${err.stack}`
|
|
290
|
+
: '\nSet NX_VERBOSE_LOGGING=true to see the stack trace.'
|
|
291
|
+
: ''
|
|
292
|
+
process.stderr.write(`nx-exec: ${message}${more}\n`)
|
|
293
|
+
finish(1)
|
|
294
|
+
})
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The two package.json / path readings every mapper in this package shares.
|
|
2
|
+
//
|
|
3
|
+
// Both existed as per-file copies. `relPosix` had FOUR (migrate-turbo,
|
|
4
|
+
// migrate-nx, turbo/turbo-map, nx-command), three identical and one that
|
|
5
|
+
// mapped the same-directory case to `.` — a difference its own caller made
|
|
6
|
+
// unreachable by returning early, which is the kind of divergence a copy
|
|
7
|
+
// acquires quietly. `packageScripts` had two, and core guards the same read
|
|
8
|
+
// while these did not (item 446).
|
|
9
|
+
//
|
|
10
|
+
// Neither divergence was reachable in a realistic workspace, which is why
|
|
11
|
+
// this is consolidation rather than a fix. The reason to do it anyway is
|
|
12
|
+
// item 445: one copy in this package HAD drifted into a defect, and the
|
|
13
|
+
// cheapest moment to collapse a duplicate is before it does.
|
|
14
|
+
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import type { ProjectMeta } from '@vzn/vx'
|
|
17
|
+
|
|
18
|
+
/** `to` relative to `from`, POSIX-separated — core's `relPosix`, which is not on the façade. */
|
|
19
|
+
export function relPosix(from: string, to: string): string {
|
|
20
|
+
return path.relative(from, to).split(path.sep).join('/')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A project's `scripts` map, or `{}` when the file holds something else.
|
|
25
|
+
*
|
|
26
|
+
* package.json is a system boundary, so the value is whatever the file
|
|
27
|
+
* holds: a string or an array would enumerate its INDICES as script names,
|
|
28
|
+
* and indexing one by a task named `0` yields a single character that reads
|
|
29
|
+
* as a usable command. Core's `migrate-scripts.ts` guards this read; these
|
|
30
|
+
* mappers did not.
|
|
31
|
+
*/
|
|
32
|
+
export function packageScripts(meta: ProjectMeta): Record<string, unknown> {
|
|
33
|
+
const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
|
|
34
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
|
|
35
|
+
return raw as Record<string, unknown>
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What a mapping could not say, reported once per run by the plugin that
|
|
2
|
+
// runs it live. One line per DISTINCT gap, not one per task: n8n has a
|
|
3
|
+
// `dev` and a `watch` in most of its 84 packages, and astro's `build`
|
|
4
|
+
// carries the same `!vendor/**` output in every package — the per-task
|
|
5
|
+
// form was a hundred identical lines before the first frame (2026-09-11).
|
|
6
|
+
// Shared by `turbo()` and `nx()` since the second one existed.
|
|
7
|
+
|
|
8
|
+
/** What both mappers' projects have in common: a name and tasks with todos. */
|
|
9
|
+
interface MappedProject {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly tasks: readonly { readonly name: string; readonly todos: readonly string[] }[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Gaps {
|
|
15
|
+
readonly notes: readonly string[]
|
|
16
|
+
/** Every task-level gap, keyed on its text, to the `pkg#task` ids that carry it. */
|
|
17
|
+
readonly todos: ReadonlyMap<string, readonly string[]>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The gaps of a mapping, indexed for the one-line-per-gap report. */
|
|
21
|
+
export function collectGaps(projects: readonly MappedProject[], notes: readonly string[]): Gaps {
|
|
22
|
+
const todos = new Map<string, string[]>()
|
|
23
|
+
for (const project of projects) {
|
|
24
|
+
for (const t of project.tasks) {
|
|
25
|
+
for (const todo of t.todos) {
|
|
26
|
+
let ids = todos.get(todo)
|
|
27
|
+
if (ids === undefined) todos.set(todo, (ids = []))
|
|
28
|
+
ids.push(`${project.name}#${t.name}`)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { notes, todos }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function warnGaps(warn: (line: string) => void, pluginName: string, gaps: Gaps): void {
|
|
36
|
+
for (const note of gaps.notes) warn(`[${pluginName}] ${note}`)
|
|
37
|
+
for (const [todo, ids] of gaps.todos) {
|
|
38
|
+
if (ids.length === 1) {
|
|
39
|
+
warn(`[${pluginName}] ${ids[0]}: ${todo}`)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
const names = [...new Set(ids.map((id) => id.slice(id.indexOf('#') + 1)))]
|
|
43
|
+
const packages = new Set(ids.map((id) => id.slice(0, id.indexOf('#'))))
|
|
44
|
+
warn(
|
|
45
|
+
`[${pluginName}] ${ids.length} task(s) (${names.join(', ')} across ${packages.size} package(s)): ${todo}`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request's deadline, named. The runtime's abort says "The operation
|
|
3
|
+
* timed out." and nothing else; core's degrade line already names the
|
|
4
|
+
* request, the artifact and the server, so the cause names the deadline
|
|
5
|
+
* that was spent. Shared by `turboCache()` and `nxCache()`.
|
|
6
|
+
*/
|
|
7
|
+
export function deadlineNamed(err: unknown, timeoutMs: number): unknown {
|
|
8
|
+
return (err as { name?: unknown } | null)?.name === 'TimeoutError'
|
|
9
|
+
? new Error(`no answer within ${timeoutMs} ms`)
|
|
10
|
+
: err
|
|
11
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request with Turbo's retry rule (`turborepo-api-client/src/retry.rs`):
|
|
3
|
+
* a 429 or a 5xx other than 501, or a request that never connected, is
|
|
4
|
+
* sent again after a backoff — 2 s, or a 429's `Retry-After` capped at
|
|
5
|
+
* 10 s. Without it one 503 from a gateway read as a miss and the task ran
|
|
6
|
+
* again. A spent deadline is not retried, unlike Turbo's reads: it has
|
|
7
|
+
* already cost its wait, and a hung server would cost it twice. Every
|
|
8
|
+
* request both wires make is idempotent (content-addressed, and Nx's
|
|
9
|
+
* second write is a 409 the caller takes as done), so a resend applies
|
|
10
|
+
* nothing twice. Shared by `turboCache()` and `nxCache()`.
|
|
11
|
+
*/
|
|
12
|
+
export async function withRetry(
|
|
13
|
+
send: () => Promise<Response>,
|
|
14
|
+
retries: number,
|
|
15
|
+
wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
16
|
+
): Promise<Response> {
|
|
17
|
+
for (let attempt = 0; ; attempt++) {
|
|
18
|
+
let res: Response
|
|
19
|
+
try {
|
|
20
|
+
res = await send()
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (attempt >= retries || !neverConnected(err)) throw err
|
|
23
|
+
await wait(BACKOFF_MS)
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (attempt >= retries || !retryable(res.status)) return res
|
|
27
|
+
const delay = res.status === 429 ? (retryAfterMs(res) ?? BACKOFF_MS) : BACKOFF_MS
|
|
28
|
+
await res.body?.cancel()
|
|
29
|
+
await wait(delay)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BACKOFF_MS = 2_000
|
|
34
|
+
const MAX_RETRY_AFTER_MS = 10_000
|
|
35
|
+
|
|
36
|
+
function retryable(status: number): boolean {
|
|
37
|
+
return status === 429 || (status >= 500 && status !== 501)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A refused port or an unresolved host, as Bun's `fetch` codes them (measured on 1.4.2). */
|
|
41
|
+
function neverConnected(err: unknown): boolean {
|
|
42
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
43
|
+
return code === 'ConnectionRefused' || code === 'ENOTFOUND'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `Retry-After` as seconds or an HTTP date, capped as Turbo caps it. */
|
|
47
|
+
function retryAfterMs(res: Response): number | undefined {
|
|
48
|
+
const v = res.headers.get('retry-after')
|
|
49
|
+
if (v === null) return undefined
|
|
50
|
+
const ms = /^\d+$/.test(v.trim()) ? Number(v) * 1000 : Date.parse(v) - Date.now()
|
|
51
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS) : undefined
|
|
52
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a string cannot be an HTTP header value, or null when it can. Bun's
|
|
3
|
+
* `fetch` refuses a value holding a line break or NUL, or a character past
|
|
4
|
+
* Latin-1, once it has trimmed the ends (measured on 1.4.2), and its error
|
|
5
|
+
* QUOTES the whole value: a token with an interior newline (a two-line
|
|
6
|
+
* secret file) reached every degrade warning as `'Bearer <token>'` (item
|
|
7
|
+
* 927). A caller refuses with this reason and never with the value. Shared
|
|
8
|
+
* by `turboCache()` and `nxCache()`.
|
|
9
|
+
*/
|
|
10
|
+
export function headerValueFault(value: string): string | null {
|
|
11
|
+
let past = false
|
|
12
|
+
for (const ch of value.trim()) {
|
|
13
|
+
const c = ch.codePointAt(0)!
|
|
14
|
+
if (c === 0x0a || c === 0x0d || c === 0) return 'a line break or NUL'
|
|
15
|
+
if (c > 0xff) past = true
|
|
16
|
+
}
|
|
17
|
+
return past ? 'a character past Latin-1' : null
|
|
18
|
+
}
|