@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,291 @@
|
|
|
1
|
+
// `@vzn/vx-migrate` — a Turbo repo under vx with nothing written.
|
|
2
|
+
//
|
|
3
|
+
// Fills the `project` stage: every package the workspace discovers is given
|
|
4
|
+
// the tasks turbo.json + its package.json scripts define, mapped by the same
|
|
5
|
+
// mapper `vx migrate --from turbo` renders files from — so what runs here is
|
|
6
|
+
// what a migration would have written, minus the file. Turbo's global fields
|
|
7
|
+
// are inlined into each task instead of imported from a generated preset.
|
|
8
|
+
// A task the package's own vx.config already declares wins; the plugin never
|
|
9
|
+
// overwrites a user's hand.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { availableParallelism } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import type { ProjectMeta, VxPlugin } from '@vzn/vx'
|
|
15
|
+
import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
|
|
16
|
+
import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
|
|
17
|
+
import type { AdoptionMapping } from '../mapping-cache.js'
|
|
18
|
+
import { collectGaps } from '../plugin-gaps.js'
|
|
19
|
+
import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
|
|
20
|
+
import { relPosix } from '../paths.js'
|
|
21
|
+
import {
|
|
22
|
+
envNamesThatMap,
|
|
23
|
+
mapTurboWorkspace,
|
|
24
|
+
microfrontendsConfigs,
|
|
25
|
+
rootTaskProject,
|
|
26
|
+
turboConfigFile,
|
|
27
|
+
type TurboMappedProject,
|
|
28
|
+
} from './turbo-map.js'
|
|
29
|
+
|
|
30
|
+
/** The note every persistent task carries; like every gap, reported once per run for all its tasks. */
|
|
31
|
+
const PERSISTENT_NOTE =
|
|
32
|
+
'persistent in turbo.json — vx runs them as persistent tasks that are ready on spawn; ' +
|
|
33
|
+
'add `exec.persistent.readyWhen` in a vx.config to gate dependents on their output'
|
|
34
|
+
|
|
35
|
+
export interface TurboPluginOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Where turbo.json lives. Defaults to the workspace root; a repo whose
|
|
38
|
+
* turbo.json sits elsewhere names the directory here.
|
|
39
|
+
*/
|
|
40
|
+
readonly root?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Each option `TurboPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
44
|
+
const TURBO_PLUGIN_KEYS: PluginOptionKinds<TurboPluginOptions> = {
|
|
45
|
+
root: 'string',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The plugin: the adoption skeleton over `mapTurboWorkspace`, one mapping
|
|
50
|
+
* per run (`adoption-plugin.ts` says why).
|
|
51
|
+
*/
|
|
52
|
+
export function turbo(options: TurboPluginOptions = {}): VxPlugin {
|
|
53
|
+
refuseUnknownOptions('turbo()', options, TURBO_PLUGIN_KEYS)
|
|
54
|
+
return adoptionPlugin(
|
|
55
|
+
import.meta,
|
|
56
|
+
(ctx) => run(options.root ?? ctx.workspaceRoot, ctx.projects),
|
|
57
|
+
// At the workspace root only: a claim is a root name (a `root` elsewhere
|
|
58
|
+
// is not claimed; its edits select as any unowned file does).
|
|
59
|
+
options.root === undefined ? ['turbo.json', 'turbo.jsonc'] : [],
|
|
60
|
+
{
|
|
61
|
+
async config(workspace, ctx) {
|
|
62
|
+
const keys = await workspaceKeys(options.root ?? ctx.workspaceRoot)
|
|
63
|
+
if (workspace.concurrency === undefined && keys.concurrency !== undefined)
|
|
64
|
+
workspace.concurrency = keys.concurrency
|
|
65
|
+
if (workspace.cacheRetention === undefined && keys.cacheRetention !== undefined)
|
|
66
|
+
workspace.cacheRetention = keys.cacheRetention
|
|
67
|
+
// `turbo run --affected` compares with TURBO_SCM_BASE when set, and
|
|
68
|
+
// on GitHub Actions with what the event names.
|
|
69
|
+
const base =
|
|
70
|
+
Bun.env['TURBO_SCM_BASE']?.trim() ||
|
|
71
|
+
githubActionsBase(options.root ?? ctx.workspaceRoot, keys.remoteBaseFallback === true)
|
|
72
|
+
if (workspace.affectedBase === undefined && base) workspace.affectedBase = base
|
|
73
|
+
},
|
|
74
|
+
// `//#task` keys run on the root, as Turbo runs them: named here, the
|
|
75
|
+
// root is a project with no vx.config written (a create-turbo repo's
|
|
76
|
+
// `//#format`). A package that holds the root's name keeps it, and
|
|
77
|
+
// the mapping's note says so.
|
|
78
|
+
async discover(ctx) {
|
|
79
|
+
if (options.root !== undefined) return []
|
|
80
|
+
const named = await rootTaskProject(ctx.workspaceRoot)
|
|
81
|
+
if (named === null) return []
|
|
82
|
+
const taken = ctx.projects.some(
|
|
83
|
+
(m) => m.name === named.name && path.resolve(m.dir) !== path.resolve(named.dir),
|
|
84
|
+
)
|
|
85
|
+
return taken ? [] : [named]
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* turbo.json's workspace keys vx has a home for, top level or under
|
|
93
|
+
* `global`: `concurrency` (`"10"`, `"50%"` of the cores) and the local
|
|
94
|
+
* cache's `cacheMaxSize` / `cacheMaxAge` (`"0"` is off; weeks become days).
|
|
95
|
+
* Read by nothing, a repo that capped its cache at 10GB (formbricks) grew
|
|
96
|
+
* it without bound under vx. A value core cannot parse is left to core's
|
|
97
|
+
* refusal, which names the field.
|
|
98
|
+
*/
|
|
99
|
+
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Turbo's size (turborepo-cache `parse_human_size`): 1024-based, a bare
|
|
103
|
+
* number is bytes, a fraction is truncated to bytes. Core takes a whole
|
|
104
|
+
* number with a unit, so `7.5GB` (langfuse) is restated as `7680MB` and
|
|
105
|
+
* `1000000` as `1000000B`; core refused both. Undefined when Turbo reads
|
|
106
|
+
* it as off (under one byte).
|
|
107
|
+
*/
|
|
108
|
+
function turboSize(v: string): string | undefined {
|
|
109
|
+
const m = /^(\d+\.?\d*|\.\d+)([KMGT]?B)?$/i.exec(v)
|
|
110
|
+
if (!m) return v
|
|
111
|
+
let n = Math.floor(Number(m[1]) * 1024 ** SIZE_UNITS.indexOf((m[2] ?? 'B').toUpperCase()))
|
|
112
|
+
if (n === 0) return undefined
|
|
113
|
+
let u = 0
|
|
114
|
+
while (u < SIZE_UNITS.length - 1 && n % 1024 === 0) {
|
|
115
|
+
n /= 1024
|
|
116
|
+
u++
|
|
117
|
+
}
|
|
118
|
+
return `${n}${SIZE_UNITS[u]}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const UNKNOWN_SHA = '0'.repeat(40)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Turbo's `--affected` base on GitHub Actions when `TURBO_SCM_BASE` is
|
|
125
|
+
* unset (`get_github_base_ref`): a pull request's `GITHUB_BASE_REF`, else
|
|
126
|
+
* the push event's `before`, or the parent of its first commit when the
|
|
127
|
+
* push is new or forced. vx guessed `origin/HEAD`, which on a push to the
|
|
128
|
+
* default branch is the commit itself: nothing was affected where Turbo
|
|
129
|
+
* ran the pushed commits' packages. `origin/<ref>` stands in for a base
|
|
130
|
+
* ref the checkout lacks only under Turbo's
|
|
131
|
+
* `futureFlags.githubActionsRemoteBaseRefFallback`, as in Turbo.
|
|
132
|
+
*/
|
|
133
|
+
function githubActionsBase(root: string, remoteFallback: boolean): string | undefined {
|
|
134
|
+
if (!Bun.env['GITHUB_ACTIONS']) return undefined
|
|
135
|
+
const ref = Bun.env['GITHUB_BASE_REF']
|
|
136
|
+
if (ref) {
|
|
137
|
+
if (!remoteFallback) return ref
|
|
138
|
+
const has = (r: string) =>
|
|
139
|
+
Bun.spawnSync(['git', 'rev-parse', '--verify', '--quiet', '--end-of-options', r], {
|
|
140
|
+
cwd: root,
|
|
141
|
+
stdout: 'ignore',
|
|
142
|
+
stderr: 'ignore',
|
|
143
|
+
}).exitCode === 0
|
|
144
|
+
return has(ref) || !has(`origin/${ref}`) ? ref : `origin/${ref}`
|
|
145
|
+
}
|
|
146
|
+
const eventPath = Bun.env['GITHUB_EVENT_PATH']
|
|
147
|
+
if (!eventPath) return undefined
|
|
148
|
+
let event: { before?: unknown; forced?: unknown; commits?: unknown }
|
|
149
|
+
try {
|
|
150
|
+
event = JSON.parse(readFileSync(eventPath, 'utf8')) as typeof event
|
|
151
|
+
} catch {
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
const before = typeof event.before === 'string' ? event.before : ''
|
|
155
|
+
if (before === UNKNOWN_SHA || event.forced === true) {
|
|
156
|
+
const commits = Array.isArray(event.commits) ? event.commits : []
|
|
157
|
+
const id = (commits[0] as { id?: unknown } | undefined)?.id
|
|
158
|
+
return commits.length > 0 && commits.length < 2048 && typeof id === 'string'
|
|
159
|
+
? `${id}^`
|
|
160
|
+
: undefined
|
|
161
|
+
}
|
|
162
|
+
return before === '' ? undefined : before
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function workspaceKeys(root: string): Promise<{
|
|
166
|
+
concurrency?: number
|
|
167
|
+
cacheRetention?: { maxSize?: string; olderThan?: string }
|
|
168
|
+
remoteBaseFallback?: boolean
|
|
169
|
+
}> {
|
|
170
|
+
const file = await turboConfigFile(root)
|
|
171
|
+
if (file === null) return {}
|
|
172
|
+
let raw: Record<string, unknown>
|
|
173
|
+
try {
|
|
174
|
+
raw = (Bun.JSONC.parse(await Bun.file(file).text()) ?? {}) as Record<string, unknown>
|
|
175
|
+
} catch {
|
|
176
|
+
return {} // the mapping refuses the file, naming it
|
|
177
|
+
}
|
|
178
|
+
const global = (typeof raw['global'] === 'object' ? raw['global'] : null) as Record<
|
|
179
|
+
string,
|
|
180
|
+
unknown
|
|
181
|
+
> | null
|
|
182
|
+
// Turbo's environment sits above turbo.json (`TURBO_CONCURRENCY`, …);
|
|
183
|
+
// a set variable decides, its `0` included.
|
|
184
|
+
const read = (key: string, envName: string): string | undefined => {
|
|
185
|
+
const e = process.env[envName]?.trim()
|
|
186
|
+
const v = e !== undefined && e !== '' ? e : (global?.[key] ?? raw[key])
|
|
187
|
+
return typeof v === 'string' && v.trim() !== '' && v.trim() !== '0' ? v.trim() : undefined
|
|
188
|
+
}
|
|
189
|
+
const out: {
|
|
190
|
+
concurrency?: number
|
|
191
|
+
cacheRetention?: { maxSize?: string; olderThan?: string }
|
|
192
|
+
remoteBaseFallback?: boolean
|
|
193
|
+
} = {}
|
|
194
|
+
const flags = raw['futureFlags'] as { githubActionsRemoteBaseRefFallback?: unknown } | undefined
|
|
195
|
+
if (flags?.githubActionsRemoteBaseRefFallback === true) out.remoteBaseFallback = true
|
|
196
|
+
const c = read('concurrency', 'TURBO_CONCURRENCY')
|
|
197
|
+
if (c !== undefined) {
|
|
198
|
+
const pct = /^(\d+)%$/.exec(c)
|
|
199
|
+
const n = pct
|
|
200
|
+
? Math.max(1, Math.floor((availableParallelism() * Number(pct[1])) / 100))
|
|
201
|
+
: Number(c)
|
|
202
|
+
if (Number.isInteger(n) && n > 0) out.concurrency = n
|
|
203
|
+
}
|
|
204
|
+
const size = read('cacheMaxSize', 'TURBO_CACHE_MAX_SIZE')
|
|
205
|
+
const maxSize = size === undefined ? undefined : turboSize(size)
|
|
206
|
+
const age = read('cacheMaxAge', 'TURBO_CACHE_MAX_AGE')
|
|
207
|
+
// Turbo reads a bare number as days (`parse_human_duration`).
|
|
208
|
+
const olderThan = age
|
|
209
|
+
?.replace(/^(\d+)w$/i, (_, w: string) => `${Number(w) * 7}d`)
|
|
210
|
+
.replace(/^\d+$/, (d) => `${d}d`)
|
|
211
|
+
if (maxSize !== undefined || olderThan !== undefined) {
|
|
212
|
+
out.cacheRetention = {
|
|
213
|
+
...(maxSize !== undefined ? { maxSize } : {}),
|
|
214
|
+
...(olderThan !== undefined ? { olderThan } : {}),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const envNamesNow = (): string[] => Object.keys(process.env).sort()
|
|
221
|
+
|
|
222
|
+
const textOf = (file: string): Promise<string> =>
|
|
223
|
+
Bun.file(file)
|
|
224
|
+
.text()
|
|
225
|
+
.catch(() => '\0absent')
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Everything the mapping reads: the root's and each package's
|
|
229
|
+
* `turbo.json` / `turbo.jsonc`, every package manifest, the root's,
|
|
230
|
+
* `.yarnrc.yml` and the microfrontends configs.
|
|
231
|
+
*/
|
|
232
|
+
async function run(root: string, metas: readonly ProjectMeta[]): Promise<AdoptionRun> {
|
|
233
|
+
const dirs = [root, ...metas.map((m) => m.dir)]
|
|
234
|
+
const configs = await Promise.all(
|
|
235
|
+
dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
|
|
236
|
+
)
|
|
237
|
+
const envNames = envNamesThatMap(configs, envNamesNow())
|
|
238
|
+
return {
|
|
239
|
+
name: 'turbo',
|
|
240
|
+
spareTracked: true,
|
|
241
|
+
reads: [
|
|
242
|
+
JSON.stringify(dirs),
|
|
243
|
+
// An env wildcard expands over these; a new name it matches maps afresh.
|
|
244
|
+
JSON.stringify(envNames),
|
|
245
|
+
process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
|
|
246
|
+
process.env['TURBO_ENV_MODE'] ?? '',
|
|
247
|
+
...configs,
|
|
248
|
+
await textOf(path.join(root, '.yarnrc.yml')),
|
|
249
|
+
// Its dependencies are global inputs, a member or not.
|
|
250
|
+
await textOf(path.join(root, 'package.json')),
|
|
251
|
+
JSON.stringify(microfrontendsConfigs(root, dirs)),
|
|
252
|
+
// A config file added beside mapped tasks changes what an output may cover.
|
|
253
|
+
JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
|
|
254
|
+
],
|
|
255
|
+
map: (tracked) => mapAll(root, metas, envNames, tracked),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The mapping indexed by package name. The stage visits every package and
|
|
261
|
+
* each visit looked its package up with a linear scan over the mapping —
|
|
262
|
+
* a million comparisons on a 1,000-package workspace, a third of the
|
|
263
|
+
* stage's cost there (2026-09-10).
|
|
264
|
+
*/
|
|
265
|
+
async function mapAll(
|
|
266
|
+
root: string,
|
|
267
|
+
metas: readonly ProjectMeta[],
|
|
268
|
+
envNames: readonly string[],
|
|
269
|
+
trackedFiles: () => Promise<readonly string[] | null>,
|
|
270
|
+
): Promise<AdoptionMapping> {
|
|
271
|
+
const tracked = await trackedFiles()
|
|
272
|
+
const configs = new Map(
|
|
273
|
+
metas.flatMap((m) =>
|
|
274
|
+
m.configPath === null ? [] : [[relPosix(root, m.dir), path.basename(m.configPath)] as const],
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
const mapped = await mapTurboWorkspace(root, metas, {
|
|
278
|
+
// Inline: the values themselves, where `vx migrate` splices a preset import.
|
|
279
|
+
splice: (_kind, values) => values,
|
|
280
|
+
persistentTodo: PERSISTENT_NOTE,
|
|
281
|
+
envNames,
|
|
282
|
+
vendorEnvPrefix: process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
|
|
283
|
+
envMode: process.env['TURBO_ENV_MODE'] ?? '',
|
|
284
|
+
...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
|
|
285
|
+
ownConfig: (rel) => configs.get(rel === '.' ? '' : rel) ?? null,
|
|
286
|
+
ignored: (rels) => gitIgnored(root, rels),
|
|
287
|
+
})
|
|
288
|
+
const byName = new Map<string, TurboMappedProject>()
|
|
289
|
+
for (const project of mapped.projects) byName.set(project.name, project)
|
|
290
|
+
return { byName, gaps: collectGaps(mapped.projects, mapped.notes) }
|
|
291
|
+
}
|