diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917c53b..0b5fcc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,30 @@ jobs: test: strategy: matrix: - platform: [ubuntu-latest, macos-latest, windows-latest] + platform: [ubuntu-latest, macos-latest] + node-version: ["20.x"] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - uses: pnpm/action-setup@v3 + with: + version: 8 + - uses: oven-sh/setup-bun@v1 + + - run: npm i + - run: npm run build --if-present + - run: npm test + + test-win: + strategy: + matrix: + platform: [windows-latest] node-version: ["20.x"] runs-on: ${{ matrix.platform }} diff --git a/src/bin.ts b/src/bin.ts index f254129..178f1a2 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -47,6 +47,7 @@ ${prettyPrintRow([ ["--npm", "Use npm to remove and install packages."], ["--yarn", "Use yarn to remove and install packages."], ["--pnpm", "Use pnpm to remove and install packages."], + ["--bun", "Use bun to remove and install packages."], ["--verbose", "Show additional debugging information."], ["-h, --help", "Show this help text."], ["--version", "Print the version number."], @@ -82,6 +83,7 @@ if (args.length === 0) { npm: { type: "boolean", default: false }, yarn: { type: "boolean", default: false }, pnpm: { type: "boolean", default: false }, + bun: { type: "boolean", default: false }, debug: { type: "boolean", default: false }, help: { type: "boolean", default: false, short: "h" }, version: { type: "boolean", default: false }, @@ -110,6 +112,8 @@ if (args.length === 0) { ? "pnpm" : options.values.yarn ? "yarn" + : options.values.bun + ? "bun" : null; const cmd = options.positionals[0]; diff --git a/src/commands.ts b/src/commands.ts index 1e66fdc..598e130 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,21 +2,63 @@ import * as path from "node:path"; import * as fs from "node:fs"; import * as kl from "kolorist"; import { JsrPackage } from "./utils"; -import { getPkgManager } from "./pkg_manager"; +import { Bun, PkgManagerName, getPkgManager } from "./pkg_manager"; +const NPMRC_FILE = ".npmrc"; +const BUNFIG_FILE = "bunfig.toml"; const JSR_NPMRC = `@jsr:registry=https://npm.jsr.io\n`; +const JSR_BUNFIG = `[install.scopes]\n"@jsr" = "https://npm.jsr.io/"\n`; + +async function wrapWithStatus(msg: string, fn: () => Promise) { + process.stdout.write(msg + "..."); + + try { + await fn(); + process.stdout.write(kl.green("ok") + "\n"); + } catch (err) { + process.stdout.write(kl.red("error") + "\n"); + throw err; + } +} export async function setupNpmRc(dir: string) { - const npmRcPath = path.join(dir, ".npmrc"); + const npmRcPath = path.join(dir, NPMRC_FILE); + const msg = `Setting up ${NPMRC_FILE}`; try { let content = await fs.promises.readFile(npmRcPath, "utf-8"); if (!content.includes(JSR_NPMRC)) { content += JSR_NPMRC; - await fs.promises.writeFile(npmRcPath, content); + await wrapWithStatus(msg, async () => { + await fs.promises.writeFile(npmRcPath, content); + }); + } + } catch (err) { + if (err instanceof Error && (err as any).code === "ENOENT") { + await wrapWithStatus(msg, async () => { + await fs.promises.writeFile(npmRcPath, JSR_NPMRC); + }); + } else { + throw err; + } + } +} + +export async function setupBunfigToml(dir: string) { + const bunfigPath = path.join(dir, BUNFIG_FILE); + const msg = `Setting up ${BUNFIG_FILE}`; + try { + let content = await fs.promises.readFile(bunfigPath, "utf-8"); + if (!/^"@myorg1"\s+=/gm.test(content)) { + content += JSR_BUNFIG; + await wrapWithStatus(msg, async () => { + await fs.promises.writeFile(bunfigPath, content); + }); } } catch (err) { if (err instanceof Error && (err as any).code === "ENOENT") { - await fs.promises.writeFile(npmRcPath, JSR_NPMRC); + await wrapWithStatus(msg, async () => { + await fs.promises.writeFile(bunfigPath, JSR_BUNFIG); + }); } else { throw err; } @@ -24,7 +66,7 @@ export async function setupNpmRc(dir: string) { } export interface BaseOptions { - pkgManagerName: "npm" | "yarn" | "pnpm" | null; + pkgManagerName: PkgManagerName | null; } export interface InstallOptions extends BaseOptions { @@ -32,15 +74,21 @@ export interface InstallOptions extends BaseOptions { } export async function install(packages: JsrPackage[], options: InstallOptions) { - console.log(`Installing ${kl.cyan(packages.join(", "))}...`); const pkgManager = await getPkgManager(process.cwd(), options.pkgManagerName); - await setupNpmRc(pkgManager.cwd); + if (pkgManager instanceof Bun) { + // Bun doesn't support reading from .npmrc yet + await setupBunfigToml(pkgManager.cwd); + } else { + await setupNpmRc(pkgManager.cwd); + } + + console.log(`Installing ${kl.cyan(packages.join(", "))}...`); await pkgManager.install(packages, options); } export async function remove(packages: JsrPackage[], options: BaseOptions) { - console.log(`Removing ${kl.cyan(packages.join(", "))}...`); const pkgManager = await getPkgManager(process.cwd(), options.pkgManagerName); + console.log(`Removing ${kl.cyan(packages.join(", "))}...`); await pkgManager.remove(packages); } diff --git a/src/pkg_manager.ts b/src/pkg_manager.ts index f87a39e..c7ba1d6 100644 --- a/src/pkg_manager.ts +++ b/src/pkg_manager.ts @@ -94,12 +94,35 @@ class Pnpm implements PackageManager { } } -export type PkgManagerName = "npm" | "yarn" | "pnpm"; +export class Bun implements PackageManager { + constructor(public cwd: string) {} + + async install(packages: JsrPackage[], options: InstallOptions) { + const args = ["add"]; + const mode = modeToFlag(options.mode); + if (mode !== "") { + args.push(mode); + } + args.push(...toPackageArgs(packages)); + await execWithLog("bun", args, this.cwd); + } + + async remove(packages: JsrPackage[]) { + await execWithLog( + "bun", + ["remove", ...packages.map((pkg) => pkg.toString())], + this.cwd + ); + } +} + +export type PkgManagerName = "npm" | "yarn" | "pnpm" | "bun"; function getPkgManagerFromEnv(value: string): PkgManagerName | null { - if (value.includes("pnpm/")) return "pnpm"; - else if (value.includes("yarn/")) return "yarn"; - else if (value.includes("npm/")) return "npm"; + if (value.startsWith("pnpm/")) return "pnpm"; + else if (value.startsWith("yarn/")) return "yarn"; + else if (value.startsWith("npm/")) return "npm"; + else if (value.startsWith("bun/")) return "bun"; else return null; } @@ -121,6 +144,8 @@ export async function getPkgManager( return new Yarn(projectDir); } else if (result === "pnpm") { return new Pnpm(projectDir); + } else if (result === "bun") { + return new Bun(projectDir); } return new Npm(projectDir); diff --git a/src/utils.ts b/src/utils.ts index 007f110..ed34fbb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -105,6 +105,14 @@ export async function findProjectDir( return result; } + const bunLockfile = path.join(dir, "bun.lockb"); + if (await fileExists(bunLockfile)) { + logDebug(`Detected bun from lockfile ${bunLockfile}`); + result.projectDir = dir; + result.pkgManagerName = "bun"; + return result; + } + const pkgJsonPath = path.join(dir, "package.json"); if (await fileExists(pkgJsonPath)) { logDebug(`Found package.json at ${pkgJsonPath}`); diff --git a/test/commands.test.ts b/test/commands.test.ts index dc1c97d..bbc9f80 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -136,22 +136,64 @@ describe("install", () => { ); }); - it("detect pnpm from npm_config_user_agent", async () => { - await withTempEnv( - ["i", "@std/encoding@0.216.0"], - async (_, dir) => { - assert.ok( - await isFile(path.join(dir, "pnpm-lock.yaml")), - "pnpm lockfile not created" - ); - }, - { - env: { - ...process.env, - npm_config_user_agent: `pnpm/8.14.3 ${process.env.npm_config_user_agent}`, + if (process.platform !== "win32") { + it("jsr add --bun @std/encoding@0.216.0 - forces bun", async () => { + await withTempEnv( + ["i", "--bun", "@std/encoding@0.216.0"], + async (_, dir) => { + assert.ok( + await isFile(path.join(dir, "bun.lockb")), + "bun lockfile not created" + ); + + const config = await fs.promises.readFile( + path.join(dir, "bunfig.toml"), + "utf-8" + ); + assert.match(config, /"@jsr"\s+=/, "bunfig.toml not created"); + } + ); + }); + } + + describe("env detection", () => { + it("detect pnpm from npm_config_user_agent", async () => { + await withTempEnv( + ["i", "@std/encoding@0.216.0"], + async (_, dir) => { + assert.ok( + await isFile(path.join(dir, "pnpm-lock.yaml")), + "pnpm lockfile not created" + ); }, - } - ); + { + env: { + ...process.env, + npm_config_user_agent: `pnpm/8.14.3 ${process.env.npm_config_user_agent}`, + }, + } + ); + }); + + if (process.platform !== "win32") { + it("detect bun from npm_config_user_agent", async () => { + await withTempEnv( + ["i", "@std/encoding@0.216.0"], + async (_, dir) => { + assert.ok( + await isFile(path.join(dir, "bun.lockb")), + "bun lockfile not created" + ); + }, + { + env: { + ...process.env, + npm_config_user_agent: `bun/1.0.29 ${process.env.npm_config_user_agent}`, + }, + } + ); + }); + } }); });