diff --git a/packages/core/src/registry/npm.ts b/packages/core/src/registry/npm.ts index cda11b0c..84c9b730 100644 --- a/packages/core/src/registry/npm.ts +++ b/packages/core/src/registry/npm.ts @@ -21,6 +21,8 @@ class NpmError extends AbstractError { name = "npm Error"; } +const NPM_OFFICIAL_REGISTRY = "https://registry.npmjs.org"; + function validateNpmLoginUrl(rawUrl: string): string | null { let parsed: URL; try { @@ -487,6 +489,11 @@ export class NpmPackageRegistry extends PackageRegistry { }; } + private isOfficialNpmRegistry(): boolean { + if (!this.registry) return true; + return normalizeRegistryUrl(this.registry) === "registry.npmjs.org"; + } + private isProvenanceError(error: unknown): boolean { if (!(error instanceof NonZeroExitError)) return false; const stderr = error.output.stderr; @@ -525,10 +532,106 @@ export class NpmPackageRegistry extends PackageRegistry { return new NpmError("Failed to publish to npm", { cause: error }); } + private async runDirectWebLogin( + // biome-ignore lint/suspicious/noExplicitAny: listr2 TaskWrapper type is complex + task: any, + ): Promise { + task.output = "Launching npm login..."; + + const isValidUrl = (url: string): boolean => { + try { + return /^https?:$/.test(new URL(url).protocol); + } catch { + return false; + } + }; + + // Step 1: POST to login endpoint + const loginEndpoint = `${NPM_OFFICIAL_REGISTRY}/-/v1/login`; + const res = await fetch(loginEndpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + if (!res.ok) { + throw new NpmError( + `npm web login initiation failed (HTTP ${res.status})`, + ); + } + + const body = (await res.json()) as { + loginUrl?: string; + doneUrl?: string; + }; + + const { loginUrl, doneUrl } = body; + if ( + !loginUrl || + !doneUrl || + !isValidUrl(loginUrl) || + !isValidUrl(doneUrl) + ) { + throw new NpmError( + "npm web login response missing valid loginUrl or doneUrl", + ); + } + + // Step 2: Open browser and show URL + task.output = `Login at: ${color.cyan(loginUrl)}`; + const { openUrl } = await import("../utils/open-url.js"); + void openUrl(loginUrl).catch(() => {}); + + // Step 3: Poll doneUrl + while (true) { + const pollRes = await fetch(doneUrl); + + if (pollRes.status === 200) { + const pollBody = (await pollRes.json()) as { token?: string }; + if (!pollBody.token) { + throw new NpmError("npm web login completed but no token received"); + } + + // Step 4: Save token + try { + await exec( + "npm", + [ + "config", + "set", + "//registry.npmjs.org/:_authToken", + pollBody.token, + "--location=user", + ], + { throwOnError: true }, + ); + } catch (error) { + throw new NpmError("Failed to save npm auth token", { cause: error }); + } + return; + } + + if (pollRes.status === 202) { + const retryAfter = Number(pollRes.headers.get("retry-after")) * 1000; + const delay = retryAfter > 0 ? retryAfter : 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + throw new NpmError( + `npm web login polling failed (HTTP ${pollRes.status})`, + ); + } + } + private async runInteractiveLogin( // biome-ignore lint/suspicious/noExplicitAny: listr2 TaskWrapper type is complex task: any, ): Promise { + if (this.isOfficialNpmRegistry()) { + return this.runDirectWebLogin(task); + } + task.output = "Launching npm login..."; const [{ spawnInteractive }, { openUrl }] = await Promise.all([ diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index b76aa922..b16bf749 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -763,6 +763,883 @@ describe("NpmPackageRegistry checkAvailability()", () => { return { FreshNpmRegistry, openUrl, spawnInteractive }; } + describe("isOfficialNpmRegistry()", () => { + it("returns true for default registry", () => { + const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH); + expect(registry["isOfficialNpmRegistry"]()).toBe(true); + }); + + it("returns true for registry with trailing slash", () => { + const registry = new NpmPackageRegistry( + "my-package", + FIXTURE_PATH, + "https://registry.npmjs.org/", + ); + expect(registry["isOfficialNpmRegistry"]()).toBe(true); + }); + + it("returns false for private registry", () => { + const registry = new NpmPackageRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); + expect(registry["isOfficialNpmRegistry"]()).toBe(false); + }); + }); + + describe("direct web login (official npm)", () => { + function makeDoneResponse(token: string) { + return new Response(JSON.stringify({ token }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + function make202Response(retryAfter = "1") { + return new Response(JSON.stringify({}), { + status: 202, + headers: { + "retry-after": retryAfter, + "content-type": "application/json", + }, + }); + } + + function makeLoginResponse(loginUrl: string, doneUrl: string) { + return new Response(JSON.stringify({ loginUrl, doneUrl }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + it("completes direct web login: POST → poll 202 → poll 200 → save token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const loginUrl = "https://www.npmjs.com/auth/cli/test-uuid"; + const doneUrl = "https://www.npmjs.com/-/v1/login/test-uuid/done"; + + mockedFetch + .mockResolvedValueOnce(makeLoginResponse(loginUrl, doneUrl)) + .mockResolvedValueOnce(make202Response("0")) + .mockResolvedValueOnce(makeDoneResponse("npm_test-token-123")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + // Verify POST to /-/v1/login + expect(mockedFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/-/v1/login", + expect.objectContaining({ method: "POST" }), + ); + // Verify doneUrl polled + expect(mockedFetch).toHaveBeenCalledWith(doneUrl); + // Verify browser opened with loginUrl + expect(openUrl).toHaveBeenCalledWith(loginUrl); + // Verify token saved + expect(mockedExec).toHaveBeenCalledWith( + "npm", + [ + "config", + "set", + "//registry.npmjs.org/:_authToken", + "npm_test-token-123", + "--location=user", + ], + expect.objectContaining({ throwOnError: true }), + ); + // Verify loginUrl shown in task output + expect(task.output).toContain(loginUrl); + }); + + it("throws when POST response is missing loginUrl", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ doneUrl: "https://example.com/done" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws when POST response has invalid URL", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ loginUrl: "not-a-url", doneUrl: "also-not-a-url" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws when polling returns 200 without token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { status: 200 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login completed but no token received"); + }); + + it("throws on unexpected polling status", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Internal Server Error" }), { + status: 500, + }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login polling failed (HTTP 500)"); + }); + + it("throws when POST to login endpoint fails", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response("Not Found", { status: 404 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login initiation failed (HTTP 404)"); + }); + + it("uses 1 second default when Retry-After header is absent", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { status: 202 }), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const delayCall = setTimeoutSpy.mock.calls.find(([, ms]) => ms === 1000); + expect(delayCall).toBeDefined(); + + setTimeoutSpy.mockRestore(); + }); + + it("succeeds even when browser open fails", async () => { + const openUrl = vi.fn().mockRejectedValue(new Error("no browser")); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).resolves.toBeUndefined(); + }); + + it("throws when npm config set fails", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockRejectedValue(new Error("permission denied")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Failed to save npm auth token"); + }); + + it("uses spawnInteractive fallback for private registries", async () => { + const child = makeChild([ + "Login at:\n", + "https://www.npmjs.com/login?next=/login/cli/abc-123\n", + ]); + + const { FreshNpmRegistry, openUrl, spawnInteractive } = + await importFreshRegistryWithMocks(child); + + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(spawnInteractive).toHaveBeenCalledWith(["npm", "login"]); + // fetch should NOT have been called with /-/v1/login + expect(mockedFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/-/v1/login"), + expect.anything(), + ); + }); + + it("deduplicates concurrent direct web login attempts", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); + const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + + for (const current of [firstRegistry, secondRegistry]) { + vi.spyOn(current, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(current, "isPublished").mockResolvedValue(true); + vi.spyOn(current, "hasPermission").mockResolvedValue(true); + } + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const ctx = makeCtx(true); + await expect( + Promise.all([ + firstRegistry.checkAvailability(makeTask(), ctx), + secondRegistry.checkAvailability(makeTask(), ctx), + ]), + ).resolves.toEqual([undefined, undefined]); + + // POST to /-/v1/login should only be called once + const loginCalls = mockedFetch.mock.calls.filter( + ([url, opts]) => + typeof url === "string" && + url.endsWith("/-/v1/login") && + (opts as any)?.method === "POST", + ); + expect(loginCalls).toHaveLength(1); + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + }); + + it("polls multiple 202 responses before receiving 200 with token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const pollCalls = mockedFetch.mock.calls.filter( + ([url]) => + typeof url === "string" && url.includes("/-/v1/login/x/done"), + ); + // 3 x 202 + 1 x 200 = 4 total poll requests to doneUrl + expect(pollCalls).toHaveLength(4); + }); + + it("respects Retry-After header value for polling delay", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(make202Response("0.005")) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const delayCall = setTimeoutSpy.mock.calls.find(([, ms]) => ms === 5); + expect(delayCall).toBeDefined(); + + setTimeoutSpy.mockRestore(); + }); + + it("throws when POST fetch throws a network error", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockRejectedValueOnce(new TypeError("fetch failed")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm login failed"); + }); + + it("throws when polling fetch throws a network error", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockRejectedValueOnce(new TypeError("network timeout")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm login failed"); + }); + + it("throws when POST returns empty JSON body", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify({}), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws when token is an empty string", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "" }), { status: 200 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login completed but no token received"); + }); + + it("rejects ftp:// protocol URLs in login response", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + loginUrl: "ftp://evil.com/login", + doneUrl: "ftp://evil.com/done", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws 'Still not logged in' when isLoggedIn returns false after successful token save", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Still not logged in after npm login"); + }); + + it("wraps direct web login errors with 'npm login failed:' prefix", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce(new Response("", { status: 503 })); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm login failed: npm web login initiation failed (HTTP 503)", + ); + }); + + it("skips login entirely when already logged in", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(mockedFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/-/v1/login"), + expect.anything(), + ); + }); + + it("clears shared promise after direct login failure so retry can start new login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const ctx = makeCtx(true); + + // First attempt: POST fails + mockedFetch.mockResolvedValueOnce(new Response("", { status: 500 })); + + await expect( + freshRegistry.checkAvailability(makeTask(), ctx), + ).rejects.toThrow("npm login failed"); + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + + // Second attempt: succeeds + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/retry", + "https://www.npmjs.com/-/v1/login/retry/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), ctx), + ).resolves.toBeUndefined(); + + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + }); + + it("second package sees 'Waiting for npm login...' during shared direct login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); + const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + + for (const current of [firstRegistry, secondRegistry]) { + vi.spyOn(current, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(current, "isPublished").mockResolvedValue(true); + vi.spyOn(current, "hasPermission").mockResolvedValue(true); + } + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const ctx = makeCtx(true); + const secondTask = makeTask(); + + await Promise.all([ + firstRegistry.checkAvailability(makeTask(), ctx), + secondRegistry.checkAvailability(secondTask, ctx), + ]); + + expect(secondTask.output).toBe("Waiting for npm login..."); + }); + + it("handles loginUrl with query parameters correctly", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const loginUrl = + "https://www.npmjs.com/login?next=/login/cli/abc-123&foo=bar"; + const doneUrl = "https://www.npmjs.com/-/v1/login/abc-123/done"; + + mockedFetch + .mockResolvedValueOnce(makeLoginResponse(loginUrl, doneUrl)) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(openUrl).toHaveBeenCalledWith(loginUrl); + }); + + it("does not expose doneUrl in task output", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const doneUrl = "https://www.npmjs.com/-/v1/login/secret-uuid/done"; + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", doneUrl), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + expect(task.output).not.toContain(doneUrl); + expect(task.output).not.toContain("secret-uuid/done"); + }); + + it("does not expose token in task output", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_super-secret-token-xyz")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + expect(task.output).not.toContain("npm_super-secret-token-xyz"); + }); + + it("proceeds to N2 permission check after successful direct login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("No permission to publish on npm."); + }); + + it("proceeds to N3 name check after successful direct login for unpublished package", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(false); + vi.spyOn(freshRegistry, "isPackageNameAvailable").mockResolvedValue( + false, + ); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Package name is not available."); + }); + }); + describe("N1: login check", () => { it("throws when not logged in in CI mode", async () => { vi.spyOn(registry, "isLoggedIn").mockResolvedValue(false); @@ -780,7 +1657,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/login?next=/login/cli/abc-123\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); @@ -808,7 +1689,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/auth/cli/xyz-789\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); @@ -832,8 +1717,16 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/login?next=/login/cli/shared-123\n", ]), ); - const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); - const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + const firstRegistry = new FreshNpmRegistry( + "pkg-a", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); + const secondRegistry = new FreshNpmRegistry( + "pkg-b", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); for (const current of [firstRegistry, secondRegistry]) { vi.spyOn(current, "isLoggedIn") @@ -866,7 +1759,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { FreshNpmRegistry } = await importFreshRegistryWithMocks( makeChild([], [], 1), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); await expect( @@ -878,7 +1775,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { FreshNpmRegistry } = await importFreshRegistryWithMocks( makeChild(["Logged in on https://registry.npmjs.org/.\n"]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); await expect( @@ -912,7 +1813,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { NpmPackageRegistry: FreshNpmRegistry } = await import( "../../../src/registry/npm.js" ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(false) @@ -944,7 +1849,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/auth/cli/still-not-logged-in\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(false);