@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.
@@ -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
+ )
@@ -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
+ }