Skip to content

Commit fa921b6

Browse files
Merge pull request #100 from torusresearch/feat/migration-inspection-cli
feat: add a read-only migration inspection command
2 parents 8618d1c + 95de591 commit fa921b6

19 files changed

Lines changed: 1128 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@toruslabs/torus-scripts": minor
3+
---
4+
5+
Add the read-only `torus-scripts-upgrade` command. It inspects an old project and does not change files.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../packages/torus-scripts/skills/migrating-to-torus-cli/SKILL.md

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Torus CLI and devtools
55
## Packages
66

77
- [`@toruslabs/config`](packages/config): TypeScript profiles for libraries and applications.
8-
- [`@toruslabs/torus-scripts`](packages/torus-scripts): Vite+ build and format presets.
8+
- [`@toruslabs/torus-scripts`](packages/torus-scripts): Vite+ build and format presets. Use `torus-scripts-upgrade` to inspect an old project.
99
- [`@toruslabs/oxlint-config-typescript`](packages/oxlint-config-typescript): JavaScript and TypeScript lint preset.
1010
- [`@toruslabs/oxlint-config-react`](packages/oxlint-config-react): React and Tailwind lint presets.
1111
- [`@toruslabs/oxlint-config-vue`](packages/oxlint-config-vue): Vue and Tailwind lint presets.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
],
1111
"repository": "git@github.com:torusresearch/torus-cli.git",
1212
"scripts": {
13-
"build": "vp run -r build --fail-if-no-match",
13+
"build": "vp run -r --fail-if-no-match build",
1414
"check": "vp check",
1515
"typecheck": "tsc --noEmit -p packages/torus-scripts/tsconfig.json && tsc --noEmit -p packages/oxlint-config-typescript/tsconfig.json && tsc --noEmit -p packages/oxlint-config-react/tsconfig.json && tsc --noEmit -p packages/oxlint-config-vue/tsconfig.json && tsc --noEmit -p packages/oxlint-config-node/tsconfig.json",
1616
"test": "vp test",

‎packages/torus-scripts/README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ For React, import `reactLintPreset` and `tailwindLintPreset` from `@toruslabs/ox
5858

5959
The old `@toruslabs/eslint-config-*` packages are removed. Do not treat an Oxlint rule as equal to an old ESLint rule until you test it in your project.
6060

61+
## Upgrade from the old CLI
62+
63+
The package includes an agent skill at `skills/migrating-to-torus-cli/SKILL.md`. In a consumer project, read it at `node_modules/@toruslabs/torus-scripts/skills/migrating-to-torus-cli/SKILL.md`. To make it available as a project skill in Cursor, copy it to `.cursor/skills/migrating-to-torus-cli/SKILL.md` in that project. Keep the skill and this README from the same package version.
64+
65+
Run `torus-scripts-upgrade <path>` to inspect an old project without changing files. It reports unsupported custom behavior and exits nonzero for those cases. Run `vp migrate` at the workspace root, not inside each package, and review its changes. The diagnostic does not run `vp migrate` for you.
66+
67+
Replace the old commands:
68+
69+
- `torus-scripts build` → define `pack` in the consumer's `vite.config.ts`, then run `vp pack`.
70+
- `torus-scripts lint` → select an Oxlint package, set root `lint` and `fmt` config, then run `vp check`.
71+
- `torus-scripts start` → set the consumer's Vite+ dev task. No shared dev server ships.
72+
- `torus-scripts release` → use Changesets at the repository root.
73+
- Lerna workspace tasks → run `vp run -r build` with explicit build tasks.
74+
6175
## TypeScript profiles
6276

6377
`@toruslabs/config` exports `tsconfig.base.json`, `tsconfig.library.json`, and `tsconfig.node.json`. Replace `tsconfig.default.json` or `tsconfig.build.json` with the matching profile.

‎packages/torus-scripts/package.json‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
"name": "@toruslabs/torus-scripts",
33
"version": "8.1.0",
44
"type": "module",
5-
"description": "Vite+ build and format presets",
5+
"description": "Vite+ build and format presets with a read-only migration diagnostic",
6+
"bin": {
7+
"torus-scripts-upgrade": "./dist/migrate/cli.mjs"
8+
},
69
"files": [
7-
"dist"
10+
"dist",
11+
"skills/migrating-to-torus-cli/SKILL.md"
812
],
913
"scripts": {
1014
"build": "vp pack",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
name: migrating-to-torus-cli
3+
description: Use when moving an existing consumer project from old Torus scripts, ESLint presets, or build tools to current Torus CLI and Vite+ presets.
4+
---
5+
6+
# Migrate a consumer to Torus CLI
7+
8+
Use `node_modules/@toruslabs/torus-scripts/README.md` in an installed consumer, or `packages/torus-scripts/README.md` in this repository, as the current source for presets, commands, and known lint gaps. A successful diagnostic does not prove that a project is ready to migrate.
9+
10+
## Inspect before changes
11+
12+
1. Find the consumer's workspace root. Check `git status`, Node and npm versions, lockfile, scripts, TypeScript profiles, build output, package entry points, lint rules, format rules, tests, and release steps.
13+
2. Use the installed `torus-scripts-upgrade <absolute-consumer-path>` diagnostic, or build this repository's `@toruslabs/torus-scripts` package first if working from source. The diagnostic reads files only. Record its output and exit code.
14+
3. Also inspect files that the diagnostic does not cover. In particular, check `package.json` scripts and entry paths, `eslint.config.*`, `tsconfig.json`, and README claims. If the diagnostic reports nothing, do not skip this step.
15+
4. Identify behavior the new presets do not provide. Stop and ask before removing or changing a required output format, path, browser target, polyfill, CSS behavior, release step, or custom transform. A diagnostic exit code of 1 requires separate review.
16+
17+
## Migrate
18+
19+
1. Agree on the required outputs and checks before editing. Preserve existing work; do not overwrite unrelated changes.
20+
2. Run `vp help` and `vp help migrate` for the installed Vite+ version. Run `vp migrate` at the consumer workspace root, not in each package. Review its diff before manual edits. If it cannot run, report why; do not claim it ran.
21+
3. Replace old `torus-scripts` commands with the documented `vp` tasks and package presets. Use a consumer-owned `vite.config.ts` for `pack`, `lint`, and `fmt`. Select the TypeScript profile that fits the package. Keep framework plugins in the consumer. If Vite+ cannot read a YAML Prettier config, compare its settings with `formatPreset()` before removing Prettier.
22+
4. Do not assume `libraryPreset` preserves CJS paths, old bundles, browser targets, polyfills, or CDN output. Compare built files with `main`, `module`, `types`, `exports`, `unpkg`, and `jsdelivr`. Test both `require` and ESM import. A new ESM build can fail if it imports named exports from a CommonJS dependency. Do not describe an external-import ESM build as a standalone CDN script.
23+
5. Replace removed ESLint packages only after testing equivalent Oxlint rules. Check Vue template-only and Vue script lint gaps separately. If Tailwind lint is used, merge `jsPlugins`, `rules`, and `settings` rather than spreading fragments over one another. Check that presets and TypeScript profiles exist in the target registry; use local packages only for an explicitly local test.
24+
6. Keep or replace release automation deliberately. The current guide uses Changesets at the workspace root; a standalone package can retain `npm publish` until its release process is set up.
25+
7. Review `vp migrate` warnings. It can skip YAML Prettier configs, ESLint rules, or plugin references and change Vitest versions. Check the new TypeScript profile for browser globals and stricter type errors. Check the selected Node runtime against preset engine requirements.
26+
27+
## Monorepos moving off Lerna
28+
29+
1. Map Lerna's workspace, build order, version, and publish tasks before removing it. Run `vp migrate` once at the workspace root. Give each package its own `build` task. Use `vp run -r build` at the root, and verify dependency order and each package's output. Put `vp run` options before the task name, as in `vp run -r --fail-if-no-match build`. `vp run` forwards arguments after the task name to the task, and npm lifecycle scripts fail on unknown options with `EUNKNOWNCONFIG`.
30+
2. Preserve public package paths before changing their manifests. For packages that expose `dist/lib.esm/index.js`, `dist/lib.cjs/index.js`, and `dist/lib.cjs/types/index.d.ts`, use a consumer-owned `vite.config.ts` with explicit output directories. Adapt this pattern to the installed Vite+ version:
31+
32+
```ts
33+
import { defineConfig } from "vite-plus";
34+
35+
export default defineConfig({
36+
pack: {
37+
entry: "src/index.ts",
38+
format: {
39+
esm: { outDir: "dist/lib.esm" },
40+
cjs: { outDir: "dist/lib.cjs" },
41+
},
42+
dts: true,
43+
outExtensions: () => ({ js: ".js", dts: ".d.ts" }),
44+
copy: [{ from: "dist/lib.cjs/index.d.ts", to: "dist/lib.cjs/types" }],
45+
sourcemap: true,
46+
},
47+
});
48+
```
49+
50+
3. Set up Changesets at the workspace root when replacing Lerna releases. Set `baseBranch` to the actual branch. Put only packages that must share a version in `fixed`. Use `access: "public"` for public scoped packages; decide whether private packages should version (`privatePackages.version`) and verify internal dependency updates. Check release scripts and published tarballs before removing old release tasks.
51+
4. Select `@toruslabs/config/tsconfig.library.json` for libraries and `tsconfig.node.json` for Node packages only after confirming those files exist in the installed registry version. For a standalone server, set `rootDir: "./src"` in its package tsconfig if the shared profile resolves it against the wrong directory. Check output layout and type errors after changing profiles.
52+
5. If a required preset has not been published, test with local packages only by explicit agreement. Keep local paths out of committed manifests and lockfiles. Separate local build results from clean registry installation; do not call the migration complete until the registry install and package checks pass.
53+
54+
## Verify and report
55+
56+
Run the consumer's install, build, lint/check, typecheck, and tests when available. Inspect package contents and import/require entry points if published files changed. If browser tests fail, separate missing browser executables from test failures and browser timeouts. Report each command and result, preserved behavior, manual follow-up, and any check you could not run. Never report a migration as complete if package outputs or lint coverage remain unverified.
57+
58+
Example: A project can get “No known unsupported behavior detected” while its `package.json` entry paths still point at old bundle folders. Inspect the scripts and published paths before changing it.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env node
2+
3+
import { argv, stderr, stdout } from "node:process";
4+
5+
import { exitCodes } from "./model.js";
6+
7+
function isMissingPeer(error: unknown): boolean {
8+
return error instanceof Error && "code" in error && error.code === "ERR_MODULE_NOT_FOUND" && error.message.includes("'vite-plus'");
9+
}
10+
11+
try {
12+
// run.js loads the vite-plus peer dependency. A dynamic import lets a missing peer exit as a failure, not as "blocked".
13+
const { run } = await import("./run.js");
14+
process.exitCode = await run(argv.slice(2), {
15+
stdout: (text) => stdout.write(text),
16+
stderr: (text) => stderr.write(text),
17+
});
18+
} catch (error) {
19+
const message = isMissingPeer(error)
20+
? "torus-scripts-upgrade needs the vite-plus peer dependency. Install vite-plus in this project, then run the command again."
21+
: `torus-scripts-upgrade failed: ${error instanceof Error ? error.message : String(error)}`;
22+
stderr.write(`${message}\n`);
23+
process.exitCode = exitCodes.failed;
24+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { stat } from "node:fs/promises";
2+
import path from "node:path";
3+
4+
import { inspectManifest } from "./manifest.js";
5+
import type { Finding } from "./model.js";
6+
import { inspectRollupConfig } from "./rollup.js";
7+
import { type ProjectFile, walkProject } from "./walk.js";
8+
9+
export interface InspectionOptions {
10+
maxDirectories?: number;
11+
}
12+
13+
async function inspectFile(file: ProjectFile, relativePath: string): Promise<Finding[]> {
14+
switch (file.kind) {
15+
case "rollup":
16+
return [{ code: "config", path: relativePath }, ...(await inspectRollupConfig(file.path, relativePath))];
17+
case "manifest":
18+
return inspectManifest(file.path, relativePath);
19+
case "torus-config":
20+
return [{ code: "torus-config", path: relativePath }];
21+
case "lerna":
22+
return [{ code: "lerna", path: relativePath }];
23+
case "config":
24+
return [{ code: "config", path: relativePath }];
25+
case "escaped-link":
26+
return [{ code: "escaped-symlink", path: relativePath }];
27+
default: {
28+
const unhandled: never = file.kind;
29+
throw new Error(`Unhandled project file kind: ${String(unhandled)}`);
30+
}
31+
}
32+
}
33+
34+
function compareText(left: string, right: string): number {
35+
return left < right ? -1 : Number(left > right);
36+
}
37+
38+
export async function inspectProject(root: string, { maxDirectories = 10_000 }: InspectionOptions = {}): Promise<Finding[]> {
39+
const projectRoot = path.resolve(root);
40+
if (!(await stat(projectRoot)).isDirectory()) throw new Error(`Inspection root is not a directory: ${projectRoot}`);
41+
42+
const { files, truncated } = await walkProject(projectRoot, maxDirectories);
43+
const findings = (await Promise.all(files.map((file) => inspectFile(file, path.relative(projectRoot, file.path))))).flat();
44+
if (truncated) findings.push({ code: "truncated", path: ".", detail: `${maxDirectories} directories` });
45+
return findings.sort((left, right) => compareText(left.path, right.path) || compareText(left.code, right.code));
46+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { readFile } from "node:fs/promises";
2+
3+
import type { Code, Finding } from "./model.js";
4+
5+
interface Manifest {
6+
commands: Set<string>;
7+
dependencies: Map<string, string[]>;
8+
hasWorkspaces: boolean;
9+
}
10+
11+
const dependencyFields = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"] as const;
12+
13+
function isRecord(value: unknown): value is Record<string, unknown> {
14+
return typeof value === "object" && value !== null && !Array.isArray(value);
15+
}
16+
17+
function stringEntries(value: unknown): [string, string][] {
18+
if (!isRecord(value)) return [];
19+
return Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string");
20+
}
21+
22+
function commandWords(script: string): string[] {
23+
return script
24+
.split(/[\s;&|()]+/)
25+
.filter(Boolean)
26+
.map((word) => word.replace(/^(?:.*\/)?node_modules\/\.bin\//, ""));
27+
}
28+
29+
async function readManifest(manifestPath: string): Promise<Manifest | undefined> {
30+
let data: unknown;
31+
try {
32+
data = JSON.parse(await readFile(manifestPath, "utf8"));
33+
} catch (error) {
34+
if (error instanceof SyntaxError) return undefined;
35+
throw error;
36+
}
37+
if (!isRecord(data)) return undefined;
38+
39+
const dependencies = new Map<string, string[]>();
40+
for (const field of dependencyFields) {
41+
for (const [name, spec] of stringEntries(data[field])) dependencies.set(name, [...(dependencies.get(name) ?? []), spec]);
42+
}
43+
return {
44+
commands: new Set(stringEntries(data.scripts).flatMap(([, script]) => commandWords(script))),
45+
dependencies,
46+
hasWorkspaces: data.workspaces !== undefined,
47+
};
48+
}
49+
50+
function torusScriptsSpecCode(spec: string): Code | undefined {
51+
const target = spec.replace(/^npm:@toruslabs\/torus-scripts@/, "");
52+
if (/^(?:workspace|file|link|portal):/.test(target)) return "local-dependency";
53+
const major = /^\s*[~^]?\s*(?:>=?\s*)?v?(\d+)/.exec(target)?.[1];
54+
if (major === undefined) return "unknown-dependency-version";
55+
return Number(major) < 8 ? "legacy-dependency" : undefined;
56+
}
57+
58+
export async function inspectManifest(manifestPath: string, relativePath: string): Promise<Finding[]> {
59+
const manifest = await readManifest(manifestPath);
60+
if (manifest === undefined) return [{ code: "unreadable-manifest", path: relativePath }];
61+
62+
const findings: Finding[] = [];
63+
const add = (code: Code, detail?: string) => findings.push({ code, path: relativePath, ...(detail === undefined ? {} : { detail }) });
64+
const uses = (tool: string) => manifest.dependencies.has(tool) || manifest.commands.has(tool);
65+
66+
if (relativePath !== "package.json" && manifest.hasWorkspaces) add("nested-workspaces");
67+
if (uses("lerna")) add("lerna");
68+
if (uses("release-it")) add("release-it");
69+
if (manifest.commands.has("torus-scripts")) add("legacy-commands");
70+
for (const spec of manifest.dependencies.get("@toruslabs/torus-scripts") ?? []) {
71+
const code = torusScriptsSpecCode(spec);
72+
if (code !== undefined) add(code, spec);
73+
}
74+
return findings;
75+
}

0 commit comments

Comments
 (0)