Files
bun.sh/test/cli/install/npmrc.test.ts
robobun 2557b1cc2a Add email field support to .npmrc for registry authentication (#23709)
### What does this PR do?

This PR implements support for the `email` field in `.npmrc` files for
registry scope authentication. Some private registries (particularly
Nexus) require the email field to be specified in the registry
configuration alongside username/password or token authentication.

The email field can now be specified in `.npmrc` files like:
```ini
//registry.example.com/:email=user@example.com
//registry.example.com/:username=myuser
//registry.example.com/:_password=base64encodedpassword
```

### How did you verify your code works?

1. **Built Bun successfully** - Confirmed the code compiles without
errors using `bun bd --debug`

2. **Wrote comprehensive unit tests** - Added two test cases to
`test/cli/install/npmrc.test.ts`:
   - Test for standalone email field parsing
   - Test for email combined with username/password authentication

3. **Verified tests pass** - Ran `bun bd test
test/cli/install/npmrc.test.ts -t "email"` and confirmed both tests
pass:
   ```
   ✓ 2 pass
   ✓ 0 fail
   ✓ 6 expect() calls
   ```

4. **Code changes include**:
   - Added `email` field to `NpmRegistry` struct in `src/api/schema.zig`
   - Updated `encode()` and `decode()` methods to handle the email field
   - Modified `ini.zig` to parse and store the email field from `.npmrc`
- Removed email from the unsupported options warning (certfile and
keyfile remain unsupported)
- Updated all `NpmRegistry` struct initializations to include the email
field
   - Updated `loadNpmrcFromJS` test API to return the email field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-20 16:32:04 -07:00

468 lines
14 KiB
TypeScript

import { write } from "bun";
import { afterAll, beforeAll, describe, expect, it, test } from "bun:test";
import { rm } from "fs/promises";
import { VerdaccioRegistry, bunExe, bunEnv as env, stderrForInstall } from "harness";
import { join } from "path";
const { iniInternals } = require("bun:internal-for-testing");
const { loadNpmrc } = iniInternals;
var registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
describe("npmrc", async () => {
const isBase64Encoded = (opt: string) => opt === "_auth" || opt === "_password";
it("should convert to utf8 if BOM", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Promise.all([
write(join(packageDir, ".npmrc"), Buffer.from(`\ufeff\ncache=hi!`, "utf16le")),
write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0" })),
rm(join(packageDir, "bunfig.toml"), { force: true }),
]);
const originalCacheDir = env.BUN_INSTALL_CACHE_DIR;
delete env.BUN_INSTALL_CACHE_DIR;
const { stdout, stderr, exited } = Bun.spawn({
cmd: [bunExe(), "pm", "cache"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
env.BUN_INSTALL_CACHE_DIR = originalCacheDir;
const out = await stdout.text();
const err = stderrForInstall(await stderr.text());
console.log({ out, err });
expect(err).toBeEmpty();
expect(out.endsWith("hi!")).toBeTrue();
expect(await exited).toBe(0);
});
it("works with empty file", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
console.log("package dir", packageDir);
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const ini = /* ini */ ``;
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`.cwd(packageDir).throws(true);
});
it("sets default registry", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
console.log("package dir", packageDir);
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const ini = /* ini */ `
registry = http://localhost:${registry.port}/
`;
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`.cwd(packageDir).throws(true);
});
it("sets scoped registry", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const ini = /* ini */ `
@types:registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"@types/no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`.cwd(packageDir).throws(true);
});
it("works with home config", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
console.log("package dir", packageDir);
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const homeDir = `${packageDir}/home_dir`;
await Bun.$`mkdir -p ${homeDir}`;
console.log("home dir", homeDir);
const ini = /* ini */ `
registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${ini} > ${homeDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`
.env({
...process.env,
XDG_CONFIG_HOME: `${homeDir}`,
})
.cwd(packageDir)
.throws(true);
});
it("works with two configs", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
console.log("package dir", packageDir);
const packageIni = /* ini */ `
@types:registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${packageIni} > ${packageDir}/.npmrc`;
const homeDir = `${packageDir}/home_dir`;
await Bun.$`mkdir -p ${homeDir}`;
console.log("home dir", homeDir);
const homeIni = /* ini */ `
registry = http://localhost:${registry.port}/
`;
await Bun.$`echo ${homeIni} > ${homeDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "1.0.0",
"@types/no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`
.env({
...process.env,
XDG_CONFIG_HOME: `${homeDir}`,
})
.cwd(packageDir)
.throws(true);
});
it("package config overrides home config", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
console.log("package dir", packageDir);
const packageIni = /* ini */ `
@types:registry=http://localhost:${registry.port}/
`;
await Bun.$`echo ${packageIni} > ${packageDir}/.npmrc`;
const homeDir = `${packageDir}/home_dir`;
await Bun.$`mkdir -p ${homeDir}`;
console.log("home dir", homeDir);
const homeIni = /* ini */ "@types:registry=https://registry.npmjs.org/";
await Bun.$`echo ${homeIni} > ${homeDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "foo",
dependencies: {
"@types/no-deps": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`
.env({
...process.env,
XDG_CONFIG_HOME: `${homeDir}`,
})
.cwd(packageDir)
.throws(true);
});
it("default registry from env variable", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const ini = /* ini */ `
registry=\${LOL}
`;
const result = loadNpmrc(ini, { LOL: `http://localhost:${registry.port}/` });
expect(result.default_registry_url).toBe(`http://localhost:${registry.port}/`);
});
it("default registry from env variable 2", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const ini = /* ini */ `
registry=http://localhost:\${PORT}/
`;
const result = loadNpmrc(ini, { ...env, PORT: registry.port });
expect(result.default_registry_url).toEqual(`http://localhost:${registry.port}/`);
});
async function makeTest(
options: [option: string, value: string][],
check: (result: {
default_registry_url: string;
default_registry_token: string;
default_registry_username: string;
default_registry_password: string;
default_registry_email: string;
}) => void,
) {
const optionName = await Promise.all(options.map(async ([name, val]) => `${name} = ${val}`));
test(optionName.join(" "), async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const iniInner = await Promise.all(
options.map(async ([option, value]) => {
let finalValue = value;
finalValue = isBase64Encoded(option) ? Buffer.from(finalValue).toString("base64") : finalValue;
return `//registry.npmjs.org/:${option}=${finalValue}`;
}),
);
const ini = /* ini */ `
${iniInner.join("\n")}
`;
await Bun.$`echo ${JSON.stringify({
name: "hello",
main: "index.js",
version: "1.0.0",
dependencies: {
"is-even": "1.0.0",
},
})} > package.json`.cwd(packageDir);
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
const result = loadNpmrc(ini);
check(result);
});
}
await makeTest([["_authToken", "skibidi"]], result => {
expect(result.default_registry_url).toEqual("https://registry.npmjs.org/");
expect(result.default_registry_token).toEqual("skibidi");
});
await makeTest(
[
["username", "zorp"],
["_password", "skibidi"],
],
result => {
expect(result.default_registry_url).toEqual("https://registry.npmjs.org/");
expect(result.default_registry_username).toEqual("zorp");
expect(result.default_registry_password).toEqual("skibidi");
},
);
it("authentication works", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const ini = /* ini */ `
registry = http://localhost:${registry.port}/
@needs-auth:registry=http://localhost:${registry.port}/
//localhost:${registry.port}/:_authToken=${await registry.generateUser("bilbo_swaggins", "verysecure")}
`;
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "hi",
main: "index.js",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
"@needs-auth/test-pkg": "1.0.0",
},
"publishConfig": {
"registry": `http://localhost:${registry.port}`,
},
})} > package.json`.cwd(packageDir);
await Bun.$`${bunExe()} install`.env(env).cwd(packageDir).throws(true);
});
type EnvMap =
| Omit<
{
[key: string]: string;
},
"dotEnv"
>
| { dotEnv?: Record<string, string> };
function registryConfigOptionTest(
name: string,
_opts: Record<string, string> | (() => Promise<Record<string, string>>),
_env?: EnvMap | (() => Promise<EnvMap>),
check?: (stdout: string, stderr: string) => void,
) {
it(`sets scoped registry option: ${name}`, async () => {
const { packageDir, packageJson } = await registry.createTestDir();
console.log("PACKAGE DIR", packageDir);
await Bun.$`rm -rf ${packageDir}/bunfig.toml`;
const { dotEnv, ...restOfEnv } = _env
? typeof _env === "function"
? await _env()
: _env
: { dotEnv: undefined };
const opts = _opts ? (typeof _opts === "function" ? await _opts() : _opts) : {};
const dotEnvInner = dotEnv
? Object.entries(dotEnv)
.map(([k, v]) => `${k}=${k.includes("SECRET_") ? Buffer.from(v).toString("base64") : v}`)
.join("\n")
: "";
const ini = `
registry = http://localhost:${registry.port}/
${Object.keys(opts)
.map(
k =>
`//localhost:${registry.port}/:${k}=${isBase64Encoded(k) && !opts[k].includes("${") ? Buffer.from(opts[k]).toString("base64") : opts[k]}`,
)
.join("\n")}
`;
if (dotEnvInner.length > 0) await Bun.$`echo ${dotEnvInner} > ${packageDir}/.env`;
await Bun.$`echo ${ini} > ${packageDir}/.npmrc`;
await Bun.$`echo ${JSON.stringify({
name: "hi",
main: "index.js",
version: "1.0.0",
dependencies: {
"@needs-auth/test-pkg": "1.0.0",
},
"publishConfig": {
"registry": `http://localhost:${registry.port}`,
},
})} > package.json`.cwd(packageDir);
const { stdout, stderr } = await Bun.$`${bunExe()} install`
.env({ ...env, ...restOfEnv })
.cwd(packageDir)
.throws(check === undefined);
if (check) check(stdout.toString(), stderr.toString());
});
}
registryConfigOptionTest("_authToken", async () => ({
"_authToken": await registry.generateUser("bilbo_baggins", "verysecure"),
}));
registryConfigOptionTest(
"_authToken with env variable value",
async () => ({ _authToken: "${SUPER_SECRET_TOKEN}" }),
async () => ({ SUPER_SECRET_TOKEN: await registry.generateUser("bilbo_baggins420", "verysecure") }),
);
registryConfigOptionTest("username and password", async () => {
await registry.generateUser("gandalf429", "verysecure");
return { username: "gandalf429", _password: "verysecure" };
});
registryConfigOptionTest(
"username and password with env variable password",
async () => {
await registry.generateUser("gandalf422", "verysecure");
return { username: "gandalf422", _password: "${SUPER_SECRET_PASSWORD}" };
},
{
SUPER_SECRET_PASSWORD: Buffer.from("verysecure").toString("base64"),
},
);
registryConfigOptionTest(
"username and password with .env variable password",
async () => {
await registry.generateUser("gandalf421", "verysecure");
return { username: "gandalf421", _password: "${SUPER_SECRET_PASSWORD}" };
},
{
dotEnv: { SUPER_SECRET_PASSWORD: "verysecure" },
},
);
registryConfigOptionTest("_auth", async () => {
await registry.generateUser("linus", "verysecure");
const _auth = "linus:verysecure";
return { _auth };
});
registryConfigOptionTest(
"_auth from .env variable",
async () => {
await registry.generateUser("zack", "verysecure");
return { _auth: "${SECRET_AUTH}" };
},
{
dotEnv: { SECRET_AUTH: "zack:verysecure" },
},
);
registryConfigOptionTest(
"_auth from .env variable with no value",
async () => {
await registry.generateUser("zack420", "verysecure");
return { _auth: "${SECRET_AUTH}" };
},
{
dotEnv: { SECRET_AUTH: "" },
},
(stdout: string, stderr: string) => {
expect(stderr).toContain("received an empty string");
},
);
await makeTest([["email", "user@example.com"]], result => {
expect(result.default_registry_url).toEqual("https://registry.npmjs.org/");
expect(result.default_registry_email).toEqual("user@example.com");
});
await makeTest(
[
["username", "testuser"],
["_password", "testpass"],
["email", "test@example.com"],
],
result => {
expect(result.default_registry_url).toEqual("https://registry.npmjs.org/");
expect(result.default_registry_username).toEqual("testuser");
expect(result.default_registry_password).toEqual("testpass");
expect(result.default_registry_email).toEqual("test@example.com");
},
);
});