diff --git a/.gitignore b/.gitignore index b9c8819..e62f69b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ node_modules/ dist/ dist-esm/ +.download/ *.log -*.tgz \ No newline at end of file +*.tgz +package/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b172b7b..5be2ff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,11 @@ "": { "name": "jsr", "version": "0.7.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "kolorist": "^1.8.0" + "kolorist": "^1.8.0", + "node-stream-zip": "^1.15.0" }, "bin": { "jsr": "dist/bin.js" @@ -942,6 +944,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 5c4af9c..9c8e344 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "typescript": "^5.3.3" }, "dependencies": { - "kolorist": "^1.8.0" + "kolorist": "^1.8.0", + "node-stream-zip": "^1.15.0" } } diff --git a/src/bin.ts b/src/bin.ts index 178f1a2..509c5e8 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,7 +3,7 @@ import * as kl from "kolorist"; import * as fs from "node:fs"; import * as path from "node:path"; import { parseArgs } from "node:util"; -import { install, remove } from "./commands"; +import { install, publish, remove } from "./commands"; import { JsrPackage, JsrPackageNameError, prettyTime, setDebug } from "./utils"; import { PkgManagerName } from "./pkg_manager"; @@ -34,6 +34,7 @@ Commands: ${prettyPrintRow([ ["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."], ])} Options: @@ -52,6 +53,24 @@ ${prettyPrintRow([ ["-h, --help", "Show this help text."], ["--version", "Print the version number."], ])} + +Publish Options: +${prettyPrintRow([ + [ + "--token ", + "The API token to use when publishing. If unset, interactive authentication is be used.", + ], + [ + "--dry-run", + "Prepare the package for publishing performing all checks and validations without uploading.", + ], + ["--allow-slow-types", "Allow publishing with slow types."], +])} + +Environment variables: +${prettyPrintRow([ + ["JSR_URL", "Use a different registry url for the publish command"], +])} `); } @@ -80,6 +99,9 @@ if (args.length === 0) { "save-prod": { type: "boolean", default: true, short: "P" }, "save-dev": { type: "boolean", default: false, short: "D" }, "save-optional": { type: "boolean", default: false, short: "O" }, + "dry-run": { type: "boolean", default: false }, + "allow-slow-types": { type: "boolean", default: false }, + token: { type: "string" }, npm: { type: "boolean", default: false }, yarn: { type: "boolean", default: false }, pnpm: { type: "boolean", default: false }, @@ -134,6 +156,16 @@ if (args.length === 0) { const packages = getPackages(options.positionals); await remove(packages, { pkgManagerName }); }); + } else if (cmd === "publish") { + const binFolder = path.join(__dirname, "..", ".download"); + run(() => + publish(process.cwd(), { + binFolder, + dryRun: options.values["dry-run"] ?? false, + allowSlowTypes: options.values["allow-slow-types"] ?? false, + token: options.values.token, + }) + ); } else { console.error(kl.red(`Unknown command: ${cmd}`)); console.log(); diff --git a/src/commands.ts b/src/commands.ts index 598e130..cad6821 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,8 +1,9 @@ import * as path from "node:path"; import * as fs from "node:fs"; import * as kl from "kolorist"; -import { JsrPackage } from "./utils"; +import { JsrPackage, exec, fileExists } from "./utils"; import { Bun, PkgManagerName, getPkgManager } from "./pkg_manager"; +import { downloadDeno, getDenoDownloadUrl } from "./download"; const NPMRC_FILE = ".npmrc"; const BUNFIG_FILE = "bunfig.toml"; @@ -92,3 +93,50 @@ export async function remove(packages: JsrPackage[], options: BaseOptions) { console.log(`Removing ${kl.cyan(packages.join(", "))}...`); await pkgManager.remove(packages); } + +export interface PublishOptions { + binFolder: string; + dryRun: boolean; + allowSlowTypes: boolean; + token: string | undefined; +} + +export async function publish(cwd: string, options: PublishOptions) { + const info = await getDenoDownloadUrl(); + + const binPath = path.join( + options.binFolder, + info.version, + // Ensure each binary has their own folder to avoid overwriting it + // in case jsr gets added to a project as a dependency where + // developers use multiple OSes + process.platform, + process.platform === "win32" ? "deno.exe" : "deno" + ); + + // Check if deno executable is available, download it if not. + if (!(await fileExists(binPath))) { + // Clear folder first to get rid of old download artifacts + // to avoid taking up lots of disk space. + try { + await fs.promises.rm(options.binFolder, { recursive: true }); + } catch (err) { + if (!(err instanceof Error) || (err as any).code !== "ENOENT") { + throw err; + } + } + + await downloadDeno(binPath, info); + } + + // Ready to publish now! + const args = [ + "publish", + "--unstable-bare-node-builtins", + "--unstable-sloppy-imports", + ]; + if (options.dryRun) args.push("--dry-run"); + if (options.allowSlowTypes) args.push("--allow-slow-types"); + if (options.token) args.push("--token", options.token); + await exec(binPath, args, cwd); +} diff --git a/src/download.ts b/src/download.ts new file mode 100644 index 0000000..3f534c9 --- /dev/null +++ b/src/download.ts @@ -0,0 +1,201 @@ +import * as os from "node:os"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as util from "node:util"; +import * as stream from "node:stream"; +import * as kl from "kolorist"; +import * as StreamZip from "node-stream-zip"; + +const streamFinished = util.promisify(stream.finished); + +const DENO_CANARY_INFO_URL = "https://dl.deno.land/canary-latest.txt"; + +// Example: https://github.com/denoland/deno/releases/download/v1.41.0/deno-aarch64-apple-darwin.zip +// Example: https://dl.deno.land/canary/d722de886b85093eeef08d1e9fd6f3193405762d/deno-aarch64-apple-darwin.zip +const FILENAMES: Record = { + "darwin arm64": "deno-aarch64-apple-darwin", + "darwin x64": "deno-x86_64-apple-darwin", + "linux arm64": "deno-aarch64-unknown-linux-gnu", + "linux x64": "deno-x86_64-unknown-linux-gnu", + "win32 x64": "deno-x86_64-pc-windows-msvc", +}; + +export interface DownloadInfo { + url: string; + filename: string; + version: string; +} + +export async function getDenoDownloadUrl(): Promise { + const key = `${process.platform} ${os.arch()}`; + if (!(key in FILENAMES)) { + throw new Error(`Unsupported platform: ${key}`); + } + + const name = FILENAMES[key]; + + const res = await fetch(DENO_CANARY_INFO_URL); + if (!res.ok) { + await res.body?.cancel(); + throw new Error( + `${res.status}: Unable to retrieve canary version information from ${DENO_CANARY_INFO_URL}.` + ); + } + const sha = (await res.text()).trim(); + + const filename = name + ".zip"; + return { + url: `https://dl.deno.land/canary/${decodeURI(sha)}/${filename}`, + filename, + version: sha, + }; +} + +export async function downloadDeno( + binPath: string, + info: DownloadInfo +): Promise { + const binFolder = path.dirname(binPath); + + await fs.promises.mkdir(binFolder, { recursive: true }); + + const res = await fetch(info.url); + const contentLen = Number(res.headers.get("content-length") ?? Infinity); + if (res.body == null) { + throw new Error(`Unexpected empty body`); + } + + console.log(`Downloading JSR binary...`); + + await withProgressBar( + async (tick) => { + const tmpFile = path.join(binFolder, info.filename + ".part"); + const writable = fs.createWriteStream(tmpFile, "utf-8"); + + for await (const chunk of streamToAsyncIterable(res.body!)) { + tick(chunk.length); + writable.write(chunk); + } + + writable.end(); + await streamFinished(writable); + const file = path.join(binFolder, info.filename); + await fs.promises.rename(tmpFile, file); + + const zip = new StreamZip.async({ file }); + await zip.extract(null, binFolder); + await zip.close(); + + // Mark as executable + await fs.promises.chmod(binPath, 493); + + // Delete downloaded file + await fs.promises.rm(file); + }, + { max: contentLen } + ); +} + +async function withProgressBar( + fn: (tick: (n: number) => void) => Promise, + options: { max: number } +): Promise { + let current = 0; + let start = Date.now(); + let passed = 0; + let logged = false; + + const printStatus = throttle(() => { + passed = Date.now() - start; + + const minutes = String(Math.floor(passed / 1000 / 60)).padStart(2, "0"); + const seconds = String(Math.floor(passed / 1000) % 60).padStart(2, "0"); + const time = `[${minutes}:${seconds}]`; + const stats = `${humanFileSize(current)}/${humanFileSize(options.max)}`; + + const width = process.stdout.columns; + + let s = time; + if (width - time.length - stats.length + 4 > 10) { + const barLength = Math.min(width, 50); + const percent = Math.floor((100 / options.max) * current); + + const bar = "#".repeat((barLength / 100) * percent) + ">"; + const remaining = kl.blue( + "-".repeat(Math.max(barLength - bar.length, 0)) + ); + s += ` [${kl.cyan(bar)}${remaining}] `; + } + s += kl.dim(stats); + + if (process.stdout.isTTY) { + if (logged) { + process.stdout.write("\r\x1b[K"); + } + logged = true; + process.stdout.write(s); + } + }, 16); + + const tick = (n: number) => { + current += n; + printStatus(); + }; + const res = await fn(tick); + if (process.stdout.isTTY) { + process.stdout.write("\n"); + } else { + console.log("Download completed"); + } + return res; +} + +async function* streamToAsyncIterable( + stream: ReadableStream +): AsyncIterable { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } +} + +function humanFileSize(bytes: number, digits = 1): string { + const thresh = 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + " B"; + } + + const units = ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + let u = -1; + const r = 10 ** digits; + + do { + bytes /= thresh; + ++u; + } while ( + Math.round(Math.abs(bytes) * r) / r >= thresh && + u < units.length - 1 + ); + + return `${bytes.toFixed(digits)} ${units[u]}`; +} + +function throttle(fn: () => void, delay: number): () => void { + let timer: NodeJS.Timeout | null = null; + + return () => { + if (timer === null) { + fn(); + timer = setTimeout(() => { + timer = null; + }, delay); + } + }; +} diff --git a/src/index.ts b/src/index.ts index 3371f1b..bb52eb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ -export { install, remove, type InstallOptions } from "./commands"; +export { + install, + remove, + type InstallOptions, + publish, + type PublishOptions, +} from "./commands"; export { JsrPackage, JsrPackageNameError } from "./utils"; diff --git a/src/utils.ts b/src/utils.ts index ed34fbb..550995f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -58,7 +58,7 @@ export class JsrPackage { } } -async function fileExists(file: string): Promise { +export async function fileExists(file: string): Promise { try { const stat = await fs.promises.stat(file); return stat.isFile(); @@ -153,7 +153,7 @@ export async function exec( cmd: string, args: string[], cwd: string, - env?: Record + env?: Record ) { const cp = spawn(cmd, args, { stdio: "inherit", cwd, shell: true, env }); diff --git a/test/commands.test.ts b/test/commands.test.ts index bbc9f80..9d44a3f 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,6 +1,16 @@ import * as path from "path"; import * as fs from "fs"; -import { isDirectory, isFile, runJsr, withTempEnv } from "./test_utils"; +import { + DenoJson, + PkgJson, + isDirectory, + isFile, + readJson, + runInTempDir, + runJsr, + withTempEnv, + writeJson, +} from "./test_utils"; import * as assert from "node:assert/strict"; describe("install", () => { @@ -252,3 +262,37 @@ describe("remove", () => { ); }); }); + +describe("publish", () => { + it("should publish a package", async () => { + await runInTempDir(async (dir) => { + const pkgJsonPath = path.join(dir, "package.json"); + const pkgJson = await readJson(pkgJsonPath); + pkgJson.exports = { + ".": "./mod.js", + }; + await fs.promises.writeFile( + pkgJsonPath, + JSON.stringify(pkgJson), + "utf-8" + ); + + await fs.promises.writeFile( + path.join(dir, "mod.ts"), + "export const value = 42;", + "utf-8" + ); + + // TODO: Change this once deno supports jsr.json + await writeJson(path.join(dir, "deno.json"), { + name: "@deno/jsr-cli-test", + version: pkgJson.version, + exports: { + ".": "./mod.ts", + }, + }); + + await runJsr(["publish", "--dry-run", "--token", "dummy-token"], dir); + }); + }).timeout(600000); +}); diff --git a/test/test_utils.ts b/test/test_utils.ts index e5c53dd..8adfdea 100644 --- a/test/test_utils.ts +++ b/test/test_utils.ts @@ -11,19 +11,40 @@ export interface PkgJson { dependencies?: Record; devDependencies?: Record; optionalDependencies?: Record; + exports?: string | Record>; +} + +export interface DenoJson { + name: string; + version: string; + exports: string | Record; } export async function runJsr( args: string[], cwd: string, env: Record = { - ...process.env, npm_config_user_agent: "npm/", } ) { const bin = path.join(__dirname, "..", "src", "bin.ts"); const tsNode = path.join(__dirname, "..", "node_modules", ".bin", "ts-node"); - return await exec(tsNode, [bin, ...args], cwd, env); + return await exec(tsNode, [bin, ...args], cwd, { ...process.env, ...env }); +} + +export async function runInTempDir(fn: (dir: string) => Promise) { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "jsr-cli")); + + await writeJson(path.join(dir, "package.json"), { + name: "jsr-test-package", + version: "0.0.1", + license: "MIT", + }); + try { + await fn(dir); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } } export async function withTempEnv( @@ -31,32 +52,12 @@ export async function withTempEnv( fn: (getPkgJson: () => Promise, dir: string) => Promise, options: { env?: Record } = {} ): Promise { - const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "jsr-cli")); - - await fs.promises.writeFile( - path.join(dir, "package.json"), - JSON.stringify( - { - name: "jsr-test-package", - version: "0.0.1", - license: "MIT", - }, - null, - 2 - ), - "utf-8" - ); - - try { + await runInTempDir(async (dir) => { await runJsr(args, dir, options.env); const pkgJson = async () => - JSON.parse( - await fs.promises.readFile(path.join(dir, "package.json"), "utf-8") - ) as PkgJson; + readJson(path.join(dir, "package.json")); await fn(pkgJson, dir); - } finally { - fs.promises.rm(dir, { recursive: true, force: true }); - } + }); } export async function isDirectory(path: string): Promise { @@ -74,3 +75,12 @@ export async function isFile(path: string): Promise { return false; } } + +export async function readJson(file: string): Promise { + const content = await fs.promises.readFile(file, "utf-8"); + return JSON.parse(content); +} + +export async function writeJson(file: string, data: T): Promise { + await fs.promises.writeFile(file, JSON.stringify(data, null, 2), "utf-8"); +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json index d35963a..280899e 100644 --- a/tsconfig.esm.json +++ b/tsconfig.esm.json @@ -6,5 +6,6 @@ "strict": true, "outDir": "dist-esm/", "declaration": true - } + }, + "include": ["src"] } \ No newline at end of file