@vzn/vx-migrate 0.0.646 → 0.0.648
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -61
- package/package.json +3 -3
- package/src/adopt.ts +53 -12
- package/src/bin.ts +21 -1
- package/src/glob-grammar.ts +82 -0
- package/src/migrate-nx.ts +7 -4
- package/src/migrate-scripts.ts +10 -102
- package/src/migrate.ts +53 -25
- package/src/nx/index.ts +19 -5
- package/src/nx/nx-inputs.ts +44 -9
- package/src/nx/nx-map.ts +36 -11
- package/src/nx/nx-outputs.ts +16 -6
- package/src/nx/nx-upstream.ts +10 -2
- package/src/nx-cache/index.ts +60 -50
- package/src/nx-exec.cjs +2 -2
- package/src/package-fs.ts +6 -1
- package/src/packages.ts +9 -7
- package/src/plugin-gaps.ts +32 -6
- package/src/remote-cache.ts +5 -1
- package/src/remote-deadline.ts +7 -1
- package/src/remote-refusal.ts +31 -0
- package/src/remote-retry.ts +9 -4
- package/src/remote-url.ts +28 -0
- package/src/script-command.ts +110 -4
- package/src/shared-outputs.ts +8 -2
- package/src/tracked-outputs.ts +1 -1
- package/src/turbo/index.ts +18 -8
- package/src/turbo/turbo-map.ts +89 -15
- package/src/turbo-cache/index.ts +205 -89
- package/src/turbo-cache/oidc.ts +25 -14
- package/src/turbo-cache/scope.ts +5 -3
- package/src/vite-task/vite-task-map.ts +11 -6
- package/src/workspace-plugins.ts +5 -2
package/src/nx-cache/index.ts
CHANGED
|
@@ -17,9 +17,12 @@ import {
|
|
|
17
17
|
type RemoteOpenContext,
|
|
18
18
|
type VxPlugin,
|
|
19
19
|
refuseUnknownOptions,
|
|
20
|
+
UserError,
|
|
20
21
|
type PluginOptionKinds,
|
|
21
22
|
} from '@vzn/vx'
|
|
22
|
-
import { deadline } from '../remote-deadline.js'
|
|
23
|
+
import { deadline, MAX_TIMER_MS } from '../remote-deadline.js'
|
|
24
|
+
import { checkRemoteUrl } from '../remote-url.js'
|
|
25
|
+
import { refusal } from '../remote-refusal.js'
|
|
23
26
|
import { OutageBreaker } from '../remote-breaker.js'
|
|
24
27
|
import { Cooldown, withRetry } from '../remote-retry.js'
|
|
25
28
|
import {
|
|
@@ -31,13 +34,13 @@ import {
|
|
|
31
34
|
|
|
32
35
|
export interface NxCacheOptions {
|
|
33
36
|
/** Base URL of the cache server, or `NX_SELF_HOSTED_REMOTE_CACHE_SERVER`. */
|
|
34
|
-
|
|
37
|
+
url?: string
|
|
35
38
|
/**
|
|
36
39
|
* Bearer token, or `NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN`, then the OS
|
|
37
40
|
* keychain. `''` (option or variable) is a server that runs open: no
|
|
38
41
|
* token, nothing asked.
|
|
39
42
|
*/
|
|
40
|
-
|
|
43
|
+
token?: string
|
|
41
44
|
/** Per-request deadline (default 30 s). */
|
|
42
45
|
timeoutMs?: number
|
|
43
46
|
/** Resends of a request answered 429 / 5xx or never connected (default 1); 0 turns them off. */
|
|
@@ -45,9 +48,9 @@ export interface NxCacheOptions {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export interface NxCacheConfig {
|
|
48
|
-
|
|
51
|
+
url: string
|
|
49
52
|
/** Undefined: the layer's `open` asks the OS keychain; `''`: an open server. */
|
|
50
|
-
|
|
53
|
+
token?: string
|
|
51
54
|
timeoutMs: number
|
|
52
55
|
retries: number
|
|
53
56
|
}
|
|
@@ -57,36 +60,22 @@ export function resolveNxCacheConfig(
|
|
|
57
60
|
options: NxCacheOptions,
|
|
58
61
|
env: Record<string, string | undefined> = Bun.env,
|
|
59
62
|
): NxCacheConfig | undefined {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
'vx/nx-cache: server carries credentials (user:pass@); pass them as the accessToken instead',
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
const accessToken = options.accessToken ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
|
|
72
|
-
const fault = accessToken === undefined ? null : headerValueFault(accessToken)
|
|
63
|
+
// Named by its source: the env's was refused as a `url` option.
|
|
64
|
+
const from =
|
|
65
|
+
options.url !== undefined ? 'nxCache() option "url"' : 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER'
|
|
66
|
+
const url = (options.url ?? env['NX_SELF_HOSTED_REMOTE_CACHE_SERVER'])?.replace(/\/+$/, '')
|
|
67
|
+
if (!url) return undefined
|
|
68
|
+
checkRemoteUrl(url, from)
|
|
69
|
+
const token = options.token ?? env['NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN']
|
|
70
|
+
const fault = token === undefined ? null : headerValueFault(token)
|
|
73
71
|
if (fault !== null)
|
|
74
72
|
throw new Error(
|
|
75
|
-
`
|
|
73
|
+
`nxCache(): the access token holds ${fault}, which no HTTP header can carry — check the secret (it is not printed)`,
|
|
76
74
|
)
|
|
77
75
|
const retries = options.retries ?? 1
|
|
78
|
-
// A NaN never reaches the bound, and the request would be resent forever.
|
|
79
|
-
if (!Number.isInteger(retries) || retries < 0)
|
|
80
|
-
throw new Error(`vx/nx-cache: retries must be a whole number ≥ 0, got ${retries}`)
|
|
81
|
-
// A zero timeout aborted every request as it started.
|
|
82
|
-
const ms = options.timeoutMs
|
|
83
|
-
if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0))
|
|
84
|
-
throw new Error(
|
|
85
|
-
`vx/nx-cache: timeoutMs must be a positive number of ms, got ${JSON.stringify(ms)}`,
|
|
86
|
-
)
|
|
87
76
|
return {
|
|
88
|
-
|
|
89
|
-
...(
|
|
77
|
+
url,
|
|
78
|
+
...(token !== undefined ? { token } : {}),
|
|
90
79
|
timeoutMs: options.timeoutMs ?? 30_000,
|
|
91
80
|
retries,
|
|
92
81
|
}
|
|
@@ -98,7 +87,7 @@ export function resolveNxCacheConfig(
|
|
|
98
87
|
* the prefetch pass calls `get`) is a GET whose body is cancelled before
|
|
99
88
|
* it answers, so no probe holds a connection. It kept the response for a
|
|
100
89
|
* `get` of the same hash, which no caller makes. Bodies stream: `get` returns the `fetch` Response and `put`
|
|
101
|
-
* sends the Blob core hands it. `put` treats `409` as success:
|
|
90
|
+
* sends the Blob core hands it. `put` treats any 2xx and `409` as success:
|
|
102
91
|
* the record is immutable and content-addressed, so "already there" is the
|
|
103
92
|
* outcome wanted. An auth failure (401/403) throws ONCE — core
|
|
104
93
|
* reports it — and then turns the layer off for the rest of the process
|
|
@@ -129,11 +118,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
|
|
|
129
118
|
private readonly fetchImpl: typeof fetch = fetch,
|
|
130
119
|
private readonly wait: (ms: number) => Promise<void> = Bun.sleep,
|
|
131
120
|
) {
|
|
132
|
-
this.endpoint = `${config.
|
|
133
|
-
this.token = config.
|
|
121
|
+
this.endpoint = `${config.url}/v1/cache`
|
|
122
|
+
this.token = config.token
|
|
134
123
|
this.keychainRemote = {
|
|
135
124
|
key: this.endpoint,
|
|
136
|
-
label: remoteLabel(config.
|
|
125
|
+
label: remoteLabel(config.url, undefined),
|
|
137
126
|
env: 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN',
|
|
138
127
|
}
|
|
139
128
|
}
|
|
@@ -171,7 +160,7 @@ export class NxRemoteCache implements RemoteCacheLayer {
|
|
|
171
160
|
() =>
|
|
172
161
|
withRetry(
|
|
173
162
|
() =>
|
|
174
|
-
this.fetchImpl(`${this.config.
|
|
163
|
+
this.fetchImpl(`${this.config.url}/v1/cache/${hash}`, {
|
|
175
164
|
method,
|
|
176
165
|
headers,
|
|
177
166
|
...(body === undefined ? {} : { body }),
|
|
@@ -187,23 +176,25 @@ export class NxRemoteCache implements RemoteCacheLayer {
|
|
|
187
176
|
const first = !this.writesRefused
|
|
188
177
|
this.writesRefused = true
|
|
189
178
|
if (!first) return undefined
|
|
179
|
+
// The status and the server's own words, as turboCache() says them.
|
|
190
180
|
throw new Error(
|
|
191
|
-
|
|
181
|
+
`${(await refusal(res)).message}: access forbidden (a read-only token cannot write); uploads off for this run, reads go on`,
|
|
192
182
|
)
|
|
193
183
|
}
|
|
194
184
|
if (res.status === 401 || res.status === 403) {
|
|
195
185
|
const first = !this.disabled
|
|
196
186
|
this.disabled = true
|
|
197
187
|
if (!first) return undefined
|
|
188
|
+
const said = (await refusal(res)).message
|
|
198
189
|
const why =
|
|
199
190
|
res.status === 401
|
|
200
191
|
? 'missing or invalid token'
|
|
201
192
|
: 'access forbidden (the token may not read)'
|
|
202
193
|
if (this.forget === undefined)
|
|
203
|
-
throw new Error(
|
|
194
|
+
throw new Error(`${said}: ${why}; remote cache off for this run`)
|
|
204
195
|
await this.forget()
|
|
205
196
|
throw new Error(
|
|
206
|
-
|
|
197
|
+
`${said}: ${why}; removed it from the OS keychain, so the next run asks again; remote cache off for this run`,
|
|
207
198
|
)
|
|
208
199
|
}
|
|
209
200
|
return res
|
|
@@ -220,9 +211,11 @@ export class NxRemoteCache implements RemoteCacheLayer {
|
|
|
220
211
|
const res = await this.request('GET', hash)
|
|
221
212
|
if (res === undefined) return null
|
|
222
213
|
if (res.status !== 200) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
214
|
+
if (res.status === 404) {
|
|
215
|
+
await res.body?.cancel()
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
throw await refusal(res)
|
|
226
219
|
}
|
|
227
220
|
return res
|
|
228
221
|
}
|
|
@@ -236,19 +229,21 @@ export class NxRemoteCache implements RemoteCacheLayer {
|
|
|
236
229
|
}
|
|
237
230
|
|
|
238
231
|
// Nx's record carries no duration.
|
|
239
|
-
async put(hash: string, body: Blob): Promise<void> {
|
|
240
|
-
|
|
232
|
+
async put(hash: string, body: Blob): Promise<void | false> {
|
|
233
|
+
// Nothing sent, so no upload is counted.
|
|
234
|
+
if (this.disabled || this.writesRefused) return false
|
|
241
235
|
const res = await this.request('PUT', hash, body)
|
|
242
|
-
if (res === undefined) return
|
|
243
|
-
|
|
244
|
-
|
|
236
|
+
if (res === undefined) return false
|
|
237
|
+
// The spec answers 200; a store behind it may say 201 or 204.
|
|
238
|
+
if (res.ok || res.status === 409) return
|
|
239
|
+
throw await refusal(res)
|
|
245
240
|
}
|
|
246
241
|
}
|
|
247
242
|
|
|
248
243
|
/** Each option `NxCacheOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
249
244
|
const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
|
|
250
|
-
|
|
251
|
-
|
|
245
|
+
url: 'string',
|
|
246
|
+
token: 'string',
|
|
252
247
|
timeoutMs: 'number',
|
|
253
248
|
retries: 'number',
|
|
254
249
|
}
|
|
@@ -257,13 +252,28 @@ const NX_CACHE_KEYS: PluginOptionKinds<NxCacheOptions> = {
|
|
|
257
252
|
* Declare in `.vx/workspace.ts`; the local store stays the floor beneath it:
|
|
258
253
|
*
|
|
259
254
|
* ```ts
|
|
260
|
-
* plugins: [nxCache({
|
|
255
|
+
* plugins: [nxCache({ url: 'https://cache.example.com', token: process.env.CACHE_TOKEN })]
|
|
261
256
|
* ```
|
|
262
257
|
*
|
|
263
|
-
* Declines without a
|
|
258
|
+
* Declines without a url, so it is safe to leave declared.
|
|
264
259
|
*/
|
|
265
260
|
export function nxCache(options: NxCacheOptions = {}): VxPlugin {
|
|
266
261
|
refuseUnknownOptions('nxCache()', options, NX_CACHE_KEYS)
|
|
262
|
+
// At the call: in the `cache` hook a laptop with no server named passed
|
|
263
|
+
// what failed the first CI run. A NaN never reaches the bound, and the
|
|
264
|
+
// request would be resent forever; a zero timeout, or one past the longest
|
|
265
|
+
// timer, aborted every request as it started.
|
|
266
|
+
const { retries, timeoutMs: ms } = options
|
|
267
|
+
if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
|
|
268
|
+
throw new UserError(`nxCache() option "retries" must be a whole number ≥ 0, got ${retries}`)
|
|
269
|
+
if (ms !== undefined && !(ms > 0 && ms <= MAX_TIMER_MS))
|
|
270
|
+
throw new UserError(
|
|
271
|
+
`nxCache() option "timeoutMs" must be a positive number of ms up to ${MAX_TIMER_MS}, got ${ms}`,
|
|
272
|
+
)
|
|
273
|
+
// At the call too, as vxCache() checks its url: in the hook alone, a verb
|
|
274
|
+
// that opens no cache (`vx show`) took a url every run then failed on.
|
|
275
|
+
const url = options.url?.replace(/\/+$/, '')
|
|
276
|
+
if (url) checkRemoteUrl(url, 'nxCache() option "url"')
|
|
267
277
|
return definePlugin(import.meta, {
|
|
268
278
|
cache(): RemoteCacheLayer | undefined {
|
|
269
279
|
const config = resolveNxCacheConfig(options)
|
package/src/nx-exec.cjs
CHANGED
|
@@ -133,7 +133,7 @@ async function loadGraph(nx, project) {
|
|
|
133
133
|
}
|
|
134
134
|
// A cache written before the project existed (one added since Nx last
|
|
135
135
|
// ran) is stale: \`nx run\` computes the graph and finds it, so compute.
|
|
136
|
-
if (graph === null || graph.nodes
|
|
136
|
+
if (graph === null || !Object.hasOwn(graph.nodes, project)) {
|
|
137
137
|
graph = unwrapGraph(await pg.createProjectGraphAsync({ exitOnError: false }))
|
|
138
138
|
}
|
|
139
139
|
if (graph === null) throw new Error('nx produced a project graph with no nodes')
|
|
@@ -168,7 +168,7 @@ async function main(argv) {
|
|
|
168
168
|
loadTaskEnv(nx, process.env, args.dotenv, undefined)
|
|
169
169
|
|
|
170
170
|
const { graph, pg } = await loadGraph(nx, args.project)
|
|
171
|
-
const node = graph.nodes[args.project]
|
|
171
|
+
const node = Object.hasOwn(graph.nodes, args.project) ? graph.nodes[args.project] : undefined
|
|
172
172
|
if (node === undefined) {
|
|
173
173
|
process.stderr.write(
|
|
174
174
|
`nx-exec: no project ${JSON.stringify(args.project)} in the Nx project graph\n`,
|
package/src/package-fs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What packages.ts needs of the file system and of globs, as core has
|
|
2
2
|
// them: core's copies are not on the façade, and these left core with the
|
|
3
|
-
// package-manager discovery (
|
|
3
|
+
// package-manager discovery (2026-10-14).
|
|
4
4
|
|
|
5
5
|
import { readlinkSync, realpathSync } from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
@@ -123,6 +123,11 @@ export async function gitTop(dir: string): Promise<string | null> {
|
|
|
123
123
|
if (await file.exists()) {
|
|
124
124
|
const gitdir = await file.text().catch(() => '')
|
|
125
125
|
if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
|
|
126
|
+
// A submodule's own worktree points there too, and is a top-level:
|
|
127
|
+
// git writes `commondir` in a worktree's gitdir alone.
|
|
128
|
+
const to = gitdir.replace(/^gitdir:\s*/, '').trim()
|
|
129
|
+
if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
|
|
130
|
+
return d || '/'
|
|
126
131
|
}
|
|
127
132
|
if (d === '') return null
|
|
128
133
|
}
|
package/src/packages.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Package-manager workspaces: the members `package.json#workspaces` or
|
|
2
2
|
// `pnpm-workspace.yaml` list, and the root a migration writes
|
|
3
|
-
// `.vx/workspace.ts` at. Core reads neither (
|
|
3
|
+
// `.vx/workspace.ts` at. Core reads neither (2026-10-14): a project is a
|
|
4
4
|
// directory holding a vx.config.
|
|
5
5
|
import { constants, existsSync, type Dirent } from 'node:fs'
|
|
6
6
|
import { access, readdir, realpath, stat } from 'node:fs/promises'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import { PROJECT_CONFIG_FILENAMES, UserError } from '@vzn/vx'
|
|
8
|
+
import { PROJECT_CONFIG_FILENAMES, ui, UserError } from '@vzn/vx'
|
|
9
9
|
import {
|
|
10
10
|
BUN_GLOB_WILDCARDS,
|
|
11
11
|
EXTGLOB,
|
|
@@ -75,7 +75,7 @@ const decoder = new TextDecoder()
|
|
|
75
75
|
* Inside git the walk never goes above the git top-level ({@link gitTop}),
|
|
76
76
|
* so a manifest above the checkout is never even read: a `package.json`
|
|
77
77
|
* planted in a shared directory above it claimed it, and its workspace
|
|
78
|
-
* config ran as you (
|
|
78
|
+
* config ran as you (2026-10-13: "find the root based on git").
|
|
79
79
|
*
|
|
80
80
|
* Outside git the walk stops above `start` at a manifest another user
|
|
81
81
|
* owns (root aside), the same case without a top-level to stop at
|
|
@@ -641,8 +641,9 @@ async function readManifest(root: string, dir: string, file: string): Promise<st
|
|
|
641
641
|
() => false,
|
|
642
642
|
)
|
|
643
643
|
if (searchable) unreadable(err, file)
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
ui.say(
|
|
645
|
+
'warn',
|
|
646
|
+
`vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it`,
|
|
646
647
|
)
|
|
647
648
|
return null
|
|
648
649
|
}
|
|
@@ -768,8 +769,9 @@ export async function discoverPackages(
|
|
|
768
769
|
shared.sort()
|
|
769
770
|
const shown =
|
|
770
771
|
shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
|
|
771
|
-
|
|
772
|
-
|
|
772
|
+
ui.say(
|
|
773
|
+
'warn',
|
|
774
|
+
`vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}`,
|
|
773
775
|
)
|
|
774
776
|
}
|
|
775
777
|
|
package/src/plugin-gaps.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// What a mapping could not say, reported once by `vx lock`, the pass that
|
|
2
|
-
// checks configs (a run checks nothing,
|
|
2
|
+
// checks configs (a run checks nothing, 2026-10-15). What this machine
|
|
3
3
|
// lacks to run the mapping (a bin, a graph export) is said on runs too. One line per DISTINCT gap, not one per task: n8n has a
|
|
4
4
|
// `dev` and a `watch` in most of its 84 packages, and astro's `build`
|
|
5
5
|
// carries the same `!vendor/**` output in every package — the per-task
|
|
6
6
|
// form was a hundred identical lines before the first frame (2026-09-11).
|
|
7
7
|
// Shared by `turbo()` and `nx()` since the second one existed.
|
|
8
8
|
|
|
9
|
+
import { taskNameProblem } from '@vzn/vx'
|
|
10
|
+
|
|
9
11
|
/** What both mappers' projects have in common: a name and tasks with todos. */
|
|
10
12
|
interface MappedProject {
|
|
11
13
|
readonly name: string
|
|
@@ -39,25 +41,49 @@ export function collectGaps(
|
|
|
39
41
|
return { notes, todos, live }
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* At `vx lock` every gap; on a run only the live notes. Each says `[vx]`
|
|
46
|
+
* first, as every plugin warning does, then the package's short name.
|
|
47
|
+
*/
|
|
43
48
|
export function warnGaps(
|
|
44
49
|
warn: (line: string) => void,
|
|
45
50
|
pluginName: string,
|
|
46
51
|
gaps: Gaps,
|
|
47
52
|
lock: boolean,
|
|
48
53
|
): void {
|
|
49
|
-
|
|
54
|
+
const tag = `[vx] ${pluginName.replace(/^@vzn\//, '')}:`
|
|
55
|
+
for (const note of gaps.live) warn(`${tag} ${note}`)
|
|
50
56
|
if (!lock) return
|
|
51
|
-
for (const note of gaps.notes) warn(
|
|
57
|
+
for (const note of gaps.notes) warn(`${tag} ${note}`)
|
|
52
58
|
for (const [todo, ids] of gaps.todos) {
|
|
53
59
|
if (ids.length === 1) {
|
|
54
|
-
warn(
|
|
60
|
+
warn(`${tag} ${ids[0]}: ${todo}`)
|
|
55
61
|
continue
|
|
56
62
|
}
|
|
57
63
|
const names = [...new Set(ids.map((id) => id.slice(id.indexOf('#') + 1)))]
|
|
58
64
|
const packages = new Set(ids.map((id) => id.slice(0, id.indexOf('#'))))
|
|
59
65
|
warn(
|
|
60
|
-
|
|
66
|
+
`${tag} ${ids.length} tasks (${names.join(', ')} across ${packages.size} package${packages.size === 1 ? '' : 's'}): ${todo}`,
|
|
61
67
|
)
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A task whose name the schema refuses (`lint#fix`, `^up`) becomes a TODO,
|
|
73
|
+
* as the scripts migrator does (item 1033): written as a task, core
|
|
74
|
+
* refused the whole config on the next command.
|
|
75
|
+
*/
|
|
76
|
+
export function refuseTaskNames(
|
|
77
|
+
projects: readonly { tasks: { name: string; todos: string[]; task: object | null }[] }[],
|
|
78
|
+
what: string,
|
|
79
|
+
): void {
|
|
80
|
+
for (const p of projects)
|
|
81
|
+
for (const t of p.tasks) {
|
|
82
|
+
const why = t.task === null ? null : taskNameProblem(t.name)
|
|
83
|
+
if (why === null) continue
|
|
84
|
+
t.task = null
|
|
85
|
+
t.todos = [
|
|
86
|
+
`${what} ${JSON.stringify(t.name)} not migrated: its name ${why} — rename it, or add it by hand under another name`,
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/remote-cache.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The remote cache a Turbo or Nx repo already uses, as the repo shows it,
|
|
2
2
|
// so the workspace file a migration writes keeps sharing it: native or
|
|
3
|
-
// keep, from `vx init` or `bunx @vzn/vx-migrate` (
|
|
3
|
+
// keep, from `vx init` or `bunx @vzn/vx-migrate` (2026-10-14). Nx
|
|
4
4
|
// Cloud's wire is Nx's own, so it is named, never declared.
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
@@ -28,6 +28,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
28
28
|
rc = (
|
|
29
29
|
Bun.JSONC.parse(readFileSync(path.join(root, f), 'utf8')) as { remoteCache?: unknown }
|
|
30
30
|
)?.remoteCache
|
|
31
|
+
// quiet: a local config file: none read is none declared
|
|
31
32
|
} catch {
|
|
32
33
|
continue
|
|
33
34
|
}
|
|
@@ -42,6 +43,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
42
43
|
let linked: unknown
|
|
43
44
|
try {
|
|
44
45
|
linked = JSON.parse(readFileSync(path.join(root, '.turbo', 'config.json'), 'utf8'))
|
|
46
|
+
// quiet: a local config file: none read is none linked
|
|
45
47
|
} catch {}
|
|
46
48
|
if (
|
|
47
49
|
typeof linked === 'object' &&
|
|
@@ -63,6 +65,7 @@ export function remoteCacheSignal(root: string, runner: 'turbo' | 'nx'): string
|
|
|
63
65
|
let text: string
|
|
64
66
|
try {
|
|
65
67
|
text = readFileSync(path.join(root, f), 'utf8')
|
|
68
|
+
// quiet: a local config file: none read is none declared
|
|
66
69
|
} catch {
|
|
67
70
|
continue
|
|
68
71
|
}
|
|
@@ -84,6 +87,7 @@ function usesNxCloud(root: string): boolean {
|
|
|
84
87
|
} | null
|
|
85
88
|
try {
|
|
86
89
|
json = Bun.JSONC.parse(readFileSync(path.join(root, 'nx.json'), 'utf8')) as typeof json
|
|
90
|
+
// quiet: a local config file: none read is none declared
|
|
87
91
|
} catch {
|
|
88
92
|
return false
|
|
89
93
|
}
|
package/src/remote-deadline.ts
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export function deadline(timeoutMs: number): AbortSignal {
|
|
13
13
|
const controller = new AbortController()
|
|
14
|
+
// A timer past 2^31-1 ms fires after 1 ms: a 30-day
|
|
15
|
+
// TURBO_REMOTE_CACHE_TIMEOUT aborted every request as it started. Turbo
|
|
16
|
+
// defines that value, so it is held here; an option is refused at the call.
|
|
14
17
|
setTimeout(
|
|
15
18
|
() => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
|
|
16
|
-
timeoutMs,
|
|
19
|
+
Math.min(timeoutMs, MAX_TIMER_MS),
|
|
17
20
|
).unref()
|
|
18
21
|
return controller.signal
|
|
19
22
|
}
|
|
23
|
+
|
|
24
|
+
/** The longest delay a timer holds. */
|
|
25
|
+
export const MAX_TIMER_MS = 2 ** 31 - 1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** How long the body's first chunk is waited for: a refusal is not held on a body that never comes. */
|
|
2
|
+
const BODY_WAIT_MS = 1_000
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A remote's refusal, named by its status and its own words: "HTTP 500"
|
|
6
|
+
* alone said nothing, and a batch query Vercel refused with 400 "Too many
|
|
7
|
+
* hashes. Limit is 100." read as no answer and was never seen (main
|
|
8
|
+
* f71b377, 2026-10-23). One line of the body, bounded: it is remote text.
|
|
9
|
+
* Shared by `turboCache()` and `nxCache()`.
|
|
10
|
+
*/
|
|
11
|
+
export async function refusal(res: Response, what = ''): Promise<Error> {
|
|
12
|
+
let said = ''
|
|
13
|
+
try {
|
|
14
|
+
const reader = res.body?.getReader()
|
|
15
|
+
if (reader !== undefined) {
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
17
|
+
const first = await Promise.race([
|
|
18
|
+
reader.read(),
|
|
19
|
+
new Promise<undefined>((r) => {
|
|
20
|
+
timer = setTimeout(() => r(undefined), BODY_WAIT_MS)
|
|
21
|
+
}),
|
|
22
|
+
]).finally(() => clearTimeout(timer))
|
|
23
|
+
// quiet: read no further; a cancel that fails has nothing left to free
|
|
24
|
+
void reader.cancel().catch(() => undefined)
|
|
25
|
+
said = new TextDecoder().decode(first?.value?.subarray(0, 512)).replace(/\s+/g, ' ').trim()
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// quiet: the status alone still names the refusal
|
|
29
|
+
}
|
|
30
|
+
return new Error(`${what}HTTP ${res.status}${said === '' ? '' : `: ${said.slice(0, 200)}`}`)
|
|
31
|
+
}
|
package/src/remote-retry.ts
CHANGED
|
@@ -24,7 +24,8 @@ export class Cooldown {
|
|
|
24
24
|
* One request with Turbo's retry rule (`turborepo-api-client/src/retry.rs`):
|
|
25
25
|
* a 5xx other than 501, or a request that never connected, is sent again
|
|
26
26
|
* after 2 s, `retries` times. A 429 opens the remote's `cooldown` for its
|
|
27
|
-
* `Retry-After` (capped at 10 s a wait, 2 s without one
|
|
27
|
+
* `Retry-After` (capped at 10 s a wait, 2 s without one or for a `0`
|
|
28
|
+
* after the first), and the request
|
|
28
29
|
* is resent once it ends, as often as fits in 20 s from its first 429: a
|
|
29
30
|
* limit longer than one capped wait lost every hit before (Growth's
|
|
30
31
|
* parity run, 2026-10-16). A spent deadline is not retried, unlike
|
|
@@ -55,8 +56,12 @@ export async function withRetry(
|
|
|
55
56
|
continue
|
|
56
57
|
}
|
|
57
58
|
if (res.status === 429) {
|
|
59
|
+
// `Retry-After: 0` resends at once, but only once in a row: each
|
|
60
|
+
// answered so sent again at once, hundreds a second for 20 s.
|
|
61
|
+
const asked = retryAfterMs(res)
|
|
62
|
+
const delay =
|
|
63
|
+
asked === undefined || (asked === 0 && firstLimit !== undefined) ? BACKOFF_MS : asked
|
|
58
64
|
firstLimit ??= now()
|
|
59
|
-
const delay = retryAfterMs(res) ?? BACKOFF_MS
|
|
60
65
|
if (retries === 0 || now() + delay - firstLimit > RATE_LIMIT_BUDGET_MS) return res
|
|
61
66
|
cooldown.extend(delay)
|
|
62
67
|
await res.body?.cancel()
|
|
@@ -77,10 +82,10 @@ function retryable(status: number): boolean {
|
|
|
77
82
|
return status >= 500 && status !== 501
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
/** A refused port or an unresolved host, as Bun's `fetch` codes them (
|
|
85
|
+
/** A refused port or an unresolved host, as Bun's `fetch` codes them (1.4.4 took errno names). */
|
|
81
86
|
function neverConnected(err: unknown): boolean {
|
|
82
87
|
const code = (err as { code?: unknown } | null)?.code
|
|
83
|
-
return code === '
|
|
88
|
+
return code === 'ECONNREFUSED' || code === 'ENOTFOUND'
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/** `Retry-After` as seconds or an HTTP date, capped as Turbo caps it. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { UserError } from '@vzn/vx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A remote cache's URL, refused as `from` names it (an option, a variable,
|
|
5
|
+
* a config file). Shared by `turboCache()` and `nxCache()`, at the call for
|
|
6
|
+
* the option and when the cache opens for every source.
|
|
7
|
+
*
|
|
8
|
+
* Plain http:// to any host is taken, where vxCache() takes it only to
|
|
9
|
+
* loopback: Turbo and Nx both send their token over it, and their
|
|
10
|
+
* self-hosted servers document such URLs (`TURBO_API=http://cache:3000`
|
|
11
|
+
* beside a CI service container; nx-cache-server's
|
|
12
|
+
* `NX_SELF_HOSTED_REMOTE_CACHE_SERVER=http://your-server:3000`). These
|
|
13
|
+
* plugins run such a repo unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function checkRemoteUrl(url: string, from: string): void {
|
|
16
|
+
// With no scheme every request failed on its own, as a bare "Invalid URL".
|
|
17
|
+
if (!/^https?:\/\//.test(url))
|
|
18
|
+
throw new UserError(`${from} must be an http(s) URL, got ${JSON.stringify(url)}`)
|
|
19
|
+
// The URL is printed in every refusal line, so a `user:pass@` in it would
|
|
20
|
+
// leak to the log. Credentials go in the token.
|
|
21
|
+
if (URL.canParse(url)) {
|
|
22
|
+
const u = new URL(url)
|
|
23
|
+
if (u.username !== '' || u.password !== '')
|
|
24
|
+
throw new UserError(
|
|
25
|
+
`${from} carries credentials (user:pass@); pass them as the token instead`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|