@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,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
|
+
}
|