@vzn/vx-migrate 0.0.636 → 0.0.638

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.636",
3
+ "version": "0.0.638",
4
4
  "license": "MIT",
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",
5
+ "description": "Adopt @vzn/vx from Turborepo, Nx or package.json scripts: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
6
  "keywords": [
7
7
  "vx",
8
8
  "vx-plugin",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "peerDependencies": {
43
- "@vzn/vx": "^0.0.636"
43
+ "@vzn/vx": "^0.0.638"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
package/src/adopt.ts CHANGED
@@ -152,7 +152,7 @@ export function listedPackages(root: string, packages: readonly string[]): strin
152
152
  )
153
153
  }
154
154
 
155
- /** Install `packages` (at `version`, given one) with the repo's manager, its output on the terminal. */
155
+ /** Install `packages` (at `version`, given one) with the repo's manager; its output only on failure. */
156
156
  export function install(
157
157
  root: string,
158
158
  packages: readonly string[],
@@ -164,7 +164,7 @@ export function install(
164
164
  installArgv(root, pm, version === null ? packages : packages.map((p) => `${p}@${version}`)),
165
165
  'installing vx',
166
166
  // pnpm 12 refuses a version younger than minimumReleaseAge, and vx
167
- // releases daily; its words scroll by above, so the way out is named.
167
+ // releases daily; its words print above, so the way out is named.
168
168
  pm === 'pnpm'
169
169
  ? `; if pnpm refused a version published within its minimumReleaseAge, list ${packages.join(', ')} under minimumReleaseAgeExclude in pnpm-workspace.yaml`
170
170
  : '',
@@ -206,11 +206,26 @@ async function runManager(
206
206
  ranHint = '',
207
207
  ): Promise<string> {
208
208
  const line = argv.join(' ')
209
- process.stdout.write(`vx-migrate: ${line}\n`)
210
- const spawn = (cmd: string[]) =>
211
- Bun.spawn(cmd, { cwd: root, stdio: ['inherit', 'inherit', 'inherit'] }).exited
209
+ // The report's `install` row says what ran; a terminal sees it while it
210
+ // runs, on a line the report then takes the place of.
211
+ const live = process.stdout.isTTY === true
212
+ if (live) process.stdout.write(`\n ${line.slice(0, (process.stdout.columns ?? 80) - 4)} …`)
213
+ // The manager's own output (progress, deprecations, audit, postinstalls)
214
+ // is shown only when it fails: mid-init it buried the report (Growth's
215
+ // first-run funnel F3, 2026-10-13).
216
+ let said = ''
217
+ const spawn = async (cmd: string[]) => {
218
+ const p = Bun.spawn(cmd, { cwd: root, stdin: 'inherit', stdout: 'pipe', stderr: 'pipe' })
219
+ const [out, err, code] = await Promise.all([
220
+ new Response(p.stdout).text(),
221
+ new Response(p.stderr).text(),
222
+ p.exited,
223
+ ])
224
+ said = out + err
225
+ return code
226
+ }
212
227
  let code: number
213
- let why = ''
228
+ let failed = ''
214
229
  try {
215
230
  code = await spawn(argv)
216
231
  } catch (e) {
@@ -220,12 +235,14 @@ async function runManager(
220
235
  code = await spawn(launcher)
221
236
  } catch (e2) {
222
237
  code = -1
223
- why = `: ${(e2 as Error).message}`
238
+ failed = `: ${(e2 as Error).message}`
224
239
  }
225
240
  }
241
+ if (live) process.stdout.write('\r\x1b[2K\x1b[1A')
226
242
  if (code !== 0) {
243
+ process.stderr.write(said)
227
244
  throw new UserError(
228
- `${what} failed (${line} exited ${code}${why}); run it, then vx-migrate again${code > 0 ? ranHint : ''}`,
245
+ `${what} failed (${line} exited ${code}${failed}); run it, then vx-migrate again${code > 0 ? ranHint : ''}`,
229
246
  )
230
247
  }
231
248
  return line
@@ -29,7 +29,7 @@ async function spare(
29
29
  if (ids === undefined) todos.set(todo, (ids = []))
30
30
  ids.push(id)
31
31
  }
32
- return { byName: mapping.byName, gaps: { notes: mapping.gaps.notes, todos } }
32
+ return { byName: mapping.byName, gaps: { ...mapping.gaps, todos } }
33
33
  }
34
34
 
35
35
  /**
@@ -97,7 +97,7 @@ export function adoptionPlugin(
97
97
  const mapped = await mapping!
98
98
  if (!warned) {
99
99
  warned = true
100
- warnGaps(ctx.warn, plugin.name, mapped.gaps)
100
+ warnGaps(ctx.warn, plugin.name, mapped.gaps, ctx.lock)
101
101
  }
102
102
  const project = mapped.byName.get(ctx.name)
103
103
  if (project === undefined) return
@@ -0,0 +1,65 @@
1
+ // The workspace edges the mappers reason about. Core reads no manifest
2
+ // edge: a run's `^task` follows a plugin's (`@vzn/vx-lockfile`'s, from the
3
+ // lockfile the manager wrote from these manifests). Turbo and Nx draw
4
+ // package edges from the same manifests, so the mappers read them here: a
5
+ // dependency, dev or optional entry whose key names a workspace package
6
+ // orders it; a peer on one only reaches it.
7
+
8
+ import type { PackageMeta } from './packages.js'
9
+
10
+ export interface ManifestGraph {
11
+ /** Packages this one builds after, sorted. */
12
+ directDeps(name: string): readonly string[]
13
+ /** Every package a change reaches this one through, sorted. */
14
+ transitiveDeps(name: string): readonly string[]
15
+ /** Every package a change here reaches, sorted. */
16
+ transitiveDependents(name: string): readonly string[]
17
+ }
18
+
19
+ const ORDER_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies'] as const
20
+
21
+ export function manifestGraph(metas: readonly PackageMeta[]): ManifestGraph {
22
+ const names = new Set(metas.map((m) => m.name))
23
+ const order = new Map<string, string[]>()
24
+ const reach = new Map<string, string[]>()
25
+ const dependents = new Map<string, string[]>()
26
+ const keys = (field: unknown): string[] =>
27
+ field !== null && typeof field === 'object' ? Object.keys(field) : []
28
+ for (const m of metas) {
29
+ const pkg = m.packageJson as unknown as Record<string, unknown>
30
+ const own = new Set<string>()
31
+ for (const f of ORDER_FIELDS) for (const k of keys(pkg[f])) if (names.has(k)) own.add(k)
32
+ own.delete(m.name)
33
+ const all = new Set(own)
34
+ for (const k of keys(pkg['peerDependencies'])) if (names.has(k) && k !== m.name) all.add(k)
35
+ order.set(m.name, [...own].sort())
36
+ reach.set(m.name, [...all].sort())
37
+ for (const d of all) {
38
+ const list = dependents.get(d)
39
+ if (list === undefined) dependents.set(d, [m.name])
40
+ else list.push(m.name)
41
+ }
42
+ }
43
+ const closure = (edges: ReadonlyMap<string, readonly string[]>) => {
44
+ const memo = new Map<string, readonly string[]>()
45
+ return (name: string): readonly string[] => {
46
+ let out = memo.get(name)
47
+ if (out !== undefined) return out
48
+ const seen = new Set<string>()
49
+ const stack = [...(edges.get(name) ?? [])]
50
+ while (stack.length > 0) {
51
+ const n = stack.pop()!
52
+ if (seen.has(n)) continue
53
+ seen.add(n)
54
+ stack.push(...(edges.get(n) ?? []))
55
+ }
56
+ memo.set(name, (out = [...seen].sort()))
57
+ return out
58
+ }
59
+ }
60
+ return {
61
+ directDeps: (name) => order.get(name) ?? [],
62
+ transitiveDeps: closure(reach),
63
+ transitiveDependents: closure(dependents),
64
+ }
65
+ }
@@ -0,0 +1,52 @@
1
+ // Core reads no `package.json`: a project is a directory with a vx.config,
2
+ // named by its config, a `discover` plugin or its path. A Turbo or Nx repo
3
+ // writes no vx.config, so its projects are the package manager's members,
4
+ // which `discover` names here (`memberProjects`). The mappers read scripts,
5
+ // names and dependencies, so each project's manifest is read here once
6
+ // per run: the members' as `discover` lists them, the rest once per
7
+ // projects array (core hands every `project` call of a run the same one).
8
+ import path from 'node:path'
9
+ import { type DiscoveredProject, type ProjectMeta } from '@vzn/vx'
10
+ import { listPackages, type PackageMeta } from './packages.js'
11
+
12
+ /** The manifests the last `discover` read, by directory. */
13
+ let listed = new Map<string, PackageMeta>()
14
+ const read = new WeakMap<readonly ProjectMeta[], Promise<PackageMeta[]>>()
15
+
16
+ /**
17
+ * The package manager's members at `root` as `discover` entries, each
18
+ * named by its `package.json`; none where the root holds no
19
+ * `package.json` or `pnpm-workspace.yaml`.
20
+ */
21
+ export async function memberProjects(root: string): Promise<DiscoveredProject[]> {
22
+ const exists = await Promise.all(
23
+ ['package.json', 'pnpm-workspace.yaml'].map((f) => Bun.file(path.join(root, f)).exists()),
24
+ )
25
+ const members = exists.some(Boolean) ? await listPackages(root) : []
26
+ listed = new Map(members.map((m) => [path.resolve(m.dir), m]))
27
+ return members.map((m) => ({ dir: m.dir, name: m.name }))
28
+ }
29
+
30
+ /** `metas` with each one's `package.json` (`{ name }` where it has none or it does not parse). */
31
+ export function withManifests(metas: readonly ProjectMeta[]): Promise<PackageMeta[]> {
32
+ let out = read.get(metas)
33
+ if (out === undefined) {
34
+ out = Promise.all(metas.map(withManifest))
35
+ read.set(metas, out)
36
+ }
37
+ return out
38
+ }
39
+
40
+ async function withManifest(meta: ProjectMeta): Promise<PackageMeta> {
41
+ const known = listed.get(path.resolve(meta.dir))
42
+ const pkg: unknown =
43
+ known?.packageJson ??
44
+ (await Bun.file(path.join(meta.dir, 'package.json'))
45
+ .json()
46
+ .catch(() => null))
47
+ const packageJson =
48
+ pkg !== null && typeof pkg === 'object' && !Array.isArray(pkg)
49
+ ? (pkg as PackageMeta['packageJson'])
50
+ : { name: meta.name }
51
+ return { name: meta.name, dir: meta.dir, configPath: meta.configPath, packageJson }
52
+ }
@@ -10,13 +10,7 @@
10
10
 
11
11
  import { mkdir, rename } from 'node:fs/promises'
12
12
  import path from 'node:path'
13
- import {
14
- buildPackageGraph,
15
- foldScriptHooks,
16
- isLiteralPattern,
17
- outputsOverlap,
18
- VERSION,
19
- } from '@vzn/vx'
13
+ import { foldScriptHooks, isLiteralPattern, outputsOverlap, VERSION } from '@vzn/vx'
20
14
  import type { Gaps } from './plugin-gaps.js'
21
15
 
22
16
  /** What the stage uses of a mapping: tags and tasks per package name, and the gaps to report once. */
@@ -41,7 +35,7 @@ function codeId(): Promise<string> {
41
35
  code ??= (async () => {
42
36
  const parts: string[] = [
43
37
  VERSION,
44
- ...[buildPackageGraph, foldScriptHooks, isLiteralPattern, outputsOverlap].map(String),
38
+ ...[foldScriptHooks, isLiteralPattern, outputsOverlap].map(String),
45
39
  ]
46
40
  const files: string[] = []
47
41
  for await (const f of new Bun.Glob('**/*.{ts,cjs}').scan(import.meta.dir)) files.push(f)
@@ -67,6 +61,7 @@ interface Held {
67
61
  ][]
68
62
  readonly notes: string[]
69
63
  readonly todos: [string, string[]][]
64
+ readonly live: string[]
70
65
  }
71
66
 
72
67
  /** `map()`, or the mapping it returned last under the same `parts`. */
@@ -84,7 +79,7 @@ export async function cachedMapping(
84
79
  if (held?.key === key) {
85
80
  return {
86
81
  byName: new Map(held.byName),
87
- gaps: { notes: held.notes, todos: new Map(held.todos) },
82
+ gaps: { notes: held.notes, todos: new Map(held.todos), live: held.live },
88
83
  }
89
84
  }
90
85
  const mapping = await map()
@@ -99,6 +94,7 @@ export async function cachedMapping(
99
94
  ]),
100
95
  notes: [...mapping.gaps.notes],
101
96
  todos: [...mapping.gaps.todos].map(([todo, ids]) => [todo, [...ids]]),
97
+ live: [...mapping.gaps.live],
102
98
  }
103
99
  // Best-effort, and whole or absent: a reader never sees half a file, and
104
100
  // a cache dir it cannot write costs the next run a mapping, not the run.
package/src/migrate-nx.ts CHANGED
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { stat } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { type MigrationFormat, type MigrationPlan, type ProjectMeta } from '@vzn/vx'
6
+ import { type GeneratedProject, type MigrationFormat, type MigrationPlan } from '@vzn/vx'
7
+ import type { PackageMeta } from './packages.js'
7
8
  import { mapNxWorkspace, nxSizeText, parseNxGraph, readNxJson } from './nx/nx-map.js'
8
9
  import { spareTrackedOutputs, trackedFiles, trackedKinds } from './tracked-outputs.js'
9
10
  import { adoptedToolNotes } from './workspace-notes.js'
@@ -17,11 +18,13 @@ export const NX_GRAPH_REL = '.nx/workspace-data/project-graph.json'
17
18
  /** `snapshot`: the graph file to read, absolute; the checked-in one by default. */
18
19
  export async function migrateNx(
19
20
  root: string,
20
- metas: readonly ProjectMeta[],
21
+ metas: readonly PackageMeta[],
21
22
  format: MigrationFormat = 'ts',
22
23
  snapshot = path.join(root, NX_GRAPH_REL),
23
- /** This run writes `vx.workspace.ts`, with nx.json's run settings as fields. */
24
+ /** This run writes `.vx/workspace.ts`, with nx.json's run settings as fields. */
24
25
  writesWorkspace = false,
26
+ /** No package.json anywhere: each config names its project and holds every edge. */
27
+ bare = false,
25
28
  ): Promise<MigrationPlan> {
26
29
  const graph = parseNxGraph(await Bun.file(snapshot).text(), path.relative(root, snapshot))
27
30
  const tracked = await trackedFiles(root)
@@ -33,9 +36,8 @@ export async function migrateNx(
33
36
  manifestField: (key) => ({ raw: `pkg.${key}` }),
34
37
  migration: true,
35
38
  })
36
- // nx() takes each committed file under an output back every run; a
37
- // written config must carry the same `!` entries, or its first run's
38
- // clean deletes them.
39
+ // A reader whose `!` hides a committed file under another task's output
40
+ // runs uncached, as under nx().
39
41
  if (tracked !== null)
40
42
  for (const [id, todo] of spareTrackedOutputs(root, mapped.projects, tracked)) {
41
43
  const at = id.lastIndexOf('#')
@@ -48,14 +50,20 @@ export async function migrateNx(
48
50
  runner: 'nx',
49
51
  keys: (lock) => `Nx keys each project on the npm packages it depends on in ${lock}`,
50
52
  })
51
- const unlisted = await unlistedProjects(root, metas, mapped.projects)
53
+ const unlisted = bare
54
+ ? { manifests: [], notes: bareNotes(mapped.projects) }
55
+ : await unlistedProjects(root, metas, mapped.projects)
56
+ if (bare) for (const p of mapped.projects) explicitOnly(p)
52
57
  return {
53
58
  headerNotes: [
54
- 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
55
- 'are frozen as static config; `nx:run-commands` targets are their shell lines, and ' +
56
- 'every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate ' +
57
- 'installed until those targets are rewritten as commands); targets with `.env` files ' +
58
- 'run through `nx-env`',
59
+ bare
60
+ ? 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
61
+ 'are frozen as static config; `nx:run-commands` targets are their shell lines'
62
+ : 'migrating from the resolved project-graph snapshot — plugin-inferred targets ' +
63
+ 'are frozen as static config; `nx:run-commands` targets are their shell lines, and ' +
64
+ 'every other executor runs as itself through `nx-exec` (keep Nx and @vzn/vx-migrate ' +
65
+ 'installed until those targets are rewritten as commands); targets with `.env` files ' +
66
+ 'run through `nx-env`',
59
67
  ...adopted,
60
68
  ],
61
69
  projects: mapped.projects.map((p) =>
@@ -74,6 +82,38 @@ export async function migrateNx(
74
82
  }
75
83
  }
76
84
 
85
+ /**
86
+ * A project with no manifest is named by its path unless its config names
87
+ * it, and a `^` edge follows the lockfile plugin's package edges, which a
88
+ * repo with no package.json has none of: the config keeps Nx's name, and
89
+ * the explicit edges the mapper drew from Nx's graph are every edge.
90
+ */
91
+ function explicitOnly(p: GeneratedProject): void {
92
+ p.writeName = true
93
+ for (const t of p.tasks) {
94
+ const deps = t.task?.['dependsOn']
95
+ if (!Array.isArray(deps)) continue
96
+ const kept = deps.filter((d) => typeof d !== 'string' || !d.startsWith('^'))
97
+ // A group keeps its list, as `dropUnheldDeps` keeps one.
98
+ if (kept.length > 0 || t.task!['exec'] === undefined) t.task!['dependsOn'] = kept
99
+ else delete t.task!['dependsOn']
100
+ }
101
+ }
102
+
103
+ /** `nx-exec` and `nx-env` are this package's bins, and a repo with no package.json cannot install it. */
104
+ function bareNotes(projects: readonly GeneratedProject[]): string[] {
105
+ const ids = projects.flatMap((p) =>
106
+ p.tasks.flatMap((t) => {
107
+ const cmd = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
108
+ return typeof cmd === 'string' && /^nx-(exec|env) /.test(cmd) ? [`${p.name}#${t.name}`] : []
109
+ }),
110
+ )
111
+ if (ids.length === 0) return []
112
+ return [
113
+ `${ids.join(', ')} run${ids.length === 1 ? 's' : ''} through nx-exec or nx-env, @vzn/vx-migrate's bins, which a repo with no package.json cannot install: rewrite ${ids.length === 1 ? 'it' : 'them'} as commands`,
114
+ ]
115
+ }
116
+
77
117
  /**
78
118
  * Nx projects no workspace glob lists (an integrated repo's `project.json`
79
119
  * libraries): core finds a project only by a listed package.json, so a
@@ -83,7 +123,7 @@ export async function migrateNx(
83
123
  */
84
124
  async function unlistedProjects(
85
125
  root: string,
86
- metas: readonly ProjectMeta[],
126
+ metas: readonly PackageMeta[],
87
127
  projects: readonly { name: string; dir: string }[],
88
128
  ): Promise<{ manifests: { relPath: string; contents: string }[]; notes: string[] }> {
89
129
  const listed = new Set(metas.map((m) => path.resolve(m.dir)))
@@ -122,9 +162,10 @@ async function unlistedProjects(
122
162
  }
123
163
  }
124
164
 
125
- /** A `vx.workspace.ts` field nx.json's run settings call for. */
165
+ /** A `.vx/workspace.ts` field nx.json's run settings call for. */
126
166
  export interface NxWorkspaceField {
127
- readonly field: 'concurrency' | 'affectedBase' | 'cacheRetention'
167
+ /** The setting's path: its group, then its field. */
168
+ readonly field: 'run.concurrency' | 'run.affectedBase' | 'cache.retention'
128
169
  /** The value as TypeScript source; undefined when vx cannot read it. */
129
170
  readonly source: string | undefined
130
171
  /** What to tell a user whose workspace file this run does not write. */
@@ -135,7 +176,7 @@ const quoted = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}
135
176
 
136
177
  /**
137
178
  * nx.json's run-wide settings `nx()` applies live: each a field of the
138
- * written `vx.workspace.ts`, or a migrated repo that ran one task at a time
179
+ * written `.vx/workspace.ts`, or a migrated repo that ran one task at a time
139
180
  * for a shared database ran on every core. nx.json only: the files are
140
181
  * written for every machine, so `NX_PARALLEL` and friends are not read.
141
182
  */
@@ -153,23 +194,23 @@ export function nxWorkspaceFields(json: Record<string, unknown> | undefined): Nx
153
194
  const parallel = nx?.parallel ?? nx?.tasksRunnerOptions?.default?.options?.parallel
154
195
  if (typeof parallel === 'number' && Number.isInteger(parallel) && parallel > 0)
155
196
  out.push({
156
- field: 'concurrency',
197
+ field: 'run.concurrency',
157
198
  source: String(parallel),
158
- note: `nx.json \`parallel: ${parallel}\`: add \`concurrency: ${parallel}\` to vx.workspace.ts`,
199
+ note: `nx.json \`parallel: ${parallel}\`: add \`run: { concurrency: ${parallel} }\` to .vx/workspace.ts`,
159
200
  })
160
201
  const base = nx?.defaultBase ?? nx?.affected?.defaultBase
161
202
  if (typeof base === 'string' && base.trim() !== '')
162
203
  out.push({
163
- field: 'affectedBase',
204
+ field: 'run.affectedBase',
164
205
  source: quoted(base.trim()),
165
- note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`affectedBase: ${JSON.stringify(base.trim())}\` to vx.workspace.ts`,
206
+ note: `nx.json \`defaultBase\` ${JSON.stringify(base.trim())}: add \`run: { affectedBase: ${JSON.stringify(base.trim())} }\` to .vx/workspace.ts`,
166
207
  })
167
208
  const size = nxSizeText(nx?.maxCacheSize)
168
209
  if (size !== undefined)
169
210
  out.push({
170
- field: 'cacheRetention',
211
+ field: 'cache.retention',
171
212
  source: /^\d+[KMG]?B$/.test(size) ? `{ maxSize: ${quoted(size)} }` : undefined,
172
- note: `nx.json \`maxCacheSize\`: add \`cacheRetention: { maxSize: ${JSON.stringify(size)} }\` to vx.workspace.ts`,
213
+ note: `nx.json \`maxCacheSize\`: add \`cache: { retention: { maxSize: ${JSON.stringify(size)} } }\` to .vx/workspace.ts`,
173
214
  })
174
215
  return out
175
216
  }