src/workspace/migration.ts — the plan → files seam
Purpose
Section titled “Purpose”What any adoption tool needs once it has decided what a package’s tasks
are: the plan’s shape, TypeScript emission, the overwrite guard, the
writes (or the --dry print), and the report. Core knows no source
format here — a mapper returns a MigrationPlan and applyMigration
does the rest — so a config written by vx init (package.json scripts,
migrate-scripts.ts, in core) and one written by @vzn/vx-migrate
(Turbo, Nx, its own package) read exactly the same. Before 2026-09-10
the Turbo and Nx mappers lived in src/cli/; core now reads no other
runner’s format.
Public surface (exported from @vzn/vx but for migrate-scripts.ts)
Section titled “Public surface (exported from @vzn/vx but for migrate-scripts.ts)”export interface MigrationPlan { headerNotes: string[] projects: GeneratedProject[] extraFiles: { relPath: string; contents: string }[] notes: string[]}export interface GeneratedProject { name: string dir: string importLines: string[] tasks: GeneratedTask[]}export interface GeneratedTask { name: string todos: string[] // rendered as `// TODO(vx-migrate): …` (a line break continues the comment) and listed in the report task: Record<string, unknown> | null // TaskConfig-shaped; arrays may hold a RawExpr splice}export interface RawExpr { readonly raw: string} // a verbatim TS expressionexport type MigrationFormat = 'ts' | 'mjs'export interface ApplyMigrationArgs { root: string metas: readonly ProjectMeta[] plan: MigrationPlan source: string verb: string dry: boolean force: boolean init?: boolean notes?: readonly string[] format?: MigrationFormat // default 'ts'}export function applyMigration(args: ApplyMigrationArgs): Promise<number>export function quoteTsLiteral(s: string): stringexport const PERSISTENT_TASK_NAMES: ReadonlySet<string>export const PERSISTENT_TODO: string// A script with the pre/post hooks npm runs around it, as one sh command:// each part a subshell, the chain stopping at the first that fails, and the// forwarded `--` args reaching the body alone (item 905). No hooks: the// body, verbatim. `vx init` and `@vzn/vx-migrate` both fold through it.export function foldScriptHooks( pre: string | undefined, body: string, post: string | undefined,): string
// migrate-scripts.ts — the package.json-scripts mapper `vx init` runs through the seam;// core-internal, not on `@vzn/vx`export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlanexport function delegatedScript(command: string): string | null- A generated config is
import type { ProjectConfig } from '@vzn/vx'plussatisfies ProjectConfig: the type-only form loads in a workspace that runs the binary without the package installed.format: 'mjs'(--mjs) writes the same object untyped asvx.config.mjs, for a package whosetsconfigincludewould not cover a.tsconfig. - An empty plan in a single-project workspace whose root
package.jsonhas noworkspacesfield, while packages with scripts sit beneath it, is reported as such: the report names the packages the missing field never reaches (item 248) instead of “nothing to migrate”. - The workspace file is written when no
vx.workspace.{ts,mts,js,mjs}exists: a migrated workspace declares its plugins, and an empty list is a complete workspace (the floor). - Nothing is overwritten without
force: a discovered project with any existing config, and every actual write target (a synthesized root project, the Turbo preset), abort the whole run before a byte lands. initdistinguishes the two callers on an empty plan:vx initon a workspace with no scripts still writes the workspace file and shows a worked example; a migration with nothing to convert is an error.quoteTsLiteralescapes backslash, quote and raw newlines — a value with an embedded newline (legal JSON) must round-trip through the loader.PERSISTENT_TASK_NAMESis the one guess every mapper shares for “this task never exits”; a source that says so is believed instead.
What it does NOT do
Section titled “What it does NOT do”- Read
turbo.json, an Nx graph, or any other runner’s format. That is@vzn/vx-migrate, whoseturbo()andnx()plugins run the same mappings live.
tests/init.test.ts (scripts → files, the empty workspace, the
overwrite guard, the vx migrate pointer, delegatedScript);
packages/vx-migrate/tests/ (Turbo and Nx end to end through the seam).