@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @vzn/vx-migrate
2
2
 
3
- Everything for adopting [`@vzn/vx`](https://github.com/vznjs/vx) from Turborepo, Nx or Vite Task, in one package with zero dependencies:
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.633",
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.633"
43
+ "@vzn/vx": "^0.0.635"
43
44
  },
44
45
  "repository": {
45
46
  "type": "git",
@@ -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
- ...presetImportLines(used, root, p.dir, format),
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: renderPreset(inputs, env, pass, shared.lists, probes.named),
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
- function renderPreset(
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 lines: string[] = []
318
- if (inputs.length > 0) lines.push(`export const globalInputs = ${arr(inputs)}`)
319
- if (env.length > 0) lines.push(`export const globalEnvInputs = ${arr(env)}`)
320
- if (pass.length > 0) lines.push(`export const globalPassThroughEnv = ${arr(pass)}`)
321
- for (const l of shared) lines.push(`export const ${l.name} = ${arr([...l.values])}`)
322
- for (const probe of probes) lines.push(`export const ${probe.name} = ${quote(probe.command)}`)
323
- lines.push('')
324
- return lines.join('\n')
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
+ }