Skip to content

Commit 5ac4b23

Browse files
feat: add publish command (#14)
* feat: add publish command * chore: add publish test * chore: fix env passing * feat: add publish arguments * chore: add deno to GH actions * chore: update help text * drop! add debug logs * drop! more logs * chore: pass dummy token for CI * chore: remove only * chore: export publish function * feat: download local deno binary in postinstall script * fix: postinstall script * chore: remove deno from ci * fix: don't compile postinstall * fix: use cjs in script * fix: include scripts in tarball * fix: windows binary extension * chore: update canary version url Co-authored-by: Luca Casonato <[email protected]> * feat: lazily download deno binary for publish * fix : ensure downloaded binaries don't overwrite each other * chore: add more comments * feat: clean old downloads --------- Co-authored-by: Luca Casonato <[email protected]>
1 parent 4b22d33 commit 5ac4b23

11 files changed

+394
-35
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@
22
node_modules/
33
dist/
44
dist-esm/
5+
.download/
56
*.log
6-
*.tgz
7+
*.tgz
8+
package/

package-lock.json

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"typescript": "^5.3.3"
4747
},
4848
"dependencies": {
49-
"kolorist": "^1.8.0"
49+
"kolorist": "^1.8.0",
50+
"node-stream-zip": "^1.15.0"
5051
}
5152
}

src/bin.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as kl from "kolorist";
33
import * as fs from "node:fs";
44
import * as path from "node:path";
55
import { parseArgs } from "node:util";
6-
import { install, remove } from "./commands";
6+
import { install, publish, remove } from "./commands";
77
import { JsrPackage, JsrPackageNameError, prettyTime, setDebug } from "./utils";
88
import { PkgManagerName } from "./pkg_manager";
99

@@ -34,6 +34,7 @@ Commands:
3434
${prettyPrintRow([
3535
["i, install, add", "Install one or more jsr packages"],
3636
["r, uninstall, remove", "Remove one or more jsr packages"],
37+
["publish", "Publish a package to the JSR registry."],
3738
])}
3839
3940
Options:
@@ -52,6 +53,24 @@ ${prettyPrintRow([
5253
["-h, --help", "Show this help text."],
5354
["--version", "Print the version number."],
5455
])}
56+
57+
Publish Options:
58+
${prettyPrintRow([
59+
[
60+
"--token <Token>",
61+
"The API token to use when publishing. If unset, interactive authentication is be used.",
62+
],
63+
[
64+
"--dry-run",
65+
"Prepare the package for publishing performing all checks and validations without uploading.",
66+
],
67+
["--allow-slow-types", "Allow publishing with slow types."],
68+
])}
69+
70+
Environment variables:
71+
${prettyPrintRow([
72+
["JSR_URL", "Use a different registry url for the publish command"],
73+
])}
5574
`);
5675
}
5776

@@ -80,6 +99,9 @@ if (args.length === 0) {
8099
"save-prod": { type: "boolean", default: true, short: "P" },
81100
"save-dev": { type: "boolean", default: false, short: "D" },
82101
"save-optional": { type: "boolean", default: false, short: "O" },
102+
"dry-run": { type: "boolean", default: false },
103+
"allow-slow-types": { type: "boolean", default: false },
104+
token: { type: "string" },
83105
npm: { type: "boolean", default: false },
84106
yarn: { type: "boolean", default: false },
85107
pnpm: { type: "boolean", default: false },
@@ -134,6 +156,16 @@ if (args.length === 0) {
134156
const packages = getPackages(options.positionals);
135157
await remove(packages, { pkgManagerName });
136158
});
159+
} else if (cmd === "publish") {
160+
const binFolder = path.join(__dirname, "..", ".download");
161+
run(() =>
162+
publish(process.cwd(), {
163+
binFolder,
164+
dryRun: options.values["dry-run"] ?? false,
165+
allowSlowTypes: options.values["allow-slow-types"] ?? false,
166+
token: options.values.token,
167+
})
168+
);
137169
} else {
138170
console.error(kl.red(`Unknown command: ${cmd}`));
139171
console.log();

src/commands.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as path from "node:path";
22
import * as fs from "node:fs";
33
import * as kl from "kolorist";
4-
import { JsrPackage } from "./utils";
4+
import { JsrPackage, exec, fileExists } from "./utils";
55
import { Bun, PkgManagerName, getPkgManager } from "./pkg_manager";
6+
import { downloadDeno, getDenoDownloadUrl } from "./download";
67

78
const NPMRC_FILE = ".npmrc";
89
const BUNFIG_FILE = "bunfig.toml";
@@ -92,3 +93,50 @@ export async function remove(packages: JsrPackage[], options: BaseOptions) {
9293
console.log(`Removing ${kl.cyan(packages.join(", "))}...`);
9394
await pkgManager.remove(packages);
9495
}
96+
97+
export interface PublishOptions {
98+
binFolder: string;
99+
dryRun: boolean;
100+
allowSlowTypes: boolean;
101+
token: string | undefined;
102+
}
103+
104+
export async function publish(cwd: string, options: PublishOptions) {
105+
const info = await getDenoDownloadUrl();
106+
107+
const binPath = path.join(
108+
options.binFolder,
109+
info.version,
110+
// Ensure each binary has their own folder to avoid overwriting it
111+
// in case jsr gets added to a project as a dependency where
112+
// developers use multiple OSes
113+
process.platform,
114+
process.platform === "win32" ? "deno.exe" : "deno"
115+
);
116+
117+
// Check if deno executable is available, download it if not.
118+
if (!(await fileExists(binPath))) {
119+
// Clear folder first to get rid of old download artifacts
120+
// to avoid taking up lots of disk space.
121+
try {
122+
await fs.promises.rm(options.binFolder, { recursive: true });
123+
} catch (err) {
124+
if (!(err instanceof Error) || (err as any).code !== "ENOENT") {
125+
throw err;
126+
}
127+
}
128+
129+
await downloadDeno(binPath, info);
130+
}
131+
132+
// Ready to publish now!
133+
const args = [
134+
"publish",
135+
"--unstable-bare-node-builtins",
136+
"--unstable-sloppy-imports",
137+
];
138+
if (options.dryRun) args.push("--dry-run");
139+
if (options.allowSlowTypes) args.push("--allow-slow-types");
140+
if (options.token) args.push("--token", options.token);
141+
await exec(binPath, args, cwd);
142+
}

src/download.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import * as os from "node:os";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import * as util from "node:util";
5+
import * as stream from "node:stream";
6+
import * as kl from "kolorist";
7+
import * as StreamZip from "node-stream-zip";
8+
9+
const streamFinished = util.promisify(stream.finished);
10+
11+
const DENO_CANARY_INFO_URL = "https://dl.deno.land/canary-latest.txt";
12+
13+
// Example: https://github.com/denoland/deno/releases/download/v1.41.0/deno-aarch64-apple-darwin.zip
14+
// Example: https://dl.deno.land/canary/d722de886b85093eeef08d1e9fd6f3193405762d/deno-aarch64-apple-darwin.zip
15+
const FILENAMES: Record<string, string> = {
16+
"darwin arm64": "deno-aarch64-apple-darwin",
17+
"darwin x64": "deno-x86_64-apple-darwin",
18+
"linux arm64": "deno-aarch64-unknown-linux-gnu",
19+
"linux x64": "deno-x86_64-unknown-linux-gnu",
20+
"win32 x64": "deno-x86_64-pc-windows-msvc",
21+
};
22+
23+
export interface DownloadInfo {
24+
url: string;
25+
filename: string;
26+
version: string;
27+
}
28+
29+
export async function getDenoDownloadUrl(): Promise<DownloadInfo> {
30+
const key = `${process.platform} ${os.arch()}`;
31+
if (!(key in FILENAMES)) {
32+
throw new Error(`Unsupported platform: ${key}`);
33+
}
34+
35+
const name = FILENAMES[key];
36+
37+
const res = await fetch(DENO_CANARY_INFO_URL);
38+
if (!res.ok) {
39+
await res.body?.cancel();
40+
throw new Error(
41+
`${res.status}: Unable to retrieve canary version information from ${DENO_CANARY_INFO_URL}.`
42+
);
43+
}
44+
const sha = (await res.text()).trim();
45+
46+
const filename = name + ".zip";
47+
return {
48+
url: `https://dl.deno.land/canary/${decodeURI(sha)}/${filename}`,
49+
filename,
50+
version: sha,
51+
};
52+
}
53+
54+
export async function downloadDeno(
55+
binPath: string,
56+
info: DownloadInfo
57+
): Promise<void> {
58+
const binFolder = path.dirname(binPath);
59+
60+
await fs.promises.mkdir(binFolder, { recursive: true });
61+
62+
const res = await fetch(info.url);
63+
const contentLen = Number(res.headers.get("content-length") ?? Infinity);
64+
if (res.body == null) {
65+
throw new Error(`Unexpected empty body`);
66+
}
67+
68+
console.log(`Downloading JSR binary...`);
69+
70+
await withProgressBar(
71+
async (tick) => {
72+
const tmpFile = path.join(binFolder, info.filename + ".part");
73+
const writable = fs.createWriteStream(tmpFile, "utf-8");
74+
75+
for await (const chunk of streamToAsyncIterable(res.body!)) {
76+
tick(chunk.length);
77+
writable.write(chunk);
78+
}
79+
80+
writable.end();
81+
await streamFinished(writable);
82+
const file = path.join(binFolder, info.filename);
83+
await fs.promises.rename(tmpFile, file);
84+
85+
const zip = new StreamZip.async({ file });
86+
await zip.extract(null, binFolder);
87+
await zip.close();
88+
89+
// Mark as executable
90+
await fs.promises.chmod(binPath, 493);
91+
92+
// Delete downloaded file
93+
await fs.promises.rm(file);
94+
},
95+
{ max: contentLen }
96+
);
97+
}
98+
99+
async function withProgressBar<T>(
100+
fn: (tick: (n: number) => void) => Promise<T>,
101+
options: { max: number }
102+
): Promise<T> {
103+
let current = 0;
104+
let start = Date.now();
105+
let passed = 0;
106+
let logged = false;
107+
108+
const printStatus = throttle(() => {
109+
passed = Date.now() - start;
110+
111+
const minutes = String(Math.floor(passed / 1000 / 60)).padStart(2, "0");
112+
const seconds = String(Math.floor(passed / 1000) % 60).padStart(2, "0");
113+
const time = `[${minutes}:${seconds}]`;
114+
const stats = `${humanFileSize(current)}/${humanFileSize(options.max)}`;
115+
116+
const width = process.stdout.columns;
117+
118+
let s = time;
119+
if (width - time.length - stats.length + 4 > 10) {
120+
const barLength = Math.min(width, 50);
121+
const percent = Math.floor((100 / options.max) * current);
122+
123+
const bar = "#".repeat((barLength / 100) * percent) + ">";
124+
const remaining = kl.blue(
125+
"-".repeat(Math.max(barLength - bar.length, 0))
126+
);
127+
s += ` [${kl.cyan(bar)}${remaining}] `;
128+
}
129+
s += kl.dim(stats);
130+
131+
if (process.stdout.isTTY) {
132+
if (logged) {
133+
process.stdout.write("\r\x1b[K");
134+
}
135+
logged = true;
136+
process.stdout.write(s);
137+
}
138+
}, 16);
139+
140+
const tick = (n: number) => {
141+
current += n;
142+
printStatus();
143+
};
144+
const res = await fn(tick);
145+
if (process.stdout.isTTY) {
146+
process.stdout.write("\n");
147+
} else {
148+
console.log("Download completed");
149+
}
150+
return res;
151+
}
152+
153+
async function* streamToAsyncIterable<T>(
154+
stream: ReadableStream<T>
155+
): AsyncIterable<T> {
156+
const reader = stream.getReader();
157+
try {
158+
while (true) {
159+
const { done, value } = await reader.read();
160+
if (done) return;
161+
yield value;
162+
}
163+
} finally {
164+
reader.releaseLock();
165+
}
166+
}
167+
168+
function humanFileSize(bytes: number, digits = 1): string {
169+
const thresh = 1024;
170+
171+
if (Math.abs(bytes) < thresh) {
172+
return bytes + " B";
173+
}
174+
175+
const units = ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
176+
let u = -1;
177+
const r = 10 ** digits;
178+
179+
do {
180+
bytes /= thresh;
181+
++u;
182+
} while (
183+
Math.round(Math.abs(bytes) * r) / r >= thresh &&
184+
u < units.length - 1
185+
);
186+
187+
return `${bytes.toFixed(digits)} ${units[u]}`;
188+
}
189+
190+
function throttle(fn: () => void, delay: number): () => void {
191+
let timer: NodeJS.Timeout | null = null;
192+
193+
return () => {
194+
if (timer === null) {
195+
fn();
196+
timer = setTimeout(() => {
197+
timer = null;
198+
}, delay);
199+
}
200+
};
201+
}

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1-
export { install, remove, type InstallOptions } from "./commands";
1+
export {
2+
install,
3+
remove,
4+
type InstallOptions,
5+
publish,
6+
type PublishOptions,
7+
} from "./commands";
28
export { JsrPackage, JsrPackageNameError } from "./utils";

0 commit comments

Comments
 (0)