@vzn/vx-migrate 0.0.0 → 0.0.484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,400 @@
|
|
|
1
|
+
// Turbo → vx migration: the RENDERING half. The mapping itself lives in
|
|
2
|
+
// `turbo()` (which runs it live); this file turns turbo's global
|
|
3
|
+
// fields into a root vx-preset.ts that each generated config imports and
|
|
4
|
+
// spreads — TypeScript composition replaces turbo's global config.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import {
|
|
8
|
+
type GeneratedProject,
|
|
9
|
+
type MigrationFormat,
|
|
10
|
+
type MigrationPlan,
|
|
11
|
+
PERSISTENT_TODO,
|
|
12
|
+
type ProjectMeta,
|
|
13
|
+
quoteTsLiteral as quote,
|
|
14
|
+
} from '@vzn/vx'
|
|
15
|
+
import {
|
|
16
|
+
mapTurboWorkspace,
|
|
17
|
+
rootTaskProject,
|
|
18
|
+
type TurboGlobal,
|
|
19
|
+
type TurboMappedProject,
|
|
20
|
+
} from './turbo/turbo-map.js'
|
|
21
|
+
import { relPosix } from './paths.js'
|
|
22
|
+
import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
|
|
23
|
+
import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
|
|
24
|
+
import { adoptedToolNotes } from './workspace-notes.js'
|
|
25
|
+
|
|
26
|
+
/** What a task's `npm_package_*` read: the manifest, so a bump reaches them. */
|
|
27
|
+
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
28
|
+
|
|
29
|
+
/** The preset takes the configs' extension: plain arrays either way. */
|
|
30
|
+
function presetFile(format: MigrationFormat): string {
|
|
31
|
+
return `vx-preset.${format}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The preset export each global field becomes. */
|
|
35
|
+
const PRESET_NAMES: Record<TurboGlobal, string> = {
|
|
36
|
+
inputs: 'globalInputs',
|
|
37
|
+
env: 'globalEnvInputs',
|
|
38
|
+
pass: 'globalPassThroughEnv',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function migrateTurbo(
|
|
42
|
+
root: string,
|
|
43
|
+
metas: readonly ProjectMeta[],
|
|
44
|
+
format: MigrationFormat = 'ts',
|
|
45
|
+
): Promise<MigrationPlan> {
|
|
46
|
+
const tracked = await trackedFiles(root)
|
|
47
|
+
const mapping = await mapTurboWorkspace(root, await withRootProject(root, metas), {
|
|
48
|
+
splice: (kind) => [{ raw: `...${PRESET_NAMES[kind]}` }],
|
|
49
|
+
persistentTodo: PERSISTENT_TODO,
|
|
50
|
+
manifestField: (key) => ({ raw: `pkg.${key}` }),
|
|
51
|
+
...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
|
|
52
|
+
// The file this writes is each task's config.
|
|
53
|
+
ownConfig: () => `vx.config.${format}`,
|
|
54
|
+
sourceNames: (dirs) => spelledNames(root, dirs, tracked),
|
|
55
|
+
ignored: (rels) => gitIgnored(root, rels),
|
|
56
|
+
})
|
|
57
|
+
// Turbo never cleans an output and vx cleans one before every run: a
|
|
58
|
+
// written `dist/**` beside a committed `dist/keep.js` deleted it on the
|
|
59
|
+
// first run. turbo() takes such files back each run; the configs must.
|
|
60
|
+
if (tracked !== null)
|
|
61
|
+
for (const [id, todo] of spareTrackedOutputs(root, mapping.projects, tracked)) {
|
|
62
|
+
const at = id.lastIndexOf('#')
|
|
63
|
+
const p = mapping.projects.find((x) => x.name === id.slice(0, at))
|
|
64
|
+
p?.tasks.find((t) => t.name === id.slice(at + 1))?.todos.push(todo)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const shared = hoistTaskEnv(mapping.projects)
|
|
68
|
+
const probes = nameProbes(mapping.projects)
|
|
69
|
+
const projects: GeneratedProject[] = mapping.projects.map((p) => {
|
|
70
|
+
const used = new Set<string>([
|
|
71
|
+
...(shared.usedBy.get(p.name) ?? []),
|
|
72
|
+
...(probes.usedBy.get(p.name) ?? []),
|
|
73
|
+
])
|
|
74
|
+
for (const t of p.tasks) for (const kind of t.uses) used.add(PRESET_NAMES[kind])
|
|
75
|
+
const readsManifest = p.tasks.some((t) => JSON.stringify(t.task ?? {}).includes('"pkg.'))
|
|
76
|
+
return {
|
|
77
|
+
name: p.name,
|
|
78
|
+
dir: p.dir,
|
|
79
|
+
importLines: [
|
|
80
|
+
...(readsManifest ? [MANIFEST_IMPORT] : []),
|
|
81
|
+
...presetImportLines(used, root, p.dir, format),
|
|
82
|
+
],
|
|
83
|
+
tasks: p.tasks.map(({ name, todos, task }) => ({ name, todos, task })),
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const { inputs, env, pass } = mapping.globals
|
|
88
|
+
const extraFiles: MigrationPlan['extraFiles'] = []
|
|
89
|
+
if (
|
|
90
|
+
inputs.length > 0 ||
|
|
91
|
+
env.length > 0 ||
|
|
92
|
+
pass.length > 0 ||
|
|
93
|
+
shared.lists.length > 0 ||
|
|
94
|
+
probes.named.length > 0
|
|
95
|
+
) {
|
|
96
|
+
extraFiles.push({
|
|
97
|
+
relPath: presetFile(format),
|
|
98
|
+
contents: renderPreset(inputs, env, pass, shared.lists, probes.named),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
headerNotes: await adoptedToolNotes(root, {
|
|
104
|
+
plugin: 'turbo',
|
|
105
|
+
config: 'turbo.json',
|
|
106
|
+
runner: 'turbo',
|
|
107
|
+
keys: (lock) => `Turbo keys each package on its own ${lock} entries`,
|
|
108
|
+
}),
|
|
109
|
+
projects,
|
|
110
|
+
extraFiles,
|
|
111
|
+
notes: mapping.notes,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Source and env-example files a framework build reads its variables from. */
|
|
116
|
+
const SPELLS_ENV =
|
|
117
|
+
/\.(c|m)?(j|t)sx?$|\.(vue|svelte|astro|html)$|(^|\/)\.env\.(example|sample|template)$/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The upper-case names the tracked source under `dirs` spells (`NEXT_PUBLIC_API`
|
|
121
|
+
* in `process.env.NEXT_PUBLIC_API` or an `.env.example`), sorted. Without
|
|
122
|
+
* git, none: the note still names the framework's prefix.
|
|
123
|
+
*/
|
|
124
|
+
async function spelledNames(
|
|
125
|
+
root: string,
|
|
126
|
+
dirs: readonly string[],
|
|
127
|
+
tracked: readonly string[] | null,
|
|
128
|
+
): Promise<string[]> {
|
|
129
|
+
if (tracked === null) return []
|
|
130
|
+
const prefixes = dirs.map((dir) => {
|
|
131
|
+
const rel = relPosix(root, dir)
|
|
132
|
+
return rel === '' || rel === '.' ? '' : `${rel}/`
|
|
133
|
+
})
|
|
134
|
+
const names = new Set<string>()
|
|
135
|
+
for (const f of tracked) {
|
|
136
|
+
if (!SPELLS_ENV.test(f) || !prefixes.some((p) => f.startsWith(p))) continue
|
|
137
|
+
const text = await Bun.file(path.join(root, f))
|
|
138
|
+
.text()
|
|
139
|
+
.catch(() => '')
|
|
140
|
+
for (const m of text.matchAll(/\b[A-Z][A-Z0-9_]*_[A-Z0-9_]+\b/g)) names.add(m[0])
|
|
141
|
+
}
|
|
142
|
+
return [...names].sort()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The workspace root as a project when turbo.json declares `//#` tasks and
|
|
147
|
+
* no glob lists the root: the `vx.config.ts` written there is what makes
|
|
148
|
+
* core read it as one (D-39), as `turbo()`'s `discover` hook does live.
|
|
149
|
+
* Five of eleven real Turbo repos had root
|
|
150
|
+
* tasks, and 36 edges to them were dropped (G-49).
|
|
151
|
+
*/
|
|
152
|
+
async function withRootProject(
|
|
153
|
+
root: string,
|
|
154
|
+
metas: readonly ProjectMeta[],
|
|
155
|
+
): Promise<readonly ProjectMeta[]> {
|
|
156
|
+
const named = await rootTaskProject(root)
|
|
157
|
+
// A root the globs list already is a project of this name; a package
|
|
158
|
+
// that took the name leaves the root none to take.
|
|
159
|
+
if (named === null || metas.some((m) => m.name === named.name)) return metas
|
|
160
|
+
const packageJson = (await Bun.file(path.join(root, 'package.json')).json()) as never
|
|
161
|
+
return [...metas, { ...named, packageJson, configPath: null }]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function presetImportLines(
|
|
165
|
+
used: ReadonlySet<string>,
|
|
166
|
+
root: string,
|
|
167
|
+
dir: string,
|
|
168
|
+
format: MigrationFormat,
|
|
169
|
+
): string[] {
|
|
170
|
+
if (used.size === 0) return []
|
|
171
|
+
// `.js` for the `.ts` file: Bun takes it to the `.ts`, and a user's
|
|
172
|
+
// `tsc` accepts it under every resolution mode, where `.ts` fails
|
|
173
|
+
// without `allowImportingTsExtensions` (TS5097; create-t3-turbo).
|
|
174
|
+
const file = format === 'ts' ? 'vx-preset.js' : presetFile(format)
|
|
175
|
+
const rel = relPosix(dir, path.join(root, file))
|
|
176
|
+
const spec = rel.startsWith('.') ? rel : `./${rel}`
|
|
177
|
+
return [`import { ${[...used].sort().join(', ')} } from '${spec}'`]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A task's own env names, the same in several packages: one preset export. */
|
|
181
|
+
interface SharedList {
|
|
182
|
+
readonly name: string
|
|
183
|
+
readonly task: string
|
|
184
|
+
readonly values: readonly string[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Fewer names than this stay inline: an import costs more than it saves. */
|
|
188
|
+
const HOIST_MIN = 3
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Turbo states a task's `env` once, in the root turbo.json; written inline,
|
|
192
|
+
* vercel/ai's 63 `build` names repeated twice in each of ~100 configs. A
|
|
193
|
+
* task's own `cache.inputs.env` names (after the spliced globals) that two
|
|
194
|
+
* or more configs share become one preset export, spread where they stood
|
|
195
|
+
* and where they lead `exec.env.passThrough` (its `passThroughEnv` names
|
|
196
|
+
* follow): the evaluated arrays, and so every key, are unchanged. A
|
|
197
|
+
* package whose list differs (its own turbo.json) keeps it inline unless
|
|
198
|
+
* that list is shared too.
|
|
199
|
+
*/
|
|
200
|
+
function hoistTaskEnv(projects: readonly TurboMappedProject[]): {
|
|
201
|
+
lists: SharedList[]
|
|
202
|
+
usedBy: Map<string, Set<string>>
|
|
203
|
+
} {
|
|
204
|
+
const lists = (t: Task): (unknown[] | undefined)[] => [
|
|
205
|
+
t.cache?.inputs?.env,
|
|
206
|
+
t.exec?.env?.passThrough,
|
|
207
|
+
]
|
|
208
|
+
// The trailing run of names after the spliced globals, or null when the
|
|
209
|
+
// list is all names or mixes them in.
|
|
210
|
+
const own = (list: unknown[] | undefined): string[] | null => {
|
|
211
|
+
if (list === undefined) return null
|
|
212
|
+
const at = list.findIndex((v) => typeof v === 'string')
|
|
213
|
+
if (at < 0 || list.slice(at).some((v) => typeof v !== 'string')) return null
|
|
214
|
+
return list.slice(at) as string[]
|
|
215
|
+
}
|
|
216
|
+
const keyOf = (task: string, names: readonly string[]) => JSON.stringify([task, names])
|
|
217
|
+
const counts = new Map<string, number>()
|
|
218
|
+
for (const p of projects)
|
|
219
|
+
for (const t of p.tasks) {
|
|
220
|
+
if (t.task === null) continue
|
|
221
|
+
const names = own((t.task as Task).cache?.inputs?.env)
|
|
222
|
+
if (names === null || names.length < HOIST_MIN) continue
|
|
223
|
+
const k = keyOf(t.name, names)
|
|
224
|
+
counts.set(k, (counts.get(k) ?? 0) + 1)
|
|
225
|
+
}
|
|
226
|
+
// Per task, the most used list takes `<task>Env`, the next `<task>Env2`.
|
|
227
|
+
const ranked = [...counts]
|
|
228
|
+
.filter(([, n]) => n >= 2)
|
|
229
|
+
.sort(([a, m], [b, n]) => n - m || (a < b ? -1 : 1))
|
|
230
|
+
const taken = new Set(Object.values(PRESET_NAMES))
|
|
231
|
+
const nameOf = new Map<string, string>()
|
|
232
|
+
const shared: SharedList[] = []
|
|
233
|
+
for (const [k] of ranked) {
|
|
234
|
+
const [task, values] = JSON.parse(k) as [string, string[]]
|
|
235
|
+
const base = `${identifier(task)}Env`
|
|
236
|
+
let name = base
|
|
237
|
+
for (let i = 2; taken.has(name); i++) name = `${base}${i}`
|
|
238
|
+
taken.add(name)
|
|
239
|
+
nameOf.set(k, name)
|
|
240
|
+
shared.push({ name, task, values })
|
|
241
|
+
}
|
|
242
|
+
const usedBy = new Map<string, Set<string>>()
|
|
243
|
+
for (const p of projects)
|
|
244
|
+
for (const t of p.tasks) {
|
|
245
|
+
if (t.task === null) continue
|
|
246
|
+
const env = own((t.task as Task).cache?.inputs?.env)
|
|
247
|
+
const name = env === null ? undefined : nameOf.get(keyOf(t.name, env))
|
|
248
|
+
if (name === undefined) continue
|
|
249
|
+
for (const list of lists(t.task as Task)) {
|
|
250
|
+
const names = own(list)
|
|
251
|
+
if (names === null || list === undefined) continue
|
|
252
|
+
if (!env!.every((n, i) => names[i] === n)) continue
|
|
253
|
+
list.splice(list.length - names.length, env!.length, { raw: `...${name}` })
|
|
254
|
+
let used = usedBy.get(p.name)
|
|
255
|
+
if (used === undefined) usedBy.set(p.name, (used = new Set()))
|
|
256
|
+
used.add(name)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { lists: shared.sort((a, b) => (a.name < b.name ? -1 : 1)), usedBy }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type Task = {
|
|
263
|
+
cache?: { inputs?: { env?: unknown[] } }
|
|
264
|
+
exec?: { env?: { passThrough?: unknown[] } }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `test:update` → `testUpdate`; a leading digit gets a `task` prefix. */
|
|
268
|
+
function identifier(task: string): string {
|
|
269
|
+
const words = task.split(/[^A-Za-z0-9]+/).filter((w) => w !== '')
|
|
270
|
+
const id = words
|
|
271
|
+
.map((w, i) => (i === 0 ? w[0]!.toLowerCase() + w.slice(1) : w[0]!.toUpperCase() + w.slice(1)))
|
|
272
|
+
.join('')
|
|
273
|
+
return id === '' || /^[0-9]/.test(id) ? `task${id[0]?.toUpperCase() ?? ''}${id.slice(1)}` : id
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The `.env` probes, by name: Turbo hashes a task's `.env` files although
|
|
278
|
+
* git ignores them, and a glob over git's files sees none, so the mapper
|
|
279
|
+
* keys them through a shell line that prints each file. Inline in every
|
|
280
|
+
* package's config, that line read as noise nobody could review.
|
|
281
|
+
*/
|
|
282
|
+
type Probe = { name: string; command: string; doc: string; field: string }
|
|
283
|
+
|
|
284
|
+
const PROBES: readonly Probe[] = [
|
|
285
|
+
{
|
|
286
|
+
name: 'dotenvFiles',
|
|
287
|
+
command: DOTENV_PROBE_TOP,
|
|
288
|
+
doc: "Each `.env` file in the task's directory, name and bytes",
|
|
289
|
+
field: 'runtime',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'dotenvFilesDeep',
|
|
293
|
+
command: DOTENV_PROBE,
|
|
294
|
+
doc: "Each `.env` file under the task's directory, name and bytes",
|
|
295
|
+
field: 'runtime',
|
|
296
|
+
},
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
function nameProbes(projects: readonly TurboMappedProject[]): {
|
|
300
|
+
named: Probe[]
|
|
301
|
+
usedBy: Map<string, Set<string>>
|
|
302
|
+
} {
|
|
303
|
+
const named: Probe[] = []
|
|
304
|
+
const rootGlobs: Probe[] = []
|
|
305
|
+
const usedBy = new Map<string, Set<string>>()
|
|
306
|
+
const probeFor = (v: unknown): Probe | undefined => {
|
|
307
|
+
const known = PROBES.find((x) => x.command === v) ?? rootGlobs.find((x) => x.command === v)
|
|
308
|
+
if (known !== undefined || typeof v !== 'string' || !v.startsWith(DOTENV_GLOBS_HEAD))
|
|
309
|
+
return known
|
|
310
|
+
// Each root `.env` glob set is its own line; the globals' is the one most share.
|
|
311
|
+
const probe: Probe = {
|
|
312
|
+
name: `dotenvRootFiles${rootGlobs.length === 0 ? '' : rootGlobs.length + 1}`,
|
|
313
|
+
command: v,
|
|
314
|
+
doc: "The `.env` files the workspace's root globs name, name and bytes",
|
|
315
|
+
field: 'workspaceRuntime',
|
|
316
|
+
}
|
|
317
|
+
rootGlobs.push(probe)
|
|
318
|
+
return probe
|
|
319
|
+
}
|
|
320
|
+
for (const p of projects)
|
|
321
|
+
for (const t of p.tasks) {
|
|
322
|
+
const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
|
|
323
|
+
for (const field of ['runtime', 'workspaceRuntime']) {
|
|
324
|
+
const list = inputs?.[field]
|
|
325
|
+
if (!Array.isArray(list)) continue
|
|
326
|
+
list.forEach((v, i) => {
|
|
327
|
+
const probe = probeFor(v)
|
|
328
|
+
if (probe === undefined) return
|
|
329
|
+
list[i] = { raw: probe.name }
|
|
330
|
+
if (!named.includes(probe)) named.push(probe)
|
|
331
|
+
let used = usedBy.get(p.name)
|
|
332
|
+
if (used === undefined) usedBy.set(p.name, (used = new Set()))
|
|
333
|
+
used.add(probe.name)
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const order = [...PROBES, ...rootGlobs]
|
|
338
|
+
return { named: named.sort((a, b) => order.indexOf(a) - order.indexOf(b)), usedBy }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderPreset(
|
|
342
|
+
inputs: string[],
|
|
343
|
+
env: string[],
|
|
344
|
+
pass: string[],
|
|
345
|
+
shared: readonly SharedList[],
|
|
346
|
+
probes: readonly Probe[],
|
|
347
|
+
): string {
|
|
348
|
+
// Escape each entry via the shared `quote()` — a turbo.json global (a file
|
|
349
|
+
// glob, or an env name a user hand-wrote) may contain a `'`/`\`/newline that
|
|
350
|
+
// would otherwise splice into a malformed, unloadable `vx-preset.ts`.
|
|
351
|
+
const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
|
|
352
|
+
const lines = [
|
|
353
|
+
// The same name the configs carry (`verb` in index.ts): `vx migrate` is
|
|
354
|
+
// no verb, and a reader who typed it got `unknown command`.
|
|
355
|
+
'// Generated by `vx-migrate` from turbo.json. TypeScript composition',
|
|
356
|
+
"// replaces turbo's global fields: each vx.config.ts imports these",
|
|
357
|
+
'// arrays and spreads them into the matching task fields.',
|
|
358
|
+
]
|
|
359
|
+
if (inputs.length > 0) {
|
|
360
|
+
lines.push(
|
|
361
|
+
'',
|
|
362
|
+
'// From globalDependencies and what Turbo adds to them (the packages the',
|
|
363
|
+
'// root depends on, microfrontends configs) — workspace-root-relative,',
|
|
364
|
+
'// spread into each task’s cache.inputs.workspaceFiles.',
|
|
365
|
+
`export const globalInputs = ${arr(inputs)}`,
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
if (env.length > 0) {
|
|
369
|
+
lines.push(
|
|
370
|
+
'',
|
|
371
|
+
'// From globalEnv: cache inputs AND passed through to every task',
|
|
372
|
+
'// (vx child environments are isolated; see docs/schema.md).',
|
|
373
|
+
`export const globalEnvInputs = ${arr(env)}`,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
if (pass.length > 0) {
|
|
377
|
+
lines.push(
|
|
378
|
+
'',
|
|
379
|
+
'// From globalPassThroughEnv: forwarded to every task, never hashed.',
|
|
380
|
+
`export const globalPassThroughEnv = ${arr(pass)}`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
for (const l of shared)
|
|
384
|
+
lines.push(
|
|
385
|
+
'',
|
|
386
|
+
`// turbo.json's \`${l.task}\` env: hashed and passed where a config spreads it.`,
|
|
387
|
+
`export const ${l.name} = ${arr([...l.values])}`,
|
|
388
|
+
)
|
|
389
|
+
for (const probe of probes) {
|
|
390
|
+
lines.push(
|
|
391
|
+
'',
|
|
392
|
+
`// ${probe.doc}. Turbo hashes`,
|
|
393
|
+
"// `.env` files although git ignores them, and a glob over git's files",
|
|
394
|
+
`// sees none: cache.inputs.${probe.field} keys them.`,
|
|
395
|
+
`export const ${probe.name} = ${quote(probe.command)}`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
lines.push('')
|
|
399
|
+
return lines.join('\n')
|
|
400
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `vx-migrate [--from turbo|nx] [--dry] [--force] [--mjs]` — one
|
|
2
|
+
// vx.config.ts per workspace package from an existing Turbo or Nx
|
|
3
|
+
// setup. Source auto-detect: turbo.json → Turbo;
|
|
4
|
+
// .nx/workspace-data/project-graph.json or nx.json → Nx (the resolved graph, exported by nx if absent);
|
|
5
|
+
// The mappers return a plan; core's migration seam
|
|
6
|
+
// (`applyMigration`) renders, guards, writes and reports, so what this
|
|
7
|
+
// package writes reads exactly like what `vx init` writes.
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
applyMigration,
|
|
14
|
+
findWorkspaceRoot,
|
|
15
|
+
isUserError,
|
|
16
|
+
listProjectMetas,
|
|
17
|
+
loadWorkspace,
|
|
18
|
+
type MigrationFormat,
|
|
19
|
+
type MigrationPlan,
|
|
20
|
+
UserError,
|
|
21
|
+
} from '@vzn/vx'
|
|
22
|
+
import { migrateNx, NX_GRAPH_REL } from './migrate-nx.js'
|
|
23
|
+
import { exportGraph } from './nx/export-graph.js'
|
|
24
|
+
import { migrateTurbo } from './migrate-turbo.js'
|
|
25
|
+
import { turboConfigFile } from './turbo/turbo-map.js'
|
|
26
|
+
|
|
27
|
+
export interface MigrateArgs {
|
|
28
|
+
dry: boolean
|
|
29
|
+
force: boolean
|
|
30
|
+
/** `vx.config.mjs` (and `vx-preset.mjs`) instead of `.ts`. */
|
|
31
|
+
mjs: boolean
|
|
32
|
+
from?: 'turbo' | 'nx'
|
|
33
|
+
/** `--help` / `-h`: the usage on stdout, exit 0 (as `nx-env --help`). */
|
|
34
|
+
help?: boolean
|
|
35
|
+
error?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const USAGE = 'usage: vx-migrate [--from turbo|nx] [--dry] [--force] [--mjs]'
|
|
39
|
+
|
|
40
|
+
export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
|
|
41
|
+
const out: MigrateArgs = { dry: false, force: false, mjs: false }
|
|
42
|
+
for (let i = 0; i < args.length; i++) {
|
|
43
|
+
const a = args[i]
|
|
44
|
+
if (a === '--dry') out.dry = true
|
|
45
|
+
else if (a === '--force') out.force = true
|
|
46
|
+
else if (a === '--mjs') out.mjs = true
|
|
47
|
+
else if (a === '--from' || a?.startsWith('--from=')) {
|
|
48
|
+
const v = a === '--from' ? args[++i] : a.slice('--from='.length)
|
|
49
|
+
if (v !== 'turbo' && v !== 'nx') {
|
|
50
|
+
return {
|
|
51
|
+
...out,
|
|
52
|
+
error: `--from must be turbo or nx (package.json scripts: \`vx init\`)`,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
out.from = v
|
|
56
|
+
} else if (a === '--help' || a === '-h') return { ...out, help: true }
|
|
57
|
+
else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}\n${USAGE}` }
|
|
58
|
+
else return { ...out, error: `unexpected argument: ${a}\n${USAGE}` }
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Core finds a root by a package manager's workspace file; a Rush repo can
|
|
65
|
+
* have none. Say which tool is there and what vx needs from it.
|
|
66
|
+
*/
|
|
67
|
+
async function noRootReason(cwd: string, err: Error): Promise<Error> {
|
|
68
|
+
if (await Bun.file(path.join(cwd, 'rush.json')).exists()) {
|
|
69
|
+
return new UserError(
|
|
70
|
+
'a Rush workspace: vx does not yet discover projects from rush.json (it finds them through package.json workspaces or pnpm-workspace.yaml)',
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return err
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The command: detect the source, map it, hand the plan to core. Returns the exit code. */
|
|
77
|
+
export async function migrateCmd(args: readonly string[]): Promise<number> {
|
|
78
|
+
const parsed = parseMigrateArgs(args)
|
|
79
|
+
if (parsed.help) {
|
|
80
|
+
process.stdout.write(`${USAGE}\n`)
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
if (parsed.error) {
|
|
84
|
+
process.stderr.write(`vx-migrate: ${parsed.error}\n`)
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
const root = await findWorkspaceRoot(process.cwd()).catch(async (err: unknown) => {
|
|
88
|
+
throw isUserError(err) ? await noRootReason(process.cwd(), err) : err
|
|
89
|
+
})
|
|
90
|
+
const metas = await listProjectMetas(await loadWorkspace(root))
|
|
91
|
+
|
|
92
|
+
const turboFile = await turboConfigFile(root)
|
|
93
|
+
const hasTurbo = turboFile !== null
|
|
94
|
+
const hasGraph = await Bun.file(path.join(root, NX_GRAPH_REL)).exists()
|
|
95
|
+
const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
|
|
96
|
+
|
|
97
|
+
// Evaluating teams routinely have two runners checked in — never
|
|
98
|
+
// ask anyone to delete anything; --from disambiguates.
|
|
99
|
+
const found = (
|
|
100
|
+
[
|
|
101
|
+
['turbo', hasTurbo, 'turbo.json'],
|
|
102
|
+
['nx', hasGraph || hasNxJson, 'an nx workspace'],
|
|
103
|
+
] as const
|
|
104
|
+
).filter(([, present]) => present)
|
|
105
|
+
if (parsed.from === undefined && found.length > 1) {
|
|
106
|
+
const what = found.map(([, , label]) => label)
|
|
107
|
+
const listed =
|
|
108
|
+
what.length === 2
|
|
109
|
+
? `both ${what[0]} and ${what[1]}`
|
|
110
|
+
: `${what.slice(0, -1).join(', ')} and ${what.at(-1)}`
|
|
111
|
+
throw new UserError(
|
|
112
|
+
`${listed} are present — pass ${found.map(([id]) => `--from ${id}`).join(' or ')}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (parsed.from === 'turbo' && !hasTurbo) {
|
|
116
|
+
throw new UserError('--from turbo, but no turbo.json at the workspace root')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const format: MigrationFormat = parsed.mjs ? 'mjs' : 'ts'
|
|
120
|
+
let source: string
|
|
121
|
+
let plan: MigrationPlan
|
|
122
|
+
if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
|
|
123
|
+
if (hasGraph) {
|
|
124
|
+
source = NX_GRAPH_REL
|
|
125
|
+
plan = await migrateNx(root, metas, format)
|
|
126
|
+
} else if (hasNxJson || parsed.from === 'nx') {
|
|
127
|
+
// Modern Nx stores the graph in SQLite, so the JSON snapshot exists only
|
|
128
|
+
// when exported. The workspace's own nx exports it, as `nx()` does, into
|
|
129
|
+
// a temp file; the user ran that step by hand until 2026-10-01.
|
|
130
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'vx-migrate-nx-'))
|
|
131
|
+
try {
|
|
132
|
+
const snapshot = path.join(tmp, 'project-graph.json')
|
|
133
|
+
const why = await exportGraph(root, snapshot)
|
|
134
|
+
if (why !== null) {
|
|
135
|
+
throw new UserError(
|
|
136
|
+
`no resolved Nx graph found, and exporting one failed (${why}) — export it with ` +
|
|
137
|
+
'`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx-migrate',
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
source = 'nx graph'
|
|
141
|
+
plan = await migrateNx(root, metas, format, snapshot)
|
|
142
|
+
} finally {
|
|
143
|
+
await rm(tmp, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
throw new UserError(
|
|
147
|
+
'nothing to migrate: no turbo.json and no Nx workspace — ' +
|
|
148
|
+
'for package.json scripts, run `vx init`',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
source = path.basename(turboFile ?? 'turbo.json')
|
|
153
|
+
plan = await migrateTurbo(root, metas, format)
|
|
154
|
+
}
|
|
155
|
+
return applyMigration({
|
|
156
|
+
root,
|
|
157
|
+
metas,
|
|
158
|
+
plan,
|
|
159
|
+
source,
|
|
160
|
+
verb: 'vx-migrate',
|
|
161
|
+
dry: parsed.dry,
|
|
162
|
+
force: parsed.force,
|
|
163
|
+
format,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The resolved Nx graph, exported by the workspace's own nx: `nx()` keys and
|
|
2
|
+
// keeps it, `vx-migrate --from nx` maps it once.
|
|
3
|
+
|
|
4
|
+
import { mkdir } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `nx graph --file=<snapshot>` from the workspace's own `nx`. Returns the
|
|
9
|
+
* reason it could not, or null. Nx's daemon setting is the user's: with the
|
|
10
|
+
* daemon up the export is served from memory, without it Nx computes.
|
|
11
|
+
*/
|
|
12
|
+
export async function exportGraph(root: string, snapshot: string): Promise<string | null> {
|
|
13
|
+
const bin = path.join(root, 'node_modules', '.bin', 'nx')
|
|
14
|
+
if (!(await Bun.file(bin).exists())) {
|
|
15
|
+
return `no ${path.relative(root, bin)} — install nx, or export a graph with \`nx graph --file=<path>\` and pass it as graph: '<path>'`
|
|
16
|
+
}
|
|
17
|
+
// `discover` runs before the run opens (and makes) the cache dir.
|
|
18
|
+
await mkdir(path.dirname(snapshot), { recursive: true })
|
|
19
|
+
const proc = Bun.spawn([bin, 'graph', `--file=${snapshot}`], {
|
|
20
|
+
cwd: root,
|
|
21
|
+
env: process.env,
|
|
22
|
+
stdout: 'pipe',
|
|
23
|
+
stderr: 'pipe',
|
|
24
|
+
})
|
|
25
|
+
const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
|
|
26
|
+
if (code === 0 && (await Bun.file(snapshot).exists())) return null
|
|
27
|
+
const tail = err.trim().split('\n').slice(-3).join(' ')
|
|
28
|
+
return `nx graph --file exited ${code}${tail ? `: ${tail}` : ''}`
|
|
29
|
+
}
|