From b5b564133aef0c4ed18b8334b12f376c35b89b68 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Wed, 6 Mar 2024 15:08:26 +0100 Subject: [PATCH] feat: add `jsr show ` command --- src/api.ts | 72 +++++++++++++++++++++++++++++++++++++++++++ src/bin.ts | 48 ++++++++++++++++++----------- src/commands.ts | 46 ++++++++++++++++++++++++++- src/pkg_manager.ts | 19 +----------- src/utils.ts | 56 +++++++++++++++++++++++++-------- test/commands.test.ts | 29 +++++++++++++++++ test/test_utils.ts | 3 +- 7 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 src/api.ts diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..e03495e --- /dev/null +++ b/src/api.ts @@ -0,0 +1,72 @@ +import { JsrPackage } from "./utils"; + +export const JSR_URL = process.env.JSR_URL ?? "https://jsr.io"; + +export interface PackageMeta { + scope: string; + name: string; + latest?: string; + description?: string; + versions: Record; +} + +export async function getPackageMeta(pkg: JsrPackage): Promise { + const url = `${JSR_URL}/@${pkg.scope}/${pkg.name}/meta.json`; + console.log("FETCH", url); + const res = await fetch(url); + if (!res.ok) { + // cancel unconsumed body to avoid memory leak + await res.body?.cancel(); + throw new Error(`Received ${res.status} from ${url}`); + } + + return (await res.json()) as PackageMeta; +} + +export async function getLatestPackageVersion( + pkg: JsrPackage, +): Promise { + const info = await getPackageMeta(pkg); + const { latest } = info; + if (latest === undefined) { + throw new Error(`Unable to find latest version of ${pkg}`); + } + return latest; +} + +export interface NpmPackageInfo { + name: string; + description: string; + "dist-tags": { latest: string }; + versions: Record; + }>; + time: { + created: string; + modified: string; + [key: string]: string; + }; +} + +export async function getNpmPackageInfo( + pkg: JsrPackage, +): Promise { + const tmpUrl = new URL(`${JSR_URL}/@jsr/${pkg.scope}__${pkg.name}`); + const url = `${tmpUrl.protocol}//npm.${tmpUrl.host}${tmpUrl.pathname}`; + const res = await fetch(url); + if (!res.ok) { + // Cancel unconsumed body to avoid memory leak + await res.body?.cancel(); + throw new Error(`Received ${res.status} from ${tmpUrl}`); + } + const json = await res.json(); + return json as NpmPackageInfo; +} diff --git a/src/bin.ts b/src/bin.ts index 8516725..5cbf11f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -4,7 +4,13 @@ import * as kl from "kolorist"; import * as fs from "node:fs"; import * as path from "node:path"; import { parseArgs } from "node:util"; -import { install, publish, remove, runScript } from "./commands"; +import { + install, + publish, + remove, + runScript, + showPackageInfo, +} from "./commands"; import { JsrPackage, JsrPackageNameError, prettyTime, setDebug } from "./utils"; import { PkgManagerName } from "./pkg_manager"; @@ -44,6 +50,7 @@ ${ ["i, install, add", "Install one or more JSR packages."], ["r, uninstall, remove", "Remove one or more JSR packages."], ["publish", "Publish a package to the JSR registry."], + ["info, show, view", "Show package information."], ]) } @@ -115,18 +122,21 @@ function getPackages(positionals: string[]): JsrPackage[] { if (args.length === 0) { printHelp(); process.exit(0); +} else if (args.some((arg) => arg === "-h" || arg === "--help")) { + printHelp(); + process.exit(0); +} else if (args.some((arg) => arg === "-v" || arg === "--version")) { + const version = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"), + ).version as string; + console.log(version); + process.exit(0); } else { const cmd = args[0]; // Bypass cli argument validation for publish command. The underlying // `deno publish` cli is under active development and args may change // frequently. - if ( - cmd === "publish" && - !args.some( - (arg) => - arg === "-h" || arg === "--help" || arg === "--version" || arg === "-v", - ) - ) { + if (cmd === "publish") { const binFolder = path.join(__dirname, "..", ".download"); run(() => publish(process.cwd(), { @@ -134,6 +144,17 @@ if (args.length === 0) { publishArgs: args.slice(1), }) ); + } else if (cmd === "view" || cmd === "show" || cmd === "info") { + const pkgName = args[1]; + if (pkgName === undefined) { + console.log(kl.red(`Missing package name.`)); + printHelp(); + process.exit(1); + } + + run(async () => { + await showPackageInfo(pkgName); + }); } else { const options = parseArgs({ args, @@ -164,16 +185,7 @@ if (args.length === 0) { setDebug(true); } - if (options.values.help) { - printHelp(); - process.exit(0); - } else if (options.values.version) { - const version = JSON.parse( - fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"), - ).version as string; - console.log(version); - process.exit(0); - } else if (options.positionals.length === 0) { + if (options.positionals.length === 0) { printHelp(); process.exit(0); } diff --git a/src/commands.ts b/src/commands.ts index 45e53db..f8f8bef 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,9 +2,16 @@ import * as path from "node:path"; import * as fs from "node:fs"; import * as kl from "kolorist"; -import { exec, fileExists, getNewLineChars, JsrPackage } from "./utils"; +import { + exec, + fileExists, + getNewLineChars, + JsrPackage, + timeAgo, +} from "./utils"; import { Bun, getPkgManager, PkgManagerName, YarnBerry } from "./pkg_manager"; import { downloadDeno, getDenoDownloadUrl } from "./download"; +import { getNpmPackageInfo, getPackageMeta } from "./api"; const NPMRC_FILE = ".npmrc"; const BUNFIG_FILE = "bunfig.toml"; @@ -165,3 +172,40 @@ export async function runScript( const pkgManager = await getPkgManager(cwd, options.pkgManagerName); await pkgManager.runScript(script); } + +export async function showPackageInfo(raw: string) { + const pkg = JsrPackage.from(raw); + + const meta = await getPackageMeta(pkg); + if (pkg.version === null) { + if (meta.latest === undefined) { + throw new Error(`Missing latest version for ${pkg}`); + } + pkg.version = meta.latest!; + } + + const versionCount = Object.keys(meta.versions).length; + + const npmInfo = await getNpmPackageInfo(pkg); + + const versionInfo = npmInfo.versions[pkg.version]!; + const time = npmInfo.time[pkg.version]; + + const publishTime = new Date(time).getTime(); + + console.log(); + console.log( + kl.cyan(`@${pkg.scope}/${pkg.name}@${pkg.version}`) + + ` | latest: ${kl.magenta(meta.latest ?? "-")} | versions: ${ + kl.magenta(versionCount) + }`, + ); + console.log(npmInfo.description); + console.log(); + console.log(`npm tarball: ${kl.cyan(versionInfo.dist.tarball)}`); + console.log(`npm integrity: ${kl.cyan(versionInfo.dist.integrity)}`); + console.log(); + console.log( + `published: ${kl.magenta(timeAgo(Date.now() - publishTime))}`, + ); +} diff --git a/src/pkg_manager.ts b/src/pkg_manager.ts index a5a0595..7e23102 100644 --- a/src/pkg_manager.ts +++ b/src/pkg_manager.ts @@ -1,10 +1,9 @@ // Copyright 2024 the JSR authors. MIT license. +import { getLatestPackageVersion } from "./api"; import { InstallOptions } from "./commands"; import { exec, findProjectDir, JsrPackage, logDebug } from "./utils"; import * as kl from "kolorist"; -const JSR_URL = "https://jsr.io"; - async function execWithLog(cmd: string, args: string[], cwd: string) { console.log(kl.dim(`$ ${cmd} ${args.join(" ")}`)); return exec(cmd, args, cwd); @@ -43,22 +42,6 @@ async function isYarnBerry(cwd: string) { return true; } -async function getLatestPackageVersion(pkg: JsrPackage) { - const url = `${JSR_URL}/${pkg}/meta.json`; - const res = await fetch(url); - if (!res.ok) { - // cancel the response body here in order to avoid a potential memory leak in node: - // https://github.com/nodejs/undici/tree/c47e9e06d19cf61b2fa1fcbfb6be39a6e3133cab/docs#specification-compliance - await res.body?.cancel(); - throw new Error(`Received ${res.status} from ${url}`); - } - const { latest } = await res.json(); - if (!latest) { - throw new Error(`Unable to find latest version of ${pkg}`); - } - return latest; -} - export interface PackageManager { cwd: string; install(packages: JsrPackage[], options: InstallOptions): Promise; diff --git a/src/utils.ts b/src/utils.ts index 48ef14d..ab9e093 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -132,6 +132,9 @@ export async function findProjectDir( } const PERIODS = { + year: 365 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, day: 24 * 60 * 60 * 1000, hour: 60 * 60 * 1000, minute: 60 * 1000, @@ -152,37 +155,66 @@ export function prettyTime(diff: number) { return diff + "ms"; } +export function timeAgo(diff: number) { + if (diff > PERIODS.year) { + const v = Math.floor(diff / PERIODS.year); + return `${v} year${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.month) { + const v = Math.floor(diff / PERIODS.month); + return `${v} month${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.week) { + const v = Math.floor(diff / PERIODS.week); + return `${v} week${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.day) { + const v = Math.floor(diff / PERIODS.day); + return `${v} day${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.hour) { + const v = Math.floor(diff / PERIODS.hour); + return `${v} hour${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.minute) { + const v = Math.floor(diff / PERIODS.minute); + return `${v} minute${v > 1 ? "s" : ""} ago`; + } else if (diff > PERIODS.seconds) { + const v = Math.floor(diff / PERIODS.seconds); + return `${v} second${v > 1 ? "s" : ""} ago`; + } + + return "just now"; +} + export async function exec( cmd: string, args: string[], cwd: string, env?: Record, - captureStdout?: boolean, + captureOutput?: boolean, ) { const cp = spawn( cmd, args.map((arg) => process.platform === "win32" ? `"${arg}"` : `'${arg}'`), { - stdio: captureStdout ? "pipe" : "inherit", + stdio: captureOutput ? "pipe" : "inherit", cwd, shell: true, env, }, ); - return new Promise((resolve) => { - let stdoutChunks: string[] | undefined; + let output = ""; - if (captureStdout) { - stdoutChunks = []; - cp.stdout?.on("data", (data) => { - stdoutChunks!.push(data); - }); - } + if (captureOutput) { + cp.stdout?.on("data", (data) => { + output += data; + }); + cp.stderr?.on("data", (data) => { + output += data; + }); + } + return new Promise((resolve) => { cp.on("exit", (code) => { - const stdout = stdoutChunks?.join(""); - if (code === 0) resolve(stdout); + console.log(output); + if (code === 0) resolve(output); else process.exit(code ?? 1); }); }); diff --git a/test/commands.test.ts b/test/commands.test.ts index 90918a2..2fe9c11 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,5 +1,6 @@ import * as path from "path"; import * as fs from "fs"; +import * as kl from "kolorist"; import { DenoJson, enableYarnBerry, @@ -546,3 +547,31 @@ describe("run", () => { }); }); }); + +describe("show", () => { + it("should show package information", async () => { + const output = await runJsr( + ["show", "@std/encoding"], + process.cwd(), + undefined, + true, + ); + const txt = kl.stripColors(output); + assert.ok(txt.includes("latest:")); + assert.ok(txt.includes("npm tarball:")); + }); + + it("can use 'view' alias", async () => { + await runJsr( + ["view", "@std/encoding"], + process.cwd(), + ); + }); + + it("can use 'info' alias", async () => { + await runJsr( + ["view", "@std/encoding"], + process.cwd(), + ); + }); +}); diff --git a/test/test_utils.ts b/test/test_utils.ts index a373658..2281282 100644 --- a/test/test_utils.ts +++ b/test/test_utils.ts @@ -33,6 +33,7 @@ export async function runJsr( args: string[], cwd: string, env: Record = {}, + captureOutput = false, ) { const bin = path.join(__dirname, "..", "src", "bin.ts"); const tsNode = path.join(__dirname, "..", "node_modules", ".bin", "ts-node"); @@ -40,7 +41,7 @@ export async function runJsr( ...process.env, npm_config_user_agent: undefined, ...env, - }); + }, captureOutput); } export async function runInTempDir(fn: (dir: string) => Promise) {