@vzn/vx-migrate 0.0.0 → 0.0.485

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }