@vzn/vx-migrate 0.0.633 → 0.0.635
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 +4 -2
- package/package.json +3 -2
- package/src/migrate-turbo.ts +27 -20
- package/src/sealed-tsconfig.ts +89 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @vzn/vx-migrate
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Website: <https://vznjs.github.io/vx/> · this plugin: <https://vznjs.github.io/vx/plugins/vx-migrate/> · source: <https://github.com/vznjs/vx>
|
|
4
|
+
|
|
5
|
+
Everything for adopting [`@vzn/vx`](https://vznjs.github.io/vx/) from Turborepo, Nx or Vite Task, in one package with zero dependencies:
|
|
4
6
|
|
|
5
7
|
- **`turbo()`** — a temporary start for a Turbo repository: the plugin fills vx's `project` stage from `turbo.json` and each package's `package.json` scripts until the migrator writes native config.
|
|
6
8
|
- **`nx()`** — the same temporary start for an Nx repository, filled from Nx's resolved project graph. Executor targets (`@nx/js:tsc`, `@nx/vite:build`, your own) run as themselves through **`nx-exec`**, one executor per process.
|
|
@@ -232,7 +234,7 @@ Your `package.json` scripts are never edited. `--dry` installs nothing and says
|
|
|
232
234
|
|
|
233
235
|
### Turbo
|
|
234
236
|
|
|
235
|
-
Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package `turbo.json` `extends` overlays and each package's scripts, through the same mapper `turbo()` runs live — so the written configs say what the plugin was doing. Turbo's global fields become a generated root `vx-preset.ts` each config imports and spreads: TypeScript composition replaces global config. So does a task's `env` that several configs share (`buildEnv`), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo's `//#` root tasks are written to a `vx.config.ts` at the workspace root, which makes the root a project (core's D-39), and a package task's `//#x` edge reaches it. When a `vx.workspace.ts` already exists without one, the report names the `@vzn/vx-lockfile` plugin for the repo's lockfile: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.
|
|
237
|
+
Reads the root pipeline (`tasks` in Turbo 2, `pipeline` in Turbo 1), per-package `turbo.json` `extends` overlays and each package's scripts, through the same mapper `turbo()` runs live — so the written configs say what the plugin was doing. Turbo's global fields become a generated root `vx-preset.ts` each config imports and spreads: TypeScript composition replaces global config. A config that a `tsconfig*.json` in its directory or above (below the root) takes in, where that project is composite or sets `rootDir`, declares the values it uses instead: `tsc` refuses a file outside the package (TS6059, TS6307; withastro/astro's `scripts/` and the `performance/` project its `packages/astro/tsconfig.test.json` takes in). So does a task's `env` that several configs share (`buildEnv`), where inline it would repeat in every package (vercel/ai: 63 names, twice in each of ~100 configs, half the output). Turbo's `//#` root tasks are written to a `vx.config.ts` at the workspace root, which makes the root a project (core's D-39), and a package task's `//#x` edge reaches it. When a `vx.workspace.ts` already exists without one, the report names the `@vzn/vx-lockfile` plugin for the repo's lockfile: Turbo keys each package on its own lockfile entries, and core keys every task on the whole file until a plugin claims it.
|
|
236
238
|
|
|
237
239
|
| Turborepo | vx |
|
|
238
240
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------- |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-migrate",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.635",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
|
|
6
6
|
"keywords": [
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"vx-plugin",
|
|
9
9
|
"monorepo",
|
|
10
10
|
"turborepo",
|
|
11
|
+
"turbo",
|
|
11
12
|
"nx",
|
|
12
13
|
"migration",
|
|
13
14
|
"remote-cache"
|
|
@@ -39,7 +40,7 @@
|
|
|
39
40
|
"access": "public"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
43
|
+
"@vzn/vx": "^0.0.635"
|
|
43
44
|
},
|
|
44
45
|
"repository": {
|
|
45
46
|
"type": "git",
|
package/src/migrate-turbo.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { gitIgnored, spareTrackedOutputs, trackedFiles, trackedKinds } from './t
|
|
|
23
23
|
import { DOTENV_GLOBS_HEAD, DOTENV_PROBE, DOTENV_PROBE_TOP } from './dotenv-probe.js'
|
|
24
24
|
import { adoptedToolNotes } from './workspace-notes.js'
|
|
25
25
|
import { spelledNames } from './spelled-env.js'
|
|
26
|
+
import { sealsConfig } from './sealed-tsconfig.js'
|
|
26
27
|
|
|
27
28
|
/** What a task's `npm_package_*` read: the manifest, so a bump reaches them. */
|
|
28
29
|
const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
|
|
@@ -67,6 +68,11 @@ export async function migrateTurbo(
|
|
|
67
68
|
|
|
68
69
|
const shared = hoistTaskEnv(mapping.projects)
|
|
69
70
|
const probes = nameProbes(mapping.projects)
|
|
71
|
+
const { inputs, env, pass } = mapping.globals
|
|
72
|
+
const decls = presetDeclarations(inputs, env, pass, shared.lists, probes.named)
|
|
73
|
+
// Written unless every config that uses it declares its values itself.
|
|
74
|
+
let imported = false
|
|
75
|
+
let anySealed = false
|
|
70
76
|
const projects: GeneratedProject[] = mapping.projects.map((p) => {
|
|
71
77
|
const used = new Set<string>([
|
|
72
78
|
...(shared.usedBy.get(p.name) ?? []),
|
|
@@ -74,29 +80,29 @@ export async function migrateTurbo(
|
|
|
74
80
|
])
|
|
75
81
|
for (const t of p.tasks) for (const kind of t.uses) used.add(PRESET_NAMES[kind])
|
|
76
82
|
const readsManifest = p.tasks.some((t) => JSON.stringify(t.task ?? {}).includes('"pkg.'))
|
|
83
|
+
const sealed =
|
|
84
|
+
used.size > 0 &&
|
|
85
|
+
sealsConfig(root, path.join(path.resolve(root, p.dir), `vx.config.${format}`))
|
|
86
|
+
if (used.size > 0 && !sealed) imported = true
|
|
87
|
+
if (sealed) anySealed = true
|
|
77
88
|
return {
|
|
78
89
|
name: p.name,
|
|
79
90
|
dir: p.dir,
|
|
80
91
|
importLines: [
|
|
81
92
|
...(readsManifest ? [MANIFEST_IMPORT] : []),
|
|
82
|
-
...
|
|
93
|
+
...(sealed
|
|
94
|
+
? decls.filter(([name]) => used.has(name)).map(([, line]) => line)
|
|
95
|
+
: presetImportLines(used, root, p.dir, format)),
|
|
83
96
|
],
|
|
84
97
|
tasks: p.tasks.map(({ name, todos, task }) => ({ name, todos, task })),
|
|
85
98
|
}
|
|
86
99
|
})
|
|
87
100
|
|
|
88
|
-
const { inputs, env, pass } = mapping.globals
|
|
89
101
|
const extraFiles: MigrationPlan['extraFiles'] = []
|
|
90
|
-
if (
|
|
91
|
-
inputs.length > 0 ||
|
|
92
|
-
env.length > 0 ||
|
|
93
|
-
pass.length > 0 ||
|
|
94
|
-
shared.lists.length > 0 ||
|
|
95
|
-
probes.named.length > 0
|
|
96
|
-
) {
|
|
102
|
+
if (decls.length > 0 && (imported || !anySealed)) {
|
|
97
103
|
extraFiles.push({
|
|
98
104
|
relPath: presetFile(format),
|
|
99
|
-
contents:
|
|
105
|
+
contents: [...decls.map(([, line]) => `export ${line}`), ''].join('\n'),
|
|
100
106
|
})
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -303,23 +309,24 @@ function nameProbes(projects: readonly TurboMappedProject[]): {
|
|
|
303
309
|
return { named: named.sort((a, b) => order.indexOf(a) - order.indexOf(b)), usedBy }
|
|
304
310
|
}
|
|
305
311
|
|
|
306
|
-
|
|
312
|
+
/** Each preset value as `[name, 'const name = …']`, in the preset's order. */
|
|
313
|
+
function presetDeclarations(
|
|
307
314
|
inputs: string[],
|
|
308
315
|
env: string[],
|
|
309
316
|
pass: string[],
|
|
310
317
|
shared: readonly SharedList[],
|
|
311
318
|
probes: readonly Probe[],
|
|
312
|
-
): string {
|
|
319
|
+
): [string, string][] {
|
|
313
320
|
// Escape each entry via the shared `quote()` — a turbo.json global (a file
|
|
314
321
|
// glob, or an env name a user hand-wrote) may contain a `'`/`\`/newline that
|
|
315
322
|
// would otherwise splice into a malformed, unloadable `vx-preset.ts`.
|
|
316
323
|
const arr = (xs: string[]): string => `[${xs.map(quote).join(', ')}]`
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
for (const
|
|
323
|
-
|
|
324
|
-
return
|
|
324
|
+
const out: [string, string][] = []
|
|
325
|
+
const add = (name: string, value: string) => out.push([name, `const ${name} = ${value}`])
|
|
326
|
+
if (inputs.length > 0) add('globalInputs', arr(inputs))
|
|
327
|
+
if (env.length > 0) add('globalEnvInputs', arr(env))
|
|
328
|
+
if (pass.length > 0) add('globalPassThroughEnv', arr(pass))
|
|
329
|
+
for (const l of shared) add(l.name, arr([...l.values]))
|
|
330
|
+
for (const probe of probes) add(probe.name, quote(probe.command))
|
|
331
|
+
return out
|
|
325
332
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// A tsconfig that is a composite project (or sets `rootDir`) refuses a
|
|
2
|
+
// source file outside its directory: withastro/astro's
|
|
3
|
+
// `scripts/tsconfig.json` type-checked the written `vx.config.mjs`, and its
|
|
4
|
+
// import of the root `vx-preset.mjs` failed the build (TS6059, TS6307). So
|
|
5
|
+
// did `packages/astro/tsconfig.test.json`, which takes in the nested
|
|
6
|
+
// `performance/vx.config.mjs`. A config such a project takes in declares
|
|
7
|
+
// the preset values it uses itself.
|
|
8
|
+
|
|
9
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
type TsConfig = {
|
|
13
|
+
extends?: unknown
|
|
14
|
+
include?: unknown
|
|
15
|
+
exclude?: unknown
|
|
16
|
+
files?: unknown
|
|
17
|
+
compilerOptions?: { composite?: unknown; rootDir?: unknown }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(file: string): TsConfig | null {
|
|
21
|
+
try {
|
|
22
|
+
return (Bun.JSONC.parse(readFileSync(file, 'utf8')) ?? {}) as TsConfig
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A tsconfig glob as `Bun.Glob` reads it, relative to the tsconfig's own directory. */
|
|
29
|
+
function pattern(p: string): string {
|
|
30
|
+
const rel = p.replace(/^\$\{configDir\}\/?/, '').replace(/^\.\//, '')
|
|
31
|
+
if (rel === '' || rel === '.') return '**/*'
|
|
32
|
+
// A directory names everything under it, as tsc reads it.
|
|
33
|
+
return /[*?{]/.test(rel) || /\.[A-Za-z0-9]+$/.test(rel) ? rel : `${rel.replace(/\/$/, '')}/**/*`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Does the project `file` states take in `rel` (relative to its directory) and refuse files outside it? */
|
|
37
|
+
function seals(file: string, rel: string): boolean {
|
|
38
|
+
let sealed: boolean | undefined
|
|
39
|
+
let include: unknown
|
|
40
|
+
let exclude: unknown
|
|
41
|
+
let files: unknown
|
|
42
|
+
let at: TsConfig | null = read(file)
|
|
43
|
+
let from = path.dirname(file)
|
|
44
|
+
// The nearest file that states a field decides it, as `extends` resolves.
|
|
45
|
+
for (let depth = 0; at !== null && depth < 8; depth++) {
|
|
46
|
+
const o = at.compilerOptions
|
|
47
|
+
if (sealed === undefined && o?.composite !== undefined) sealed = o.composite === true
|
|
48
|
+
if (sealed !== true && typeof o?.rootDir === 'string') sealed = true
|
|
49
|
+
include ??= at.include
|
|
50
|
+
exclude ??= at.exclude
|
|
51
|
+
files ??= at.files
|
|
52
|
+
const ext = at.extends
|
|
53
|
+
// A package (`@tsconfig/node20`) is not followed: what it seals is rare.
|
|
54
|
+
if (typeof ext !== 'string' || !ext.startsWith('.')) break
|
|
55
|
+
const next = path.resolve(from, ext.endsWith('.json') ? ext : `${ext}.json`)
|
|
56
|
+
from = path.dirname(next)
|
|
57
|
+
at = read(next)
|
|
58
|
+
}
|
|
59
|
+
if (sealed !== true) return false
|
|
60
|
+
const hit = (list: unknown): boolean =>
|
|
61
|
+
Array.isArray(list) &&
|
|
62
|
+
list.some((g) => typeof g === 'string' && new Bun.Glob(pattern(g)).match(rel))
|
|
63
|
+
if (hit(exclude)) return false
|
|
64
|
+
if (Array.isArray(files) && files.some((f) => typeof f === 'string' && pattern(f) === rel))
|
|
65
|
+
return true
|
|
66
|
+
if (Array.isArray(include)) return hit(include)
|
|
67
|
+
// `files` alone lists exactly what it compiles; neither means everything.
|
|
68
|
+
return !Array.isArray(files)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Is `config` (absolute) taken in by a sealing tsconfig (`tsconfig*.json`)
|
|
73
|
+
* in its directory or one above it, below `root`? The root's own project
|
|
74
|
+
* holds the preset, so it refuses nothing.
|
|
75
|
+
*/
|
|
76
|
+
export function sealsConfig(root: string, config: string): boolean {
|
|
77
|
+
for (let dir = path.dirname(config); dir !== root && dir.startsWith(root);) {
|
|
78
|
+
let names: string[] = []
|
|
79
|
+
try {
|
|
80
|
+
names = readdirSync(dir).filter((n) => /^tsconfig.*\.json$/.test(n))
|
|
81
|
+
} catch {}
|
|
82
|
+
const rel = path.relative(dir, config).split(path.sep).join('/')
|
|
83
|
+
if (names.some((n) => seals(path.join(dir, n), rel))) return true
|
|
84
|
+
const up = path.dirname(dir)
|
|
85
|
+
if (up === dir) break
|
|
86
|
+
dir = up
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|