@vzn/vx-migrate 0.0.0 → 0.0.484

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,236 @@
1
+ // @vzn/vx-migrate (nxCache) — a vx `cache` plugin that stores artifacts in any server
2
+ // implementing Nx's self-hosted remote cache OpenAPI spec: `GET` and `PUT`
3
+ // `/v1/cache/{hash}`, a Bearer token, `application/octet-stream` bodies,
4
+ // and an IMMUTABLE record — a second write of an existing hash is `409`.
5
+ // The wire is Nx's; the bytes are vx's own artifacts under vx's own keys,
6
+ // so the server is storage — an Nx binary cannot read them.
7
+ //
8
+ // Nothing is on by default: declare `nxCache()` in `vx.workspace.ts` (the
9
+ // local store is the floor beneath it) and give it a server (options, or Nx's own
10
+ // environment variables so a self-hosted setup carries over). Without one
11
+ // the plugin DECLINES and the run stays local.
12
+ //
13
+ // Imports core only through the public `@vzn/vx` specifier.
14
+ import {
15
+ definePlugin,
16
+ LayeredCache,
17
+ type CacheLayer,
18
+ type RemoteCacheLayer,
19
+ type VxPlugin,
20
+ refuseUnknownOptions,
21
+ type PluginOptionKinds,
22
+ } from '@vzn/vx'
23
+ import { deadlineNamed } from '../remote-deadline.js'
24
+ import { withRetry } from '../remote-retry.js'
25
+ import { headerValueFault } from '../remote-token.js'
26
+
27
+ export interface NxCacheOptions {
28
+ /** Base URL of the cache server, or `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. */
29
+ server?: string
30
+ /** Bearer token, or `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN`. Optional: a server may run open. */
31
+ accessToken?: string
32
+ /** Per-request deadline (default 30 s). */
33
+ timeoutMs?: number
34
+ /** Resends of a request answered 429 / 5xx or never connected (default 1); 0 turns them off. */
35
+ retries?: number
36
+ }
37
+
38
+ export interface NxCacheConfig {
39
+ server: string
40
+ accessToken?: string
41
+ timeoutMs: number
42
+ retries: number
43
+ }
44
+
45
+ /** Resolve options over Nx's environment; `undefined` = not configured. */
46
+ export function resolveNxCacheConfig(
47
+ options: NxCacheOptions,
48
+ env: Record<string, string | undefined> = Bun.env,
49
+ ): NxCacheConfig | undefined {
50
+ // Nx's own off switches (`skipRemoteCache`): a CI that sets one for an
51
+ // untrusted pull request kept Nx off the shared cache and not vx.
52
+ if (env['NX_SKIP_REMOTE_CACHE'] === 'true' || env['NX_DISABLE_REMOTE_CACHE'] === 'true')
53
+ return undefined
54
+ const server = (options.server ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
55
+ if (!server) return undefined
56
+ // The URL is printed in every refusal line, so a `user:pass@` in it would
57
+ // leak to the log. Credentials go in the access token.
58
+ if (URL.canParse(server)) {
59
+ const u = new URL(server)
60
+ if (u.username !== '' || u.password !== '')
61
+ throw new Error(
62
+ 'vx/nx-cache: server carries credentials (user:pass@); pass them as the accessToken instead',
63
+ )
64
+ }
65
+ const accessToken = options.accessToken ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
66
+ const fault = accessToken ? headerValueFault(accessToken) : null
67
+ if (fault !== null)
68
+ throw new Error(
69
+ `vx/nx-cache: the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
70
+ )
71
+ const retries = options.retries ?? 1
72
+ // A NaN never reaches the bound, and the request would be resent forever.
73
+ if (!Number.isInteger(retries) || retries < 0)
74
+ throw new Error(`vx/nx-cache: retries must be a whole number ≥ 0, got ${retries}`)
75
+ // A zero timeout aborted every request as it started.
76
+ const ms = options.timeoutMs
77
+ if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0))
78
+ throw new Error(
79
+ `vx/nx-cache: timeoutMs must be a positive number of ms, got ${JSON.stringify(ms)}`,
80
+ )
81
+ return {
82
+ server,
83
+ ...(accessToken ? { accessToken } : {}),
84
+ timeoutMs: options.timeoutMs ?? 30_000,
85
+ retries,
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The seam implementation over Nx's two endpoints. The spec has no
91
+ * existence probe, so `has` (core asks it only for a `--dry` prediction;
92
+ * the prefetch pass calls `get`) is a GET whose body is cancelled before
93
+ * it answers, so no probe holds a connection. It kept the response for a
94
+ * `get` of the same hash, which no caller makes. Bodies stream: `get` returns the `fetch` Response and `put`
95
+ * sends the Blob core hands it. `put` treats `409` as success:
96
+ * the record is immutable and content-addressed, so "already there" is the
97
+ * outcome wanted. An auth failure (401/403) throws ONCE — LayeredCache
98
+ * reports it — and then turns the layer off for the rest of the process
99
+ * (a write's 403 turns off writes alone: the read-only token);
100
+ * the requests already in flight when it lands degrade in silence rather
101
+ * than repeating it.
102
+ */
103
+ export class NxRemoteCache implements RemoteCacheLayer {
104
+ private disabled = false
105
+ /**
106
+ * A `403` on a write is the spec's read-only token (a CI's pull-request
107
+ * token): uploads stop, reads go on. Read as a refused token, it turned
108
+ * the reads off too, and every dependant looked up after the first
109
+ * upload missed and rebuilt.
110
+ */
111
+ private writesRefused = false
112
+ readonly endpoint: string
113
+ constructor(
114
+ private readonly config: NxCacheConfig,
115
+ private readonly fetchImpl: typeof fetch = fetch,
116
+ private readonly wait: (ms: number) => Promise<void> = Bun.sleep,
117
+ ) {
118
+ this.endpoint = `${config.server}/v1/cache`
119
+ }
120
+
121
+ /**
122
+ * `undefined` = the token was refused and the refusal is ALREADY
123
+ * reported, so the caller degrades to its miss value in silence. Only the
124
+ * first refusal throws, and a run's calls are concurrent: six projects
125
+ * under a bad token printed five identical lines before this (2026-09-20).
126
+ */
127
+ private async request(
128
+ method: 'GET' | 'PUT',
129
+ hash: string,
130
+ body?: Blob,
131
+ ): Promise<Response | undefined> {
132
+ const headers: Record<string, string> = {}
133
+ if (this.config.accessToken) headers['Authorization'] = `Bearer ${this.config.accessToken}`
134
+ if (body !== undefined) {
135
+ headers['Content-Type'] = 'application/octet-stream'
136
+ headers['Content-Length'] = String(body.size)
137
+ } else {
138
+ // Nx's own client asks for the binary type. A gateway that keys binary
139
+ // media on Accept (AWS API Gateway) base64-encodes a `*/*` response, and
140
+ // every hit read as a corrupt artifact (nx#33092).
141
+ headers['Accept'] = 'application/octet-stream'
142
+ }
143
+ const res = await withRetry(
144
+ () =>
145
+ this.fetchImpl(`${this.config.server}/v1/cache/${hash}`, {
146
+ method,
147
+ headers,
148
+ ...(body === undefined ? {} : { body }),
149
+ signal: AbortSignal.timeout(this.config.timeoutMs),
150
+ }),
151
+ this.config.retries,
152
+ this.wait,
153
+ ).catch((err: unknown) => {
154
+ throw deadlineNamed(err, this.config.timeoutMs)
155
+ })
156
+ if (method === 'PUT' && res.status === 403) {
157
+ const first = !this.writesRefused
158
+ this.writesRefused = true
159
+ if (!first) return undefined
160
+ throw new Error(
161
+ 'HTTP 403: access forbidden (a read-only token cannot write); uploads off for this run, reads go on',
162
+ )
163
+ }
164
+ if (res.status === 401 || res.status === 403) {
165
+ const first = !this.disabled
166
+ this.disabled = true
167
+ if (!first) return undefined
168
+ throw new Error(
169
+ `HTTP ${res.status}: ${res.status === 401 ? 'missing or invalid token' : 'access forbidden (a read-only token cannot write)'}; remote cache off for this run`,
170
+ )
171
+ }
172
+ return res
173
+ }
174
+
175
+ async get(hash: string): Promise<{ body: Response; durationMs: number | undefined } | null> {
176
+ const res = await this.fetch(hash)
177
+ // The Nx wire carries no producing-task duration.
178
+ return res === null ? null : { body: res, durationMs: undefined }
179
+ }
180
+
181
+ private async fetch(hash: string): Promise<Response | null> {
182
+ if (this.disabled) return null
183
+ const res = await this.request('GET', hash)
184
+ if (res === undefined) return null
185
+ if (res.status === 404) return null
186
+ if (res.status !== 200) throw new Error(`HTTP ${res.status}`)
187
+ return res
188
+ }
189
+
190
+ async has(hash: string): Promise<boolean> {
191
+ const res = await this.fetch(hash)
192
+ if (res === null) return false
193
+ await res.body?.cancel()
194
+ return true
195
+ }
196
+
197
+ // Nx's record carries no duration; the seam's `meta` is accepted and unused.
198
+ async put(hash: string, body: Blob, _meta: { durationMs: number }): Promise<void> {
199
+ if (this.disabled || this.writesRefused) return
200
+ const res = await this.request('PUT', hash, body)
201
+ if (res === undefined) return
202
+ if (res.status === 200 || res.status === 202 || res.status === 409) return
203
+ throw new Error(`HTTP ${res.status}`)
204
+ }
205
+ }
206
+
207
+ /** Each option `NxCacheOptions` names, with its kind: derived from the type, so the two cannot drift. */
208
+ const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
209
+ server: 'string',
210
+ accessToken: 'string',
211
+ timeoutMs: 'number',
212
+ retries: 'number',
213
+ }
214
+
215
+ /**
216
+ * Declare in `vx.workspace.ts`; the local store stays the floor beneath it:
217
+ *
218
+ * ```ts
219
+ * plugins: [nxCache({ server: 'https://cache.example.com', accessToken: process.env.CACHE_TOKEN })]
220
+ * ```
221
+ *
222
+ * Declines without a server, so it is safe to leave declared.
223
+ */
224
+ export function nxCache(options: NxCacheOptions = {}): VxPlugin {
225
+ refuseUnknownOptions('nxCache()', options, NX_CACHE_KEYS)
226
+ return definePlugin(import.meta, {
227
+ cache(ctx): CacheLayer | undefined {
228
+ const config = resolveNxCacheConfig(options)
229
+ if (config === undefined) return undefined
230
+ return new LayeredCache(ctx.localCache, new NxRemoteCache(config), {
231
+ policy: ctx.policy,
232
+ onRemoteError: (err) => ctx.warn(`vx/nx-cache: ${err.message}`),
233
+ })
234
+ },
235
+ })
236
+ }
@@ -0,0 +1,458 @@
1
+ // What an `nx:run-commands` target (or a plain `command`, its shorthand)
2
+ // runs as under vx: Nx's own option handling — `normalizeOptions` in
3
+ // run-commands.impl.ts and the two runners in running-tasks.ts, read from
4
+ // nx 22.7 and 23.2, where they agree — rendered as ONE POSIX sh line.
5
+ //
6
+ // Where: Nx runs the commands from the WORKSPACE ROOT unless `options.cwd`
7
+ // says otherwise; vx runs every command from the project dir and has no
8
+ // per-task cwd, so the line starts with the `cd` that gets there —
9
+ // `cd ../.. && node ./scripts/build/build-package.ts --cwd code/lib/cli`
10
+ // is exactly what storybook's `compile` does (2026-09-11).
11
+ //
12
+ // How: Nx runs each command in a shell of its own, and `commands` run in
13
+ // PARALLEL unless `parallel: false` (the schema's default is `true`): all
14
+ // start at once, the first failure SIGTERMs the rest and fails the task,
15
+ // the task succeeds when every command has. Joined with `&&` they ran one
16
+ // after another, so a failing check never ran while the server beside it
17
+ // was up (nx#28477), and one command's `exit` or `cd` leaked into the next.
18
+ // Each command is a subshell here, and the parallel form is background
19
+ // jobs whose first failure signals the line's shell, which TERMs its own
20
+ // process group — every task vx runs is one (runner.ts spawns `detached`)
21
+ // — and returns only once every command has exited, as Nx settles every
22
+ // command before it fails the task: a TERMed server's cleanup trap runs
23
+ // to its end inside the task, not after it. (Under vx's Linux sandbox the
24
+ // runtime's wrapper shell shares that group, and the TERM ends it too.)
25
+ //
26
+ // Arguments: Nx appends every option it does not consume (`--name=value`),
27
+ // the `args` option and the arguments given on its command line to EACH
28
+ // command unless `forwardAllArgs` is false, fills `{args}` with them, and
29
+ // `{args.name}` with one of them, a forwarded one first. vx appends `vx run … -- <args>` to the
30
+ // end of the line, so a line of more than one command is a function the
31
+ // forwarded arguments reach as `"$@"` (nx#12165). An option the forwarded
32
+ // arguments name is not appended: Nx drops it, and the line decides it
33
+ // once it has them (`nx_opt`).
34
+
35
+ import path from 'node:path'
36
+ import { relPosix } from './paths.js'
37
+
38
+ interface NxCommandContext {
39
+ /** Project dir relative to the workspace root, `.` for the root. */
40
+ readonly projectRel: string
41
+ /** The Nx project name, what `{projectName}` expands to. */
42
+ readonly projectName: string
43
+ }
44
+
45
+ /** The line `nx-env --ready-when` prints once every string is seen (`READY` in nx-env.cjs). */
46
+ const NX_ENV_READY = '^nx-env: ready$'
47
+
48
+ /** A run-commands target as vx runs it. */
49
+ export interface RunCommandsLine {
50
+ readonly command: string
51
+ /** `exec.env.define`: the target's `env`, and `FORCE_COLOR` under `color`. */
52
+ readonly env: Readonly<Record<string, string>>
53
+ /** Nx's `readyWhen` as the pattern `exec.persistent.readyWhen` takes, when the target has one. */
54
+ readonly readyWhen: string | undefined
55
+ /**
56
+ * Several `readyWhen` strings, all of which Nx waits for: the line runs
57
+ * under `nx-env --ready-when`, which prints its ready line once all have
58
+ * appeared, and `readyWhen` matches that line.
59
+ */
60
+ readonly readyAll?: readonly string[]
61
+ /** `envFile` as declared: workspace-root-relative, or absolute. */
62
+ readonly envFile: string | undefined
63
+ }
64
+
65
+ /** The options run-commands consumes itself; every other one is forwarded to each command. */
66
+ const PROP_KEYS: ReadonlySet<string> = new Set([
67
+ 'command',
68
+ 'commands',
69
+ 'color',
70
+ 'no-color',
71
+ 'parallel',
72
+ 'no-parallel',
73
+ 'readyWhen',
74
+ 'cwd',
75
+ 'args',
76
+ 'envFile',
77
+ '__unparsed__',
78
+ 'env',
79
+ 'usePty',
80
+ 'streamOutput',
81
+ 'verbose',
82
+ 'forwardAllArgs',
83
+ 'tty',
84
+ ])
85
+
86
+ /** The function a line of more than one command defines, so forwarded arguments reach each. */
87
+ const FN = 'nx_run_commands'
88
+
89
+ /**
90
+ * Runs one command with the forwarded arguments appended as TEXT, as Nx
91
+ * appends them: a command ending in `done` or `fi` takes no words after it,
92
+ * so an unconditional `"$@"` broke it even with no arguments. The command
93
+ * sees the arguments as `$@` and `nx_c` as a variable; under Nx it sees
94
+ * neither, and a command that reads them means nothing there.
95
+ */
96
+ const NX_RUN = `nx_run() { nx_c=$1; shift; if [ $# -eq 0 ]; then eval "$nx_c"; else eval "$nx_c \\"\\$@\\""; fi; }`
97
+
98
+ /**
99
+ * Whether the forwarded arguments name option `$1`, as the yargs-parser
100
+ * call over `__unparsed__` reads them (no camel-case expansion, no dot
101
+ * notation): `--k`, `--k=v`, `--no-k`, and `-k` for a one-letter key.
102
+ * A bare `--` stops nothing: `nx run` hands what follows it on as well.
103
+ */
104
+ const NX_OPT = `nx_opt() { nx_k=$1; shift; for nx_a; do case $nx_a in "--$nx_k"|"--$nx_k="*|"--no-$nx_k") return 0;; esac; if [ \${#nx_k} -eq 1 ]; then case $nx_a in "-$nx_k"|"-$nx_k="*) return 0;; esac; fi; done; return 1; }`
105
+
106
+ /**
107
+ * Prints `{args.$1}` as Nx fills it: the forwarded arguments' value as the
108
+ * yargs-parser call over `__unparsed__` reads it (`--k=v`, `--k v`, `--k`
109
+ * as `true`, `--no-k` as `false`, `-k` for a one-letter key, repeats
110
+ * joined with a comma), else `$2`, the options' value. A bare `--` stops
111
+ * nothing, as under `nx run`.
112
+ */
113
+ const NX_VAL =
114
+ 'nx_val() { nx_k=$1; nx_v=$2; nx_n=; shift 2; while [ $# -gt 0 ]; do nx_x=; nx_s=; ' +
115
+ 'case $1 in "--$nx_k="*) nx_x=${1#*=}; nx_s=1;; "--$nx_k") nx_s=2;; "--no-$nx_k") nx_x=false; nx_s=1;; esac; ' +
116
+ 'if [ -z "$nx_s" ] && [ ${#nx_k} -eq 1 ]; then case $1 in "-$nx_k="*) nx_x=${1#*=}; nx_s=1;; "-$nx_k") nx_s=2;; esac; fi; ' +
117
+ 'if [ "$nx_s" = 2 ]; then if [ $# -gt 1 ] && case $2 in --*|-[!0-9]*) false;; *) true;; esac; then nx_x=$2; shift; else nx_x=true; fi; fi; ' +
118
+ 'if [ -n "$nx_s" ]; then if [ -n "$nx_n" ]; then nx_v="$nx_v,$nx_x"; else nx_v=$nx_x; nx_n=1; fi; fi; ' +
119
+ `shift; done; printf '%s' "$nx_v"; }`
120
+
121
+ /** A no-op: Nx completes a target with `commands: []` at once (nx#31345). */
122
+ const NOOP = 'true'
123
+
124
+ /** `{workspaceRoot}` etc. as Nx expands them, relative to the workspace root. */
125
+ function expand(s: string, ctx: NxCommandContext): string {
126
+ return s
127
+ .replaceAll('{workspaceRoot}', '.')
128
+ .replaceAll('{projectRoot}', ctx.projectRel)
129
+ .replaceAll('{projectName}', ctx.projectName)
130
+ }
131
+
132
+ /** Single-quoted unless the word is safe bare. */
133
+ export function shellQuote(word: string): string {
134
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`
135
+ }
136
+
137
+ /**
138
+ * Nx's `needsShellQuoting` / `isAlreadyQuoted` / `wrapArgIntoQuotesIfNeeded`
139
+ * for the one shape it appends here, `--name=value`: Nx's branch for a word
140
+ * without `=` has no caller in this file.
141
+ */
142
+ const SHELL_META = /[|&;<>()$`\\!"'*?[\]{}~#\s]/
143
+ function nxQuoteArg(arg: string): string {
144
+ const quoted = (s: string) =>
145
+ s.length >= 2 && ((s[0] === "'" && s.at(-1) === "'") || (s[0] === '"' && s.at(-1) === '"'))
146
+ const eq = arg.indexOf('=')
147
+ const value = arg.slice(eq + 1)
148
+ return SHELL_META.test(value) && !quoted(value)
149
+ ? `${arg.slice(0, eq)}="${value.replaceAll('"', '\\"')}"`
150
+ : arg
151
+ }
152
+
153
+ /**
154
+ * The `args` option as the yargs-parser call in Nx's `parseArgs` reads it,
155
+ * for `{args.name}` and for which forwarded option it overrides: `--k=v`,
156
+ * `--k v`, `--k`, `--no-k`, camel-case expansion, numbers parsed.
157
+ */
158
+ function parseArgsOption(text: string): Record<string, unknown> {
159
+ const tokens: string[] = []
160
+ for (const m of text.matchAll(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)) tokens.push(m[0])
161
+ const unquote = (s: string) => s.replace(/^(["'])(.*)\1$/s, '$2')
162
+ const value = (s: string): unknown => {
163
+ const v = unquote(s)
164
+ return /^0[^.]/.test(v) || !/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?$/i.test(v) ? v : Number(v)
165
+ }
166
+ const out: Record<string, unknown> = {}
167
+ const set = (key: string, v: unknown) => {
168
+ const names = [key, key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())]
169
+ for (const k of new Set(names)) {
170
+ const prev = out[k]
171
+ out[k] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v]
172
+ }
173
+ }
174
+ for (let i = 0; i < tokens.length; i++) {
175
+ const t = tokens[i]!
176
+ const flag = /^--?([^=]+)(?:=(.*))?$/s.exec(t)
177
+ if (flag === null) continue
178
+ const [, name, inline] = flag
179
+ if (name!.includes('.')) continue
180
+ if (inline !== undefined) set(name!, value(inline))
181
+ else if (name!.startsWith('no-')) set(name!.slice(3), false)
182
+ else if (tokens[i + 1] !== undefined && !tokens[i + 1]!.startsWith('-'))
183
+ set(name!, value(tokens[++i]!))
184
+ else set(name!, true)
185
+ }
186
+ return out
187
+ }
188
+
189
+ /** A value as Nx's template replacement coerces it; an absent one is empty. */
190
+ function templateText(v: unknown): string {
191
+ if (v === undefined) return ''
192
+ if (Array.isArray(v)) return v.join(',')
193
+ if (typeof v === 'object' && v !== null) return '[object Object]'
194
+ return String(v as string | number | boolean | null)
195
+ }
196
+
197
+ /** `cd <from the project dir to where Nx ran it> && `, or nothing when that is the project dir. */
198
+ function cdPrefix(cwd: unknown, ctx: NxCommandContext): string {
199
+ let where = '.'
200
+ if (typeof cwd === 'string' && cwd.length > 0) {
201
+ const e = expand(cwd, ctx)
202
+ if (path.posix.isAbsolute(e)) return `cd ${shellQuote(e)} && `
203
+ where = path.posix.normalize(e).replace(/\/$/, '') || '.'
204
+ }
205
+ const projectDir = ctx.projectRel === '' ? '.' : ctx.projectRel
206
+ if (where === projectDir) return ''
207
+ return `cd ${shellQuote(relPosix(projectDir, where))} && `
208
+ }
209
+
210
+ interface CommandEntry {
211
+ readonly command: string
212
+ readonly forwardAllArgs?: unknown
213
+ readonly prefix?: unknown
214
+ readonly prefixColor?: unknown
215
+ readonly color?: unknown
216
+ readonly bgColor?: unknown
217
+ }
218
+
219
+ /**
220
+ * One command after Nx's interpolation. `runtime` is where the arguments
221
+ * given after `vx run … --` go: appended, already in the text as `"$@"`
222
+ * (`{args}`), or nowhere.
223
+ */
224
+ interface Interpolated {
225
+ readonly text: string
226
+ readonly runtime: 'append' | 'inline' | 'none'
227
+ /**
228
+ * The unconsumed options as `[name, --name=value]`, for a command that
229
+ * forwards them: Nx drops one the forwarded arguments name
230
+ * (`unknownOptions` skips a key `__unparsed__` holds), so which of them
231
+ * the command gets is known only once vx run's arguments are.
232
+ */
233
+ readonly baked: readonly (readonly [string, string])[]
234
+ /** The `args` option, after the unconsumed options. */
235
+ readonly tail: string | undefined
236
+ }
237
+
238
+ /**
239
+ * The line for one run-commands target, or null when Nx itself refuses the
240
+ * options (the caller writes the placeholder; `todos` says why). `todos`
241
+ * also receives every option this line does not reproduce.
242
+ */
243
+ export function mapRunCommands(
244
+ options: Record<string, unknown>,
245
+ ctx: NxCommandContext,
246
+ todos: string[],
247
+ ): RunCommandsLine | null {
248
+ const readyWhen =
249
+ typeof options['readyWhen'] === 'string'
250
+ ? [options['readyWhen']]
251
+ : Array.isArray(options['readyWhen'])
252
+ ? options['readyWhen'].filter((s): s is string => typeof s === 'string')
253
+ : []
254
+ const single = options['command']
255
+ let entries: CommandEntry[]
256
+ let parallel: boolean
257
+ if ((typeof single === 'string' && single.length > 0) || Array.isArray(single)) {
258
+ entries = [{ command: Array.isArray(single) ? single.join(' ') : single }]
259
+ parallel = readyWhen.length > 0
260
+ } else if (Array.isArray(options['commands'])) {
261
+ const raw = options['commands'] as unknown[]
262
+ entries = raw.map((c) => (typeof c === 'string' ? { command: c } : (c as CommandEntry)))
263
+ if (entries.some((e) => typeof e?.command !== 'string')) {
264
+ todos.push(
265
+ `nx:run-commands: a command entry has no command — options: ${JSON.stringify(options)}`,
266
+ )
267
+ return null
268
+ }
269
+ parallel = options['parallel'] !== false
270
+ } else {
271
+ todos.push(`nx:run-commands target has no command — options: ${JSON.stringify(options)}`)
272
+ return null
273
+ }
274
+ if (readyWhen.length > 0 && !parallel) {
275
+ todos.push('nx:run-commands: Nx refuses `readyWhen` without `parallel: true`')
276
+ return null
277
+ }
278
+ const decorated = entries.some(
279
+ (e) =>
280
+ e.prefix !== undefined ||
281
+ e.prefixColor !== undefined ||
282
+ e.color !== undefined ||
283
+ e.bgColor !== undefined,
284
+ )
285
+ // Nx throws on decoration in a serial run; the target never ran under Nx.
286
+ if (decorated && !parallel) {
287
+ todos.push(
288
+ 'nx:run-commands: Nx refuses `prefix` / `prefixColor` / `color` / `bgColor` without `parallel: true`',
289
+ )
290
+ return null
291
+ }
292
+
293
+ const env = mapEnv(options, todos)
294
+ const envFile =
295
+ typeof options['envFile'] === 'string' && options['envFile'].length > 0
296
+ ? expand(options['envFile'], ctx)
297
+ : undefined
298
+ if (options['streamOutput'] === false) {
299
+ todos.push('nx:run-commands: `streamOutput: false` — vx shows the output per its own modes')
300
+ }
301
+ if (Array.isArray(options['__unparsed__']) && options['__unparsed__'].length > 0) {
302
+ todos.push('nx:run-commands: `__unparsed__` in the graph is not forwarded')
303
+ }
304
+ if (decorated) {
305
+ todos.push(
306
+ 'nx:run-commands: per-command `prefix` / `color` output decoration is not reproduced',
307
+ )
308
+ }
309
+ if (entries.length === 0) return { command: NOOP, env, readyWhen: undefined, envFile }
310
+
311
+ const argsOption = Array.isArray(options['args'])
312
+ ? options['args'].join(' ')
313
+ : typeof options['args'] === 'string'
314
+ ? options['args']
315
+ : undefined
316
+ const unknown = Object.entries(options).filter(([k]) => !PROP_KEYS.has(k))
317
+ const parsed: Record<string, unknown> = {
318
+ ...Object.fromEntries(unknown),
319
+ ...(argsOption ? parseArgsOption(argsOption.replace(/(^"|"$)/g, '')) : {}),
320
+ }
321
+ const baked = unknown
322
+ .filter(([k, v]) => typeof v !== 'object' && parsed[k] === v)
323
+ .map(([k, v]) => [k, expand(nxQuoteArg(`--${k}=${String(v)}`), ctx)] as const)
324
+ const forwardAll = (e: CommandEntry): boolean =>
325
+ typeof e.forwardAllArgs === 'boolean'
326
+ ? e.forwardAllArgs
327
+ : typeof options['forwardAllArgs'] === 'boolean'
328
+ ? options['forwardAllArgs']
329
+ : true
330
+
331
+ const commands: Interpolated[] = []
332
+ let named = false
333
+ for (const e of entries) {
334
+ const c = e.command
335
+ if (c.includes('{args.') && c.includes('{args}')) {
336
+ todos.push('nx:run-commands: Nx refuses a command with both `{args}` and `{args.*}`')
337
+ return null
338
+ }
339
+ let text: string
340
+ let runtime: Interpolated['runtime']
341
+ let tail: string | undefined
342
+ if (c.includes('{args.')) {
343
+ // Spliced in as text, as Nx splices it, once the arguments are known.
344
+ text =
345
+ 'eval ' +
346
+ c
347
+ .split(/\{args\.([^}]+)\}/)
348
+ .map((p, i) =>
349
+ i % 2 === 0
350
+ ? shellQuote(p)
351
+ : `"$(nx_val ${shellQuote(p)} ${shellQuote(templateText(parsed[p]))} "$@")"`,
352
+ )
353
+ .join('')
354
+ runtime = 'none'
355
+ named = true
356
+ } else if (c.includes('{args}')) {
357
+ text = c
358
+ tail = `"$@" ${argsOption ?? ''}`
359
+ runtime = 'inline'
360
+ } else if (forwardAll(e)) {
361
+ text = c
362
+ tail = argsOption
363
+ runtime = 'append'
364
+ } else {
365
+ text = c
366
+ runtime = 'none'
367
+ }
368
+ commands.push({
369
+ text: expand(text, ctx),
370
+ runtime,
371
+ baked: runtime === 'none' ? [] : baked,
372
+ tail: tail === undefined ? undefined : expand(tail, ctx),
373
+ })
374
+ }
375
+
376
+ const cd = cdPrefix(options['cwd'], ctx)
377
+ const pattern =
378
+ readyWhen.length === 0
379
+ ? undefined
380
+ : readyWhen.length === 1
381
+ ? readyWhen[0]!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
382
+ : NX_ENV_READY
383
+ const ready = readyWhen.length > 1 ? { readyAll: readyWhen } : {}
384
+ const only = commands.length === 1 ? commands[0]! : undefined
385
+ if (only !== undefined && only.runtime === 'append' && only.baked.length === 0) {
386
+ const line = only.tail === undefined ? only.text : `${only.text} ${only.tail}`
387
+ return { command: `${cd}${line}`, env, readyWhen: pattern, envFile, ...ready }
388
+ }
389
+ // A subshell per command, as Nx gives each a shell of its own; a comment
390
+ // in one must not swallow the `)` that closes it.
391
+ const pieces = commands.map((c) => {
392
+ // The unconsumed options the forwarded arguments leave, as text: each
393
+ // after a space when appended, before one where `{args}` stood.
394
+ const kept = c.baked
395
+ .map(([k, arg]) => {
396
+ const word =
397
+ c.runtime === 'inline' ? `"$nx_u"${shellQuote(`${arg} `)}` : `"$nx_u "${shellQuote(arg)}`
398
+ return `nx_opt ${shellQuote(k)} "$@" || nx_u=${word}; `
399
+ })
400
+ .join('')
401
+ if (c.runtime === 'append') {
402
+ if (kept === '') {
403
+ const text = c.tail === undefined ? c.text : `${c.text} ${c.tail}`
404
+ return `(nx_run ${shellQuote(text)} "$@")`
405
+ }
406
+ const tail = c.tail === undefined ? '' : `" "${shellQuote(c.tail)}`
407
+ return `(nx_u=; ${kept}nx_run ${shellQuote(c.text)}"$nx_u"${tail} "$@")`
408
+ }
409
+ if (c.runtime === 'inline') {
410
+ if (kept === '') {
411
+ const text = c.text.replaceAll('{args}', c.tail!)
412
+ return `(${text}${text.includes('#') ? '\n' : ''})`
413
+ }
414
+ const text = c.text
415
+ .split('{args}')
416
+ .map((p) => shellQuote(p))
417
+ .join(`"$nx_u"${shellQuote(c.tail!)}`)
418
+ return `(nx_u=; ${kept}eval ${text})`
419
+ }
420
+ return `(${c.text}${c.text.includes('#') ? '\n' : ''})`
421
+ })
422
+ // Each job's shell catches the TERM so it outlives it and the line's
423
+ // `wait` covers its command's exit (a subshell resets a caught signal,
424
+ // so the command itself still takes the TERM); the flag keeps a job TERMed
425
+ // from outside from signalling a line's shell that is already gone.
426
+ const body =
427
+ parallel && pieces.length > 1
428
+ ? `trap 'trap "" TERM USR1; kill -TERM 0; wait; exit 1' USR1; ${pieces.map((p) => `{ trap 'nx_term=1' TERM; ${p} || [ -n "$nx_term" ] || kill -USR1 $$; } &`).join(' ')} wait`
429
+ : pieces.join(' && ')
430
+ const helper =
431
+ (commands.some((c) => c.runtime === 'append') ? `${NX_RUN}; ` : '') +
432
+ (commands.some((c) => c.runtime !== 'none' && c.baked.length > 0) ? `${NX_OPT}; ` : '') +
433
+ (named ? `${NX_VAL}; ` : '')
434
+ return {
435
+ command: `${helper}${FN}() { ${body}; }; ${cd}${FN}`,
436
+ env,
437
+ readyWhen: pattern,
438
+ envFile,
439
+ ...ready,
440
+ }
441
+ }
442
+
443
+ /** The target's `env` (Nx gives it priority over every other source), and `color`'s `FORCE_COLOR`. */
444
+ function mapEnv(options: Record<string, unknown>, todos: string[]): Record<string, string> {
445
+ const env: Record<string, string> = {}
446
+ const declared = options['env']
447
+ if (typeof declared === 'object' && declared !== null && !Array.isArray(declared)) {
448
+ for (const [k, v] of Object.entries(declared)) {
449
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')
450
+ env[k] = String(v)
451
+ else todos.push(`nx:run-commands: \`env.${k}\` is not a string — not set`)
452
+ }
453
+ } else if (declared !== undefined) {
454
+ todos.push('nx:run-commands: `env` is not an object — not set')
455
+ }
456
+ if (options['color'] === true) env['FORCE_COLOR'] = 'true'
457
+ return env
458
+ }