feat(canvas): provider-picker MissingKeysModal for multi-provider runtimes

Runtimes like Hermes and LangGraph accept any one of several LLM
provider keys (OpenRouter OR OpenAI OR Anthropic OR Nous-native).
Before this change, the missing-keys modal treated all supported
providers as simultaneously required — a fresh user on Hermes was
asked for three parallel API keys when any one suffices.

Introduces RUNTIME_PROVIDERS in deploy-preflight.ts as the canonical
per-runtime provider list (label, envVar, note). checkDeploySecrets
now returns all alternatives as missingKeys when nothing is
configured, so the modal can offer a picker.

MissingKeysModal dispatches between two render paths:

  * ProviderPickerModal — radio list of supported providers, a single
    env input for the chosen one. Saving that one key satisfies the
    preflight. Activated whenever the runtime has ≥2 provider choices.

  * AllKeysModal — legacy parallel-inputs UX, all keys must be saved
    before deploy. Kept for single-provider runtimes (claude-code,
    gemini-cli) and callers that pass unrelated-key lists.

Dual-mode preserves the pre-existing contract for every caller while
fixing the multi-provider UX. All 930 canvas vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 16:41:09 -07:00
parent 03b56fa5af
commit baa7e1531f
6 changed files with 448 additions and 55 deletions

View File

@ -1,14 +1,18 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { api } from "@/lib/api";
import { getKeyLabel } from "@/lib/deploy-preflight";
import {
getKeyLabel,
getRuntimeProviders,
type ProviderChoice,
} from "@/lib/deploy-preflight";
interface Props {
open: boolean;
missingKeys: string[];
runtime: string;
/** Called when user adds all keys and wants to proceed with deploy. */
/** Called when user adds all required keys and wants to proceed with deploy. */
onKeysAdded: () => void;
/** Called when user cancels the deploy. */
onCancel: () => void;
@ -27,6 +31,24 @@ interface KeyEntry {
error: string | null;
}
/**
* MissingKeysModal
* ----------------
* Two rendering modes, picked automatically from the runtime:
*
* 1. PROVIDER-PICKER mode when `getRuntimeProviders(runtime)` returns
* 2 alternatives. The modal shows a radio list of supported
* providers first ("Hermes supports OpenRouter / OpenAI / Nous
* native pick one") and only the chosen provider's env input
* below. Saving that one key satisfies the deploy.
*
* 2. LEGACY all-keys mode when the runtime has <2 provider
* alternatives, or the caller supplied multiple unrelated keys.
* Renders one input per `missingKeys` entry; all must be saved
* before deploy. Preserves the pre-provider-picker contract so
* callers that pass unrelated-key lists (e.g. a workspace that
* needs an LLM key AND a separate tool key) keep working.
*/
export function MissingKeysModal({
open,
missingKeys,
@ -35,12 +57,291 @@ export function MissingKeysModal({
onCancel,
onOpenSettings,
workspaceId,
}: Props) {
const providers: ProviderChoice[] = useMemo(
() => getRuntimeProviders(runtime),
[runtime],
);
// Picker mode activates only when we have a real provider list with
// genuine alternatives. If the runtime is unknown (providers=[]) or
// has a single forced provider, fall back to the legacy all-keys UX.
const pickerMode = providers.length > 1;
if (pickerMode) {
return (
<ProviderPickerModal
open={open}
providers={providers}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
/>
);
}
return (
<AllKeysModal
open={open}
missingKeys={missingKeys}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
/>
);
}
// -----------------------------------------------------------------------------
// Provider-picker mode — one-of-N providers, save one, deploy.
// -----------------------------------------------------------------------------
function ProviderPickerModal({
open,
providers,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
}: {
open: boolean;
providers: ProviderChoice[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
}) {
const [selectedId, setSelectedId] = useState(providers[0].id);
const [value, setValue] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSelectedId(providers[0].id);
setValue("");
setSaving(false);
setSaved(false);
setError(null);
}, [open, providers]);
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, selectedId]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onCancel]);
const selected = providers.find((p) => p.id === selectedId) ?? providers[0];
const handleSave = useCallback(async () => {
if (!value.trim()) return;
setSaving(true);
setError(null);
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: selected.envVar,
value: value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: selected.envVar,
value: value.trim(),
});
}
setSaved(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
}, [selected, value, workspaceId]);
if (!open) return null;
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onCancel} />
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
Missing API Keys
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
supports multiple providers. Pick one and paste its API key.
</p>
</div>
<div className="px-5 py-4 space-y-3">
<fieldset className="space-y-1.5">
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
Provider
</legend>
{providers.map((p) => (
<label
key={p.id}
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
selectedId === p.id
? "bg-blue-600/15 border-blue-500/50"
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
}`}
>
<input
type="radio"
name="provider"
value={p.id}
checked={selectedId === p.id}
onChange={() => {
setSelectedId(p.id);
setValue("");
setSaved(false);
setError(null);
}}
className="mt-0.5 accent-blue-500"
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
<div className="text-[10px] font-mono text-zinc-500">{p.envVar}</div>
{p.note && (
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">{p.note}</div>
)}
</div>
</label>
))}
</fieldset>
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50">
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{getKeyLabel(selected.envVar)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{selected.envVar}</div>
</div>
{saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
)}
</div>
{!saved && (
<div className="flex gap-2 mt-2">
<input
value={value}
onChange={(e) => setValue(e.target.value.trimStart())}
placeholder={selected.envVar.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
ref={firstInputRef}
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) {
handleSave();
}
}}
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
/>
<button
onClick={handleSave}
disabled={!value.trim() || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{saving ? "..." : "Save"}
</button>
</div>
)}
{error && <div className="mt-1.5 text-[10px] text-red-400">{error}</div>}
</div>
</div>
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
>
Open Settings Panel
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
>
Cancel Deploy
</button>
<button
onClick={onKeysAdded}
disabled={!saved || saving}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
>
{saved ? "Deploy" : "Add Key"}
</button>
</div>
</div>
</div>
</div>
);
}
// -----------------------------------------------------------------------------
// Legacy all-keys mode — every missingKey rendered as its own input,
// all must save before deploy. Kept for single-provider runtimes +
// callers that pass unrelated-key lists (old contract).
// -----------------------------------------------------------------------------
function AllKeysModal({
open,
missingKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
}: Props) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
// Initialize entries when modal opens or missingKeys change
useEffect(() => {
if (!open) return;
setEntries(
@ -56,14 +357,12 @@ export function MissingKeysModal({
setGlobalError(null);
}, [open, missingKeys]);
// Focus first input when modal opens
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => {
firstInputRef.current?.focus();
});
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
@ -90,7 +389,6 @@ export function MissingKeysModal({
updateEntry(index, { saving: true, error: null });
try {
// Save to global scope by default (available to all workspaces)
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
@ -135,31 +433,19 @@ export function MissingKeysModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onCancel} />
{/* Dialog */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden"
>
{/* Header */}
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M6 1L11 10H1L6 1Z"
stroke="#fbbf24"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
</svg>
@ -174,7 +460,6 @@ export function MissingKeysModal({
</p>
</div>
{/* Body — key list */}
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
{entries.map((entry, index) => (
<div
@ -183,12 +468,8 @@ export function MissingKeysModal({
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{entry.label}
</div>
<div className="text-[9px] font-mono text-zinc-500">
{entry.key}
</div>
<div className="text-[11px] text-zinc-300 font-medium">{entry.label}</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
@ -225,9 +506,7 @@ export function MissingKeysModal({
</div>
)}
{entry.error && (
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>}
</div>
))}
@ -238,7 +517,6 @@ export function MissingKeysModal({
)}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (

View File

@ -27,6 +27,10 @@ vi.mock("@/lib/deploy-preflight", () => ({
};
return labels[key] ?? key;
},
// These tests use unknown runtimes ("test" / "openai") — let the
// modal fall back to synthesising providers from the missingKeys
// prop. Real runtimes look this up from RUNTIME_PROVIDERS.
getRuntimeProviders: () => [],
}));
// ── Import after mocks ────────────────────────────────────────────────────────

View File

@ -36,6 +36,11 @@ vi.mock("@/lib/deploy-preflight", () => ({
};
return labels[key] ?? key;
},
// Runtime names here ("test" / "openai") aren't in the real
// RUNTIME_PROVIDERS map; return [] so the modal falls back to
// synthesising providers from the missingKeys prop. That preserves
// the single-key-per-runtime semantics these tests were written for.
getRuntimeProviders: () => [],
}));
// ── Suite 1: Visibility and ARIA ────────────────────────────────────────────
@ -265,7 +270,7 @@ describe("MissingKeysModal — save flow", () => {
onCancel={vi.fn()}
/>
);
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!;
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(true);
});
@ -284,7 +289,7 @@ describe("MissingKeysModal — save flow", () => {
act(() => {
fireEvent.change(input, { target: { value: "sk-123" } });
});
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!;
const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});

View File

@ -49,7 +49,13 @@ describe("MissingKeysModal preflight logic", () => {
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
// langgraph accepts OpenAI, Anthropic, or OpenRouter — when none are
// configured we surface all three so the picker modal can offer a choice.
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
expect(result.runtime).toBe("langgraph");
});

View File

@ -141,7 +141,13 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
// langgraph supports any of three providers — when none are configured,
// surface all alternatives so the modal can offer a picker.
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
});
it("returns ok=false when secret exists but has_value is false", async () => {
@ -155,7 +161,11 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
});
it("returns ok=true for runtimes with no required keys", async () => {
@ -203,6 +213,10 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
});
});

View File

@ -8,19 +8,73 @@
import { api } from "./api";
/* ---------- Required keys per runtime ---------- */
/* ---------- Required keys per runtime ----------
*
* A runtime may accept ANY of several provider keys (Hermes speaks
* OpenRouter or OpenAI or its native Nous API; LangGraph speaks
* OpenAI or Anthropic; ). Represent that as a list of provider
* choices the UI renders a picker when length > 1, and the
* preflight check treats the runtime as satisfied if *any one* of
* the listed keys is configured.
*
* The first entry is the default / recommended provider for that
* runtime.
*/
export const RUNTIME_REQUIRED_KEYS: Record<string, string[]> = {
langgraph: ["OPENAI_API_KEY"],
"claude-code": ["ANTHROPIC_API_KEY"],
openclaw: ["OPENAI_API_KEY"],
deepagents: ["OPENAI_API_KEY"],
crewai: ["OPENAI_API_KEY"],
autogen: ["OPENAI_API_KEY"],
hermes: ["OPENROUTER_API_KEY"],
"gemini-cli": ["GOOGLE_API_KEY"],
export interface ProviderChoice {
/** Stable id for the provider. Used as React key + picker value. */
id: string;
/** Human label shown in the provider picker. */
label: string;
/** Env var name the workspace container reads at runtime. */
envVar: string;
/** Short rationale shown under the picker option, optional. */
note?: string;
}
export const RUNTIME_PROVIDERS: Record<string, ProviderChoice[]> = {
langgraph: [
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" },
{ id: "openrouter", label: "OpenRouter (proxy — any model)", envVar: "OPENROUTER_API_KEY", note: "Broadest model coverage incl. Minimax, DeepSeek, Groq" },
],
"claude-code": [
{ id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" },
],
openclaw: [
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
],
deepagents: [
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
],
crewai: [
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" },
],
autogen: [
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
],
hermes: [
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", note: "Recommended — widest model coverage (Minimax, DeepSeek, Llama, …)" },
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
{ id: "hermes-native", label: "Nous Research (Hermes native)", envVar: "HERMES_API_KEY" },
],
"gemini-cli": [
{ id: "google", label: "Google AI", envVar: "GOOGLE_API_KEY" },
],
};
/** Back-compat: flat list of the DEFAULT (first) env var per runtime.
* Preserved so existing callers keep working; the richer provider-
* aware UX consumes RUNTIME_PROVIDERS directly. */
export const RUNTIME_REQUIRED_KEYS: Record<string, string[]> = Object.fromEntries(
Object.entries(RUNTIME_PROVIDERS).map(([rt, choices]) => [rt, [choices[0].envVar]]),
);
/** Human-readable labels for common secret keys */
export const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: "OpenAI API Key",
@ -32,6 +86,24 @@ export const KEY_LABELS: Record<string, string> = {
DEEPSEEK_API_KEY: "DeepSeek API Key",
};
/** Get the provider choices for a runtime. Returns [] for unknown runtimes. */
export function getRuntimeProviders(runtime: string): ProviderChoice[] {
return RUNTIME_PROVIDERS[runtime] ?? [];
}
/** Returns the first provider choice whose env var is in `configured`,
* or null if none are set. Used to auto-skip the picker when the
* user has already wired up a supported provider. */
export function findConfiguredProvider(
runtime: string,
configured: Set<string>,
): ProviderChoice | null {
for (const p of getRuntimeProviders(runtime)) {
if (configured.has(p.envVar)) return p;
}
return null;
}
/* ---------- Types ---------- */
export interface SecretEntry {
@ -81,8 +153,9 @@ export async function checkDeploySecrets(
runtime: string,
workspaceId?: string,
): Promise<PreflightResult> {
const requiredKeys = getRequiredKeys(runtime);
if (requiredKeys.length === 0) {
const providers = getRuntimeProviders(runtime);
if (providers.length === 0) {
// Unknown runtime — nothing to preflight.
return { ok: true, missingKeys: [], runtime };
}
@ -95,12 +168,25 @@ export async function checkDeploySecrets(
secrets.filter((s) => s.has_value).map((s) => s.key),
);
const missingKeys = findMissingKeys(runtime, configuredKeys);
return { ok: missingKeys.length === 0, missingKeys, runtime };
// If ANY supported provider's key is already set we're satisfied —
// the picker is only for "none yet" cases.
if (findConfiguredProvider(runtime, configuredKeys)) {
return { ok: true, missingKeys: [], runtime };
}
// Nothing configured — surface every supported provider so the
// modal can render a picker. The default (first) still renders at
// the top.
const missingKeys = providers.map((p) => p.envVar);
return { ok: false, missingKeys, runtime };
} catch (error) {
// Log the error before falling back — aids debugging when the API is down.
console.error("[deploy-preflight] Failed to check secrets, assuming all missing:", error);
// If we can't reach the secrets API, assume missing — safer to prompt the user.
return { ok: false, missingKeys: requiredKeys, runtime };
return {
ok: false,
missingKeys: providers.map((p) => p.envVar),
runtime,
};
}
}