From 0c1c0830c16c7763ac7403f58ddfbd646bce5b0d Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 6 Nov 2026 19:47:17 +0100 Subject: [PATCH 1/9] fix(cli): name VERYFRONT_API_TOKEN when only VERYFRONT_API_KEY is set Studio tells users to export VERYFRONT_API_KEY, which the CLI does not read. whoami, login and the commands that need a credential now say so instead of a bare 'Not logged in'. Fixes veryfront/veryfront-issue-inbox#4220 --- cli/auth/index.ts | 1 + cli/auth/login.test.ts | 99 ++++++++++++++++++++++++++++++++++++++ cli/auth/login.ts | 38 +++++++++++++-- cli/commands/up/command.ts | 6 ++- cli/shared/config.ts | 7 ++- 5 files changed, 142 insertions(+), 9 deletions(-) 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..79064ad166 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3719,3 +3719,102 @@ describe("Interactive sign-in completion", () => { } }); }); + +describe("Missing-auth hint for VERYFRONT_API_KEY", { + sanitizeOps: false, + sanitizeResources: false, +}, () => { + 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 }, + jsonMode: boolean, + ): Promise<{ result: unknown; output: string[] }> { + const dir = await makeTempDir({ prefix: "whoami-api-key-" }); + const env = createTestEnvironmentConfig({ + homeDir: dir, + xdgConfigHome: dir, + apiBaseUrl: "https://auth.example.test", + apiUrl: undefined, + apiToken: vars.VERYFRONT_API_TOKEN, + }); + const names = ["VERYFRONT_API_KEY", "VERYFRONT_API_TOKEN"] as const; + const originals = names.map((name) => getEnv(name)); + const originalLog = console.log; + const output: string[] = []; + try { + for (const name of names) { + const value = vars[name]; + if (value === undefined) deleteEnv(name); + else setEnv(name, value); + } + 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; + 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.", + ); + }); +}); diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 6ced28ea4d..a4c8d0475a 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -117,6 +117,21 @@ 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". + */ +export function misnamedApiKeyHint(): string | undefined { + if (!getEnv("VERYFRONT_API_KEY") || getEnv("VERYFRONT_API_TOKEN")) return undefined; + return MISNAMED_API_KEY_HINT_LINES.join(" "); +} + const DEFAULT_EXISTING_SESSION_TIMEOUT_MS = 5_000; const DEFAULT_OAUTH_BROWSER_LAUNCH_TIMEOUT_MS = 10_000; let existingSessionTimeoutMs = DEFAULT_EXISTING_SESSION_TIMEOUT_MS; @@ -142,7 +157,9 @@ async function outputLoginAuthenticationRequiredJson(): Promise { 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. ${ + misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." + }`, })); } @@ -898,7 +915,11 @@ 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."); + cliLogger.error( + `Not logged in. ${ + misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." + }`, + ); return null; } @@ -936,7 +957,11 @@ async function requestInteractiveAuthentication( validationOptions: CredentialValidationOptions, ): Promise { if (!isTTY() || !isInteractive()) { - cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode."); + cliLogger.error( + `Not logged in. ${ + misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." + }`, + ); return null; } @@ -1091,7 +1116,7 @@ export async function whoami( code: "AUTHENTICATION_ERROR", slug: "authentication-required", registrySlug: "authentication-required", - message: "Not logged in. Run 'veryfront login' to authenticate.", + message: `Not logged in. ${misnamedApiKeyHint() ?? "Run 'veryfront login' to authenticate."}`, context: { authenticated: false }, })); return null; @@ -1099,7 +1124,10 @@ export async function whoami( console.log(); console.log(" " + error("✗") + " Not logged in"); - console.log(" " + dim("Run 'veryfront login' to authenticate")); + const hintLines = misnamedApiKeyHint() + ? 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/up/command.ts b/cli/commands/up/command.ts index 1eeeb76744..e385cdb54e 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,9 @@ 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 message = `Not authenticated. ${ + misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run veryfront login." + }`; const authError = AUTHENTICATION_REQUIRED.create({ detail: message }); streamJsonLine( createStreamErrorResult({ diff --git a/cli/shared/config.ts b/cli/shared/config.ts index ff093ef3f9..2468eeea1c 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, @@ -720,7 +720,10 @@ async function resolveConfigBase( if (!apiToken) { throw new Error( - "Missing API token. Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable", + `Missing API token. ${ + misnamedApiKeyHint() ?? + "Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable" + }`, ); } From 47e9c8c3900e144accbbb5e6ce52fb970e115695 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 6 Nov 2026 19:51:33 +0100 Subject: [PATCH 2/9] fix(cli): add the VERYFRONT_API_KEY hint to the JSON-mode auth failure --- cli/shared/config.test.ts | 50 +++++++++++++++++++++++++++++++++++++++ cli/shared/config.ts | 5 +++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/cli/shared/config.test.ts b/cli/shared/config.test.ts index 2fbdb49adf..a3350a4224 100644 --- a/cli/shared/config.test.ts +++ b/cli/shared/config.test.ts @@ -2072,3 +2072,53 @@ 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"); + try { + Deno.env.set("VERYFRONT_API_KEY", "vf_misnamed_key"); + Deno.env.delete("VERYFRONT_API_TOKEN"); + await fn(createMockEnv({ + projectSlug: "test-project", + homeDir: configHome, + xdgConfigHome: configHome, + })); + } finally { + restoreEnv("VERYFRONT_API_KEY", originalKey); + restoreEnv("VERYFRONT_API_TOKEN", originalToken); + 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 2468eeea1c..80882824ff 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -712,7 +712,10 @@ 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 = misnamedApiKeyHint(); + 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."); From cc64523e4e27be9a4ecaef0c26fdd73ba6e9bb2a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 11:35:00 +0100 Subject: [PATCH 3/9] test(cli): drop sanitizer opt-outs from the API key hint tests They pushed the sanitizer opt-out count over its baseline (342 > 340) and the tests pass without them. --- cli/auth/login.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 79064ad166..b128940a4a 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3720,10 +3720,7 @@ describe("Interactive sign-in completion", () => { }); }); -describe("Missing-auth hint for VERYFRONT_API_KEY", { - sanitizeOps: false, - sanitizeResources: false, -}, () => { +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'."; From 6529730b8fb8fc819163fb917f900668d14b2322 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 14:05:42 +0100 Subject: [PATCH 4/9] fix(cli): only suggest an exported VERYFRONT_API_KEY as the CLI token Skip the hint when the key came from a project .env, or when VERYFRONT_API_TOKEN_FILE supplies the credential. --- cli/auth/login.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++-- cli/auth/login.ts | 9 ++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index b128940a4a..7c3e66b93c 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3725,8 +3725,13 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { "Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'."; async function runWhoami( - vars: { VERYFRONT_API_KEY?: string; VERYFRONT_API_TOKEN?: string }, + vars: { + VERYFRONT_API_KEY?: string; + VERYFRONT_API_TOKEN?: string; + VERYFRONT_API_TOKEN_FILE?: string; + }, jsonMode: boolean, + apiKeyEnvFile?: string, ): Promise<{ result: unknown; output: string[] }> { const dir = await makeTempDir({ prefix: "whoami-api-key-" }); const env = createTestEnvironmentConfig({ @@ -3736,16 +3741,24 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { apiUrl: undefined, apiToken: vars.VERYFRONT_API_TOKEN, }); - const names = ["VERYFRONT_API_KEY", "VERYFRONT_API_TOKEN"] as const; + const names = [ + "VERYFRONT_API_KEY", + "VERYFRONT_API_TOKEN", + "VERYFRONT_API_TOKEN_FILE", + ] 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); console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); setJsonMode(jsonMode); const { whoami } = await import("./login.ts"); @@ -3758,6 +3771,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { } finally { setJsonMode(false); console.log = originalLog; + markProcessEnvSource("VERYFRONT_API_KEY"); names.forEach((name, index) => { const original = originals[index]; if (original === undefined) deleteEnv(name); @@ -3814,4 +3828,31 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { "Not logged in. Run 'veryfront login' to authenticate.", ); }); + + 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 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 a4c8d0475a..96b4dbe29b 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -126,9 +126,16 @@ const MISNAMED_API_KEY_HINT_LINES = [ * 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. A token file, when set, is the + * effective credential, and setting the variable would not change that. */ export function misnamedApiKeyHint(): string | undefined { - if (!getEnv("VERYFRONT_API_KEY") || getEnv("VERYFRONT_API_TOKEN")) return undefined; + if (!getEnv("VERYFRONT_API_KEY")) return undefined; + if (getEnvSource("VERYFRONT_API_KEY").source !== "process") return undefined; + if (getEnv("VERYFRONT_API_TOKEN") || getEnv("VERYFRONT_API_TOKEN_FILE")) return undefined; return MISNAMED_API_KEY_HINT_LINES.join(" "); } From dfabc365194b85a6e0e5689eb3ddf5c14e5191cb Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 14:47:27 +0100 Subject: [PATCH 5/9] fix(cli): show the API key hint only when no credential is configured The hint now stays out when a stored login or veryfront.json token is the credential that failed, and init --deploy --json carries it in its deployment failure message. Config hint tests clear the token-file variable. --- cli/auth/login.test.ts | 17 +++++++ cli/auth/login.ts | 53 ++++++++++++++-------- cli/commands/init/init-command.ts | 10 ++-- cli/commands/init/init.integration.test.ts | 45 ++++++++++++++++++ cli/commands/up/command.ts | 3 +- cli/shared/config.test.ts | 3 ++ cli/shared/config.ts | 6 +-- 7 files changed, 112 insertions(+), 25 deletions(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 7c3e66b93c..66d53eda4f 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3732,6 +3732,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { }, jsonMode: boolean, apiKeyEnvFile?: string, + storedToken?: string, ): Promise<{ result: unknown; output: string[] }> { const dir = await makeTempDir({ prefix: "whoami-api-key-" }); const env = createTestEnvironmentConfig({ @@ -3759,6 +3760,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { else setEnv(name, value); } if (apiKeyEnvFile) markEnvFileSource("VERYFRONT_API_KEY", apiKeyEnvFile); + if (storedToken) await saveToken(storedToken, env); console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); setJsonMode(jsonMode); const { whoami } = await import("./login.ts"); @@ -3843,6 +3845,21 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { ); }); + 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 VERYFRONT_API_TOKEN_FILE supplies the credential", async () => { const { result, output } = await runWhoami({ VERYFRONT_API_KEY: "vf_misnamed_key", diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 96b4dbe29b..3c88b9089e 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -129,16 +129,34 @@ const MISNAMED_API_KEY_HINT_LINES = [ * * 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. A token file, when set, is the - * effective credential, and setting the variable would not change that. + * 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 function misnamedApiKeyHint(): string | undefined { +export async function misnamedApiKeyHint( + env: EnvironmentConfig = getEnvironmentConfig(), + projectDir: string = cwd(), +): Promise { if (!getEnv("VERYFRONT_API_KEY")) return undefined; if (getEnvSource("VERYFRONT_API_KEY").source !== "process") return undefined; if (getEnv("VERYFRONT_API_TOKEN") || getEnv("VERYFRONT_API_TOKEN_FILE")) 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 { + 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; @@ -159,14 +177,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. ${ - misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." - }`, + message: `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, })); } @@ -909,7 +929,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; } } @@ -922,10 +942,9 @@ export async function login( await assertApiUrlAcceptsNewCredential(env, projectDir); if (!isInteractive() && (method === undefined || method === "token")) { + const hint = await misnamedApiKeyHint(env, projectDir); cliLogger.error( - `Not logged in. ${ - misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." - }`, + `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, ); return null; } @@ -964,10 +983,9 @@ async function requestInteractiveAuthentication( validationOptions: CredentialValidationOptions, ): Promise { if (!isTTY() || !isInteractive()) { + const hint = await misnamedApiKeyHint(env, projectDir); cliLogger.error( - `Not logged in. ${ - misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run in interactive mode." - }`, + `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`, ); return null; } @@ -1118,12 +1136,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. ${misnamedApiKeyHint() ?? "Run 'veryfront login' to authenticate."}`, + message: `Not logged in. ${hint ?? "Run 'veryfront login' to authenticate."}`, context: { authenticated: false }, })); return null; @@ -1131,9 +1150,7 @@ export async function whoami( console.log(); console.log(" " + error("✗") + " Not logged in"); - const hintLines = misnamedApiKeyHint() - ? MISNAMED_API_KEY_HINT_LINES - : ["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 e385cdb54e..13565daec9 100644 --- a/cli/commands/up/command.ts +++ b/cli/commands/up/command.ts @@ -237,8 +237,9 @@ export async function upCommand( const userInfo = await ensureAuthenticated(env, projectDir); if (!userInfo) { if (jsonOutput) { + const hint = await misnamedApiKeyHint(env, projectDir); const message = `Not authenticated. ${ - misnamedApiKeyHint() ?? "Set VERYFRONT_API_TOKEN or run veryfront login." + hint ?? "Set VERYFRONT_API_TOKEN or run veryfront login." }`; const authError = AUTHENTICATION_REQUIRED.create({ detail: message }); streamJsonLine( diff --git a/cli/shared/config.test.ts b/cli/shared/config.test.ts index a3350a4224..48d2f7ef0d 100644 --- a/cli/shared/config.test.ts +++ b/cli/shared/config.test.ts @@ -2081,9 +2081,11 @@ describe("missing-auth hint for VERYFRONT_API_KEY", () => { 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, @@ -2092,6 +2094,7 @@ describe("missing-auth hint for VERYFRONT_API_KEY", () => { } finally { restoreEnv("VERYFRONT_API_KEY", originalKey); restoreEnv("VERYFRONT_API_TOKEN", originalToken); + restoreEnv("VERYFRONT_API_TOKEN_FILE", originalTokenFile); await Deno.remove(configHome, { recursive: true }); } } diff --git a/cli/shared/config.ts b/cli/shared/config.ts index 80882824ff..394c9c380b 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -713,7 +713,7 @@ async function resolveConfigBase( if (!apiToken && interactive && allowAuthPrompt) { const userInfo = await ensureAuthenticated(env, dir, authOptions); if (!userInfo) { - const hint = misnamedApiKeyHint(); + const hint = await misnamedApiKeyHint(env, dir); throw new Error(`Authentication required for this operation.${hint ? ` ${hint}` : ""}`); } apiToken = (await readToken(env)) ?? null; @@ -722,10 +722,10 @@ async function resolveConfigBase( } if (!apiToken) { + const hint = await misnamedApiKeyHint(env, dir); throw new Error( `Missing API token. ${ - misnamedApiKeyHint() ?? - "Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable" + hint ?? "Run 'veryfront login' or set VERYFRONT_API_TOKEN environment variable" }`, ); } From 3bdb180b0ba8fb02954d9343657d8bf86330a651 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 15:26:08 +0100 Subject: [PATCH 6/9] fix(cli): leave out the API key hint for a repository-steered API host That host filters out the user's own credentials, so copying the key into VERYFRONT_API_TOKEN would not help. --- cli/auth/login.test.ts | 20 ++++++++++++++++++++ cli/auth/login.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 66d53eda4f..307848fda3 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3733,6 +3733,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { jsonMode: boolean, apiKeyEnvFile?: string, storedToken?: string, + veryfrontJson?: Record, ): Promise<{ result: unknown; output: string[] }> { const dir = await makeTempDir({ prefix: "whoami-api-key-" }); const env = createTestEnvironmentConfig({ @@ -3761,6 +3762,9 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { } if (apiKeyEnvFile) markEnvFileSource("VERYFRONT_API_KEY", apiKeyEnvFile); if (storedToken) await saveToken(storedToken, 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"); @@ -3860,6 +3864,22 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { ); }); + it("keeps the plain message when the project steers the API host", async () => { + const { result, output } = await runWhoami( + { VERYFRONT_API_KEY: "vf_misnamed_key" }, + true, + undefined, + undefined, + { apiUrl: "https://attacker.example/api" }, + ); + + 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", diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 3c88b9089e..addc9644ef 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; @@ -149,6 +151,11 @@ async function hasConfiguredCredential( projectDir: string, ): Promise { try { + // A repository-steered host filters out the user's own credentials, and + // would filter a newly exported VERYFRONT_API_TOKEN the same way. + const configFile = await readConfigJsonFile(projectDir); + const trust = resolveApiUrlTrust(env, configFile, join(projectDir, "veryfront.json")); + if (trust.repositorySteered) return true; const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir); return candidates.length > 0; } catch { From 2a0b4563b288eb9dc4b6c1ed3a01883864741e3c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 15:27:47 +0100 Subject: [PATCH 7/9] test(cli): steer the API host through a project .env in the hint test The fixture's operator API base outranks veryfront.json, so the old test never steered the host. --- cli/auth/login.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 307848fda3..69f2f1f956 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3729,11 +3729,12 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { VERYFRONT_API_KEY?: string; VERYFRONT_API_TOKEN?: string; VERYFRONT_API_TOKEN_FILE?: string; + VERYFRONT_API_URL?: string; }, jsonMode: boolean, apiKeyEnvFile?: string, storedToken?: string, - veryfrontJson?: Record, + apiUrlFromProjectEnv = false, ): Promise<{ result: unknown; output: string[] }> { const dir = await makeTempDir({ prefix: "whoami-api-key-" }); const env = createTestEnvironmentConfig({ @@ -3742,11 +3743,13 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { apiBaseUrl: "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; @@ -3762,9 +3765,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { } if (apiKeyEnvFile) markEnvFileSource("VERYFRONT_API_KEY", apiKeyEnvFile); if (storedToken) await saveToken(storedToken, env); - if (veryfrontJson) { - await Deno.writeTextFile(`${dir}/veryfront.json`, JSON.stringify(veryfrontJson)); - } + if (apiUrlFromProjectEnv) markEnvFileSource("VERYFRONT_API_URL", `${dir}/.env`); console.log = (...args: unknown[]) => output.push(args.map(String).join(" ")); setJsonMode(jsonMode); const { whoami } = await import("./login.ts"); @@ -3778,6 +3779,7 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { 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); @@ -3866,11 +3868,14 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { 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_KEY: "vf_misnamed_key", + VERYFRONT_API_URL: "https://attacker.example/api", + }, true, undefined, undefined, - { apiUrl: "https://attacker.example/api" }, + true, ); assertEquals(result, null); From a5c9089f3689b2f907cdb4ab807f42418cf9c067 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 15:55:07 +0100 Subject: [PATCH 8/9] fix(cli): treat a whitespace-only VERYFRONT_API_TOKEN as unset for the hint --- cli/auth/login.test.ts | 10 ++++++++++ cli/auth/login.ts | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 69f2f1f956..3e0a8f7ebb 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3837,6 +3837,16 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { ); }); + 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" }, diff --git a/cli/auth/login.ts b/cli/auth/login.ts index addc9644ef..50a28934ca 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -141,7 +141,9 @@ export async function misnamedApiKeyHint( ): Promise { if (!getEnv("VERYFRONT_API_KEY")) return undefined; if (getEnvSource("VERYFRONT_API_KEY").source !== "process") return undefined; - if (getEnv("VERYFRONT_API_TOKEN") || getEnv("VERYFRONT_API_TOKEN_FILE")) 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(" "); } From 5c33c92ae5f5e92198539d9306000330ef54e16c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sat, 7 Nov 2026 16:35:41 +0100 Subject: [PATCH 9/9] fix(cli): trim the API key and keep the hint for veryfront.json-only steering Shell credentials still validate against the operator host when only veryfront.json names an apiUrl, so exporting VERYFRONT_API_TOKEN works there and the hint applies. --- cli/auth/login.test.ts | 31 ++++++++++++++++++++++++++++++- cli/auth/login.ts | 15 +++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/cli/auth/login.test.ts b/cli/auth/login.test.ts index 3e0a8f7ebb..e4caf587d4 100644 --- a/cli/auth/login.test.ts +++ b/cli/auth/login.test.ts @@ -3735,12 +3735,14 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { 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, - apiBaseUrl: "https://auth.example.test", + // 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 } : {}), @@ -3766,6 +3768,9 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { 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"); @@ -3837,6 +3842,30 @@ describe("Missing-auth hint for VERYFRONT_API_KEY", () => { ); }); + 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", diff --git a/cli/auth/login.ts b/cli/auth/login.ts index 50a28934ca..ba04f89b75 100644 --- a/cli/auth/login.ts +++ b/cli/auth/login.ts @@ -139,7 +139,7 @@ export async function misnamedApiKeyHint( env: EnvironmentConfig = getEnvironmentConfig(), projectDir: string = cwd(), ): Promise { - if (!getEnv("VERYFRONT_API_KEY")) return undefined; + 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; @@ -153,11 +153,18 @@ async function hasConfiguredCredential( projectDir: string, ): Promise { try { - // A repository-steered host filters out the user's own credentials, and - // would filter a newly exported VERYFRONT_API_TOKEN the same way. + // 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) return true; + if ( + trust.repositorySteered && + (trust.steeringEnvFile !== undefined || trust.steeringConfigFile === undefined) + ) { + return true; + } const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir); return candidates.length > 0; } catch {