diff --git a/package-lock.json b/package-lock.json index 478778d..987ee2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,9 @@ "": { "name": "jsr", "version": "0.12.1", - "hasInstallScript": true, "license": "MIT", "dependencies": { + "jsonc-parser": "^3.2.1", "kolorist": "^1.8.0", "node-stream-zip": "^1.15.0" }, @@ -788,6 +788,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsonc-parser": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", diff --git a/package.json b/package.json index 6441730..0057ada 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "typescript": "^5.3.3" }, "dependencies": { + "jsonc-parser": "^3.2.1", "kolorist": "^1.8.0", "node-stream-zip": "^1.15.0" } diff --git a/src/bin.ts b/src/bin.ts index 9167cea..6ca7b60 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -12,11 +12,15 @@ import { showPackageInfo, } from "./commands"; import { + DenoJson, ExecError, findProjectDir, JsrPackage, JsrPackageNameError, + NpmPackage, + Package, prettyTime, + readJson, setDebug, } from "./utils"; import { PkgManagerName } from "./pkg_manager"; @@ -74,6 +78,10 @@ ${ ["--yarn", "Use yarn to remove and install packages."], ["--pnpm", "Use pnpm to remove and install packages."], ["--bun", "Use bun to remove and install packages."], + [ + "--from-jsr-config", + "Install 'jsr:*' and 'npm:*' packages from jsr config file as npm packages.", + ], ["--verbose", "Show additional debugging information."], ["-h, --help", "Show this help text."], ["-v, --version", "Print the version number."], @@ -174,6 +182,7 @@ if (args.length === 0) { "save-optional": { type: "boolean", default: false, short: "O" }, "dry-run": { type: "boolean", default: false }, "allow-slow-types": { type: "boolean", default: false }, + "from-jsr-config": { type: "boolean", default: false }, token: { type: "string" }, config: { type: "string", short: "c" }, "no-config": { type: "boolean" }, @@ -211,7 +220,39 @@ if (args.length === 0) { if (cmd === "i" || cmd === "install" || cmd === "add") { run(async () => { - const packages = getPackages(options.positionals, true); + const packages: Package[] = getPackages(options.positionals, true); + if (options.values["from-jsr-config"]) { + if (packages.length > 0) { + console.error( + kl.red( + "The flag '--from-jsr-config' cannot be used when package names are passed to the install command.", + ), + ); + process.exit(1); + } + + const projectInfo = await findProjectDir(process.cwd()); + const jsrFile = projectInfo.jsrJsonPath || projectInfo.denoJsonPath; + if (jsrFile === null) { + console.error( + `Could not find either jsr.json, jsr.jsonc, deno.json or deno.jsonc file in the project.`, + ); + process.exit(1); + } + + const json = await readJson(jsrFile); + if (json.imports !== null && typeof json.imports === "object") { + for (const specifier of Object.values(json.imports)) { + if (specifier.startsWith("jsr:")) { + const raw = specifier.slice("jsr:".length); + packages.push(JsrPackage.from(raw)); + } else if (specifier.startsWith("npm:")) { + const raw = specifier.slice("npm:".length); + packages.push(NpmPackage.from(raw)); + } + } + } + } await install(packages, { mode: options.values["save-dev"] diff --git a/src/commands.ts b/src/commands.ts index 2a02043..f4e9111 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,6 +7,7 @@ import { fileExists, getNewLineChars, JsrPackage, + Package, timeAgo, } from "./utils"; import { Bun, getPkgManager, PkgManagerName, YarnBerry } from "./pkg_manager"; @@ -86,10 +87,10 @@ export interface InstallOptions extends BaseOptions { mode: "dev" | "prod" | "optional"; } -export async function install(packages: JsrPackage[], options: InstallOptions) { +export async function install(packages: Package[], options: InstallOptions) { const pkgManager = await getPkgManager(process.cwd(), options.pkgManagerName); - if (packages.length > 0) { + if (packages.some((pkg) => pkg instanceof JsrPackage)) { if (pkgManager instanceof Bun) { // Bun doesn't support reading from .npmrc yet await setupBunfigToml(pkgManager.cwd); diff --git a/src/pkg_manager.ts b/src/pkg_manager.ts index 7e23102..79e394f 100644 --- a/src/pkg_manager.ts +++ b/src/pkg_manager.ts @@ -1,7 +1,7 @@ // Copyright 2024 the JSR authors. MIT license. import { getLatestPackageVersion } from "./api"; import { InstallOptions } from "./commands"; -import { exec, findProjectDir, JsrPackage, logDebug } from "./utils"; +import { exec, findProjectDir, JsrPackage, logDebug, Package } from "./utils"; import * as kl from "kolorist"; async function execWithLog(cmd: string, args: string[], cwd: string) { @@ -21,9 +21,15 @@ function modeToFlagYarn(mode: InstallOptions["mode"]): string { return mode === "dev" ? "--dev" : mode === "optional" ? "--optional" : ""; } -function toPackageArgs(pkgs: JsrPackage[]): string[] { +function toPackageArgs(pkgs: Package[]): string[] { return pkgs.map( - (pkg) => `@${pkg.scope}/${pkg.name}@npm:${pkg.toNpmPackage()}`, + (pkg) => { + if (pkg instanceof JsrPackage) { + return `@${pkg.scope}/${pkg.name}@npm:${pkg.toNpmPackage()}`; + } else { + return pkg.toString(); + } + }, ); } @@ -44,8 +50,8 @@ async function isYarnBerry(cwd: string) { export interface PackageManager { cwd: string; - install(packages: JsrPackage[], options: InstallOptions): Promise; - remove(packages: JsrPackage[]): Promise; + install(packages: Package[], options: InstallOptions): Promise; + remove(packages: Package[]): Promise; runScript(script: string): Promise; setConfigValue?(key: string, value: string): Promise; } @@ -53,7 +59,7 @@ export interface PackageManager { class Npm implements PackageManager { constructor(public cwd: string) {} - async install(packages: JsrPackage[], options: InstallOptions) { + async install(packages: Package[], options: InstallOptions) { const args = ["install"]; const mode = modeToFlag(options.mode); if (mode !== "") { @@ -64,7 +70,7 @@ class Npm implements PackageManager { await execWithLog("npm", args, this.cwd); } - async remove(packages: JsrPackage[]) { + async remove(packages: Package[]) { await execWithLog( "npm", ["remove", ...packages.map((pkg) => pkg.toString())], @@ -80,7 +86,7 @@ class Npm implements PackageManager { class Yarn implements PackageManager { constructor(public cwd: string) {} - async install(packages: JsrPackage[], options: InstallOptions) { + async install(packages: Package[], options: InstallOptions) { const args = ["add"]; const mode = modeToFlagYarn(options.mode); if (mode !== "") { @@ -90,7 +96,7 @@ class Yarn implements PackageManager { await execWithLog("yarn", args, this.cwd); } - async remove(packages: JsrPackage[]) { + async remove(packages: Package[]) { await execWithLog( "yarn", ["remove", ...packages.map((pkg) => pkg.toString())], @@ -104,7 +110,7 @@ class Yarn implements PackageManager { } export class YarnBerry extends Yarn { - async install(packages: JsrPackage[], options: InstallOptions) { + async install(packages: Package[], options: InstallOptions) { const args = ["add"]; const mode = modeToFlagYarn(options.mode); if (mode !== "") { @@ -121,10 +127,12 @@ export class YarnBerry extends Yarn { await execWithLog("yarn", ["config", "set", key, value], this.cwd); } - private async toPackageArgs(pkgs: JsrPackage[]) { + private async toPackageArgs(pkgs: Package[]) { // nasty workaround for https://github.com/yarnpkg/berry/issues/1816 await Promise.all(pkgs.map(async (pkg) => { - pkg.version ??= `^${await getLatestPackageVersion(pkg)}`; + if (pkg instanceof JsrPackage) { + pkg.version ??= `^${await getLatestPackageVersion(pkg)}`; + } })); return toPackageArgs(pkgs); } @@ -133,7 +141,7 @@ export class YarnBerry extends Yarn { class Pnpm implements PackageManager { constructor(public cwd: string) {} - async install(packages: JsrPackage[], options: InstallOptions) { + async install(packages: Package[], options: InstallOptions) { const args = ["add"]; const mode = modeToFlag(options.mode); if (mode !== "") { @@ -143,7 +151,7 @@ class Pnpm implements PackageManager { await execWithLog("pnpm", args, this.cwd); } - async remove(packages: JsrPackage[]) { + async remove(packages: Package[]) { await execWithLog( "yarn", ["remove", ...packages.map((pkg) => pkg.toString())], @@ -159,7 +167,7 @@ class Pnpm implements PackageManager { export class Bun implements PackageManager { constructor(public cwd: string) {} - async install(packages: JsrPackage[], options: InstallOptions) { + async install(packages: Package[], options: InstallOptions) { const args = ["add"]; const mode = modeToFlagYarn(options.mode); if (mode !== "") { @@ -169,7 +177,7 @@ export class Bun implements PackageManager { await execWithLog("bun", args, this.cwd); } - async remove(packages: JsrPackage[]) { + async remove(packages: Package[]) { await execWithLog( "bun", ["remove", ...packages.map((pkg) => pkg.toString())], diff --git a/src/utils.ts b/src/utils.ts index 002fb54..7fc2d3d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import * as fs from "node:fs"; import { PkgManagerName } from "./pkg_manager"; import { spawn } from "node:child_process"; +import * as JSONC from "jsonc-parser"; export let DEBUG = false; export function setDebug(enabled: boolean) { @@ -14,10 +15,12 @@ export function logDebug(msg: string) { } } +const EXTRACT_REG_NPM = /^(@([a-z][a-z0-9-]+)\/)?([a-z0-9-]+)(@(.+))?$/; const EXTRACT_REG = /^@([a-z][a-z0-9-]+)\/([a-z0-9-]+)(@(.+))?$/; const EXTRACT_REG_PROXY = /^@jsr\/([a-z][a-z0-9-]+)__([a-z0-9-]+)(@(.+))?$/; export class JsrPackageNameError extends Error {} +export class NpmPackageNameError extends Error {} export class JsrPackage { static from(input: string) { @@ -59,6 +62,36 @@ export class JsrPackage { } } +export class NpmPackage { + static from(input: string): NpmPackage { + const match = input.match(EXTRACT_REG_NPM); + if (match === null) { + throw new NpmPackageNameError(`Invalid npm package name: ${input}`); + } + + const scope = match[2] ?? null; + const name = match[3]; + const version = match[5] ?? null; + + return new NpmPackage(scope, name, version); + } + + private constructor( + public scope: string | null, + public name: string, + public version: string | null, + ) {} + + toString() { + let s = this.scope !== null ? `@${this.scope}/` : ""; + s += this.name; + if (this.version !== null) s += `@${this.version}`; + return s; + } +} + +export type Package = JsrPackage | NpmPackage; + export async function fileExists(file: string): Promise { try { const stat = await fs.promises.stat(file); @@ -72,6 +105,8 @@ export interface ProjectInfo { projectDir: string; pkgManagerName: PkgManagerName | null; pkgJsonPath: string | null; + denoJsonPath: string | null; + jsrJsonPath: string | null; } export async function findProjectDir( cwd: string, @@ -80,6 +115,8 @@ export async function findProjectDir( projectDir: cwd, pkgManagerName: null, pkgJsonPath: null, + denoJsonPath: null, + jsrJsonPath: null, }, ): Promise { // Ensure we check for `package.json` first as this defines @@ -94,6 +131,29 @@ export async function findProjectDir( } } + if (result.denoJsonPath === null) { + const denoJsonPath = path.join(dir, "deno.json"); + const denoJsoncPath = path.join(dir, "deno.jsonc"); + if (await fileExists(denoJsonPath)) { + logDebug(`Found deno.json at ${denoJsonPath}`); + result.denoJsonPath = denoJsonPath; + } else if (await fileExists(denoJsoncPath)) { + logDebug(`Found deno.jsonc at ${denoJsoncPath}`); + result.denoJsonPath = denoJsoncPath; + } + } + if (result.jsrJsonPath === null) { + const jsrJsonPath = path.join(dir, "jsr.json"); + const jsrJsoncPath = path.join(dir, "jsr.jsonc"); + if (await fileExists(jsrJsonPath)) { + logDebug(`Found jsr.json at ${jsrJsonPath}`); + result.jsrJsonPath = jsrJsonPath; + } else if (await fileExists(jsrJsoncPath)) { + logDebug(`Found jsr.jsonc at ${jsrJsoncPath}`); + result.jsrJsonPath = jsrJsoncPath; + } + } + const npmLockfile = path.join(dir, "package-lock.json"); if (await fileExists(npmLockfile)) { logDebug(`Detected npm from lockfile ${npmLockfile}`); @@ -238,7 +298,14 @@ export function getNewLineChars(source: string) { export async function readJson(file: string): Promise { const content = await fs.promises.readFile(file, "utf-8"); - return JSON.parse(content); + return file.endsWith(".jsonc") ? JSONC.parse(content) : JSON.parse(content); +} + +export interface DenoJson { + name?: string; + version?: string; + exports?: string | Record; + imports?: Record; } export interface PkgJson { diff --git a/test/commands.test.ts b/test/commands.test.ts index 0eb0eef..1bea7df 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -259,6 +259,73 @@ describe("install", () => { }); }); + describe("jsr i --from-jsr-config", () => { + it("throws if packages passed", async () => { + await runInTempDir(async (dir) => { + try { + await runJsr(["i", "--from-jsr-config", "@std/encoding"], dir); + assert.fail(); + } catch (err) { + assert.ok(err instanceof ExecError, `Unknown exec error thrown`); + } + }); + }); + + it("throws if no jsr.json or jsr.jsonc is found", async () => { + await runInTempDir(async (dir) => { + try { + await runJsr(["i", "--from-jsr-config"], dir); + assert.fail(); + } catch (err) { + assert.ok(err instanceof ExecError, `Unknown exec error thrown`); + } + }); + + // Should not throw when one is present + for (const file of ["jsr.json", "jsr.jsonc", "deno.json", "deno.jsonc"]) { + await runInTempDir(async (dir) => { + await writeJson(path.join(dir, file), {}); + await runJsr(["i", "--from-jsr-config"], dir); + }); + } + }).timeout(600000); + + it("installs packages from jsr.json", async () => { + for (const file of ["jsr.json", "jsr.jsonc", "deno.json", "deno.jsonc"]) { + await runInTempDir(async (dir) => { + await writeJson(path.join(dir, file), { + imports: { + "@std/encoding": "jsr:@std/encoding@^0.216.0", + "preact": "npm:preact@10.20.0", + }, + }); + await runJsr(["i", "--from-jsr-config"], dir); + + const pkgJson = await readJson( + path.join(dir, "package.json"), + ); + assert.ok( + pkgJson.dependencies && "@std/encoding" in pkgJson.dependencies, + "Missing dependency entry", + ); + assert.ok( + pkgJson.dependencies && "preact" in pkgJson.dependencies, + "Missing dependency entry", + ); + + assert.match( + pkgJson.dependencies["@std/encoding"], + /^npm:@jsr\/std__encoding@\^\d+\.\d+\.\d+.*$/, + ); + assert.match( + pkgJson.dependencies["preact"], + /^\^\d+\.\d+\.\d+.*$/, + ); + }); + } + }).timeout(600000); + }); + it("jsr add --npm @std/encoding@0.216.0 - forces npm", async () => { await withTempEnv( ["i", "--npm", "@std/encoding@0.216.0"], diff --git a/test/unit.test.ts b/test/unit.test.ts index c221d14..ad90401 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { runInTempDir } from "./test_utils"; import { setupNpmRc } from "../src/commands"; import * as assert from "assert/strict"; -import { readTextFile, writeTextFile } from "../src/utils"; +import { NpmPackage, readTextFile, writeTextFile } from "../src/utils"; describe("npmrc", () => { it("doesn't overwrite exising jsr mapping", async () => { @@ -35,3 +35,22 @@ describe("npmrc", () => { }); }); }); + +describe("NpmPackage", () => { + it("parse", () => { + assert.equal(NpmPackage.from("foo").toString(), "foo"); + assert.equal(NpmPackage.from("foo-bar").toString(), "foo-bar"); + assert.equal( + NpmPackage.from("@foo-bar/foo-bar").toString(), + "@foo-bar/foo-bar", + ); + assert.throws( + () => NpmPackage.from("@foo-bar@1.0.0").toString(), + "@foo-bar@1.0.0", + ); + assert.equal( + NpmPackage.from("@foo-bar/baz@1.0.0").toString(), + "@foo-bar/baz@1.0.0", + ); + }); +});