diff --git a/.changeset/codemod-infra-upgrade-command.md b/.changeset/codemod-infra-upgrade-command.md new file mode 100644 index 000000000..95bb89c69 --- /dev/null +++ b/.changeset/codemod-infra-upgrade-command.md @@ -0,0 +1,6 @@ +--- +"@tailor-platform/sdk": minor +"@tailor-platform/sdk-codemod": patch +--- + +Add `upgrade` command with codemod.com-based architecture for automated SDK version migrations. Codemod execution is handled by the new `@tailor-platform/sdk-codemod` package. diff --git a/CLAUDE.md b/CLAUDE.md index 3f4f82045..4a7100b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Refer to `example/` for working implementations of all patterns (config, models, Key files: -- `example/tailor.config.ts` - Configuration with defineConfig, defineAuth, defineIdp, defineStaticWebSite, defineGenerators +- `example/tailor.config.ts` - Configuration with defineConfig, defineAuth, defineIdp, defineStaticWebSite, definePlugins - `example/tailordb/*.ts` - Model definitions with `db.type()` - `example/resolvers/*.ts` - Resolver implementations with `createResolver` - `example/executors/*.ts` - Executor implementations with `createExecutor` @@ -74,13 +74,12 @@ Multi-event trigger variants handle multiple events in one executor: Args include `event` (short name like `"created"`) and `rawEvent` (full event type like `"tailordb.type_record.created"`) for runtime type narrowing. -### Generators +### Plugins -`defineGenerators()` takes tuples as rest arguments (see `example/tailor.config.ts`). The `@tailor-platform/kysely-type` generator (built into the SDK) is required for `getDB()` in resolvers/executors/workflows. +`definePlugins()` takes plugin instances as rest arguments (see `example/tailor.config.ts`). The `kyselyTypePlugin` from `@tailor-platform/sdk/plugin/kysely-type` is required for `getDB()` in resolvers/executors/workflows. `defineGenerators()` is deprecated — use `definePlugins()` instead. ### Configuration -- `definePlugins()` is available for reusable type/resolver/executor generation - Static website `.url` property is resolved at deployment time — use it in CORS and redirect URIs ## Developer Guides diff --git a/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/codemod.yaml b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/codemod.yaml new file mode 100644 index 000000000..40c07fc3e --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/codemod.yaml @@ -0,0 +1,7 @@ +name: "@tailor-platform/define-generators-to-plugins" +version: "1.0.0" +description: "Migrate defineGenerators() tuple syntax to definePlugins() with explicit plugin imports" +engine: jssg +language: typescript +since: "1.0.0" +until: "2.0.0" diff --git a/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/scripts/transform.ts b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/scripts/transform.ts new file mode 100644 index 000000000..27ee03017 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/scripts/transform.ts @@ -0,0 +1,217 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { Edit, SgNode } from "@ast-grep/napi"; + +/** + * Known plugin mappings from package name to plugin function/import. + */ +const PLUGIN_MAP: Record = { + "@tailor-platform/kysely-type": { + functionName: "kyselyTypePlugin", + importPath: "@tailor-platform/sdk/plugin/kysely-type", + }, + "@tailor-platform/seed": { + functionName: "seedPlugin", + importPath: "@tailor-platform/sdk/plugin/seed", + }, + "@tailor-platform/enum-constants": { + functionName: "enumConstantsPlugin", + importPath: "@tailor-platform/sdk/plugin/enum-constants", + }, + "@tailor-platform/file-utils": { + functionName: "fileUtilsPlugin", + importPath: "@tailor-platform/sdk/plugin/file-utils", + }, +}; + +/** + * Transform defineGenerators() to definePlugins(): + * + * 1. Rename `defineGenerators` → `definePlugins` in import and call + * 2. Transform tuple arguments `["pkg-name", config]` → `pluginFn(config)` + * 3. Add plugin imports from their respective SDK paths + * @param source - Source code to transform + * @returns Transformed source or null if no changes needed + */ +export default function transform(source: string): string | null { + const tree = parse(Lang.TypeScript, source).root(); + + // Only process files that import defineGenerators from the SDK. + // This prevents modifying unrelated files that happen to contain the identifier. + if (!source.includes("defineGenerators")) { + return null; + } + if (!source.includes("@tailor-platform/sdk")) { + return null; + } + + const edits: Edit[] = []; + const importsToAdd: Map = new Map(); // importPath -> functionName + + // Step 1: Find and transform defineGenerators call arguments (tuples → plugin calls) + const callNodes = tree.findAll({ + rule: { + pattern: "defineGenerators($$$ARGS)", + }, + }); + + let totalArgs = 0; + let migratedArgs = 0; + + for (const callNode of callNodes) { + // Find array/tuple arguments inside the call + const args = callNode.getMultipleMatches("ARGS"); + for (const arg of args) { + if (!arg.isNamed() || arg.kind() === "comment") continue; + totalArgs++; + + // Match tuple pattern: ["package-name", config] + if (arg.kind() === "array") { + const children = arg + .children() + .filter((c: SgNode) => c.isNamed() && c.kind() !== "comment"); + if (children.length >= 1) { + const packageNameNode = children[0]!; + const packageName = packageNameNode.text().replace(/^["']|["']$/g, ""); + const mapping = PLUGIN_MAP[packageName]; + + if (mapping) { + migratedArgs++; + importsToAdd.set(mapping.importPath, mapping.functionName); + // Build replacement: pluginFn(config) or pluginFn() if no config + const configNodes = children.slice(1); + const configText = + configNodes.length > 0 ? configNodes.map((c: SgNode) => c.text()).join(", ") : ""; + const replacement = `${mapping.functionName}(${configText})`; + edits.push(arg.replace(replacement)); + } + } + } + } + } + + // If any arguments could not be migrated, skip the entire transform to avoid + // producing invalid code (e.g. mixing tuple syntax with plugin calls). + if (totalArgs > 0 && migratedArgs < totalArgs) { + return null; + } + + // Step 2: Rename defineGenerators → definePlugins in call expressions only. + // Import specifiers are handled separately in step 3 to avoid duplicates. + const callIdentifiers = tree.findAll({ + rule: { + pattern: "defineGenerators", + kind: "identifier", + inside: { + kind: "call_expression", + }, + }, + }); + + for (const id of callIdentifiers) { + edits.push(id.replace("definePlugins")); + } + + // Step 3: Handle import specifier for defineGenerators. + // If the import already contains definePlugins (mixed config), remove the + // defineGenerators specifier instead of renaming it to avoid duplicates. + const sdkImportStatements = tree.findAll({ + rule: { + kind: "import_statement", + has: { + kind: "string", + regex: "^[\"']@tailor-platform/sdk[\"']$", + }, + }, + }); + + // Check across ALL SDK import statements whether definePlugins is already + // imported (may be in a different statement than defineGenerators). + const hasDefinePlugins = sdkImportStatements.some((stmt) => + stmt + .findAll({ rule: { kind: "import_specifier" } }) + .some((s) => + s.children().some((c: SgNode) => c.kind() === "identifier" && c.text() === "definePlugins"), + ), + ); + + for (const importStmt of sdkImportStatements) { + const specifiers = importStmt.findAll({ + rule: { kind: "import_specifier" }, + }); + + for (const spec of specifiers) { + const identNode = spec + .children() + .find((c: SgNode) => c.kind() === "identifier" && c.text() === "defineGenerators"); + if (!identNode) continue; + + if (hasDefinePlugins) { + // Remove the entire specifier (including trailing/leading comma+whitespace) + // by replacing the specifier text + any adjacent comma + const specText = spec.text(); + const importText = importStmt.text(); + const idx = importText.indexOf(specText); + if (idx !== -1) { + // Check for trailing comma+whitespace or leading comma+whitespace + const afterSpec = importText.slice(idx + specText.length); + const beforeSpec = importText.slice(0, idx); + let removeFrom = idx; + let removeTo = idx + specText.length; + + if (afterSpec.match(/^\s*,/)) { + removeTo = idx + specText.length + (afterSpec.match(/^\s*,\s*/)![0]?.length ?? 0); + } else if (beforeSpec.match(/,\s*$/)) { + removeFrom = idx - (beforeSpec.match(/,\s*$/)![0]?.length ?? 0); + } + + const cleaned = importText.slice(0, removeFrom) + importText.slice(removeTo); + edits.push(importStmt.replace(cleaned)); + } + } else { + edits.push(identNode.replace("definePlugins")); + } + } + } + + if (edits.length === 0) { + return null; + } + + // Apply all edits + let result = tree.commitEdits(edits); + + // Step 4: Add new import statements for plugin functions (skip already-present ones) + if (importsToAdd.size > 0) { + const importLines: string[] = []; + for (const [importPath, functionName] of importsToAdd) { + const line = `import { ${functionName} } from "${importPath}";`; + // Skip if this function is already imported (mixed config scenario). + // Use a targeted regex to match import statements containing the function + // name, rather than checking the import path which could match unrelated + // imports from the same module (e.g. importing a different symbol). + const importExists = new RegExp(`import\\s*\\{[^}]*\\b${functionName}\\b[^}]*\\}`, "m").test( + result, + ); + if (importExists) continue; + importLines.push(line); + } + // Sort for deterministic output and skip if all imports already present + importLines.sort(); + if (importLines.length === 0) { + return result; + } + + // Find insertion point: after the @tailor-platform/sdk import line + const sdkImportRegex = /^(import\s+.*from\s+["']@tailor-platform\/sdk["'];?)$/m; + const match = sdkImportRegex.exec(result); + if (match) { + const insertPos = (match.index ?? 0) + match[0].length; + result = result.slice(0, insertPos) + "\n" + importLines.join("\n") + result.slice(insertPos); + } else { + // Fallback: prepend imports at the top of the file + result = importLines.join("\n") + "\n" + result; + } + } + + return result; +} diff --git a/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/expected.ts b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/expected.ts new file mode 100644 index 000000000..66d4266ea --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/expected.ts @@ -0,0 +1,15 @@ +import * as path from "node:path"; +import * as url from "node:url"; +import { definePlugins } from "@tailor-platform/sdk"; +import { enumConstantsPlugin } from "@tailor-platform/sdk/plugin/enum-constants"; +import { kyselyTypePlugin } from "@tailor-platform/sdk/plugin/kysely-type"; +import config from "./tailor.config"; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const outDir = path.join(__dirname, "generators-compat-out"); + +export default config; +export const generators = definePlugins( + kyselyTypePlugin({ distPath: path.join(outDir, "db.ts") }), + enumConstantsPlugin({ distPath: path.join(outDir, "enums.ts") }), +); diff --git a/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/input.ts b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/input.ts new file mode 100644 index 000000000..1ab554761 --- /dev/null +++ b/packages/sdk-codemod/codemods/v2/define-generators-to-plugins/tests/basic/input.ts @@ -0,0 +1,13 @@ +import * as path from "node:path"; +import * as url from "node:url"; +import { defineGenerators } from "@tailor-platform/sdk"; +import config from "./tailor.config"; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const outDir = path.join(__dirname, "generators-compat-out"); + +export default config; +export const generators = defineGenerators( + ["@tailor-platform/kysely-type", { distPath: path.join(outDir, "db.ts") }], + ["@tailor-platform/enum-constants", { distPath: path.join(outDir, "enums.ts") }], +); diff --git a/packages/sdk-codemod/eslint.config.js b/packages/sdk-codemod/eslint.config.js new file mode 100644 index 000000000..19c30ca72 --- /dev/null +++ b/packages/sdk-codemod/eslint.config.js @@ -0,0 +1,11 @@ +import eslint from "@eslint/js"; +import { defineConfig, globalIgnores } from "eslint/config"; +import oxlint from "eslint-plugin-oxlint"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + globalIgnores(["dist/", "codemods/"]), + eslint.configs.recommended, + tseslint.configs.recommended, + ...oxlint.configs["flat/recommended"], +]); diff --git a/packages/sdk-codemod/package.json b/packages/sdk-codemod/package.json new file mode 100644 index 000000000..92d9f5dfa --- /dev/null +++ b/packages/sdk-codemod/package.json @@ -0,0 +1,52 @@ +{ + "name": "@tailor-platform/sdk-codemod", + "version": "0.1.0", + "description": "Codemod runner for Tailor Platform SDK upgrades", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/tailor-platform/sdk.git", + "directory": "packages/sdk-codemod" + }, + "bin": "dist/index.js", + "files": [ + "CHANGELOG.md", + "dist", + "LICENSE", + "README.md" + ], + "type": "module", + "scripts": { + "build": "tsdown", + "lint": "oxlint . && eslint --cache .", + "lint:fix": "oxlint --fix . && eslint --cache --fix .", + "typecheck": "tsc --noEmit", + "test": "vitest", + "prepublish": "pnpm run build", + "publint": "publint --strict" + }, + "dependencies": { + "@ast-grep/napi": "^0.42.0", + "chalk": "5.6.2", + "diff": "8.0.4", + "pathe": "2.0.3", + "picomatch": "4.0.4", + "pkg-types": "2.3.0", + "politty": "0.4.13", + "semver": "7.7.4", + "zod": "4.3.6" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.12.2", + "@types/picomatch": "4.0.2", + "@types/semver": "7.7.1", + "eslint": "10.2.0", + "eslint-plugin-oxlint": "1.58.0", + "oxlint": "1.58.0", + "tsdown": "0.21.7", + "typescript": "5.9.3", + "typescript-eslint": "8.58.0", + "vitest": "4.1.2" + } +} diff --git a/packages/sdk-codemod/src/helpers.ts b/packages/sdk-codemod/src/helpers.ts new file mode 100644 index 000000000..cf97126a8 --- /dev/null +++ b/packages/sdk-codemod/src/helpers.ts @@ -0,0 +1,22 @@ +import { parse, Lang } from "@ast-grep/napi"; +import type { SgRoot } from "@ast-grep/napi"; + +/** + * Parse TypeScript source code into an ast-grep root. + * @param source - TypeScript source code + * @returns Parsed AST root + */ +export function parseTS(source: string): SgRoot { + return parse(Lang.TypeScript, source); +} + +/** + * Parse TSX source code into an ast-grep root. + * @param source - TSX source code + * @returns Parsed AST root + */ +export function parseTSX(source: string): SgRoot { + return parse(Lang.Tsx, source); +} + +export type { SgRoot, SgNode, Edit } from "@ast-grep/napi"; diff --git a/packages/sdk-codemod/src/index.ts b/packages/sdk-codemod/src/index.ts new file mode 100644 index 000000000..48693cb5a --- /dev/null +++ b/packages/sdk-codemod/src/index.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import * as path from "pathe"; +import { arg, defineCommand, runMain } from "politty"; +import { readPackageJSON } from "pkg-types"; +import { z } from "zod"; +import { getApplicableCodemods, resolveCodemodScript } from "./registry"; +import { runCodemods } from "./runner"; +import type { RunOutput } from "./types"; + +const packageJson = await readPackageJSON(path.dirname(fileURLToPath(import.meta.url)) + "/.."); + +const main = defineCommand({ + name: packageJson.name ?? "sdk-codemod", + description: packageJson.description ?? "Codemod runner for Tailor Platform SDK upgrades", + args: z + .object({ + from: arg(z.string(), { + description: "Source SDK version (the version before upgrade)", + }), + to: arg(z.string(), { + description: "Target SDK version (the version after upgrade)", + }), + target: arg(z.string().default("."), { + description: "Project directory to transform", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + }) + .strict(), + run: async (args) => { + const targetPath = path.resolve(args.target); + const dryRun = args["dry-run"]; + + const codemods = getApplicableCodemods(args.from, args.to); + + const output: RunOutput = { + codemodsApplied: 0, + codemodsSkipped: 0, + filesModified: [], + warnings: [], + errors: [], + }; + + if (codemods.length === 0) { + process.stdout.write(JSON.stringify(output) + "\n"); + return; + } + + // Resolve script paths for all applicable codemods + const codemodEntries = codemods.map((codemod) => ({ + codemod, + scriptPath: resolveCodemodScript(codemod.scriptPath), + })); + + for (const { codemod } of codemodEntries) { + process.stderr.write(`Running: ${codemod.name} - ${codemod.description}\n`); + } + + try { + const result = await runCodemods(codemodEntries, targetPath, dryRun); + + output.codemodsApplied = result.appliedCodemodIds.size; + output.codemodsSkipped = codemods.length - result.appliedCodemodIds.size; + output.filesModified = result.filesModified; + output.warnings = result.warnings; + + if (result.changed) { + process.stderr.write(` ${result.filesModified.length} file(s) modified\n`); + } else { + process.stderr.write(" No changes needed\n"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.errors.push({ codemodId: "pipeline", message }); + process.stderr.write(` Failed: ${message}\n`); + } + + // Write JSON result to stdout + process.stdout.write(JSON.stringify(output) + "\n"); + + if (output.errors.length > 0) { + process.exit(1); + } + }, +}); + +runMain(main, { version: packageJson.version }); diff --git a/packages/sdk-codemod/src/registry.test.ts b/packages/sdk-codemod/src/registry.test.ts new file mode 100644 index 000000000..76ea8e3b8 --- /dev/null +++ b/packages/sdk-codemod/src/registry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getApplicableCodemods } from "./registry"; + +describe("getApplicableCodemods", () => { + it("returns codemods when upgrading across their version boundary", () => { + const codemods = getApplicableCodemods("1.33.0", "2.0.0"); + expect(codemods.length).toBeGreaterThan(0); + expect(codemods[0]!.id).toBe("v2/define-generators-to-plugins"); + }); + + it("returns empty when both versions are before the codemod boundary", () => { + expect(getApplicableCodemods("1.0.0", "1.5.0")).toEqual([]); + }); + + it("returns empty when both versions are after the codemod boundary", () => { + expect(getApplicableCodemods("2.0.0", "3.0.0")).toEqual([]); + }); + + it("returns empty when from is already at the codemod boundary", () => { + expect(getApplicableCodemods("2.0.0", "2.1.0")).toEqual([]); + }); + + it("throws for invalid semver versions", () => { + expect(() => getApplicableCodemods("invalid", "2.0.0")).toThrow("Invalid fromVersion"); + expect(() => getApplicableCodemods("1.0.0", "invalid")).toThrow("Invalid toVersion"); + }); +}); diff --git a/packages/sdk-codemod/src/registry.ts b/packages/sdk-codemod/src/registry.ts new file mode 100644 index 000000000..950f8a7d9 --- /dev/null +++ b/packages/sdk-codemod/src/registry.ts @@ -0,0 +1,51 @@ +import * as url from "node:url"; +import * as path from "pathe"; +import { lt, gte, valid } from "semver"; +import type { CodemodPackage } from "./types"; + +const CODEMODS_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "codemods"); + +const allCodemods: CodemodPackage[] = [ + { + id: "v2/define-generators-to-plugins", + name: "defineGenerators → definePlugins", + description: + "Migrate defineGenerators() tuple syntax to definePlugins() with explicit plugin imports", + since: "1.0.0", + until: "2.0.0", + scriptPath: "v2/define-generators-to-plugins/scripts/transform.js", + legacyPatterns: ["defineGenerators"], + }, +]; + +/** + * Resolve the absolute path to a codemod script. + * @param scriptPath - Relative path from the codemods root + * @returns Absolute path to the script file + */ +export function resolveCodemodScript(scriptPath: string): string { + return path.resolve(CODEMODS_ROOT, scriptPath); +} + +/** + * Get codemod packages applicable for a version range. + * A codemod applies when: since <= fromVersion < until <= toVersion + * @param fromVersion - Current SDK version (semver) + * @param toVersion - Target SDK version (semver) + * @returns Array of applicable codemod packages in registration order + */ +export function getApplicableCodemods(fromVersion: string, toVersion: string): CodemodPackage[] { + if (!valid(fromVersion)) { + throw new Error(`Invalid fromVersion: ${fromVersion}`); + } + if (!valid(toVersion)) { + throw new Error(`Invalid toVersion: ${toVersion}`); + } + + return allCodemods.filter( + (codemod) => + gte(fromVersion, codemod.since) && + lt(fromVersion, codemod.until) && + gte(toVersion, codemod.until), + ); +} diff --git a/packages/sdk-codemod/src/runner.test.ts b/packages/sdk-codemod/src/runner.test.ts new file mode 100644 index 000000000..f8f1ecd72 --- /dev/null +++ b/packages/sdk-codemod/src/runner.test.ts @@ -0,0 +1,216 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "pathe"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runCodemods } from "./runner"; +import type { CodemodPackage } from "./types"; + +/** + * Create a temporary directory with a test file for codemod testing. + * @param fileName - Name of the test file + * @param content - Content of the test file + * @returns Object with tmpDir path and absolute file path + */ +async function createTestProject( + fileName: string, + content: string, +): Promise<{ tmpDir: string; filePath: string }> { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-test-")); + const filePath = path.join(tmpDir, fileName); + await fs.promises.writeFile(filePath, content, "utf-8"); + return { tmpDir, filePath }; +} + +function makeCodemod(id: string, scriptPath: string, filePatterns?: string[]): CodemodPackage { + return { + id, + name: id, + description: `Test codemod ${id}`, + since: "1.0.0", + until: "2.0.0", + scriptPath, + filePatterns, + }; +} + +describe("runCodemods", () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + } + }); + + describe("chained transforms in dry-run", () => { + // Transform A: renames "oldFunc" → "midFunc" + const transformAPath = path.join(os.tmpdir(), "transform-a.ts"); + // Transform B: renames "midFunc" → "newFunc" (depends on A's output) + const transformBPath = path.join(os.tmpdir(), "transform-b.ts"); + + beforeEach(async () => { + await fs.promises.writeFile( + transformAPath, + `export default function transformA(source) { + if (!source.includes("oldFunc")) return null; + return source.replace(/\\boldFunc\\b/g, "midFunc"); + }`, + "utf-8", + ); + await fs.promises.writeFile( + transformBPath, + `export default function transformB(source) { + if (!source.includes("midFunc")) return null; + return source.replace(/\\bmidFunc\\b/g, "newFunc"); + }`, + "utf-8", + ); + }); + + afterEach(async () => { + await fs.promises.rm(transformAPath, { force: true }); + await fs.promises.rm(transformBPath, { force: true }); + }); + + it("should chain transforms so B sees A's output in dry-run", async () => { + const { tmpDir: dir } = await createTestProject("test.ts", 'const oldFunc = "hello";'); + tmpDir = dir; + + // Suppress stderr (diff output) during test + const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const result = await runCodemods( + [ + { codemod: makeCodemod("test/a", transformAPath), scriptPath: transformAPath }, + { codemod: makeCodemod("test/b", transformBPath), scriptPath: transformBPath }, + ], + tmpDir, + true, // dry-run + ); + + stderrSpy.mockRestore(); + + // Both transforms should have applied (B saw A's "midFunc" output) + expect(result.changed).toBe(true); + expect(result.filesModified).toHaveLength(1); + + // Original file should be unchanged (dry-run) + const onDisk = await fs.promises.readFile(path.join(tmpDir, "test.ts"), "utf-8"); + expect(onDisk).toBe('const oldFunc = "hello";'); + }); + + it("should produce diff showing final result (oldFunc → newFunc) in dry-run", async () => { + const { tmpDir: dir } = await createTestProject("test.ts", 'const oldFunc = "hello";'); + tmpDir = dir; + + const stderrOutput: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderrOutput.push(String(chunk)); + return true; + }); + + await runCodemods( + [ + { codemod: makeCodemod("test/a", transformAPath), scriptPath: transformAPath }, + { codemod: makeCodemod("test/b", transformBPath), scriptPath: transformBPath }, + ], + tmpDir, + true, + ); + + stderrSpy.mockRestore(); + + const output = stderrOutput.join(""); + // Diff should show oldFunc → newFunc (the chained result), NOT oldFunc → midFunc + expect(output).toContain("-const oldFunc"); + expect(output).toContain("+const newFunc"); + expect(output).not.toContain("midFunc"); + }); + + it("should write final chained result in non-dry-run", async () => { + const { tmpDir: dir, filePath } = await createTestProject( + "test.ts", + 'const oldFunc = "hello";', + ); + tmpDir = dir; + + await runCodemods( + [ + { codemod: makeCodemod("test/a", transformAPath), scriptPath: transformAPath }, + { codemod: makeCodemod("test/b", transformBPath), scriptPath: transformBPath }, + ], + tmpDir, + false, // actual run + ); + + const result = await fs.promises.readFile(filePath, "utf-8"); + expect(result).toBe('const newFunc = "hello";'); + }); + + it("should skip transform B if A produces no match for B", async () => { + const { tmpDir: dir } = await createTestProject("test.ts", 'const something = "hello";'); + tmpDir = dir; + + const result = await runCodemods( + [ + { codemod: makeCodemod("test/a", transformAPath), scriptPath: transformAPath }, + { codemod: makeCodemod("test/b", transformBPath), scriptPath: transformBPath }, + ], + tmpDir, + true, + ); + + // Neither transform matches → no changes + expect(result.changed).toBe(false); + expect(result.filesModified).toHaveLength(0); + }); + }); + + describe("filePatterns filtering", () => { + const transformPath = path.join(os.tmpdir(), "transform-upper.ts"); + + beforeEach(async () => { + await fs.promises.writeFile( + transformPath, + `export default function transform(source) { + return source.toUpperCase(); + }`, + "utf-8", + ); + }); + + afterEach(async () => { + await fs.promises.rm(transformPath, { force: true }); + }); + + it("should only apply transform to files matching filePatterns", async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "runner-pattern-test-")); + tmpDir = dir; + await fs.promises.writeFile(path.join(dir, "config.ts"), "hello", "utf-8"); + await fs.promises.writeFile(path.join(dir, "data.json"), "world", "utf-8"); + + const result = await runCodemods( + [ + { + codemod: makeCodemod("test/upper", transformPath, ["**/*.json"]), + scriptPath: transformPath, + }, + ], + dir, + false, + ); + + // Only JSON file should be modified + expect(result.filesModified).toHaveLength(1); + expect(result.filesModified[0]).toContain("data.json"); + + // TS file should be unchanged + const tsContent = await fs.promises.readFile(path.join(dir, "config.ts"), "utf-8"); + expect(tsContent).toBe("hello"); + + // JSON file should be uppercased + const jsonContent = await fs.promises.readFile(path.join(dir, "data.json"), "utf-8"); + expect(jsonContent).toBe("WORLD"); + }); + }); +}); diff --git a/packages/sdk-codemod/src/runner.ts b/packages/sdk-codemod/src/runner.ts new file mode 100644 index 000000000..fb55fb2a7 --- /dev/null +++ b/packages/sdk-codemod/src/runner.ts @@ -0,0 +1,201 @@ +import * as fs from "node:fs"; +import { glob } from "node:fs/promises"; +import * as url from "node:url"; +import chalk from "chalk"; +import { structuredPatch } from "diff"; +import * as path from "pathe"; +import picomatch from "picomatch"; +import type { CodemodPackage } from "./types"; + +/** + * A transform function that receives source text and file path, + * and returns modified source or null if no changes are needed. + * + * For AST-based transforms, use `parseTS`/`parseTSX` from helpers: + * ```typescript + * import { parseTS } from "@tailor-platform/sdk-codemod/helpers"; + * export default function transform(source: string): string | null { + * const root = parseTS(source); + * // ... findAll, replace, commitEdits + * } + * ``` + * + * For text-based transforms (e.g., JSON): + * ```typescript + * export default function transform(source: string, filePath: string): string | null { + * const json = JSON.parse(source); + * json.key = "newValue"; + * return JSON.stringify(json, null, 2); + * } + * ``` + */ +export type TransformFn = ( + source: string, + filePath: string, +) => Promise | string | null; + +/** Result of running codemods on a project. */ +export interface CodemodRunResult { + changed: boolean; + filesModified: string[]; + warnings: string[]; + /** IDs of codemods that actually produced changes in at least one file. */ + appliedCodemodIds: Set; +} + +/** Default file patterns for TypeScript files. */ +const DEFAULT_FILE_PATTERNS = ["**/*.{ts,tsx,mts,cts}"]; + +/** Directory names always excluded from file scanning. */ +const EXCLUDE_DIRS = new Set(["node_modules", "dist", ".git"]); + +/** + * Print a colorized unified diff for a single file to stderr. + * @param filePath - Absolute path to the file + * @param before - Original content + * @param after - Transformed content + */ +function printDiff(filePath: string, before: string, after: string): void { + const patch = structuredPatch(filePath, filePath, before, after, "", "", { context: 3 }); + if (patch.hunks.length === 0) return; + + process.stderr.write(`\n${chalk.bold(`--- ${filePath}`)}\n`); + process.stderr.write(`${chalk.bold(`+++ ${filePath}`)}\n`); + + for (const hunk of patch.hunks) { + process.stderr.write( + chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`), + ); + for (const line of hunk.lines) { + if (line.startsWith("+")) { + process.stderr.write(`${chalk.green(line)}\n`); + } else if (line.startsWith("-")) { + process.stderr.write(`${chalk.red(line)}\n`); + } else { + process.stderr.write(`${line}\n`); + } + } + } +} + +/** + * Load a transform module from a TypeScript file path. + * Expects the module to have a default export that is a TransformFn. + * @param scriptPath - Absolute path to the transform script + * @returns The transform function + */ +async function loadTransform(scriptPath: string): Promise { + const mod = await import(url.pathToFileURL(scriptPath).href); + if (typeof mod.default !== "function") { + throw new Error(`Transform at ${scriptPath} does not have a default export function`); + } + return mod.default as TransformFn; +} + +/** A loaded transform with its file matcher. */ +interface LoadedTransform { + id: string; + transform: TransformFn; + matches: (relativePath: string) => boolean; + legacyPatterns: string[]; +} + +/** + * Run multiple codemods on a project directory using in-memory chaining. + * Each file is processed through all transforms whose filePatterns match it. + * Later transforms see earlier transforms' output — even in dry-run mode. + * + * In dry-run mode, colorized diffs are printed to stderr. + * @param codemods - Codemod packages to run (with resolved script paths) + * @param targetPath - Project directory to transform + * @param dryRun - Whether to preview changes without writing + * @returns Combined result of all codemod executions + */ +export async function runCodemods( + codemods: Array<{ codemod: CodemodPackage; scriptPath: string }>, + targetPath: string, + dryRun: boolean, +): Promise { + // Load all transform functions with their file matchers + const loaded: LoadedTransform[] = []; + for (const { codemod, scriptPath } of codemods) { + const patterns = codemod.filePatterns ?? DEFAULT_FILE_PATTERNS; + loaded.push({ + id: codemod.id, + transform: await loadTransform(scriptPath), + matches: picomatch(patterns), + legacyPatterns: codemod.legacyPatterns ?? [], + }); + } + + // Collect all unique file patterns for glob scanning + const allPatterns = new Set(); + for (const { codemod } of codemods) { + for (const p of codemod.filePatterns ?? DEFAULT_FILE_PATTERNS) { + allPatterns.add(p); + } + } + + const filesModified: string[] = []; + const warnings: string[] = []; + const appliedCodemodIds = new Set(); + const seen = new Set(); + + // Iterate over all matching files (deduplicate across patterns) + for (const pattern of allPatterns) { + for await (const relative of glob(pattern, { + cwd: targetPath, + exclude: (name) => EXCLUDE_DIRS.has(name), + })) { + const absolute = path.resolve(targetPath, relative); + if (seen.has(absolute)) continue; + seen.add(absolute); + + let original: string; + try { + original = await fs.promises.readFile(absolute, "utf-8"); + } catch { + continue; + } + + // Chain only transforms whose filePatterns match this file + let current = original; + const matchedTransforms: LoadedTransform[] = []; + for (const lt of loaded) { + if (!lt.matches(relative)) continue; + matchedTransforms.push(lt); + const result = await lt.transform(current, absolute); + if (result != null) { + current = result; + appliedCodemodIds.add(lt.id); + } + } + + if (current !== original) { + filesModified.push(absolute); + if (dryRun) { + printDiff(absolute, original, current); + } else { + await fs.promises.writeFile(absolute, current, "utf-8"); + } + } else { + // Check each matched codemod's legacyPatterns for unmodified files + for (const lt of matchedTransforms) { + const found = lt.legacyPatterns.filter((p) => original.includes(p)); + if (found.length > 0) { + warnings.push( + `${relative}: contains ${found.join(", ")} but was not migrated automatically (rule: ${lt.id}). Manual migration may be needed.`, + ); + } + } + } + } + } + + return { + changed: filesModified.length > 0, + filesModified, + warnings, + appliedCodemodIds, + }; +} diff --git a/packages/sdk-codemod/src/transform.test.ts b/packages/sdk-codemod/src/transform.test.ts new file mode 100644 index 000000000..9e30b4c04 --- /dev/null +++ b/packages/sdk-codemod/src/transform.test.ts @@ -0,0 +1,33 @@ +import * as fs from "node:fs"; +import * as path from "pathe"; +import { describe, expect, it } from "vitest"; +import type { TransformFn } from "./runner"; + +const CODEMODS_DIR = path.resolve(__dirname, "../codemods"); + +/** + * Run a transform against its fixture files (input.ts → expected.ts). + * @param codemodPath - Relative path from the codemods root + */ +async function runFixtureTest(codemodPath: string): Promise { + const scriptPath = path.join(CODEMODS_DIR, codemodPath, "scripts/transform.ts"); + const inputPath = path.join(CODEMODS_DIR, codemodPath, "tests/basic/input.ts"); + const expectedPath = path.join(CODEMODS_DIR, codemodPath, "tests/basic/expected.ts"); + + const input = await fs.promises.readFile(inputPath, "utf-8"); + const expected = await fs.promises.readFile(expectedPath, "utf-8"); + + const mod = await import(scriptPath); + const transform = mod.default as TransformFn; + + const result = await transform(input, inputPath); + + expect(result).not.toBeNull(); + expect(result).toBe(expected); +} + +describe("codemod transforms", () => { + it("v2/define-generators-to-plugins transforms correctly", async () => { + await runFixtureTest("v2/define-generators-to-plugins"); + }); +}); diff --git a/packages/sdk-codemod/src/types.ts b/packages/sdk-codemod/src/types.ts new file mode 100644 index 000000000..f4bcd3245 --- /dev/null +++ b/packages/sdk-codemod/src/types.ts @@ -0,0 +1,34 @@ +/** + * Metadata for a codemod package. + */ +export interface CodemodPackage { + /** Unique identifier (e.g., "v2/define-generators-to-plugins") */ + id: string; + /** Human-readable display name */ + name: string; + /** Description of what this codemod transforms */ + description: string; + /** Minimum source version this codemod applies from (semver, inclusive) */ + since: string; + /** Target version this codemod upgrades to (semver, exclusive upper bound) */ + until: string; + /** Path to the jssg transform script relative to the codemods root */ + scriptPath: string; + /** Target language for codemod CLI (default: "typescript") */ + language?: string; + /** Custom file glob patterns. Defaults to TypeScript patterns when omitted. */ + filePatterns?: string[]; + /** Legacy patterns to detect in unmodified files for manual migration warnings. */ + legacyPatterns?: string[]; +} + +/** + * JSON output written to stdout by the sdk-codemod CLI. + */ +export interface RunOutput { + codemodsApplied: number; + codemodsSkipped: number; + filesModified: string[]; + warnings: string[]; + errors: Array<{ codemodId: string; message: string }>; +} diff --git a/packages/sdk-codemod/tsconfig.json b/packages/sdk-codemod/tsconfig.json new file mode 100644 index 000000000..c7b1da09f --- /dev/null +++ b/packages/sdk-codemod/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts", "tsdown.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/sdk-codemod/tsdown.config.ts b/packages/sdk-codemod/tsdown.config.ts new file mode 100644 index 000000000..54bba9aba --- /dev/null +++ b/packages/sdk-codemod/tsdown.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig([ + { + entry: ["src/index.ts"], + format: ["esm"], + target: "node18", + platform: "node", + clean: true, + outDir: "dist", + tsconfig: "./tsconfig.json", + outExtensions: () => ({ + js: ".js", + }), + }, + { + entry: { + "v2/define-generators-to-plugins/scripts/transform": + "codemods/v2/define-generators-to-plugins/scripts/transform.ts", + }, + format: ["esm"], + target: "node18", + platform: "node", + outDir: "dist/codemods", + tsconfig: "./tsconfig.json", + outExtensions: () => ({ + js: ".js", + }), + }, +]); diff --git a/packages/sdk-codemod/vitest.config.ts b/packages/sdk-codemod/vitest.config.ts new file mode 100644 index 000000000..d6d8f7e46 --- /dev/null +++ b/packages/sdk-codemod/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["**/?(*.)+(spec|test).ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + }, + ], + environment: "node", + globals: true, + watch: false, + }, +}); diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index d4b8e2af6..407c3517a 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -259,6 +259,14 @@ Commands for setting up project infrastructure. | ------------------------------------------- | ------------------------------------------------------- | | [setup github](./cli/setup.md#setup-github) | Generate GitHub Actions workflow for deployment. (beta) | +### [Upgrade Commands](./cli/upgrade.md) + +Commands for upgrading SDK versions with automated code migration. + +| Command | Description | +| ----------------------------------- | ------------------------------------------------------------ | +| [upgrade](./cli/upgrade.md#upgrade) | Run codemods to upgrade your project to a newer SDK version. | + ### [Completion](./cli/completion.md) Generate shell completion scripts for bash, zsh, and fish. diff --git a/packages/sdk/docs/cli/upgrade.md b/packages/sdk/docs/cli/upgrade.md new file mode 100644 index 000000000..8ef3d6a35 --- /dev/null +++ b/packages/sdk/docs/cli/upgrade.md @@ -0,0 +1,51 @@ + + +## upgrade + + + + + +Run codemods to upgrade your project to a newer SDK version. + + + + + +**Usage** + +``` +tailor-sdk upgrade [options] +``` + + + + + +**Options** + +| Option | Alias | Description | Required | Default | +| --------------- | ----- | --------------------------------------------- | -------- | ------- | +| `--from ` | - | SDK version before the upgrade (e.g., 1.33.0) | Yes | - | +| `--dry-run` | `-d` | Preview changes without modifying files | No | `false` | +| `--path ` | - | Project directory to upgrade | No | `"."` | + + + + + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + + + +### How It Works + +The `upgrade` command runs codemods that automatically transform your project code for breaking changes between SDK versions. The target version (`--to`) is auto-detected from the installed `@tailor-platform/sdk` in `node_modules`. + +**Typical workflow:** + +1. Update your SDK packages to the new version (e.g., `pnpm update @tailor-platform/sdk`) +2. Run `tailor-sdk upgrade --from ` to apply codemods +3. Review changes and commit + +Use `--dry-run` to preview what changes will be made before applying them. diff --git a/packages/sdk/src/cli/commands/upgrade/index.ts b/packages/sdk/src/cli/commands/upgrade/index.ts new file mode 100644 index 000000000..5f19dd147 --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/index.ts @@ -0,0 +1,35 @@ +import * as path from "pathe"; +import { arg } from "politty"; +import { z } from "zod"; +import { defineAppCommand } from "@/cli/shared/command"; + +export const upgradeCommand = defineAppCommand({ + name: "upgrade", + description: "Run codemods to upgrade your project to a newer SDK version.", + args: z + .object({ + from: arg(z.string(), { + description: "SDK version before the upgrade (e.g., 1.33.0)", + }), + "dry-run": arg(z.boolean().default(false), { + alias: "d", + description: "Preview changes without modifying files", + }), + path: arg(z.string().default("."), { + description: "Project directory to upgrade", + completion: { type: "directory" }, + }), + }) + .strict(), + run: async (args) => { + const { initTelemetry } = await import("@/cli/telemetry"); + await initTelemetry(); + + const { upgrade } = await import("./service"); + await upgrade({ + from: args.from, + dryRun: args["dry-run"], + path: path.resolve(args.path), + }); + }, +}); diff --git a/packages/sdk/src/cli/commands/upgrade/service.test.ts b/packages/sdk/src/cli/commands/upgrade/service.test.ts new file mode 100644 index 000000000..4858318bc --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/service.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RunOutput } from "./types"; + +vi.mock("@/cli/shared/logger", () => ({ + logger: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + out: vi.fn(), + }, + styles: { + success: (s: string) => s, + error: (s: string) => s, + warning: (s: string) => s, + info: (s: string) => s, + dim: (s: string) => s, + bold: (s: string) => s, + highlight: (s: string) => s, + path: (s: string) => s, + }, +})); + +vi.mock("./version-detector", () => ({ + detectInstalledVersion: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +function makeOutput(overrides: Partial = {}): RunOutput { + return { + codemodsApplied: 0, + codemodsSkipped: 0, + filesModified: [], + warnings: [], + errors: [], + ...overrides, + }; +} + +describe("upgrade service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should throw CLIError when SDK version is not detected", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue(null); + + const { upgrade } = await import("./service"); + await expect(upgrade({ from: "1.33.0", dryRun: false, path: "/test" })).rejects.toThrow( + "Could not detect installed @tailor-platform/sdk version", + ); + }); + + it("should invoke sdk-codemod with correct arguments", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: JSON.stringify(makeOutput()), + stderr: "", + status: 0, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const { upgrade } = await import("./service"); + await upgrade({ from: "1.33.0", dryRun: false, path: "/test" }); + + const expectedNpx = process.platform === "win32" ? "npx.cmd" : "npx"; + expect(spawnSync).toHaveBeenCalledWith( + expectedNpx, + expect.arrayContaining([ + "@tailor-platform/sdk-codemod@latest", + "--from", + "1.33.0", + "--to", + "2.0.0", + "--target", + "/test", + ]), + expect.objectContaining({ encoding: "utf-8" }), + ); + }); + + it("should pass --dry-run to sdk-codemod", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: JSON.stringify(makeOutput()), + stderr: "", + status: 0, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const { upgrade } = await import("./service"); + await upgrade({ from: "1.33.0", dryRun: true, path: "/test" }); + + const expectedNpx = process.platform === "win32" ? "npx.cmd" : "npx"; + expect(spawnSync).toHaveBeenCalledWith( + expectedNpx, + expect.arrayContaining(["--dry-run"]), + expect.anything(), + ); + }); + + it("should display summary from sdk-codemod output", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: JSON.stringify( + makeOutput({ + codemodsApplied: 1, + filesModified: ["/test/config.ts"], + }), + ), + stderr: "", + status: 0, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const { upgrade } = await import("./service"); + await upgrade({ from: "1.33.0", dryRun: false, path: "/test" }); + + const { logger } = await import("@/cli/shared/logger"); + const infoCalls = vi.mocked(logger.info).mock.calls.map((c) => c[0]); + expect(infoCalls.some((c) => c.includes("1 applied"))).toBe(true); + }); + + it("should throw CLIError when sdk-codemod returns errors", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: JSON.stringify( + makeOutput({ + errors: [{ codemodId: "test/fail", message: "transform failed" }], + }), + ), + stderr: "", + status: 1, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const { upgrade } = await import("./service"); + await expect(upgrade({ from: "1.33.0", dryRun: false, path: "/test" })).rejects.toThrow( + "Upgrade completed with 1 error(s)", + ); + }); + + it("should throw CLIError when spawning fails", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: "", + stderr: "", + status: null, + signal: null, + pid: 0, + output: [], + error: new Error("ENOENT"), + }); + + const { upgrade } = await import("./service"); + await expect(upgrade({ from: "1.33.0", dryRun: false, path: "/test" })).rejects.toThrow( + "Failed to run @tailor-platform/sdk-codemod", + ); + }); + + it("should forward captured stderr to process.stderr in the success path", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + const stderrPayload = "Running: define-generators-to-plugins\n 1 file(s) modified\n"; + vi.mocked(spawnSync).mockReturnValue({ + stdout: JSON.stringify( + makeOutput({ + codemodsApplied: 1, + filesModified: ["/test/config.ts"], + }), + ), + stderr: stderrPayload, + status: 0, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const stderrWrite = vi.spyOn(process.stderr, "write").mockReturnValue(true); + + const { upgrade } = await import("./service"); + await upgrade({ from: "1.33.0", dryRun: true, path: "/test" }); + + expect(stderrWrite).toHaveBeenCalledWith(stderrPayload); + }); + + it("should throw CLIError when stdout is not valid JSON", async () => { + const { detectInstalledVersion } = await import("./version-detector"); + vi.mocked(detectInstalledVersion).mockResolvedValue("2.0.0"); + + const { spawnSync } = await import("node:child_process"); + vi.mocked(spawnSync).mockReturnValue({ + stdout: "not json", + stderr: "", + status: 0, + signal: null, + pid: 0, + output: [], + error: undefined, + }); + + const { upgrade } = await import("./service"); + await expect(upgrade({ from: "1.33.0", dryRun: false, path: "/test" })).rejects.toThrow( + "Failed to parse output", + ); + }); +}); diff --git a/packages/sdk/src/cli/commands/upgrade/service.ts b/packages/sdk/src/cli/commands/upgrade/service.ts new file mode 100644 index 000000000..199633e7f --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/service.ts @@ -0,0 +1,166 @@ +import { spawnSync } from "node:child_process"; +import { CLIError } from "@/cli/shared/errors"; +import { logger, styles } from "@/cli/shared/logger"; +import { detectInstalledVersion } from "./version-detector"; +import type { RunOutput } from "./types"; + +interface UpgradeOptions { + from: string; + dryRun: boolean; + path: string; +} + +/** + * Print the upgrade summary to the terminal. + * @param output - The parsed JSON output from sdk-codemod + * @param dryRun - Whether this was a dry-run + */ +function printUpgradeSummary(output: RunOutput, dryRun: boolean): void { + if (dryRun) { + logger.info(`${styles.bold("[Dry Run]")} No files were modified.`); + logger.log(""); + } + + const total = output.codemodsApplied + output.codemodsSkipped + output.errors.length; + logger.info( + `Upgrade complete: ${styles.success(`${output.codemodsApplied} applied`)}, ${styles.dim(`${output.codemodsSkipped} skipped`)} (${total} total codemods)`, + ); + + if (output.filesModified.length > 0) { + logger.log(""); + logger.info( + `${dryRun ? "Files that would be modified" : "Modified files"} (${output.filesModified.length}):`, + ); + for (const file of output.filesModified) { + logger.log(` ${styles.path(file)}`); + } + } + + if (output.warnings.length > 0) { + logger.log(""); + logger.warn(`Manual attention needed (${output.warnings.length}):`); + for (const warning of output.warnings) { + logger.log(` ${styles.warning("!")} ${warning}`); + } + } + + if (output.errors.length > 0) { + logger.log(""); + logger.error(`Failed codemods (${output.errors.length}):`); + for (const { codemodId, message } of output.errors) { + logger.log(` ${styles.error(codemodId)}: ${message}`); + } + } +} + +/** + * Run the upgrade pipeline: + * 1. Detect target SDK version from node_modules + * 2. Invoke @tailor-platform/sdk-codemod CLI + * 3. Parse JSON output and display results + * @param options - Upgrade options + */ +export async function upgrade(options: UpgradeOptions): Promise { + const projectRoot = options.path; + + // Step 1: Detect target SDK version (the newly installed version) + const targetVersion = await detectInstalledVersion(projectRoot); + if (!targetVersion) { + throw CLIError({ + message: `Could not detect installed @tailor-platform/sdk version in ${projectRoot}`, + suggestion: + "Ensure @tailor-platform/sdk is installed. Run 'pnpm install' or 'npm install' first.", + command: "upgrade", + }); + } + + logger.info( + `Upgrading from ${styles.highlight(options.from)} → ${styles.highlight(targetVersion)}`, + ); + + if (options.dryRun) { + logger.info(`${styles.bold("[Dry Run]")} Changes will be previewed but not applied.`); + } + + logger.log(""); + + // Step 2: Invoke sdk-codemod CLI + // Use "latest" because sdk-codemod may not be published at the exact same + // version as @tailor-platform/sdk. Version filtering is handled internally + // by sdk-codemod's registry via the --from / --to arguments. + const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; + + const result = spawnSync( + npxCommand, + [ + "@tailor-platform/sdk-codemod@latest", + "--from", + options.from, + "--to", + targetVersion, + "--target", + projectRoot, + ...(options.dryRun ? ["--dry-run"] : []), + ], + { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + timeout: 300_000, + }, + ); + + if (result.error) { + throw CLIError({ + message: `Failed to run @tailor-platform/sdk-codemod: ${result.error.message}`, + suggestion: "Ensure npx is available and the network is accessible.", + command: "upgrade", + }); + } + + // Check for non-zero exit without a launch error (e.g. registry/auth/network failures) + if (result.status !== 0 && !result.stdout?.trim()) { + throw CLIError({ + message: `@tailor-platform/sdk-codemod exited with code ${result.status}`, + details: result.stderr?.trim() || "(no stderr output)", + suggestion: + "Review the error above. Common causes: invalid version arguments, network issues, or missing package registry access.", + command: "upgrade", + }); + } + + // Forward captured stderr so users see dry-run diffs and progress messages + // written by sdk-codemod. stderr is piped (not inherited) so that the error + // path above can surface it via CLIError, but on success we still need to + // replay it verbatim to keep the colorized unified diff output visible. + if (result.stderr) { + process.stderr.write(result.stderr); + } + + // Step 3: Parse JSON output + let output: RunOutput; + try { + output = JSON.parse(result.stdout); + } catch { + throw CLIError({ + message: "Failed to parse output from @tailor-platform/sdk-codemod", + details: result.stdout || "(empty stdout)", + suggestion: "This is likely a bug. Please report it.", + command: "upgrade", + }); + } + + // Step 4: Display results + // Emit structured data on stdout (honors --json via logger.out) and + // human-readable summary on stderr (via printUpgradeSummary). + logger.out(output); + printUpgradeSummary(output, options.dryRun); + + if (output.errors.length > 0) { + throw CLIError({ + message: `Upgrade completed with ${output.errors.length} error(s)`, + suggestion: "Review the errors above and re-run the upgrade after fixing the issues.", + command: "upgrade", + }); + } +} diff --git a/packages/sdk/src/cli/commands/upgrade/types.ts b/packages/sdk/src/cli/commands/upgrade/types.ts new file mode 100644 index 000000000..2e42ff740 --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/types.ts @@ -0,0 +1,10 @@ +/** + * JSON output from the @tailor-platform/sdk-codemod CLI. + */ +export interface RunOutput { + codemodsApplied: number; + codemodsSkipped: number; + filesModified: string[]; + warnings: string[]; + errors: Array<{ codemodId: string; message: string }>; +} diff --git a/packages/sdk/src/cli/commands/upgrade/version-detector.test.ts b/packages/sdk/src/cli/commands/upgrade/version-detector.test.ts new file mode 100644 index 000000000..6dad71620 --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/version-detector.test.ts @@ -0,0 +1,117 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { detectDeclaredVersion, detectInstalledVersion } from "./version-detector"; + +describe("version-detector", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "version-detect-test-")); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + describe("detectInstalledVersion", () => { + it("should return null when SDK is not installed", async () => { + const version = await detectInstalledVersion(tmpDir); + expect(version).toBeNull(); + }); + + it("should detect version from node_modules", async () => { + const sdkDir = path.join(tmpDir, "node_modules", "@tailor-platform", "sdk"); + await fs.promises.mkdir(sdkDir, { recursive: true }); + await fs.promises.writeFile( + path.join(sdkDir, "package.json"), + JSON.stringify({ + name: "@tailor-platform/sdk", + version: "1.32.1", + }), + ); + + const version = await detectInstalledVersion(tmpDir); + expect(version).toBe("1.32.1"); + }); + + it("should return null when package.json has no version field", async () => { + const sdkDir = path.join(tmpDir, "node_modules", "@tailor-platform", "sdk"); + await fs.promises.mkdir(sdkDir, { recursive: true }); + await fs.promises.writeFile( + path.join(sdkDir, "package.json"), + JSON.stringify({ name: "@tailor-platform/sdk" }), + ); + + const version = await detectInstalledVersion(tmpDir); + expect(version).toBeNull(); + }); + + it("should detect version from parent node_modules (workspace hoisting)", async () => { + const sdkDir = path.join(tmpDir, "node_modules", "@tailor-platform", "sdk"); + await fs.promises.mkdir(sdkDir, { recursive: true }); + await fs.promises.writeFile( + path.join(sdkDir, "package.json"), + JSON.stringify({ name: "@tailor-platform/sdk", version: "1.32.1" }), + ); + + const subDir = path.join(tmpDir, "packages", "app"); + await fs.promises.mkdir(subDir, { recursive: true }); + + const version = await detectInstalledVersion(subDir); + expect(version).toBe("1.32.1"); + }); + + it("should return null for nonexistent directory", async () => { + const version = await detectInstalledVersion(path.join(tmpDir, "nonexistent")); + expect(version).toBeNull(); + }); + }); + + describe("detectDeclaredVersion", () => { + it("should detect version from dependencies", async () => { + await fs.promises.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: { "@tailor-platform/sdk": "^2.0.0" }, + }), + ); + + const version = await detectDeclaredVersion(tmpDir); + expect(version).toBe("^2.0.0"); + }); + + it("should detect version from devDependencies", async () => { + await fs.promises.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + devDependencies: { "@tailor-platform/sdk": "~2.1.0" }, + }), + ); + + const version = await detectDeclaredVersion(tmpDir); + expect(version).toBe("~2.1.0"); + }); + + it("should return null when SDK is not a dependency", async () => { + await fs.promises.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify({ + name: "test-project", + dependencies: { "some-other-package": "1.0.0" }, + }), + ); + + const version = await detectDeclaredVersion(tmpDir); + expect(version).toBeNull(); + }); + + it("should return null when package.json does not exist", async () => { + const version = await detectDeclaredVersion(path.join(tmpDir, "nonexistent")); + expect(version).toBeNull(); + }); + }); +}); diff --git a/packages/sdk/src/cli/commands/upgrade/version-detector.ts b/packages/sdk/src/cli/commands/upgrade/version-detector.ts new file mode 100644 index 000000000..bfe1d1122 --- /dev/null +++ b/packages/sdk/src/cli/commands/upgrade/version-detector.ts @@ -0,0 +1,43 @@ +import * as path from "pathe"; +import { readPackageJSON } from "pkg-types"; + +const SDK_PACKAGE_NAME = "@tailor-platform/sdk"; + +/** + * Detect the installed SDK version from the user's project. + * Walks up from projectRoot to find the SDK package in node_modules, + * matching Node's module resolution for workspace setups with hoisted deps. + * @param projectRoot - The project root directory to search from + * @returns The installed SDK version string, or null if not found + */ +export async function detectInstalledVersion(projectRoot: string): Promise { + let dir = path.resolve(projectRoot); + while (true) { + try { + const sdkPath = path.join(dir, "node_modules", SDK_PACKAGE_NAME); + const pkg = await readPackageJSON(sdkPath); + if (pkg.version) return pkg.version; + } catch { + // Not found at this level, try parent + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** + * Read the SDK version range from the project's package.json dependencies. + * Used as the default target version when --to is not specified. + * @param projectRoot - The project root directory + * @returns The version range string from dependencies, or null if not found + */ +export async function detectDeclaredVersion(projectRoot: string): Promise { + try { + const pkg = await readPackageJSON(projectRoot); + return pkg.dependencies?.[SDK_PACKAGE_NAME] ?? pkg.devDependencies?.[SDK_PACKAGE_NAME] ?? null; + } catch { + return null; + } +} diff --git a/packages/sdk/src/cli/docs.test.ts b/packages/sdk/src/cli/docs.test.ts index 3da0f2dc9..f1e11f231 100644 --- a/packages/sdk/src/cli/docs.test.ts +++ b/packages/sdk/src/cli/docs.test.ts @@ -111,6 +111,12 @@ const files: Record = { commands: ["setup"], render: defaultRender, }, + "docs/cli/upgrade.md": { + title: "Upgrade Commands", + description: "Commands for upgrading SDK versions with automated code migration.", + commands: ["upgrade"], + render: defaultRender, + }, "docs/cli/completion.md": { title: "Completion", description: "Generate shell completion scripts for bash, zsh, and fish.", diff --git a/packages/sdk/src/cli/index.ts b/packages/sdk/src/cli/index.ts index c472e249b..371c1318b 100644 --- a/packages/sdk/src/cli/index.ts +++ b/packages/sdk/src/cli/index.ts @@ -24,6 +24,7 @@ import { setupCommand } from "./commands/setup"; import { showCommand } from "./commands/show"; import { staticwebsiteCommand } from "./commands/staticwebsite"; import { tailordbCommand } from "./commands/tailordb"; +import { upgradeCommand } from "./commands/upgrade"; import { userCommand } from "./commands/user"; import { workflowCommand } from "./commands/workflow"; import { workspaceCommand } from "./commands/workspace"; @@ -79,6 +80,7 @@ export const mainCommand = withCompletionCommand( show: showCommand, staticwebsite: staticwebsiteCommand, tailordb: tailordbCommand, + upgrade: upgradeCommand, user: userCommand, workflow: workflowCommand, workspace: workspaceCommand, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c42eabce9..17cfb1932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -709,6 +709,70 @@ importers: specifier: 0.1.7 version: 0.1.7(typescript@5.9.3)(zod@4.3.6) + packages/sdk-codemod: + dependencies: + '@ast-grep/napi': + specifier: ^0.42.0 + version: 0.42.1 + chalk: + specifier: 5.6.2 + version: 5.6.2 + diff: + specifier: 8.0.4 + version: 8.0.4 + pathe: + specifier: 2.0.3 + version: 2.0.3 + picomatch: + specifier: 4.0.4 + version: 4.0.4 + pkg-types: + specifier: 2.3.0 + version: 2.3.0 + politty: + specifier: 0.4.13 + version: 0.4.13(@clack/prompts@1.2.0)(@inquirer/prompts@8.3.2(@types/node@24.12.2))(zod@4.3.6) + semver: + specifier: 7.7.4 + version: 7.7.4 + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@eslint/js': + specifier: 10.0.1 + version: 10.0.1(eslint@10.2.0(jiti@2.6.1)) + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + '@types/picomatch': + specifier: 4.0.2 + version: 4.0.2 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 + eslint: + specifier: 10.2.0 + version: 10.2.0(jiti@2.6.1) + eslint-plugin-oxlint: + specifier: 1.58.0 + version: 1.58.0(oxlint@1.58.0(oxlint-tsgolint@0.20.0)) + oxlint: + specifier: 1.58.0 + version: 1.58.0(oxlint-tsgolint@0.20.0) + tsdown: + specifier: 0.21.7 + version: 0.21.7(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@typescript/native-preview@7.0.0-dev.20260404.1)(oxc-resolver@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1))(publint@0.3.18)(typescript@5.9.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: 8.58.0 + version: 8.58.0(eslint@10.2.0(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: 4.1.2 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.2)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/tailor-proto: dependencies: '@bufbuild/protobuf': @@ -744,6 +808,68 @@ packages: resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} engines: {node: '>=14.13.1'} + '@ast-grep/napi-darwin-arm64@0.42.1': + resolution: {integrity: sha512-VtO4DX20ODCfRBwv1I71lZx+qlrhlMbt9Rpo3LozoaUpHnLmyFMBSgpUal5KTd1SCKUK8ekJGgxpKWo27H4AVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@ast-grep/napi-darwin-x64@0.42.1': + resolution: {integrity: sha512-V2uaKP6QZLb60iFHK0IiXAcwSoUliiDJ3c1zLLzHnBFyCbTKC4b3L3XtkiyKsnpET+uzY7hQLpTIAhW5aOCX4w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@ast-grep/napi-linux-arm64-gnu@0.42.1': + resolution: {integrity: sha512-wmt59yzvcZT4Z5XpxB1B1FoFrc32l0vmy2G7yrY2lG9qP2M157mWdp1T50h2XoYrotyRhCyLDXP70SiTZHZkaQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-arm64-musl@0.42.1': + resolution: {integrity: sha512-cnU+H0drvdkApQDJEcBsYGlPq2gk3l2Xxq0y8EmcxAXYXDNkz+Gc2vfvyM7ib2jD9Y51+cQIsb0RFzA2g9VnZQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-linux-x64-gnu@0.42.1': + resolution: {integrity: sha512-gY+PtqbFtFlR8rCL9F6GEPuymqLhh2eG/e8Ly01Z/S5x3e357nNaF69xAvNRpYi/HnEUZ5cE1MzshDCjubqE1A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@ast-grep/napi-linux-x64-musl@0.42.1': + resolution: {integrity: sha512-yDTlIgFOzglpzs3Ua9w43uVeEW4csf80F5/n2FqCK5pip4Iyfu21Q+M8iC9AmTRl/OGHVI48ieuPwOD9i1i6hA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@ast-grep/napi-win32-arm64-msvc@0.42.1': + resolution: {integrity: sha512-6WQhKEfZmtfMSIOzluMoBaQhNqfRKXzj5y2YA2U0Y3x7HxNAZBO067y8xlSMddKFN/FtCwft8GFktFxqSYWl1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@ast-grep/napi-win32-ia32-msvc@0.42.1': + resolution: {integrity: sha512-ET2vRrsHo0e4JJbCrejzDcDPsfTmRaYK9VIpq1MqXXAUvLoiMly+cQYZ64MWdXTlgITKMXCYxhCbFPTn/9XZaQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@ast-grep/napi-win32-x64-msvc@0.42.1': + resolution: {integrity: sha512-NAeA2Q6jp7F9uXtSuG12c1xjTzipXFCTvuAcEBnsTwBXq0kdPV6H6Y4GZJVcDhsHk3TX4sGlQGkuV/6FT2Ngig==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@ast-grep/napi@0.42.1': + resolution: {integrity: sha512-+YEv9ElJi9azr8AYII79NxYXQRJsrUy1kUqZfxZfvPM7rhs3174mzB+qEE9Pl3sVKAJS5cevyT4lgLNV0AZK6A==} + engines: {node: '>= 10'} + '@babel/generator@8.0.0-rc.3': resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2647,6 +2773,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/picomatch@4.0.2': + resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -3327,6 +3456,10 @@ packages: peerDependencies: typescript: ^5.4.4 + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -5425,6 +5558,45 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 4.0.0 + '@ast-grep/napi-darwin-arm64@0.42.1': + optional: true + + '@ast-grep/napi-darwin-x64@0.42.1': + optional: true + + '@ast-grep/napi-linux-arm64-gnu@0.42.1': + optional: true + + '@ast-grep/napi-linux-arm64-musl@0.42.1': + optional: true + + '@ast-grep/napi-linux-x64-gnu@0.42.1': + optional: true + + '@ast-grep/napi-linux-x64-musl@0.42.1': + optional: true + + '@ast-grep/napi-win32-arm64-msvc@0.42.1': + optional: true + + '@ast-grep/napi-win32-ia32-msvc@0.42.1': + optional: true + + '@ast-grep/napi-win32-x64-msvc@0.42.1': + optional: true + + '@ast-grep/napi@0.42.1': + optionalDependencies: + '@ast-grep/napi-darwin-arm64': 0.42.1 + '@ast-grep/napi-darwin-x64': 0.42.1 + '@ast-grep/napi-linux-arm64-gnu': 0.42.1 + '@ast-grep/napi-linux-arm64-musl': 0.42.1 + '@ast-grep/napi-linux-x64-gnu': 0.42.1 + '@ast-grep/napi-linux-x64-musl': 0.42.1 + '@ast-grep/napi-win32-arm64-msvc': 0.42.1 + '@ast-grep/napi-win32-ia32-msvc': 0.42.1 + '@ast-grep/napi-win32-x64-msvc': 0.42.1 + '@babel/generator@8.0.0-rc.3': dependencies: '@babel/parser': 8.0.0-rc.3 @@ -6963,6 +7135,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/picomatch@4.0.2': {} + '@types/semver@7.7.1': {} '@types/tinycolor2@1.4.6': {} @@ -7609,6 +7783,8 @@ snapshots: transitivePeerDependencies: - supports-color + diff@8.0.4: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0