diff --git a/packages/turbo-workspaces/__tests__/utils.test.ts b/packages/turbo-workspaces/__tests__/utils.test.ts index 4c9ec32dbdccd..868f0802c0d8c 100644 --- a/packages/turbo-workspaces/__tests__/utils.test.ts +++ b/packages/turbo-workspaces/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import type { Project } from "../src/types"; -import { isCompatibleWithBunWorkspace } from "../src/utils"; +import { isCompatibleWithBunWorkspaces } from "../src/utils"; describe("utils", () => { describe("isCompatibleWithBunWorkspace", () => { @@ -12,7 +12,7 @@ describe("utils", () => { { globs: ["apps/*", "packages/*/utils/*"], expected: false }, { globs: ["internal-*/*"], expected: false }, ])("should return $result when given %globs", ({ globs, expected }) => { - const result = isCompatibleWithBunWorkspace({ + const result = isCompatibleWithBunWorkspaces({ project: { workspaceData: { globs }, } as Project, diff --git a/packages/turbo-workspaces/src/install.ts b/packages/turbo-workspaces/src/install.ts index 67eb21faabdfa..588b1d0a886ed 100644 --- a/packages/turbo-workspaces/src/install.ts +++ b/packages/turbo-workspaces/src/install.ts @@ -70,13 +70,13 @@ export const PACKAGE_MANAGERS: Record< ], bun: [ { - name: "bun1", + name: "bun", template: "bun", command: "bun", installArgs: ["install"], - version: "1.x", + version: "latest", executable: "bunx", - semver: "<2", + semver: "^1.0.1", default: true, }, ], diff --git a/packages/turbo-workspaces/src/managers/bun.ts b/packages/turbo-workspaces/src/managers/bun.ts index e5289d70cdd3d..69be76b88a996 100644 --- a/packages/turbo-workspaces/src/managers/bun.ts +++ b/packages/turbo-workspaces/src/managers/bun.ts @@ -11,6 +11,7 @@ import type { CleanArgs, Project, ManagerHandler, + Manager, } from "../types"; import { getMainStep, @@ -20,9 +21,15 @@ import { expandWorkspaces, getWorkspacePackageManager, parseWorkspacePackages, - isCompatibleWithBunWorkspace, + isCompatibleWithBunWorkspaces, + removeLockFile, } from "../utils"; +const PACKAGE_MANAGER_DETAILS: Manager = { + name: "bun", + lock: "bun.lockb", +}; + /** * Check if a given project is using bun workspaces * Verify by checking for the existence of: @@ -31,11 +38,13 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await -- must match the detect type signature async function detect(args: DetectArgs): Promise { - const lockFile = path.join(args.workspaceRoot, "bun.lockb"); + const lockFile = path.join(args.workspaceRoot, PACKAGE_MANAGER_DETAILS.lock); const packageManager = getWorkspacePackageManager({ workspaceRoot: args.workspaceRoot, }); - return existsSync(lockFile) || packageManager === "bun"; + return ( + existsSync(lockFile) || packageManager === PACKAGE_MANAGER_DETAILS.name + ); } /** @@ -57,10 +66,10 @@ async function read(args: ReadArgs): Promise { return { name, description, - packageManager: "bun", + packageManager: PACKAGE_MANAGER_DETAILS.name, paths: expandPaths({ root: args.workspaceRoot, - lockFile: "bun.lockb", + lockFile: PACKAGE_MANAGER_DETAILS.lock, }), workspaceData: { globs: workspaceGlobs, @@ -86,7 +95,7 @@ async function create(args: CreateArgs): Promise { const { project, to, logger, options } = args; const hasWorkspaces = project.workspaceData.globs.length > 0; - if (!isCompatibleWithBunWorkspace({ project })) { + if (!isCompatibleWithBunWorkspaces({ project })) { throw new ConvertError( "Unable to convert project to bun - workspace globs unsupported", { @@ -96,7 +105,11 @@ async function create(args: CreateArgs): Promise { } logger.mainStep( - getMainStep({ packageManager: "bun", action: "create", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "create", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); logger.rootHeader(); @@ -156,7 +169,11 @@ async function remove(args: RemoveArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ packageManager: "bun", action: "remove", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "remove", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); @@ -219,11 +236,23 @@ async function clean(args: CleanArgs): Promise { async function convertLock(args: ConvertArgs): Promise { const { project, options } = args; - if (project.packageManager !== "bun") { - // remove the lockfile - if (!options?.dry) { - rmSync(project.paths.lockfile, { force: true }); - } + // handle moving lockfile from `packageManager` to npm + switch (project.packageManager) { + case "pnpm": + // can't convert from pnpm to bun - just remove the lock + removeLockFile({ project, options }); + break; + case "bun": + // we're already using bun, so we don't need to convert + break; + case "npm": + // can't convert from npm to bun - just remove the lock + removeLockFile({ project, options }); + break; + case "yarn": + // can't convert from yarn to bun - just remove the lock + removeLockFile({ project, options }); + break; } } diff --git a/packages/turbo-workspaces/src/managers/npm.ts b/packages/turbo-workspaces/src/managers/npm.ts index 78d7d537de7f7..74d3c422ecd26 100644 --- a/packages/turbo-workspaces/src/managers/npm.ts +++ b/packages/turbo-workspaces/src/managers/npm.ts @@ -11,6 +11,7 @@ import type { Project, ConvertArgs, ManagerHandler, + Manager, } from "../types"; import { getMainStep, @@ -20,8 +21,14 @@ import { getWorkspacePackageManager, expandPaths, parseWorkspacePackages, + removeLockFile, } from "../utils"; +const PACKAGE_MANAGER_DETAILS: Manager = { + name: "npm", + lock: "package-lock.json", +}; + /** * Check if a given project is using npm workspaces * Verify by checking for the existence of: @@ -30,11 +37,13 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await -- must match the detect type signature async function detect(args: DetectArgs): Promise { - const lockFile = path.join(args.workspaceRoot, "package-lock.json"); + const lockFile = path.join(args.workspaceRoot, PACKAGE_MANAGER_DETAILS.lock); const packageManager = getWorkspacePackageManager({ workspaceRoot: args.workspaceRoot, }); - return existsSync(lockFile) || packageManager === "npm"; + return ( + existsSync(lockFile) || packageManager === PACKAGE_MANAGER_DETAILS.name + ); } /** @@ -56,10 +65,10 @@ async function read(args: ReadArgs): Promise { return { name, description, - packageManager: "npm", + packageManager: PACKAGE_MANAGER_DETAILS.name, paths: expandPaths({ root: args.workspaceRoot, - lockFile: "package-lock.json", + lockFile: PACKAGE_MANAGER_DETAILS.lock, }), workspaceData: { globs: workspaceGlobs, @@ -85,7 +94,11 @@ async function create(args: CreateArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ packageManager: "npm", action: "create", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "create", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); logger.rootHeader(); @@ -144,7 +157,11 @@ async function remove(args: RemoveArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ packageManager: "npm", action: "remove", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "remove", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); @@ -207,11 +224,23 @@ async function clean(args: CleanArgs): Promise { async function convertLock(args: ConvertArgs): Promise { const { project, options } = args; - if (project.packageManager !== "npm") { - // remove the lockfile - if (!options?.dry) { - rmSync(project.paths.lockfile, { force: true }); - } + // handle moving lockfile from `packageManager` to npm + switch (project.packageManager) { + case "pnpm": + // can't convert from pnpm to npm - just remove the lock + removeLockFile({ project, options }); + break; + case "bun": + // can't convert from bun to npm - just remove the lock + removeLockFile({ project, options }); + break; + case "npm": + // we're already using npm, so we don't need to convert + break; + case "yarn": + // can't convert from yarn to npm - just remove the lock + removeLockFile({ project, options }); + break; } } diff --git a/packages/turbo-workspaces/src/managers/pnpm.ts b/packages/turbo-workspaces/src/managers/pnpm.ts index ef1df18fe2d56..4f439331558c4 100644 --- a/packages/turbo-workspaces/src/managers/pnpm.ts +++ b/packages/turbo-workspaces/src/managers/pnpm.ts @@ -12,6 +12,7 @@ import type { CleanArgs, Project, ManagerHandler, + Manager, } from "../types"; import { getMainStep, @@ -21,8 +22,15 @@ import { getPnpmWorkspaces, getPackageJson, getWorkspacePackageManager, + removeLockFile, + bunLockToYarnLock, } from "../utils"; +const PACKAGE_MANAGER_DETAILS: Manager = { + name: "pnpm", + lock: "pnpm-lock.yaml", +}; + /** * Check if a given project is using pnpm workspaces * Verify by checking for the existence of: @@ -31,7 +39,7 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await -- must match the detect type signature async function detect(args: DetectArgs): Promise { - const lockFile = path.join(args.workspaceRoot, "pnpm-lock.yaml"); + const lockFile = path.join(args.workspaceRoot, PACKAGE_MANAGER_DETAILS.lock); const workspaceFile = path.join(args.workspaceRoot, "pnpm-workspace.yaml"); const packageManager = getWorkspacePackageManager({ workspaceRoot: args.workspaceRoot, @@ -39,7 +47,7 @@ async function detect(args: DetectArgs): Promise { return ( existsSync(lockFile) || existsSync(workspaceFile) || - packageManager === "pnpm" + packageManager === PACKAGE_MANAGER_DETAILS.name ); } @@ -58,10 +66,10 @@ async function read(args: ReadArgs): Promise { return { name, description, - packageManager: "pnpm", + packageManager: PACKAGE_MANAGER_DETAILS.name, paths: expandPaths({ root: args.workspaceRoot, - lockFile: "pnpm-lock.yaml", + lockFile: PACKAGE_MANAGER_DETAILS.lock, workspaceConfig: "pnpm-workspace.yaml", }), workspaceData: { @@ -88,7 +96,11 @@ async function create(args: CreateArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ action: "create", packageManager: "pnpm", project }) + getMainStep({ + action: "create", + packageManager: PACKAGE_MANAGER_DETAILS.name, + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); @@ -144,7 +156,11 @@ async function remove(args: RemoveArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ action: "remove", packageManager: "pnpm", project }) + getMainStep({ + action: "remove", + packageManager: PACKAGE_MANAGER_DETAILS.name, + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); @@ -205,33 +221,55 @@ async function clean(args: CleanArgs): Promise { * If this is not possible, the non pnpm lockfile is removed */ async function convertLock(args: ConvertArgs): Promise { - const { project, logger, options, to } = args; - - if (project.packageManager !== "pnpm") { - // pnpm does not support importing a bun lockfile - if (to.name !== "bun") { - logger.subStep( - `converting ${path.relative( - project.paths.root, - project.paths.lockfile - )} to pnpm-lock.yaml` - ); + const { project, options, logger } = args; - if (!options?.dry && existsSync(project.paths.lockfile)) { - try { - await execa("pnpm", ["import"], { - stdio: "ignore", - cwd: project.paths.root, - }); - } catch (err) { - // do nothing - } + const logLockConversionStep = (): void => { + logger.subStep( + `converting ${path.relative( + project.paths.root, + project.paths.lockfile + )} to ${PACKAGE_MANAGER_DETAILS.lock}` + ); + }; + + const importLockfile = async (): Promise => { + if (!options?.dry && existsSync(project.paths.lockfile)) { + try { + await execa(PACKAGE_MANAGER_DETAILS.name, ["import"], { + stdio: "ignore", + cwd: project.paths.root, + }); + } catch (err) { + // do nothing + } finally { + removeLockFile({ project, options }); } } + }; - if (!options?.dry) { - rmSync(project.paths.lockfile, { force: true }); - } + // handle moving lockfile from `packageManager` to npm + switch (project.packageManager) { + case "pnpm": + // we're already using pnpm, so we don't need to convert + break; + case "bun": + logLockConversionStep(); + // convert bun -> yarn -> pnpm + await bunLockToYarnLock({ project, options }); + await importLockfile(); + // remove the intermediate yarn lockfile + rmSync(path.join(project.paths.root, "yarn.lock"), { force: true }); + break; + case "npm": + // convert npm -> pnpm + logLockConversionStep(); + await importLockfile(); + break; + case "yarn": + // convert yarn -> pnpm + logLockConversionStep(); + await importLockfile(); + break; } } diff --git a/packages/turbo-workspaces/src/managers/yarn.ts b/packages/turbo-workspaces/src/managers/yarn.ts index 3a1f0c2944f76..3cd7f91616199 100644 --- a/packages/turbo-workspaces/src/managers/yarn.ts +++ b/packages/turbo-workspaces/src/managers/yarn.ts @@ -11,6 +11,7 @@ import type { CleanArgs, Project, ManagerHandler, + Manager, } from "../types"; import { getMainStep, @@ -20,8 +21,15 @@ import { expandWorkspaces, getWorkspacePackageManager, parseWorkspacePackages, + removeLockFile, + bunLockToYarnLock, } from "../utils"; +const PACKAGE_MANAGER_DETAILS: Manager = { + name: "yarn", + lock: "yarn.lock", +}; + /** * Check if a given project is using yarn workspaces * Verify by checking for the existence of: @@ -30,11 +38,13 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await -- must match the detect type signature async function detect(args: DetectArgs): Promise { - const lockFile = path.join(args.workspaceRoot, "yarn.lock"); + const lockFile = path.join(args.workspaceRoot, PACKAGE_MANAGER_DETAILS.lock); const packageManager = getWorkspacePackageManager({ workspaceRoot: args.workspaceRoot, }); - return existsSync(lockFile) || packageManager === "yarn"; + return ( + existsSync(lockFile) || packageManager === PACKAGE_MANAGER_DETAILS.name + ); } /** @@ -56,10 +66,10 @@ async function read(args: ReadArgs): Promise { return { name, description, - packageManager: "yarn", + packageManager: PACKAGE_MANAGER_DETAILS.name, paths: expandPaths({ root: args.workspaceRoot, - lockFile: "yarn.lock", + lockFile: PACKAGE_MANAGER_DETAILS.lock, }), workspaceData: { globs: workspaceGlobs, @@ -85,7 +95,11 @@ async function create(args: CreateArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ packageManager: "yarn", action: "create", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "create", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); logger.rootHeader(); @@ -144,7 +158,11 @@ async function remove(args: RemoveArgs): Promise { const hasWorkspaces = project.workspaceData.globs.length > 0; logger.mainStep( - getMainStep({ packageManager: "yarn", action: "remove", project }) + getMainStep({ + packageManager: PACKAGE_MANAGER_DETAILS.name, + action: "remove", + project, + }) ); const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); @@ -203,15 +221,36 @@ async function clean(args: CleanArgs): Promise { * * If this is not possible, the non yarn lockfile is removed */ -// eslint-disable-next-line @typescript-eslint/require-await -- must match the convertLock type signature async function convertLock(args: ConvertArgs): Promise { - const { project, options } = args; + const { project, options, logger } = args; - if (project.packageManager !== "yarn") { - // remove the lockfile - if (!options?.dry) { - rmSync(project.paths.lockfile, { force: true }); - } + const logLockConversionStep = (): void => { + logger.subStep( + `converting ${path.relative( + project.paths.root, + project.paths.lockfile + )} to ${PACKAGE_MANAGER_DETAILS.lock}` + ); + }; + + // handle moving lockfile from `packageManager` to yarn + switch (project.packageManager) { + case "pnpm": + // can't convert from pnpm to yarn - just remove the lock + removeLockFile({ project, options }); + break; + case "bun": + // convert from bun lockfile to yarn + logLockConversionStep(); + await bunLockToYarnLock({ project, options }); + break; + case "npm": + // can't convert from npm to yarn - just remove the lock + removeLockFile({ project, options }); + break; + case "yarn": + // we're already using yarn, so we don't need to convert + break; } } diff --git a/packages/turbo-workspaces/src/types.ts b/packages/turbo-workspaces/src/types.ts index c5cde2dc90936..2659c4fb5b1c3 100644 --- a/packages/turbo-workspaces/src/types.ts +++ b/packages/turbo-workspaces/src/types.ts @@ -1,6 +1,11 @@ import type { PackageManager } from "@turbo/utils"; import type { Logger } from "./logger"; +export interface Manager { + name: PackageManager; + lock: string; +} + export interface RequestedPackageManagerDetails { name: PackageManager; version?: string; diff --git a/packages/turbo-workspaces/src/utils.ts b/packages/turbo-workspaces/src/utils.ts index 69eb66eaba787..9a25fe54024b1 100644 --- a/packages/turbo-workspaces/src/utils.ts +++ b/packages/turbo-workspaces/src/utils.ts @@ -1,9 +1,16 @@ import path from "node:path"; -import { readJsonSync, existsSync, readFileSync } from "fs-extra"; +import execa from "execa"; +import { + readJsonSync, + existsSync, + readFileSync, + rmSync, + writeFile, +} from "fs-extra"; import { sync as globSync } from "fast-glob"; import yaml from "js-yaml"; import type { PackageJson, PackageManager } from "@turbo/utils"; -import type { Project, Workspace, WorkspaceInfo } from "./types"; +import type { Project, Workspace, WorkspaceInfo, Options } from "./types"; import { ConvertError } from "./errors"; // adapted from https://github.com/nodejs/corepack/blob/cae770694e62f15fed33dd8023649d77d96023c1/sources/specUtils.ts#L14 @@ -205,12 +212,12 @@ function getMainStep({ * At the time of writing, bun only support simple globs (can only end in /*) for workspaces. This means we can't convert all projects * from other package manager workspaces to bun workspaces, we first have to validate that the globs are compatible. * - * NOTE: It's possible a project could work with bun workspaces, but just not in the way it is currently defined. We will + * NOTE: It's possible a project could work with bun workspaces, but just not in the way its globs are currently defined. We will * not change existing globs to make a project work with bun, we will only convert projects that are already compatible. * - * This function matches the behavior of bun's glob validation: https://github.com/oven-sh/bun/blob/main/src/install/lockfile.zig#L2889 + * This function matches the behavior of bun's glob validation: https://github.com/oven-sh/bun/blob/92e95c86dd100f167fb4cf8da1db202b5211d2c1/src/install/lockfile.zig#L2889 */ -function isCompatibleWithBunWorkspace({ +function isCompatibleWithBunWorkspaces({ project, }: { project: Project; @@ -223,10 +230,7 @@ function isCompatibleWithBunWorkspace({ } // no * in the middle of a path - const withoutLastPathSegment = glob - .split(path.sep) - .slice(0, -1) - .join(path.sep); + const withoutLastPathSegment = glob.split("/").slice(0, -1).join("/"); if (withoutLastPathSegment.includes("*")) { return false; } @@ -242,6 +246,43 @@ function isCompatibleWithBunWorkspace({ return project.workspaceData.globs.every(validator); } +function removeLockFile({ + project, + options, +}: { + project: Project; + options?: Options; +}) { + if (!options?.dry) { + // remove the lockfile + rmSync(project.paths.lockfile, { force: true }); + } +} + +async function bunLockToYarnLock({ + project, + options, +}: { + project: Project; + options?: Options; +}) { + if (!options?.dry && existsSync(project.paths.lockfile)) { + try { + const { stdout } = await execa("bun", ["bun.lockb"], { + stdin: "ignore", + cwd: project.paths.root, + }); + // write the yarn lockfile + await writeFile(path.join(project.paths.root, "yarn.lock"), stdout); + } catch (err) { + // do nothing + } finally { + // remove the old lockfile + rmSync(project.paths.lockfile, { force: true }); + } + } +} + export { getPackageJson, getWorkspacePackageManager, @@ -252,5 +293,7 @@ export { getPnpmWorkspaces, directoryInfo, getMainStep, - isCompatibleWithBunWorkspace, + isCompatibleWithBunWorkspaces, + removeLockFile, + bunLockToYarnLock, };