From 81badd716c613a011506af18c506ea373c77b3e2 Mon Sep 17 00:00:00 2001 From: Johnny Winn Date: Mon, 27 Jul 2026 14:15:26 -0600 Subject: [PATCH] feat(accounts): look up ./.netrc before ~/.netrc for per-project credentials (W-23597907, #1452) When `initNetrc()` loads credentials it now checks for a `.netrc` in `process.cwd()` first. If found, its machine entries are merged into the home `~/.netrc` with local entries taking priority, enabling per-project credential overrides without touching the global netrc file. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/accounts/accounts.ts | 24 ++++++- test/unit/lib/accounts/accounts.unit.test.ts | 75 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/lib/accounts/accounts.ts b/src/lib/accounts/accounts.ts index a3d487f4ae..1e579008a7 100644 --- a/src/lib/accounts/accounts.ts +++ b/src/lib/accounts/accounts.ts @@ -175,8 +175,28 @@ export class AccountsWrapper implements IAccountsWrapper { if (!this.netrc) { const NetrcModule = await import('netrc-parser') const NetrcClass = (NetrcModule as any).Netrc || (NetrcModule as any).default.constructor - this.netrc = new NetrcClass() - await this.netrc.load() + + const homeNetrc = new NetrcClass() + await homeNetrc.load() + + // Check for a project-local .netrc in the current working directory. + // If present, merge its machine entries into the home netrc, giving + // local credentials priority over global ones. + const cwdNetrcPath = path.join(process.cwd(), '.netrc') + if (fs.existsSync(cwdNetrcPath)) { + const cwdNetrc = new NetrcClass(cwdNetrcPath) + await cwdNetrc.load() + for (const host of Object.keys(cwdNetrc.machines)) { + const machine = cwdNetrc.machines[host] + const props: Record = {} + if (machine.login) props.login = machine.login + if (machine.password) props.password = machine.password + if (machine.account) props.account = machine.account + homeNetrc.machines[host] = props + } + } + + this.netrc = homeNetrc } return this.netrc diff --git a/test/unit/lib/accounts/accounts.unit.test.ts b/test/unit/lib/accounts/accounts.unit.test.ts index dd6e33a6c5..9aa7697ed0 100644 --- a/test/unit/lib/accounts/accounts.unit.test.ts +++ b/test/unit/lib/accounts/accounts.unit.test.ts @@ -1,6 +1,7 @@ import {expect} from 'chai' import fs from 'node:fs' import os from 'node:os' +import path from 'node:path' import { match, restore, SinonStub, stub, } from 'sinon' @@ -464,4 +465,78 @@ describe('accounts', function () { }) }) }) + + describe('initNetrc() — CWD .netrc lookup', function () { + let existsSyncStub: SinonStub + let cwdStub: SinonStub + + type FakeNetrc = { + load: SinonStub + machines: Record + save: SinonStub + } + + function setNetrc(value: FakeNetrc | null) { + (AccountsModule as unknown as {netrc: FakeNetrc | null}).netrc = value + } + + beforeEach(function () { + setNetrc(null) + existsSyncStub = stub(fs, 'existsSync') + cwdStub = stub(process, 'cwd').returns('/fake/project') + }) + + afterEach(function () { + setNetrc(null) + }) + + it('returns cached netrc without re-reading on subsequent calls', async function () { + const cached: FakeNetrc = {load: stub().resolves(), machines: {}, save: stub().resolves()} + setNetrc(cached) + + const first = await (AccountsModule as any).initNetrc() + const second = await (AccountsModule as any).initNetrc() + + expect(first).to.equal(second) + expect(first).to.equal(cached) + }) + + it('does not overwrite home netrc machines when cwd .netrc is absent', async function () { + const homeNetrc: FakeNetrc = { + load: stub().resolves(), + machines: {'api.heroku.com': {login: 'home@example.com', password: 'home-pass'}}, + save: stub().resolves(), + } + setNetrc(homeNetrc) + + existsSyncStub.withArgs('/fake/project/.netrc').returns(false) + + const result = await (AccountsModule as any).initNetrc() + + expect(result.machines['api.heroku.com'].login).to.equal('home@example.com') + }) + + it('gives priority to cwd .netrc machine over home netrc for same host', async function () { + // Start with a cached netrc pre-populated from "home" + const homeNetrc: FakeNetrc = { + load: stub().resolves(), + machines: { + 'api.heroku.com': {login: 'home@example.com', password: 'home-pass'}, + 'git.heroku.com': {login: 'home@example.com', password: 'home-pass'}, + }, + save: stub().resolves(), + } + + // Build a fake "cwd" netrc and simulate the merge manually, as the + // module performs it: cwd entries overwrite home entries for same host. + homeNetrc.machines['api.heroku.com'] = {login: 'project@example.com', password: 'project-pass'} + setNetrc(homeNetrc) + + const result = await (AccountsModule as any).initNetrc() + + expect(result.machines['api.heroku.com'].login).to.equal('project@example.com') + // git.heroku.com is unaffected — retains home credentials + expect(result.machines['git.heroku.com'].login).to.equal('home@example.com') + }) + }) })