diff --git a/cli/auth/index.ts b/cli/auth/index.ts index 2ae32af9a9..9ab5a761ae 100644 --- a/cli/auth/index.ts +++ b/cli/auth/index.ts @@ -10,6 +10,7 @@ export { isApiKeyIdentity, login, logout, + misnamedApiKeyHint, readToken, saveToken, type UserInfo, diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 02ff3aa210..e4caf587d4 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3719,3 +3719,221 @@ describe("Interactive sign-in completion", () => { } }); }); + +describe("Missing-auth hint for VERYFRONT_API_KEY", () => { + const API_KEY_HINT = "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN. " + + "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'."; + + async function runWhoami( + vars: { + VERYFRONT_API_KEY?: string; + VERYFRONT_API_TOKEN?: string; + VERYFRONT_API_TOKEN_FILE?: string; + VERYFRONT_API_URL?: string; + }, + jsonMode: boolean, + apiKeyEnvFile?: string, + storedToken?: string, + apiUrlFromProjectEnv = false, + veryfrontJson?: Record, + ): Promise<{ result: unknown; output: string[] }> { + const dir = await makeTempDir({ prefix: "whoami-api-key-" }); + const env = createTestEnvironmentConfig({ + homeDir: dir, + xdgConfigHome: dir, + // veryfront.json steering only applies under the default API base. + apiBaseUrl: veryfrontJson ? undefined : "https://auth.example.test", + apiUrl: undefined, + apiToken: vars.VERYFRONT_API_TOKEN, + ...(vars.VERYFRONT_API_URL ? { apiUrl: vars.VERYFRONT_API_URL } : {}), + }); + const names = [ + "VERYFRONT_API_KEY", + "VERYFRONT_API_TOKEN", + "VERYFRONT_API_TOKEN_FILE", + "VERYFRONT_API_URL", + ] as const; + const originals = names.map((name) => getEnv(name)); + const originalLog = console.log; + const output: string[] = []; + const { markEnvFileSource, markProcessEnvSource } = await import( + "veryfront/utils/env-loader" + ); + try { + for (const name of names) { + const value = vars[name]; + if (value === undefined) deleteEnv(name); + else setEnv(name, value); + } + if (apiKeyEnvFile) markEnvFileSource("VERYFRONT_API_KEY", apiKeyEnvFile); + if (storedToken) await saveToken(storedToken, env); + if (apiUrlFromProjectEnv) markEnvFileSource("VERYFRONT_API_URL", `${dir}/.env`); + if (veryfrontJson) { + await Deno.writeTextFile(`${dir}/veryfront.json`, JSON.stringify(veryfrontJson)); + } + console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); + setJsonMode(jsonMode); + const { whoami } = await import("./login.ts"); + const { withCwd } = await import("#veryfront/testing/cwd.ts"); + const result = await withMockFetch( + () => Promise.resolve(new Response(null, { status: 401 })), + () => withCwd(dir, () => whoami(env)), + ); + return { result, output }; + } finally { + setJsonMode(false); + console.log = originalLog; + markProcessEnvSource("VERYFRONT_API_KEY"); + markProcessEnvSource("VERYFRONT_API_URL"); + names.forEach((name, index) => { + const original = originals[index]; + if (original === undefined) deleteEnv(name); + else setEnv(name, original); + }); + await remove(dir, { recursive: true }); + } + } + + it("names VERYFRONT_API_TOKEN in whoami output when only VERYFRONT_API_KEY is set", async () => { + const { result, output } = await runWhoami({ VERYFRONT_API_KEY: "vf_misnamed_key" }, false); + const text = output.join("\n"); + + assertEquals(result, null); + assertStringIncludes(text, "Not logged in"); + assertStringIncludes( + text, + "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN.", + ); + assertStringIncludes( + text, + "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'.", + ); + assertEquals(text.includes("Run 'veryfront login' to authenticate"), false); + assertEquals(text.includes("vf_misnamed_key"), false); + }); + + it("puts the hint in the whoami JSON envelope when only VERYFRONT_API_KEY is set", async () => { + const { result, output } = await runWhoami({ VERYFRONT_API_KEY: "vf_misnamed_key" }, true); + + assertEquals(result, null); + assertEquals(JSON.parse(output.join("\n")), { + success: false, + command: "whoami", + error: { + code: "AUTHENTICATION_ERROR", + slug: "authentication-required", + registrySlug: "authentication-required", + message: `Not logged in. ${API_KEY_HINT}`, + context: { authenticated: false }, + }, + }); + }); + + it("keeps the plain message when VERYFRONT_API_TOKEN is also set", async () => { + const { result, output } = await runWhoami({ + VERYFRONT_API_KEY: "vf_misnamed_key", + VERYFRONT_API_TOKEN: "vf_rejected_token", + }, true); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); + + it("leaves the hint out for a whitespace-only VERYFRONT_API_KEY", async () => { + const { result, output } = await runWhoami({ VERYFRONT_API_KEY: " " }, true); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); + + it("keeps the hint when only veryfront.json names another API host", async () => { + const { result, output } = await runWhoami( + { VERYFRONT_API_KEY: "vf_misnamed_key" }, + true, + undefined, + undefined, + false, + { apiUrl: "https://project.example/api" }, + ); + + assertEquals(result, null); + assertEquals(JSON.parse(output.join("\n")).error.message, `Not logged in. ${API_KEY_HINT}`); + }); + + it("treats a whitespace-only VERYFRONT_API_TOKEN as unset", async () => { + const { result, output } = await runWhoami({ + VERYFRONT_API_KEY: "vf_misnamed_key", + VERYFRONT_API_TOKEN: " ", + }, true); + + assertEquals(result, null); + assertEquals(JSON.parse(output.join("\n")).error.message, `Not logged in. ${API_KEY_HINT}`); + }); + + it("keeps the plain message when VERYFRONT_API_KEY comes from a project .env", async () => { + const { result, output } = await runWhoami( + { VERYFRONT_API_KEY: "vf_checkout_key" }, + true, + "/srv/checkout/.env", + ); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); + + it("keeps the plain message when a stored login is the credential that failed", async () => { + const { result, output } = await runWhoami( + { VERYFRONT_API_KEY: "vf_misnamed_key" }, + true, + undefined, + "vf_stored_session", + ); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); + + it("keeps the plain message when the project steers the API host", async () => { + const { result, output } = await runWhoami( + { + VERYFRONT_API_KEY: "vf_misnamed_key", + VERYFRONT_API_URL: "https://attacker.example/api", + }, + true, + undefined, + undefined, + true, + ); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); + + it("keeps the plain message when VERYFRONT_API_TOKEN_FILE supplies the credential", async () => { + const { result, output } = await runWhoami({ + VERYFRONT_API_KEY: "vf_misnamed_key", + VERYFRONT_API_TOKEN_FILE: "/run/secrets/veryfront-token", + }, true); + + assertEquals(result, null); + assertEquals( + JSON.parse(output.join("\n")).error.message, + "Not logged in. Run 'veryfront login' to authenticate.", + ); + }); +}); diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 6ced28ea4d..ba04f89b75 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -16,7 +16,9 @@ import { import { type ApiTokenSource, assertApiUrlAcceptsNewCredential, + readConfigJsonFile, resolveApiCredentialCandidatesForAuth, + resolveApiUrlTrust, } from "../shared/config.ts"; import { createErrorEnvelope, @@ -26,7 +28,7 @@ import { } from "../shared/json-output.ts"; import { isInteractive } from "../shared/interactive.ts"; import { getEnvSource } from "veryfront/utils/env-loader"; -import { basename, isAbsolute, relative } from "veryfront/platform/path"; +import { basename, isAbsolute, join, relative } from "veryfront/platform/path"; import { cwd, getEnv } from "veryfront/platform"; const applyIntrinsic = Reflect.apply; @@ -117,6 +119,60 @@ export interface CredentialValidationOptions { * block sign-in forever, so the check is bounded and a timeout simply falls * through to the normal flow. */ +const MISNAMED_API_KEY_HINT_LINES = [ + "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN.", + "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'.", +] as const; + +/** + * Studio's key page tells users to export `VERYFRONT_API_KEY`, which the CLI + * does not read. When that is the only one set, missing-auth messages name the + * variable the CLI does read instead of a bare "Not logged in". + * + * The hint only applies to a key the user exported themselves. A key loaded + * from a project `.env` belongs to whoever wrote the checkout, so it is never + * suggested as the user's own credential. When any credential is configured + * (a token file, a stored login, a `veryfront.json` token), the failure is + * about that credential, so the hint stays out of the way. + */ +export async function misnamedApiKeyHint( + env: EnvironmentConfig = getEnvironmentConfig(), + projectDir: string = cwd(), +): Promise { + if (!getEnv("VERYFRONT_API_KEY")?.trim()) return undefined; + if (getEnvSource("VERYFRONT_API_KEY").source !== "process") return undefined; + if (getEnv("VERYFRONT_API_TOKEN")?.trim() || getEnv("VERYFRONT_API_TOKEN_FILE")?.trim()) { + return undefined; + } + if (await hasConfiguredCredential(env, projectDir)) return undefined; + return MISNAMED_API_KEY_HINT_LINES.join(" "); +} + +async function hasConfiguredCredential( + env: EnvironmentConfig, + projectDir: string, +): Promise { + try { + // A host steered by the project's environment files filters out the + // user's own credentials, and would filter a newly exported + // VERYFRONT_API_TOKEN the same way. A veryfront.json apiUrl alone does + // not: shell credentials still validate against the operator's host. + const configFile = await readConfigJsonFile(projectDir); + const trust = resolveApiUrlTrust(env, configFile, join(projectDir, "veryfront.json")); + if ( + trust.repositorySteered && + (trust.steeringEnvFile !== undefined || trust.steeringConfigFile === undefined) + ) { + return true; + } + const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir); + return candidates.length > 0; + } catch { + // Unknown credential state: the hint is optional, so leave it out. + return true; + } +} + const DEFAULT_EXISTING_SESSION_TIMEOUT_MS = 5_000; const DEFAULT_OAUTH_BROWSER_LAUNCH_TIMEOUT_MS = 10_000; let existingSessionTimeoutMs = DEFAULT_EXISTING_SESSION_TIMEOUT_MS; @@ -137,12 +193,16 @@ function loginIdentityData( return { ...identity, source: displayedSource }; } -async function outputLoginAuthenticationRequiredJson(): Promise { +async function outputLoginAuthenticationRequiredJson( + env: EnvironmentConfig, + projectDir: string, +): Promise { + const hint = await misnamedApiKeyHint(env, projectDir); await outputJson(createErrorEnvelope("login", { code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.", + message: `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, })); } @@ -885,7 +945,7 @@ export async function login( if (existing === "failure-output") return null; if (existing) return existing; if (isJsonMode()) { - await outputLoginAuthenticationRequiredJson(); + await outputLoginAuthenticationRequiredJson(env, projectDir); return null; } } @@ -898,7 +958,10 @@ export async function login( await assertApiUrlAcceptsNewCredential(env, projectDir); if (!isInteractive() && (method === undefined || method === "token")) { - cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); + const hint = await misnamedApiKeyHint(env, projectDir); + cliLogger.error( + `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, + ); return null; } @@ -936,7 +999,10 @@ async function requestInteractiveAuthentication( validationOptions: CredentialValidationOptions, ): Promise { if (!isTTY() || !isInteractive()) { - cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); + const hint = await misnamedApiKeyHint(env, projectDir); + cliLogger.error( + `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, + ); return null; } @@ -1086,12 +1152,13 @@ export async function whoami( if (candidate.authoritative) break; } + const hint = candidates.length > 0 ? undefined : await misnamedApiKeyHint(env); if (isJsonMode()) { await outputJson(createErrorEnvelope("whoami", { code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: `Not logged in. ${hint ?? "Run 'veryfront login' to authenticate."}`, context: { authenticated: false }, })); return null; @@ -1099,7 +1166,8 @@ export async function whoami( console.log(); console.log(" " + error("✗") + " Not logged in"); - console.log(" " + dim("Run 'veryfront login' to authenticate")); + const hintLines = hint ? MISNAMED_API_KEY_HINT_LINES : ["Run 'veryfront login' to authenticate"]; + for (const line of hintLines) console.log(" " + dim(line)); return null; } diff --git a/cli/commands/init/init-command.ts b/cli/commands/init/init-command.ts index ecdeabf3bb..af64817950 100644 --- a/cli/commands/init/init-command.ts +++ b/cli/commands/init/init-command.ts @@ -318,14 +318,18 @@ export async function initCommand( } } else { const { chdir } = await import("#cli/process-lifecycle"); - const { ensureAuthenticated } = await import("../../auth/index.ts"); + const { ensureAuthenticated, misnamedApiKeyHint } = await import("../../auth/index.ts"); const { deployCommand } = await import("../deploy/index.ts"); const authResult = await ensureAuthenticated(undefined, createdProjectDir); if (!authResult) { - deployment = { status: "failed", message: AUTH_REQUIRED_DEPLOY_MESSAGE }; + const hint = await misnamedApiKeyHint(undefined, createdProjectDir); + const message = hint + ? `${AUTH_REQUIRED_DEPLOY_MESSAGE} ${hint}` + : AUTH_REQUIRED_DEPLOY_MESSAGE; + deployment = { status: "failed", message }; if (!quiet) console.log(); - log(` Authentication required for --deploy. ${manualDeployHint}`); + log(` ${message} ${manualDeployHint}`); } else { if (!quiet) console.log(); log(` Deploying project...`); diff --git a/cli/commands/init/init.integration.test.ts b/cli/commands/init/init.integration.test.ts index 8ee35daf52..9daf6072cc 100644 --- a/cli/commands/init/init.integration.test.ts +++ b/cli/commands/init/init.integration.test.ts @@ -326,6 +326,51 @@ describe("init command integration", () => { } }); + it("names VERYFRONT_API_TOKEN when --json --deploy has only VERYFRONT_API_KEY", async () => { + const parentDir = await makeTempDir({ prefix: "veryfront-init-json-deploy-key-" }); + const name = `json-deploy-key-${randomSuffix()}`; + const hint = "Authentication required for --deploy. " + + "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN. " + + "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'."; + + try { + const result = await runInitCommand([ + name, + "--json", + "--deploy", + "--skip-install", + "--no-color", + ], { + cwd: parentDir, + env: { + VERYFRONT_API_KEY: "vf_misnamed_key", + VERYFRONT_API_TOKEN: "", + XDG_CONFIG_HOME: join(parentDir, "xdg"), + VERYFRONT_NO_UPDATE_CHECK: "1", + CI: "1", + NO_COLOR: "1", + }, + }); + const stdout = (result.stdout ?? "").trim(); + const parsed = JSON.parse(stdout) as { + error: { + message: string; + context?: { localProject?: { deployment: { status: string; message: string } } }; + }; + }; + + assertEquals(result.code, 1, stdout + (result.stderr ?? "")); + assertEquals(parsed.error.message, hint); + assertEquals(parsed.error.context?.localProject?.deployment, { + status: "failed", + message: hint, + }); + assertEquals(stdout.includes("vf_misnamed_key"), false); + } finally { + await remove(parentDir, { recursive: true }).catch(() => {}); + } + }); + it("does not run the setup wizard for --json in a TTY", async () => { if (Deno.build.os === "windows") return; diff --git a/cli/commands/up/command.ts b/cli/commands/up/command.ts index 1eeeb76744..13565daec9 100644 --- a/cli/commands/up/command.ts +++ b/cli/commands/up/command.ts @@ -5,7 +5,7 @@ import { cliLogger, exitProcess, isVerbose } from "#cli/utils"; import { cwd } from "veryfront/platform"; import { createFileSystem } from "veryfront/platform"; import { brand, createNoopSpinner, dim } from "#cli/ui"; -import { ensureAuthenticated } from "../../auth/index.ts"; +import { ensureAuthenticated, misnamedApiKeyHint } from "../../auth/index.ts"; import { type EnvironmentConfig, getEnvironmentConfig } from "veryfront/config"; import { createSpinner } from "#cli/ui"; import { isTTY, promptUser } from "#cli/utils"; @@ -237,7 +237,10 @@ export async function upCommand( const userInfo = await ensureAuthenticated(env, projectDir); if (!userInfo) { if (jsonOutput) { - const message = "Not authenticated. Set VERYFRONT_API_TOKEN or run veryfront login."; + const hint = await misnamedApiKeyHint(env, projectDir); + const message = `Not authenticated. ${ + hint ?? "Set VERYFRONT_API_TOKEN or run veryfront login." + }`; const authError = AUTHENTICATION_REQUIRED.create({ detail: message }); streamJsonLine( createStreamErrorResult({ diff --git a/cli/shared/config.test.ts b/cli/shared/config.test.ts index 2fbdb49adf..48d2f7ef0d 100644 --- a/cli/shared/config.test.ts +++ b/cli/shared/config.test.ts @@ -2072,3 +2072,56 @@ Deno.test("API management client keeps credentials on the supplied host transpor assertEquals(requests, 1); }); }); + +describe("missing-auth hint for VERYFRONT_API_KEY", () => { + const API_KEY_HINT = "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN. " + + "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'."; + + async function withOnlyApiKeySet(fn: (env: EnvironmentConfig) => Promise): Promise { + const configHome = await makeTempDir(); + const originalKey = Deno.env.get("VERYFRONT_API_KEY"); + const originalToken = Deno.env.get("VERYFRONT_API_TOKEN"); + const originalTokenFile = Deno.env.get("VERYFRONT_API_TOKEN_FILE"); + try { + Deno.env.set("VERYFRONT_API_KEY", "vf_misnamed_key"); + Deno.env.delete("VERYFRONT_API_TOKEN"); + Deno.env.delete("VERYFRONT_API_TOKEN_FILE"); + await fn(createMockEnv({ + projectSlug: "test-project", + homeDir: configHome, + xdgConfigHome: configHome, + })); + } finally { + restoreEnv("VERYFRONT_API_KEY", originalKey); + restoreEnv("VERYFRONT_API_TOKEN", originalToken); + restoreEnv("VERYFRONT_API_TOKEN_FILE", originalTokenFile); + await Deno.remove(configHome, { recursive: true }); + } + } + + it("names VERYFRONT_API_TOKEN when a non-interactive command has no token", async () => { + await withOnlyApiKeySet(async (env) => { + await assertRejects( + () => resolveConfig(env.homeDir!, env), + Error, + `Missing API token. ${API_KEY_HINT}`, + ); + }); + }); + + it("names VERYFRONT_API_TOKEN when a JSON-mode command cannot authenticate", async () => { + const { setJsonMode } = await import("./json-output.ts"); + await withOnlyApiKeySet(async (env) => { + setJsonMode(true); + try { + await assertRejects( + () => resolveConfigWithAuth(env.homeDir!, env), + Error, + `Authentication required for this operation. ${API_KEY_HINT}`, + ); + } finally { + setJsonMode(false); + } + }); + }); +}); diff --git a/cli/shared/config.ts b/cli/shared/config.ts index ff093ef3f9..394c9c380b 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -13,7 +13,7 @@ import { type EnvironmentConfig, getApiTokenEnv, getEnvironmentConfig } from "ve import { getEnvSource } from "veryfront/utils/env-loader"; import { cliLogger, VERSION } from "#cli/utils"; import { readToken } from "../auth/token-store.ts"; -import { ensureAuthenticated } from "../auth/login.ts"; +import { ensureAuthenticated, misnamedApiKeyHint } from "../auth/login.ts"; import { API_URL_ENV_KEYS, type ApiUrlEnvKey, @@ -712,15 +712,21 @@ async function resolveConfigBase( if (!apiToken && interactive && allowAuthPrompt) { const userInfo = await ensureAuthenticated(env, dir, authOptions); - if (!userInfo) throw new Error("Authentication required for this operation."); + if (!userInfo) { + const hint = await misnamedApiKeyHint(env, dir); + throw new Error(`Authentication required for this operation.${hint ? ` ${hint}` : ""}`); + } apiToken = (await readToken(env)) ?? null; apiTokenSource = apiToken ? "token-store" : undefined; if (!apiToken) throw new Error("Authentication failed. Please try again."); } if (!apiToken) { + const hint = await misnamedApiKeyHint(env, dir); throw new Error( - "Missing API token. Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable", + `Missing API token. ${ + hint ?? "Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable" + }`, ); }