Skip to content

Commit 7fb628d

Browse files
authored
feat: add yarn v2+ support (#39)
* feat: add yarn v2+ support resolves #32 * fix: set mode flag correctly and workaround alias resolver bug * chore: add test for yarn berry env detection method * fix: improve yarn berry detection * chore: add dev and optional flag tests for yarn berry * chore: add dev and optional tests for bun * refactor: use runInTempDir instead of modifying withTempEnv * perf: replace latest-version package with alternative solution * perf: get latest package versions in parallel * refactor: rename jsr npm registry constant * refactor: remove extraneous changes to utils * fix: add corepack enable yarn to test windows job * chore: run deno fmt * fix: only parse json if response is ok * chore: add test for resolve latest package version with yarn berry * chore: add yarn npm_config_user_agent test * chore: add comment for parse text over json * refactor: call res.body.cancel and use res.json instead * fix: quote command args when calling spawn with shell
1 parent 0209631 commit 7fb628d

File tree

6 files changed

+264
-14
lines changed

6 files changed

+264
-14
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ jobs:
3434
with:
3535
version: 8
3636
- uses: oven-sh/setup-bun@v1
37+
- run: corepack enable yarn
3738

3839
- run: npm i
3940
- run: npm run build --if-present
@@ -56,6 +57,7 @@ jobs:
5657
- uses: pnpm/action-setup@v3
5758
with:
5859
version: 8
60+
- run: corepack enable yarn
5961

6062
- run: npm i
6163
- run: npm run build --if-present

src/commands.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import * as path from "node:path";
33
import * as fs from "node:fs";
44
import * as kl from "kolorist";
55
import { exec, fileExists, JsrPackage } from "./utils";
6-
import { Bun, getPkgManager, PkgManagerName } from "./pkg_manager";
6+
import { Bun, getPkgManager, PkgManagerName, YarnBerry } from "./pkg_manager";
77
import { downloadDeno, getDenoDownloadUrl } from "./download";
88

99
const NPMRC_FILE = ".npmrc";
1010
const BUNFIG_FILE = "bunfig.toml";
11-
const JSR_NPMRC = `@jsr:registry=https://npm.jsr.io\n`;
12-
const JSR_BUNFIG = `[install.scopes]\n"@jsr" = "https://npm.jsr.io/"\n`;
11+
const JSR_NPM_REGISTRY_URL = "https://npm.jsr.io";
12+
const JSR_NPMRC = `@jsr:registry=${JSR_NPM_REGISTRY_URL}\n`;
13+
const JSR_BUNFIG = `[install.scopes]\n"@jsr" = "${JSR_NPM_REGISTRY_URL}"\n`;
14+
const JSR_YARN_BERRY_CONFIG_KEY = "npmScopes.jsr.npmRegistryServer";
1315

1416
async function wrapWithStatus(msg: string, fn: () => Promise<void>) {
1517
process.stdout.write(msg + "...");
@@ -81,6 +83,13 @@ export async function install(packages: JsrPackage[], options: InstallOptions) {
8183
if (pkgManager instanceof Bun) {
8284
// Bun doesn't support reading from .npmrc yet
8385
await setupBunfigToml(pkgManager.cwd);
86+
} else if (pkgManager instanceof YarnBerry) {
87+
// Yarn v2+ does not read from .npmrc intentionally
88+
// https://yarnpkg.com/migration/guide#update-your-configuration-to-the-new-settings
89+
await pkgManager.setConfigValue(
90+
JSR_YARN_BERRY_CONFIG_KEY,
91+
JSR_NPM_REGISTRY_URL,
92+
);
8493
} else {
8594
await setupNpmRc(pkgManager.cwd);
8695
}

src/pkg_manager.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Copyright 2024 the JSR authors. MIT license.
22
import { InstallOptions } from "./commands";
3-
import { exec, findProjectDir, JsrPackage } from "./utils";
3+
import { exec, findProjectDir, JsrPackage, logDebug } from "./utils";
44
import * as kl from "kolorist";
55

6+
const JSR_URL = "https://jsr.io";
7+
68
async function execWithLog(cmd: string, args: string[], cwd: string) {
79
console.log(kl.dim(`$ ${cmd} ${args.join(" ")}`));
810
return exec(cmd, args, cwd);
@@ -16,17 +18,53 @@ function modeToFlag(mode: InstallOptions["mode"]): string {
1618
: "";
1719
}
1820

21+
function modeToFlagYarn(mode: InstallOptions["mode"]): string {
22+
return mode === "dev" ? "--dev" : mode === "optional" ? "--optional" : "";
23+
}
24+
1925
function toPackageArgs(pkgs: JsrPackage[]): string[] {
2026
return pkgs.map(
2127
(pkg) => `@${pkg.scope}/${pkg.name}@npm:${pkg.toNpmPackage()}`,
2228
);
2329
}
2430

31+
async function isYarnBerry(cwd: string) {
32+
// this command works for both yarn classic and berry
33+
const version = await exec("yarn", ["--version"], cwd, undefined, true);
34+
if (!version) {
35+
logDebug("Unable to detect yarn version, assuming classic");
36+
return false;
37+
}
38+
if (version.startsWith("1.")) {
39+
logDebug("Detected yarn classic from version");
40+
return false;
41+
}
42+
logDebug("Detected yarn berry from version");
43+
return true;
44+
}
45+
46+
async function getLatestPackageVersion(pkg: JsrPackage) {
47+
const url = `${JSR_URL}/${pkg}/meta.json`;
48+
const res = await fetch(url);
49+
if (!res.ok) {
50+
// cancel the response body here in order to avoid a potential memory leak in node:
51+
// https://github.com/nodejs/undici/tree/c47e9e06d19cf61b2fa1fcbfb6be39a6e3133cab/docs#specification-compliance
52+
await res.body?.cancel();
53+
throw new Error(`Received ${res.status} from ${url}`);
54+
}
55+
const { latest } = await res.json();
56+
if (!latest) {
57+
throw new Error(`Unable to find latest version of ${pkg}`);
58+
}
59+
return latest;
60+
}
61+
2562
export interface PackageManager {
2663
cwd: string;
2764
install(packages: JsrPackage[], options: InstallOptions): Promise<void>;
2865
remove(packages: JsrPackage[]): Promise<void>;
2966
runScript(script: string): Promise<void>;
67+
setConfigValue?(key: string, value: string): Promise<void>;
3068
}
3169

3270
class Npm implements PackageManager {
@@ -61,7 +99,7 @@ class Yarn implements PackageManager {
6199

62100
async install(packages: JsrPackage[], options: InstallOptions) {
63101
const args = ["add"];
64-
const mode = modeToFlag(options.mode);
102+
const mode = modeToFlagYarn(options.mode);
65103
if (mode !== "") {
66104
args.push(mode);
67105
}
@@ -82,6 +120,33 @@ class Yarn implements PackageManager {
82120
}
83121
}
84122

123+
export class YarnBerry extends Yarn {
124+
async install(packages: JsrPackage[], options: InstallOptions) {
125+
const args = ["add"];
126+
const mode = modeToFlagYarn(options.mode);
127+
if (mode !== "") {
128+
args.push(mode);
129+
}
130+
args.push(...(await this.toPackageArgs(packages)));
131+
await execWithLog("yarn", args, this.cwd);
132+
}
133+
134+
/**
135+
* Calls the `yarn config set` command, https://yarnpkg.com/cli/config/set.
136+
*/
137+
async setConfigValue(key: string, value: string) {
138+
await execWithLog("yarn", ["config", "set", key, value], this.cwd);
139+
}
140+
141+
private async toPackageArgs(pkgs: JsrPackage[]) {
142+
// nasty workaround for https://github.com/yarnpkg/berry/issues/1816
143+
await Promise.all(pkgs.map(async (pkg) => {
144+
pkg.version ??= `^${await getLatestPackageVersion(pkg)}`;
145+
}));
146+
return toPackageArgs(pkgs);
147+
}
148+
}
149+
85150
class Pnpm implements PackageManager {
86151
constructor(public cwd: string) {}
87152

@@ -113,7 +178,7 @@ export class Bun implements PackageManager {
113178

114179
async install(packages: JsrPackage[], options: InstallOptions) {
115180
const args = ["add"];
116-
const mode = modeToFlag(options.mode);
181+
const mode = modeToFlagYarn(options.mode);
117182
if (mode !== "") {
118183
args.push(mode);
119184
}
@@ -160,7 +225,9 @@ export async function getPkgManager(
160225
const result = pkgManagerName || fromEnv || fromLockfile || "npm";
161226

162227
if (result === "yarn") {
163-
return new Yarn(projectDir);
228+
return await isYarnBerry(projectDir)
229+
? new YarnBerry(projectDir)
230+
: new Yarn(projectDir);
164231
} else if (result === "pnpm") {
165232
return new Pnpm(projectDir);
166233
} else if (result === "bun") {

src/utils.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,32 @@ export async function exec(
158158
args: string[],
159159
cwd: string,
160160
env?: Record<string, string | undefined>,
161+
captureStdout?: boolean,
161162
) {
162-
const cp = spawn(cmd, args, { stdio: "inherit", cwd, shell: true, env });
163+
const cp = spawn(
164+
cmd,
165+
args.map((arg) => process.platform === "win32" ? `"${arg}"` : `'${arg}'`),
166+
{
167+
stdio: captureStdout ? "pipe" : "inherit",
168+
cwd,
169+
shell: true,
170+
env,
171+
},
172+
);
173+
174+
return new Promise<string | undefined>((resolve) => {
175+
let stdoutChunks: string[] | undefined;
176+
177+
if (captureStdout) {
178+
stdoutChunks = [];
179+
cp.stdout?.on("data", (data) => {
180+
stdoutChunks!.push(data);
181+
});
182+
}
163183

164-
return new Promise<void>((resolve) => {
165184
cp.on("exit", (code) => {
166-
if (code === 0) resolve();
185+
const stdout = stdoutChunks?.join("");
186+
if (code === 0) resolve(stdout);
167187
else process.exit(code ?? 1);
168188
});
169189
});

test/commands.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as path from "path";
22
import * as fs from "fs";
33
import {
44
DenoJson,
5+
enableYarnBerry,
56
isDirectory,
67
isFile,
78
PkgJson,
@@ -37,6 +38,31 @@ describe("install", () => {
3738
"Missing npmrc registry",
3839
);
3940
});
41+
42+
await runInTempDir(async (dir) => {
43+
await enableYarnBerry(dir);
44+
await fs.promises.writeFile(path.join(dir, "yarn.lock"), "");
45+
46+
await runJsr(["i", "@std/encoding"], dir);
47+
48+
const pkgJson = await readJson<PkgJson>(path.join(dir, "package.json"));
49+
assert.ok(
50+
pkgJson.dependencies && "@std/encoding" in pkgJson.dependencies,
51+
"Missing dependency entry",
52+
);
53+
54+
assert.match(
55+
pkgJson.dependencies["@std/encoding"],
56+
/^npm:@jsr\/std__encoding@\^\d+\.\d+\.\d+.*$/,
57+
);
58+
59+
const yarnrcPath = path.join(dir, ".yarnrc.yml");
60+
const yarnRc = await fs.promises.readFile(yarnrcPath, "utf-8");
61+
assert.ok(
62+
/jsr:\s*npmRegistryServer: "https:\/\/npm\.jsr\.io"/.test(yarnRc),
63+
"Missing yarnrc.yml registry",
64+
);
65+
});
4066
});
4167

4268
it("jsr i @std/[email protected] - with version", async () => {
@@ -86,6 +112,38 @@ describe("install", () => {
86112
});
87113
},
88114
);
115+
116+
await runInTempDir(async (dir) => {
117+
await enableYarnBerry(dir);
118+
await fs.promises.writeFile(path.join(dir, "yarn.lock"), "");
119+
120+
await runJsr(["i", "--save-dev", "@std/[email protected]"], dir);
121+
122+
assert.ok(
123+
await isFile(path.join(dir, ".yarnrc.yml")),
124+
"yarnrc file not created",
125+
);
126+
const pkgJson = await readJson<PkgJson>(path.join(dir, "package.json"));
127+
assert.deepEqual(pkgJson.devDependencies, {
128+
"@std/encoding": "npm:@jsr/[email protected]",
129+
});
130+
});
131+
132+
if (process.platform !== "win32") {
133+
await withTempEnv(
134+
["i", "--bun", "--save-dev", "@std/[email protected]"],
135+
async (getPkgJson, dir) => {
136+
assert.ok(
137+
await isFile(path.join(dir, "bun.lockb")),
138+
"bun lockfile not created",
139+
);
140+
const pkgJson = await getPkgJson();
141+
assert.deepEqual(pkgJson.devDependencies, {
142+
"@std/encoding": "npm:@jsr/[email protected]",
143+
});
144+
},
145+
);
146+
}
89147
});
90148

91149
it("jsr add -O @std/[email protected] - dev dependency", async () => {
@@ -108,6 +166,38 @@ describe("install", () => {
108166
});
109167
},
110168
);
169+
170+
await runInTempDir(async (dir) => {
171+
await enableYarnBerry(dir);
172+
await fs.promises.writeFile(path.join(dir, "yarn.lock"), "");
173+
174+
await runJsr(["i", "--save-optional", "@std/[email protected]"], dir);
175+
176+
assert.ok(
177+
await isFile(path.join(dir, ".yarnrc.yml")),
178+
"yarnrc file not created",
179+
);
180+
const pkgJson = await readJson<PkgJson>(path.join(dir, "package.json"));
181+
assert.deepEqual(pkgJson.optionalDependencies, {
182+
"@std/encoding": "npm:@jsr/[email protected]",
183+
});
184+
});
185+
186+
if (process.platform !== "win32") {
187+
await withTempEnv(
188+
["i", "--bun", "--save-optional", "@std/[email protected]"],
189+
async (getPkgJson, dir) => {
190+
assert.ok(
191+
await isFile(path.join(dir, "bun.lockb")),
192+
"bun lockfile not created",
193+
);
194+
const pkgJson = await getPkgJson();
195+
assert.deepEqual(pkgJson.optionalDependencies, {
196+
"@std/encoding": "npm:@jsr/[email protected]",
197+
});
198+
},
199+
);
200+
}
111201
});
112202

113203
it("jsr add --npm @std/[email protected] - forces npm", async () => {
@@ -132,6 +222,21 @@ describe("install", () => {
132222
);
133223
},
134224
);
225+
226+
await runInTempDir(async (dir) => {
227+
await enableYarnBerry(dir);
228+
229+
await runJsr(["i", "--yarn", "@std/[email protected]"], dir);
230+
231+
assert.ok(
232+
await isFile(path.join(dir, "yarn.lock")),
233+
"yarn lockfile not created",
234+
);
235+
assert.ok(
236+
await isFile(path.join(dir, ".yarnrc.yml")),
237+
"yarnrc file not created",
238+
);
239+
});
135240
});
136241

137242
it("jsr add --pnpm @std/[email protected] - forces pnpm", async () => {
@@ -186,6 +291,43 @@ describe("install", () => {
186291
);
187292
});
188293

294+
it("detect yarn from npm_config_user_agent", async () => {
295+
await withTempEnv(
296+
["i", "@std/[email protected]"],
297+
async (_, dir) => {
298+
assert.ok(
299+
await isFile(path.join(dir, "yarn.lock")),
300+
"yarn lockfile not created",
301+
);
302+
},
303+
{
304+
env: {
305+
...process.env,
306+
npm_config_user_agent:
307+
`yarn/1.22.19 ${process.env.npm_config_user_agent}`,
308+
},
309+
},
310+
);
311+
312+
await runInTempDir(async (dir) => {
313+
await enableYarnBerry(dir);
314+
315+
await runJsr(["i", "@std/[email protected]"], dir, {
316+
npm_config_user_agent:
317+
`yarn/4.1.0 ${process.env.npm_config_user_agent}`,
318+
});
319+
320+
assert.ok(
321+
await isFile(path.join(dir, "yarn.lock")),
322+
"yarn lockfile not created",
323+
);
324+
assert.ok(
325+
await isFile(path.join(dir, ".yarnrc.yml")),
326+
"yarnrc file not created",
327+
);
328+
});
329+
});
330+
189331
if (process.platform !== "win32") {
190332
it("detect bun from npm_config_user_agent", async () => {
191333
await withTempEnv(

0 commit comments

Comments
 (0)