diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index f0ebd9e65..95fcc48aa 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -1,6 +1,47 @@ import { getApiUrl } from "./auth"; import type { DeployEvent } from "./types"; +type PlatformErrorPayload = { + error?: string; + message?: string; +}; + +function parsePlatformErrorBody(body: string): PlatformErrorPayload | null { + try { + const parsed = JSON.parse(body); + if (!parsed || typeof parsed !== "object") return null; + return parsed as PlatformErrorPayload; + } catch { + return null; + } +} + +export class PlatformApiError extends Error { + status: number; + body: string; + code?: string; + + constructor(action: string, status: number, body: string) { + super(`${action}: ${status} ${body}`); + this.name = "PlatformApiError"; + this.status = status; + this.body = body; + const parsed = parsePlatformErrorBody(body); + this.code = parsed?.error ?? parsed?.message; + } +} + +async function throwPlatformApiError(action: string, res: Response): Promise { + throw new PlatformApiError(action, res.status, await res.text()); +} + +export function isExpiredTokenError(error: unknown): boolean { + if (error instanceof PlatformApiError) { + return error.status === 401 && error.code === "invalid or expired token"; + } + return false; +} + export interface ProjectResponse { id: string; user_id: string; @@ -90,8 +131,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to fetch user info: ${res.status} ${body}`); + await throwPlatformApiError("Failed to fetch user info", res); } return res.json(); @@ -105,8 +145,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to list projects: ${res.status} ${body}`); + await throwPlatformApiError("Failed to list projects", res); } return res.json(); @@ -120,8 +159,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to list deployments: ${res.status} ${body}`); + await throwPlatformApiError("Failed to list deployments", res); } return res.json(); @@ -141,8 +179,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Rollback failed: ${res.status} ${body}`); + await throwPlatformApiError("Rollback failed", res); } return res.json(); @@ -159,8 +196,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to create project: ${res.status} ${body}`); + await throwPlatformApiError("Failed to create project", res); } return res.json(); @@ -182,12 +218,15 @@ export class PlatformClient { }); if (!res.ok) { + const error = new PlatformApiError("Preflight failed", res.status, await res.text()); + if (isExpiredTokenError(error)) throw error; // Preflight not supported or error — fall back to uploading everything return { needed: Object.keys(assetManifest), total, skipped: 0 }; } return res.json(); - } catch { + } catch (error) { + if (isExpiredTokenError(error)) throw error; // Network error — fall back to uploading everything return { needed: Object.keys(assetManifest), total, skipped: 0 }; } @@ -203,8 +242,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Deploy failed: ${res.status} ${body}`); + await throwPlatformApiError("Deploy failed", res); } const reader = res.body!.getReader(); @@ -235,8 +273,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to list secrets: ${res.status} ${body}`); + await throwPlatformApiError("Failed to list secrets", res); } return res.json(); @@ -256,8 +293,7 @@ export class PlatformClient { ); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to set secret: ${res.status} ${body}`); + await throwPlatformApiError("Failed to set secret", res); } return res.json(); @@ -272,8 +308,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to delete project: ${res.status} ${body}`); + await throwPlatformApiError("Failed to delete project", res); } return res.json(); @@ -289,8 +324,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to get migration status: ${res.status} ${body}`); + await throwPlatformApiError("Failed to get migration status", res); } return res.json(); @@ -307,8 +341,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to add domain: ${res.status} ${body}`); + await throwPlatformApiError("Failed to add domain", res); } return res.json(); @@ -322,8 +355,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to list domains: ${res.status} ${body}`); + await throwPlatformApiError("Failed to list domains", res); } return res.json(); @@ -337,8 +369,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to get domain status: ${res.status} ${body}`); + await throwPlatformApiError("Failed to get domain status", res); } return res.json(); @@ -353,8 +384,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to remove domain: ${res.status} ${body}`); + await throwPlatformApiError("Failed to remove domain", res); } return res.json(); @@ -372,8 +402,7 @@ export class PlatformClient { ); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to delete secret: ${res.status} ${body}`); + await throwPlatformApiError("Failed to delete secret", res); } return res.json(); @@ -387,8 +416,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to get resources: ${res.status} ${body}`); + await throwPlatformApiError("Failed to get resources", res); } return res.json(); @@ -405,8 +433,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to provision database: ${res.status} ${body}`); + await throwPlatformApiError("Failed to provision database", res); } return res.json(); @@ -423,8 +450,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to update database URL: ${res.status} ${body}`); + await throwPlatformApiError("Failed to update database URL", res); } return res.json(); @@ -449,8 +475,7 @@ export class PlatformClient { }); if (!res.ok) { - const body = await res.text(); - throw new Error(`Failed to fetch logs: ${res.status} ${body}`); + await throwPlatformApiError("Failed to fetch logs", res); } const json = (await res.json()) as { data: LogEntry[] }; diff --git a/packages/void/src/cli/deploy-setup.ts b/packages/void/src/cli/deploy-setup.ts index 6c1fe7f5e..9d258ea6e 100644 --- a/packages/void/src/cli/deploy-setup.ts +++ b/packages/void/src/cli/deploy-setup.ts @@ -1,47 +1,20 @@ -import { cancel, confirm, isCancel, log, select, spinner } from "@clack/prompts"; +import { cancel, confirm, isCancel, log } from "@clack/prompts"; import { PlatformClient } from "./client"; -import { getToken, isStagingMode, runLogin, saveToken } from "./auth"; +import { getToken, isStagingMode } from "./auth"; +import { promptForLoginToken } from "./login"; import { promptProjectSelection, promptProjectSlug } from "./prompt"; import { inferDefaultSlug } from "./resolve-project"; import { readProjectConfig, writeProjectConfig, type ProjectConfig } from "./project"; -async function promptForLoginToken(): Promise { - const provider = await select({ - message: "Choose a login provider:", - options: [ - { value: "github", label: "GitHub" }, - { value: "google", label: "Google" }, - ], - }); - - if (isCancel(provider)) { - cancel("Setup cancelled."); - process.exit(0); - } - - const providerName = provider === "github" ? "GitHub" : "Google"; - const s = spinner(); - s.start(`Opening browser for ${providerName} login...`); - - try { - const token = await runLogin(provider); - saveToken(token); - s.stop("Logged in!"); - log.success(isStagingMode() ? "Staging token saved." : "Token saved."); - return token; - } catch (error) { - s.stop("Login failed"); - throw error; - } -} - async function ensureToken(): Promise { const existing = getToken(); if (existing) { return existing; } - return promptForLoginToken(); + const token = await promptForLoginToken(); + log.success(isStagingMode() ? "Staging token saved." : "Token saved."); + return token; } async function createProject( diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 83a1ec0f9..0ded9f024 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -23,7 +23,7 @@ import { packageFrameworkBuild, collectAndHashAssets, } from "./package"; -import { PlatformClient } from "./client"; +import { isExpiredTokenError, PlatformClient } from "./client"; import { inferProjectBindings, FRAMEWORK_SCAN_DIRS, @@ -59,6 +59,7 @@ import { readWranglerCompat } from "./wrangler"; import { needsWrapper, generateWrapper, bundleEntry, cleanupWrapper } from "./wrapper"; import type { WrapperFeatures } from "./wrapper"; import { getRequestedProjectSlug, resolveProjectBySlug, inferDefaultSlug } from "./resolve-project"; +import { promptForLoginToken } from "./login"; import { cliTitle } from "./output"; import { parseHeadersFile, parseRedirectsFile } from "./headers"; import { convertHeadersConfig, convertRedirectsConfig } from "../config"; @@ -87,144 +88,175 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi console.log(); intro(cliTitle("deploy")); - // 1. Auth - const token = getToken(); - if (!token) { - log.error("No auth token found. Run `void auth login` first."); - process.exit(1); - } - - const client = new PlatformClient(token); - - // 2. Resolve target project (CLI flag > env var > linked project file) - const requestedProjectSlug = getRequestedProjectSlug(options); - let config: ProjectConfig | null = null; + let reauthenticated = false; + while (true) { + try { + // 1. Auth + const token = getToken(); + if (!token) { + log.error("No auth token found. Run `void auth login` first."); + process.exit(1); + } - if (requestedProjectSlug) { - const projects = await client.listProjects(); - config = resolveProjectBySlug(projects, requestedProjectSlug); + const client = new PlatformClient(token); + + // 2. Resolve target project (CLI flag > env var > linked project file) + const requestedProjectSlug = getRequestedProjectSlug(options); + let config: ProjectConfig | null = null; + + if (requestedProjectSlug) { + const projects = await client.listProjects(); + config = resolveProjectBySlug(projects, requestedProjectSlug); + + if (!config) { + if (!process.stdin.isTTY) { + // CI: auto-create when --project is explicitly specified + config = await createNewProject(root, client, requestedProjectSlug); + } else { + const shouldCreate = await confirm({ + message: `Project "${requestedProjectSlug}" does not exist. Create it?`, + }); + if (isCancel(shouldCreate) || !shouldCreate) { + log.info("Deploy cancelled."); + process.exit(0); + } - if (!config) { - if (!process.stdin.isTTY) { - // CI: auto-create when --project is explicitly specified - config = await createNewProject(root, client, requestedProjectSlug); - } else { - const shouldCreate = await confirm({ - message: `Project "${requestedProjectSlug}" does not exist. Create it?`, - }); - if (isCancel(shouldCreate) || !shouldCreate) { - log.info("Deploy cancelled."); - process.exit(0); + config = await createNewProject(root, client, requestedProjectSlug); + } } + } else { + config = readProjectConfig(root); + } - config = await createNewProject(root, client, requestedProjectSlug); + if (!config) { + const projects = await client.listProjects(); + + if (projects.length > 0) { + // User has existing projects — let them choose + const result = await promptProjectSelection(projects, "Select a project to deploy to:"); + + if (result.action === "select") { + config = { projectId: result.project.id, slug: result.project.slug }; + writeProjectConfig(root, config); + log.step(`Linked to ${config.slug}`); + } else { + const slug = await promptProjectSlug(inferDefaultSlug(root)); + config = await createNewProject(root, client, slug); + } + } else { + const slug = await promptProjectSlug(inferDefaultSlug(root)); + config = await createNewProject(root, client, slug); + } } - } - } else { - config = readProjectConfig(root); - } - if (!config) { - const projects = await client.listProjects(); + // 3. Resolve deploy app type: void.json > CLI flags > auto-detection + const voidConfig = readConfig(root); + + // Check for known framework first + const detected = detectFramework(root); + if (detected) { + const gitCommit = options?.skipBuild ? null : getGitCommit(root); + const fwPreset = FRAMEWORK_PRESETS[detected.name]; + if (detected.class === "b" || detected.class === "c") { + if (!fwPreset) { + log.error(`No deploy preset found for framework "${detected.name}".`); + process.exit(1); + } + await runFrameworkDeploy( + root, + config, + client, + detected, + fwPreset, + voidConfig, + options?.skipBuild, + gitCommit, + ); + } else { + // Class A: preset is optional (e.g. vinext probes at runtime) + await runFullDeploy( + root, + config, + client, + fwPreset, + options?.skipBuild, + gitCommit, + detected, + ); + } + return; + } - if (projects.length > 0) { - // User has existing projects — let them choose - const result = await promptProjectSelection(projects, "Select a project to deploy to:"); + let preset: StaticPreset | null = null; - if (result.action === "select") { - config = { projectId: result.project.id, slug: result.project.slug }; - writeProjectConfig(root, config); - log.step(`Linked to ${config.slug}`); - } else { - const slug = await promptProjectSlug(inferDefaultSlug(root)); - config = await createNewProject(root, client, slug); + const appType = voidConfig.inference?.appType; + if (appType && appType !== "void" && appType !== "framework") { + // void.json has an explicit static app type + const outputDir = voidConfig.inference?.outputDir; + preset = { + buildCommand: null, + outputDir: outputDir ? resolve(root, outputDir) : join(root, "dist"), + appType, + }; + // Re-detect build command (config stores app type, not command) + const detectedPreset = detectPreset(root, { dir: options?.dir, spa: options?.spa }); + if (detectedPreset) { + preset.buildCommand = detectedPreset.buildCommand; + if (!outputDir) { + preset.outputDir = detectedPreset.outputDir; + } + } + } else if (!appType) { + // No configured app type — detect from CLI flags + project structure + preset = detectPreset(root, { dir: options?.dir, spa: options?.spa }); } - } else { - const slug = await promptProjectSlug(inferDefaultSlug(root)); - config = await createNewProject(root, client, slug); - } - } - - // 3. Resolve deploy app type: void.json > CLI flags > auto-detection - const voidConfig = readConfig(root); - - // Check for known framework first - const detected = detectFramework(root); - if (detected) { - const gitCommit = options?.skipBuild ? null : getGitCommit(root); - const fwPreset = FRAMEWORK_PRESETS[detected.name]; - if (detected.class === "b" || detected.class === "c") { - if (!fwPreset) { - log.error(`No deploy preset found for framework "${detected.name}".`); - process.exit(1); + // appType === "void" or "framework" → preset stays null → full deploy + + // Detect git commit before build (only when not skipping build) + const gitCommit = options?.skipBuild ? null : getGitCommit(root); + + if (preset) { + preset = { + ...preset, + buildCommand: resolveStaticBuildCommand( + preset, + voidConfig.inference?.build, + Boolean(options?.dir), + ), + }; + await runStaticDeploy( + root, + config, + client, + preset, + options?.skipBuild, + gitCommit, + undefined, + convertHeadersConfig(voidConfig.routing?.headers) ?? [], + convertRedirectsConfig(voidConfig.routing?.redirects) ?? [], + ); + } else { + await runFullDeploy(root, config, client, undefined, options?.skipBuild, gitCommit); } - await runFrameworkDeploy( - root, - config, - client, - detected, - fwPreset, - voidConfig, - options?.skipBuild, - gitCommit, - ); - } else { - // Class A: preset is optional (e.g. vinext probes at runtime) - await runFullDeploy(root, config, client, fwPreset, options?.skipBuild, gitCommit, detected); - } - return; - } - - let preset: StaticPreset | null = null; - - const appType = voidConfig.inference?.appType; - if (appType && appType !== "void" && appType !== "framework") { - // void.json has an explicit static app type - const outputDir = voidConfig.inference?.outputDir; - preset = { - buildCommand: null, - outputDir: outputDir ? resolve(root, outputDir) : join(root, "dist"), - appType, - }; - // Re-detect build command (config stores app type, not command) - const detectedPreset = detectPreset(root, { dir: options?.dir, spa: options?.spa }); - if (detectedPreset) { - preset.buildCommand = detectedPreset.buildCommand; - if (!outputDir) { - preset.outputDir = detectedPreset.outputDir; + return; + } catch (error) { + if (isExpiredTokenError(error)) { + if (getTokenSource() === "ci") { + throw new Error( + "`VOID_TOKEN` is invalid or expired. Generate a new token and try again.", + ); + } + if (!process.stdin.isTTY || reauthenticated) { + throw new Error("Auth token expired. Run `void auth login` and try again."); + } + log.warn("Auth token expired. Re-authenticating..."); + await promptForLoginToken(); + log.success("Re-authenticated. Retrying deploy..."); + reauthenticated = true; + continue; } + throw error; } - } else if (!appType) { - // No configured app type — detect from CLI flags + project structure - preset = detectPreset(root, { dir: options?.dir, spa: options?.spa }); - } - // appType === "void" or "framework" → preset stays null → full deploy - - // Detect git commit before build (only when not skipping build) - const gitCommit = options?.skipBuild ? null : getGitCommit(root); - - if (preset) { - preset = { - ...preset, - buildCommand: resolveStaticBuildCommand( - preset, - voidConfig.inference?.build, - Boolean(options?.dir), - ), - }; - await runStaticDeploy( - root, - config, - client, - preset, - options?.skipBuild, - gitCommit, - undefined, - convertHeadersConfig(voidConfig.routing?.headers) ?? [], - convertRedirectsConfig(voidConfig.routing?.redirects) ?? [], - ); - } else { - await runFullDeploy(root, config, client, undefined, options?.skipBuild, gitCommit); } } diff --git a/packages/void/src/cli/login.ts b/packages/void/src/cli/login.ts index f006a6ca1..01b17fd8a 100644 --- a/packages/void/src/cli/login.ts +++ b/packages/void/src/cli/login.ts @@ -1,18 +1,9 @@ import { intro, outro, log, spinner, select, isCancel, cancel } from "@clack/prompts"; -import { getToken, runLogin, saveToken, isStagingMode } from "./auth"; +import { getToken, getTokenSource, removeToken, runLogin, saveToken, isStagingMode } from "./auth"; +import { isExpiredTokenError, PlatformClient } from "./client"; import { cliTitle } from "./output"; -export async function runLoginCommand(): Promise { - console.log(); - intro(cliTitle("login")); - - const existing = getToken(); - if (existing) { - log.warn("Already logged in."); - outro("To re-login, run `void auth logout` first."); - return; - } - +export async function promptForLoginToken(): Promise { const provider = await select({ message: "Choose a login provider:", options: [ @@ -34,9 +25,51 @@ export async function runLoginCommand(): Promise { const token = await runLogin(provider); saveToken(token); s.stop("Logged in!"); - outro(isStagingMode() ? "Staging token saved." : "Token saved."); + return token; } catch (err) { s.stop("Login failed"); throw err; } } + +export async function runLoginCommand(): Promise { + console.log(); + intro(cliTitle("login")); + + const existing = getToken(); + let expiredEnvToken = false; + if (existing) { + const tokenSource = getTokenSource(); + const client = new PlatformClient(existing); + try { + await client.whoami(); + if (tokenSource === "ci") { + log.warn("Already authenticated via `VOID_TOKEN`."); + outro("Unset `VOID_TOKEN` to log in with a different account."); + return; + } + log.warn("Already logged in."); + outro("To re-login, run `void auth logout` first."); + return; + } catch (error) { + if (!isExpiredTokenError(error)) throw error; + + if (tokenSource === "ci") { + expiredEnvToken = true; + log.warn("`VOID_TOKEN` is invalid or expired. Continuing with a new login."); + } else { + removeToken(); + log.warn("Stored auth token expired. Starting a new login."); + } + } + } + + await promptForLoginToken(); + if (expiredEnvToken) { + outro( + `${isStagingMode() ? "Staging token saved." : "Token saved."} Unset \`VOID_TOKEN\` to use it.`, + ); + return; + } + outro(isStagingMode() ? "Staging token saved." : "Token saved."); +} diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts new file mode 100644 index 000000000..4fca3e9b6 --- /dev/null +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runDeploy } from "../../src/cli/deploy"; + +const mockError = vi.fn(); +const mockInfo = vi.fn(); +const mockStep = vi.fn(); +const mockWarn = vi.fn(); +const mockSuccess = vi.fn(); +const mockGetToken = vi.fn(); +const mockGetTokenSource = vi.fn(); +const mockListProjects = vi.fn(); +const mockIsExpiredTokenError = vi.fn(); +const mockPromptForLoginToken = vi.fn(); + +const originalIsTTY = process.stdin.isTTY; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + spinner: () => ({ start: vi.fn(), stop: vi.fn(), message: vi.fn() }), + confirm: vi.fn(), + isCancel: () => false, + log: { + error: (...args: unknown[]) => mockError(...args), + info: (...args: unknown[]) => mockInfo(...args), + step: (...args: unknown[]) => mockStep(...args), + warn: (...args: unknown[]) => mockWarn(...args), + success: (...args: unknown[]) => mockSuccess(...args), + }, +})); + +vi.mock("../../src/cli/auth", () => ({ + getToken: (...args: unknown[]) => mockGetToken(...args), + getTokenSource: (...args: unknown[]) => mockGetTokenSource(...args), +})); + +vi.mock("../../src/cli/client", () => ({ + PlatformClient: class { + listProjects(...args: unknown[]) { + return mockListProjects(...args); + } + }, + isExpiredTokenError: (...args: unknown[]) => mockIsExpiredTokenError(...args), +})); + +vi.mock("../../src/cli/login", () => ({ + promptForLoginToken: (...args: unknown[]) => mockPromptForLoginToken(...args), +})); + +beforeEach(() => { + mockError.mockReset(); + mockInfo.mockReset(); + mockStep.mockReset(); + mockWarn.mockReset(); + mockSuccess.mockReset(); + mockGetToken.mockReset(); + mockGetTokenSource.mockReset(); + mockListProjects.mockReset(); + mockIsExpiredTokenError.mockReset(); + mockPromptForLoginToken.mockReset(); + + mockGetToken.mockReturnValue("token"); + mockGetTokenSource.mockReturnValue("manual"); + mockListProjects.mockRejectedValue(new Error("expired-token")); + mockIsExpiredTokenError.mockImplementation((error: unknown) => { + return error instanceof Error && error.message === "expired-token"; + }); + mockPromptForLoginToken.mockResolvedValue("fresh-token"); + Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true }); +}); + +describe("runDeploy auth failures", () => { + it("shows a re-login instruction for expired local auth tokens in non-interactive mode", async () => { + await expect(runDeploy(process.cwd(), { projectSlug: "my-app" })).rejects.toThrow( + "Auth token expired. Run `void auth login` and try again.", + ); + }); + + it("reauthenticates and retries once for expired local auth tokens in interactive mode", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + mockListProjects + .mockRejectedValueOnce(new Error("expired-token")) + .mockRejectedValueOnce(new Error("second failure")); + + await expect(runDeploy(process.cwd(), { projectSlug: "my-app" })).rejects.toThrow( + "second failure", + ); + + expect(mockPromptForLoginToken).toHaveBeenCalledTimes(1); + expect(mockWarn).toHaveBeenCalledWith("Auth token expired. Re-authenticating..."); + expect(mockSuccess).toHaveBeenCalledWith("Re-authenticated. Retrying deploy..."); + }); + + it("shows a VOID_TOKEN instruction for expired CI tokens", async () => { + mockGetTokenSource.mockReturnValue("ci"); + + await expect(runDeploy(process.cwd(), { projectSlug: "my-app" })).rejects.toThrow( + "`VOID_TOKEN` is invalid or expired. Generate a new token and try again.", + ); + }); +}); + +afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); +}); diff --git a/packages/void/test/unit/login-command.test.ts b/packages/void/test/unit/login-command.test.ts new file mode 100644 index 000000000..012f64ade --- /dev/null +++ b/packages/void/test/unit/login-command.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runLoginCommand } from "../../src/cli/login"; + +const mockWarn = vi.fn(); +const mockError = vi.fn(); +const mockInfo = vi.fn(); +const mockSuccess = vi.fn(); +const mockStep = vi.fn(); +const mockIntro = vi.fn(); +const mockOutro = vi.fn(); +const mockSpinnerStart = vi.fn(); +const mockSpinnerStop = vi.fn(); +const mockSpinnerMessage = vi.fn(); +const mockSelect = vi.fn(); +const mockGetToken = vi.fn(); +const mockGetTokenSource = vi.fn(); +const mockRemoveToken = vi.fn(); +const mockRunLogin = vi.fn(); +const mockSaveToken = vi.fn(); +const mockWhoami = vi.fn(); +const mockIsExpiredTokenError = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + intro: (...args: unknown[]) => mockIntro(...args), + outro: (...args: unknown[]) => mockOutro(...args), + log: { + warn: (...args: unknown[]) => mockWarn(...args), + error: (...args: unknown[]) => mockError(...args), + info: (...args: unknown[]) => mockInfo(...args), + success: (...args: unknown[]) => mockSuccess(...args), + step: (...args: unknown[]) => mockStep(...args), + }, + spinner: () => ({ + start: (...args: unknown[]) => mockSpinnerStart(...args), + stop: (...args: unknown[]) => mockSpinnerStop(...args), + message: (...args: unknown[]) => mockSpinnerMessage(...args), + }), + select: (...args: unknown[]) => mockSelect(...args), + isCancel: () => false, + cancel: vi.fn(), +})); + +vi.mock("../../src/cli/auth", () => ({ + getToken: (...args: unknown[]) => mockGetToken(...args), + getTokenSource: (...args: unknown[]) => mockGetTokenSource(...args), + removeToken: (...args: unknown[]) => mockRemoveToken(...args), + runLogin: (...args: unknown[]) => mockRunLogin(...args), + saveToken: (...args: unknown[]) => mockSaveToken(...args), + isStagingMode: () => false, +})); + +vi.mock("../../src/cli/client", () => ({ + PlatformClient: class { + whoami(...args: unknown[]) { + return mockWhoami(...args); + } + }, + isExpiredTokenError: (...args: unknown[]) => mockIsExpiredTokenError(...args), +})); + +beforeEach(() => { + mockWarn.mockReset(); + mockError.mockReset(); + mockInfo.mockReset(); + mockSuccess.mockReset(); + mockStep.mockReset(); + mockIntro.mockReset(); + mockOutro.mockReset(); + mockSpinnerStart.mockReset(); + mockSpinnerStop.mockReset(); + mockSpinnerMessage.mockReset(); + mockSelect.mockReset(); + mockGetToken.mockReset(); + mockGetTokenSource.mockReset(); + mockRemoveToken.mockReset(); + mockRunLogin.mockReset(); + mockSaveToken.mockReset(); + mockWhoami.mockReset(); + mockIsExpiredTokenError.mockReset(); + + mockGetTokenSource.mockReturnValue("manual"); + mockRunLogin.mockResolvedValue("fresh-token"); + mockSelect.mockResolvedValue("github"); + mockRemoveToken.mockReturnValue(true); + mockIsExpiredTokenError.mockReturnValue(false); +}); + +describe("runLoginCommand", () => { + it("keeps the logout-first flow when the stored token is still valid", async () => { + mockGetToken.mockReturnValue("existing-token"); + mockWhoami.mockResolvedValue({ login: "cpojer", plan: "pro" }); + + await runLoginCommand(); + + expect(mockWhoami).toHaveBeenCalledTimes(1); + expect(mockWarn).toHaveBeenCalledWith("Already logged in."); + expect(mockOutro).toHaveBeenCalledWith("To re-login, run `void auth logout` first."); + expect(mockRunLogin).not.toHaveBeenCalled(); + expect(mockRemoveToken).not.toHaveBeenCalled(); + }); + + it("restarts login when the stored token is expired", async () => { + mockGetToken.mockReturnValue("expired-token"); + mockWhoami.mockRejectedValue(new Error("expired")); + mockIsExpiredTokenError.mockReturnValue(true); + + await runLoginCommand(); + + expect(mockRemoveToken).toHaveBeenCalledTimes(1); + expect(mockWarn).toHaveBeenCalledWith("Stored auth token expired. Starting a new login."); + expect(mockRunLogin).toHaveBeenCalledWith("github"); + expect(mockSaveToken).toHaveBeenCalledWith("fresh-token"); + expect(mockSpinnerStop).toHaveBeenCalledWith("Logged in!"); + }); + + it("continues into login when VOID_TOKEN is expired", async () => { + mockGetToken.mockReturnValue("expired-env-token"); + mockGetTokenSource.mockReturnValue("ci"); + mockWhoami.mockRejectedValue(new Error("expired")); + mockIsExpiredTokenError.mockReturnValue(true); + + await runLoginCommand(); + + expect(mockRemoveToken).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + "`VOID_TOKEN` is invalid or expired. Continuing with a new login.", + ); + expect(mockRunLogin).toHaveBeenCalledWith("github"); + expect(mockSaveToken).toHaveBeenCalledWith("fresh-token"); + expect(mockOutro).toHaveBeenCalledWith("Token saved. Unset `VOID_TOKEN` to use it."); + }); +});