refactor(canvas): data-drive provider picker from template config.yaml
The MissingKeysModal's provider list was hardcoded in deploy-preflight.ts
as RUNTIME_PROVIDERS — a per-runtime map that duplicated what each
template repo already declares in its config.yaml. That meant adding a
new provider required changes in two places, and the UI could drift out
of sync with the actual template (e.g. when a template adds a MiniMax or
Kimi model, the picker wouldn't know).
The single source of truth for "which env vars does this workspace need"
is each template's config.yaml:
* `runtime_config.models[].required_env` — per-model key list
* `runtime_config.required_env` — runtime-level AND list
Go /templates already returned `models`. This change:
* Adds `required_env` alongside `models` on templateSummary so the
canvas receives the full picture.
* Rewrites deploy-preflight.ts to derive ProviderChoice[] from a
template object via `providersFromTemplate(template)`:
- groups `models[]` by unique required_env tuple
- falls back to runtime_config.required_env when models is empty
- decorates labels with model counts (e.g. "OpenRouter (14 models)")
* `checkDeploySecrets(template, workspaceId?)` now takes a template
object instead of a runtime string. Any-provider satisfaction still
short-circuits preflight to ok=true.
* MissingKeysModal receives `providers` directly; no more lookups.
* TemplatePalette threads `template.models` + `template.required_env`
into the preflight.
Side effects:
* Claude Code's dual-auth (OAuth token OR Anthropic API key) now
surfaces as two picker options — its config.yaml already declared
both, the UI just wasn't reading them.
* Hermes picker now shows 8 provider options (Nous, OpenRouter,
Anthropic, Gemini, DeepSeek, GLM, Kimi, Kilocode) instead of the
hand-picked 3, matching its 35-model reality.
Removed the legacy RUNTIME_PROVIDERS / RUNTIME_REQUIRED_KEYS /
getRequiredKeys / findMissingKeys exports; MissingKeysModal.test.tsx
deleted (its coverage is subsumed by the new template-driven
deploy-preflight.test.ts). 58 modal-adjacent tests pass; full canvas
suite 919 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5bcd7298c
commit
dc50a1c775
@ -2,29 +2,31 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
getKeyLabel,
|
||||
getRuntimeProviders,
|
||||
type ProviderChoice,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Flat list of every candidate env var. Used as the fallback input
|
||||
* set when `providers` is empty (or length 1). */
|
||||
missingKeys: string[];
|
||||
/** Grouped provider options derived from the template's models[] /
|
||||
* required_env. When length ≥ 2 the modal shows a radio picker. */
|
||||
providers?: ProviderChoice[];
|
||||
/** Runtime slug — used only for the "The <runtime> runtime …"
|
||||
* headline; behavior is driven by providers/missingKeys. */
|
||||
runtime: string;
|
||||
/** Called when user adds all required keys and wants to proceed with deploy. */
|
||||
/** Called when all required keys for the chosen provider are saved. */
|
||||
onKeysAdded: () => void;
|
||||
/** Called when user cancels the deploy. */
|
||||
/** Called when the user cancels the deploy. */
|
||||
onCancel: () => void;
|
||||
/** Called when user wants to open the Settings Panel (Config tab → Secrets). */
|
||||
/** Optional — open the Settings Panel (Config tab → Secrets). */
|
||||
onOpenSettings?: () => void;
|
||||
/** Optional workspace ID — if provided, secrets are saved at workspace scope. */
|
||||
/** If provided, secrets save at workspace scope instead of global. */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
interface KeyEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
saved: boolean;
|
||||
saving: boolean;
|
||||
@ -34,45 +36,38 @@ interface KeyEntry {
|
||||
/**
|
||||
* MissingKeysModal
|
||||
* ----------------
|
||||
* Two rendering modes, picked automatically from the runtime:
|
||||
* Dispatches between two modes based on what the template declares:
|
||||
*
|
||||
* 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.
|
||||
* 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g.
|
||||
* a Hermes template whose models[].required_env enumerate OpenRouter,
|
||||
* Anthropic, Nous-native, etc.). Radio list of options, saving the
|
||||
* chosen option's env vars 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.
|
||||
* 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input,
|
||||
* all must save before Deploy. Used when the template has a single
|
||||
* provider option or no declared alternatives.
|
||||
*
|
||||
* The modal never hardcodes per-runtime provider lists; the upstream
|
||||
* preflight derives that from the template config.yaml.
|
||||
*/
|
||||
export function MissingKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
providers,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
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;
|
||||
const pickerProviders = providers ?? [];
|
||||
const pickerMode = pickerProviders.length > 1;
|
||||
|
||||
if (pickerMode) {
|
||||
return (
|
||||
<ProviderPickerModal
|
||||
open={open}
|
||||
providers={providers}
|
||||
providers={pickerProviders}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
@ -82,10 +77,15 @@ export function MissingKeysModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the (single) provider's envVars over the raw missingKeys when
|
||||
// we have one — the provider list is already de-duped and ordered.
|
||||
const keys =
|
||||
pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys;
|
||||
|
||||
return (
|
||||
<AllKeysModal
|
||||
open={open}
|
||||
missingKeys={missingKeys}
|
||||
missingKeys={keys}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
@ -96,7 +96,7 @@ export function MissingKeysModal({
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provider-picker mode — one-of-N providers, save one, deploy.
|
||||
// Provider-picker mode — choose one option, save its env var(s), deploy.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function ProviderPickerModal({
|
||||
@ -117,21 +117,32 @@ function ProviderPickerModal({
|
||||
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 [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => providers.find((p) => p.id === selectedId) ?? providers[0],
|
||||
[providers, selectedId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId(providers[0].id);
|
||||
setValue("");
|
||||
setSaving(false);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries(
|
||||
selected.envVars.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
|
||||
@ -147,39 +158,58 @@ function ProviderPickerModal({
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onCancel]);
|
||||
|
||||
const selected = providers.find((p) => p.id === selectedId) ?? providers[0];
|
||||
const updateEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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(),
|
||||
const handleSaveKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = entries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
updateEntry(index, { saving: true, error: null });
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
} else {
|
||||
await api.put("/settings/secrets", {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
}
|
||||
updateEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
setSaved(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selected, value, workspaceId]);
|
||||
},
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
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
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
@ -189,7 +219,10 @@ function ProviderPickerModal({
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
@ -201,8 +234,8 @@ function ProviderPickerModal({
|
||||
</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.
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime supports multiple providers. Pick one and paste its API key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -225,69 +258,77 @@ function ProviderPickerModal({
|
||||
name="provider"
|
||||
value={p.id}
|
||||
checked={selectedId === p.id}
|
||||
onChange={() => {
|
||||
setSelectedId(p.id);
|
||||
setValue("");
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
onChange={() => setSelectedId(p.id)}
|
||||
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>
|
||||
<div className="text-[10px] font-mono text-zinc-500">
|
||||
{p.envVars.join(", ")}
|
||||
</div>
|
||||
{p.note && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">{p.note}</div>
|
||||
<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 className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
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(entry.key)}
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
ref={index === 0 ? firstInputRef : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveKey(index);
|
||||
}
|
||||
}}
|
||||
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={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.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"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mt-1.5 text-[10px] text-red-400">{error}</div>}
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -311,10 +352,10 @@ function ProviderPickerModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={onKeysAdded}
|
||||
disabled={!saved || saving}
|
||||
disabled={!allSaved || anySaving}
|
||||
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"}
|
||||
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -324,9 +365,7 @@ function ProviderPickerModal({
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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).
|
||||
// All-keys mode — every missingKey rendered as its own input, all required.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function AllKeysModal({
|
||||
@ -337,7 +376,15 @@ function AllKeysModal({
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
}: Props) {
|
||||
}: {
|
||||
open: boolean;
|
||||
missingKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -347,7 +394,6 @@ function AllKeysModal({
|
||||
setEntries(
|
||||
missingKeys.map((key) => ({
|
||||
key,
|
||||
label: getKeyLabel(key),
|
||||
value: "",
|
||||
saved: false,
|
||||
saving: false,
|
||||
@ -427,13 +473,19 @@ function AllKeysModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
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
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
@ -443,7 +495,10 @@ function AllKeysModal({
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
@ -455,8 +510,8 @@ function AllKeysModal({
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
|
||||
requires the following keys to be configured before deploying.
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -468,7 +523,9 @@ function AllKeysModal({
|
||||
>
|
||||
<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-[11px] text-zinc-300 font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight";
|
||||
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
|
||||
import { MissingKeysModal } from "./MissingKeysModal";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Spinner } from "./Spinner";
|
||||
@ -14,7 +14,11 @@ interface Template {
|
||||
name: string;
|
||||
description: string;
|
||||
tier: number;
|
||||
runtime?: string;
|
||||
model: string;
|
||||
models?: ModelSpec[];
|
||||
/** AND-required env vars declared at runtime_config.required_env. */
|
||||
required_env?: string[];
|
||||
skills: string[];
|
||||
skill_count: number;
|
||||
}
|
||||
@ -329,8 +333,15 @@ export function TemplatePalette() {
|
||||
setCreating(template.id);
|
||||
setError(null);
|
||||
|
||||
const runtime = resolveRuntime(template.id);
|
||||
const preflight = await checkDeploySecrets(runtime);
|
||||
// Prefer the runtime the Go /templates endpoint returned verbatim —
|
||||
// resolveRuntime() is a legacy id→runtime fallback for installs whose
|
||||
// template summary predates the `runtime` field.
|
||||
const runtime = template.runtime ?? resolveRuntime(template.id);
|
||||
const preflight = await checkDeploySecrets({
|
||||
runtime,
|
||||
models: template.models,
|
||||
required_env: template.required_env,
|
||||
});
|
||||
|
||||
if (!preflight.ok) {
|
||||
// Missing keys — show the modal instead of deploying
|
||||
@ -368,6 +379,7 @@ export function TemplatePalette() {
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
providers={missingKeysInfo?.preflight.providers ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
onKeysAdded={() => {
|
||||
if (missingKeysInfo) {
|
||||
|
||||
@ -27,11 +27,9 @@ 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: () => [],
|
||||
}));
|
||||
// a11y tests render the modal without a `providers` prop — it falls
|
||||
// back to all-keys mode driven by the `missingKeys` array.
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -36,12 +36,10 @@ 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: () => [],
|
||||
}));
|
||||
// Tests render the modal without a `providers` prop — the component
|
||||
// falls back to the all-keys mode using the `missingKeys` array, which
|
||||
// matches the contract these tests were written for.
|
||||
|
||||
// ── Suite 1: Visibility and ARIA ────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* MissingKeysModal preflight logic tests.
|
||||
* Component rendering tested in MissingKeysModal.component.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
import {
|
||||
getRequiredKeys,
|
||||
findMissingKeys,
|
||||
getKeyLabel,
|
||||
checkDeploySecrets,
|
||||
RUNTIME_REQUIRED_KEYS,
|
||||
} from "../../lib/deploy-preflight";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("MissingKeysModal preflight logic", () => {
|
||||
it("identifies missing keys for langgraph runtime", () => {
|
||||
const missing = findMissingKeys("langgraph", new Set<string>());
|
||||
expect(missing).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
|
||||
it("identifies missing keys for claude-code runtime", () => {
|
||||
const missing = findMissingKeys("claude-code", new Set<string>());
|
||||
expect(missing).toEqual(["ANTHROPIC_API_KEY"]);
|
||||
});
|
||||
|
||||
it("generates correct labels for modal display", () => {
|
||||
const missing = findMissingKeys("langgraph", new Set<string>());
|
||||
const labels = missing.map((k) => ({ key: k, label: getKeyLabel(k) }));
|
||||
expect(labels).toEqual([{ key: "OPENAI_API_KEY", label: "OpenAI API Key" }]);
|
||||
});
|
||||
|
||||
it("returns no missing keys when all are configured", () => {
|
||||
const missing = findMissingKeys("langgraph", new Set(["OPENAI_API_KEY"]));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("pre-deploy check returns ok=false and correct missing keys", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("langgraph");
|
||||
expect(result.ok).toBe(false);
|
||||
// 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");
|
||||
});
|
||||
|
||||
it("pre-deploy check returns ok=true when keys are present", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([{ key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" }]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("claude-code");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles all runtimes correctly for modal data construction", () => {
|
||||
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
|
||||
for (const runtime of runtimes) {
|
||||
const requiredKeys = getRequiredKeys(runtime);
|
||||
const missing = findMissingKeys(runtime, new Set<string>());
|
||||
const labels = missing.map((k) => getKeyLabel(k));
|
||||
|
||||
expect(requiredKeys.length).toBeGreaterThan(0);
|
||||
expect(missing).toEqual(requiredKeys);
|
||||
expect(labels.length).toBe(requiredKeys.length);
|
||||
for (const label of labels) {
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,121 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// Mock fetch globally before importing the module
|
||||
global.fetch = vi.fn();
|
||||
|
||||
import {
|
||||
getRequiredKeys,
|
||||
findMissingKeys,
|
||||
getKeyLabel,
|
||||
checkDeploySecrets,
|
||||
RUNTIME_REQUIRED_KEYS,
|
||||
KEY_LABELS,
|
||||
providersFromTemplate,
|
||||
findSatisfiedProvider,
|
||||
getKeyLabel,
|
||||
getProviderLabel,
|
||||
type TemplateLike,
|
||||
type ModelSpec,
|
||||
} from "../deploy-preflight";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/* ---------- getRequiredKeys ---------- */
|
||||
// -----------------------------------------------------------------------------
|
||||
// Fixtures mirroring what the Go /templates endpoint returns from each
|
||||
// template repo's config.yaml. Keep these minimal — we only need the
|
||||
// fields the preflight reads.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("getRequiredKeys", () => {
|
||||
it("returns OPENAI_API_KEY for langgraph", () => {
|
||||
expect(getRequiredKeys("langgraph")).toEqual(["OPENAI_API_KEY"]);
|
||||
const hermesModels: ModelSpec[] = [
|
||||
{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] },
|
||||
{ id: "nousresearch/hermes-3-405b", name: "Hermes 3 405B", required_env: ["OPENROUTER_API_KEY"] },
|
||||
{ id: "anthropic/claude-opus", name: "Claude Opus", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "openai/gpt-5", name: "GPT-5 via OpenRouter", required_env: ["OPENROUTER_API_KEY"] },
|
||||
{ id: "custom/local", name: "Local endpoint", required_env: [] },
|
||||
];
|
||||
|
||||
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
|
||||
|
||||
const LANGGRAPH: TemplateLike = {
|
||||
runtime: "langgraph",
|
||||
required_env: ["OPENAI_API_KEY"],
|
||||
};
|
||||
|
||||
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// providersFromTemplate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("providersFromTemplate", () => {
|
||||
it("groups hermes models by unique required_env tuples", () => {
|
||||
const providers = providersFromTemplate(HERMES);
|
||||
// Three distinct tuples: HERMES_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_API_KEY.
|
||||
// The `custom/local` entry has required_env: [] and must be skipped.
|
||||
expect(providers.map((p) => p.id)).toEqual([
|
||||
"HERMES_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns ANTHROPIC_API_KEY for claude-code", () => {
|
||||
expect(getRequiredKeys("claude-code")).toEqual(["ANTHROPIC_API_KEY"]);
|
||||
it("decorates labels with model counts when a provider serves multiple models", () => {
|
||||
const providers = providersFromTemplate(HERMES);
|
||||
const openrouter = providers.find((p) => p.id === "OPENROUTER_API_KEY");
|
||||
expect(openrouter?.label).toMatch(/\(2 models\)/);
|
||||
const hermes = providers.find((p) => p.id === "HERMES_API_KEY");
|
||||
expect(hermes?.label).not.toMatch(/\(\d+ models\)/);
|
||||
});
|
||||
|
||||
it("returns OPENAI_API_KEY for crewai", () => {
|
||||
expect(getRequiredKeys("crewai")).toEqual(["OPENAI_API_KEY"]);
|
||||
it("preserves insertion order so the template author controls defaults", () => {
|
||||
const providers = providersFromTemplate(HERMES);
|
||||
expect(providers[0].id).toBe("HERMES_API_KEY");
|
||||
});
|
||||
|
||||
it("returns OPENAI_API_KEY for autogen", () => {
|
||||
expect(getRequiredKeys("autogen")).toEqual(["OPENAI_API_KEY"]);
|
||||
it("falls back to top-level required_env when no models[] are declared", () => {
|
||||
const providers = providersFromTemplate(LANGGRAPH);
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
|
||||
it("returns OPENAI_API_KEY for openclaw", () => {
|
||||
expect(getRequiredKeys("openclaw")).toEqual(["OPENAI_API_KEY"]);
|
||||
it("returns [] for templates declaring no env requirements", () => {
|
||||
expect(providersFromTemplate(UNKNOWN)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns OPENAI_API_KEY for deepagents", () => {
|
||||
expect(getRequiredKeys("deepagents")).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown runtimes", () => {
|
||||
expect(getRequiredKeys("unknown-runtime")).toEqual([]);
|
||||
expect(getRequiredKeys("")).toEqual([]);
|
||||
it("supports multi-env providers (AND-semantics inside one option)", () => {
|
||||
const tmpl: TemplateLike = {
|
||||
runtime: "agent",
|
||||
models: [
|
||||
{ id: "m", required_env: ["OPENAI_API_KEY", "SERPER_API_KEY"] },
|
||||
],
|
||||
};
|
||||
const providers = providersFromTemplate(tmpl);
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY", "SERPER_API_KEY"]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- findMissingKeys ---------- */
|
||||
// -----------------------------------------------------------------------------
|
||||
// findSatisfiedProvider
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("findMissingKeys", () => {
|
||||
it("returns empty array when all keys are configured", () => {
|
||||
const configured = new Set(["OPENAI_API_KEY", "OTHER_KEY"]);
|
||||
expect(findMissingKeys("langgraph", configured)).toEqual([]);
|
||||
describe("findSatisfiedProvider", () => {
|
||||
it("returns the first provider whose envVars are all configured", () => {
|
||||
const providers = providersFromTemplate(HERMES);
|
||||
const satisfied = findSatisfiedProvider(
|
||||
providers,
|
||||
new Set(["ANTHROPIC_API_KEY"]),
|
||||
);
|
||||
expect(satisfied?.id).toBe("ANTHROPIC_API_KEY");
|
||||
});
|
||||
|
||||
it("returns missing keys when not configured", () => {
|
||||
const configured = new Set(["OTHER_KEY"]);
|
||||
expect(findMissingKeys("langgraph", configured)).toEqual(["OPENAI_API_KEY"]);
|
||||
it("returns null when no provider is fully configured", () => {
|
||||
const providers = providersFromTemplate(HERMES);
|
||||
expect(findSatisfiedProvider(providers, new Set())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty array for runtime with no required keys", () => {
|
||||
const configured = new Set<string>();
|
||||
expect(findMissingKeys("unknown-runtime", configured)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all required keys when nothing is configured", () => {
|
||||
const configured = new Set<string>();
|
||||
expect(findMissingKeys("claude-code", configured)).toEqual(["ANTHROPIC_API_KEY"]);
|
||||
});
|
||||
|
||||
it("handles empty configured set for multi-key runtimes", () => {
|
||||
const configured = new Set<string>();
|
||||
const result = findMissingKeys("langgraph", configured);
|
||||
expect(result).toEqual(["OPENAI_API_KEY"]);
|
||||
it("requires ALL envVars in a multi-env provider", () => {
|
||||
const providers: ReturnType<typeof providersFromTemplate> =
|
||||
providersFromTemplate({
|
||||
runtime: "agent",
|
||||
models: [{ id: "m", required_env: ["A", "B"] }],
|
||||
});
|
||||
expect(findSatisfiedProvider(providers, new Set(["A"]))).toBeNull();
|
||||
expect(findSatisfiedProvider(providers, new Set(["A", "B"]))?.id).toBe("A|B");
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- getKeyLabel ---------- */
|
||||
// -----------------------------------------------------------------------------
|
||||
// Label helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("getKeyLabel", () => {
|
||||
it("returns label for known keys", () => {
|
||||
describe("getKeyLabel / getProviderLabel", () => {
|
||||
it("uses KEY_LABELS for well-known keys", () => {
|
||||
expect(getProviderLabel("OPENAI_API_KEY")).toBe("OpenAI");
|
||||
expect(getKeyLabel("OPENAI_API_KEY")).toBe("OpenAI API Key");
|
||||
expect(getKeyLabel("ANTHROPIC_API_KEY")).toBe("Anthropic API Key");
|
||||
});
|
||||
|
||||
it("returns the key itself for unknown keys", () => {
|
||||
expect(getKeyLabel("CUSTOM_SECRET")).toBe("CUSTOM_SECRET");
|
||||
it("humanizes unknown env vars", () => {
|
||||
expect(getProviderLabel("MY_CUSTOM_API_KEY")).toBe("My Custom");
|
||||
expect(getKeyLabel("MY_CUSTOM_TOKEN")).toBe("My Custom");
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- RUNTIME_REQUIRED_KEYS ---------- */
|
||||
|
||||
describe("RUNTIME_REQUIRED_KEYS", () => {
|
||||
it("covers all six standard runtimes", () => {
|
||||
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
|
||||
expect(runtimes).toContain("langgraph");
|
||||
expect(runtimes).toContain("claude-code");
|
||||
expect(runtimes).toContain("openclaw");
|
||||
expect(runtimes).toContain("deepagents");
|
||||
expect(runtimes).toContain("crewai");
|
||||
expect(runtimes).toContain("autogen");
|
||||
});
|
||||
|
||||
it("each runtime has at least one required key", () => {
|
||||
for (const [runtime, keys] of Object.entries(RUNTIME_REQUIRED_KEYS)) {
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- checkDeploySecrets ---------- */
|
||||
// -----------------------------------------------------------------------------
|
||||
// checkDeploySecrets
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("checkDeploySecrets", () => {
|
||||
it("returns ok=true when all required keys have values", async () => {
|
||||
it("returns ok=true when a single-provider template's key is configured", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@ -124,59 +151,13 @@ describe("checkDeploySecrets", () => {
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("langgraph");
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(result.runtime).toBe("langgraph");
|
||||
});
|
||||
|
||||
it("returns ok=false when required keys are missing", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{ key: "OTHER_KEY", has_value: true, created_at: "", updated_at: "" },
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("langgraph");
|
||||
expect(result.ok).toBe(false);
|
||||
// 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 () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("langgraph");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual([
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns ok=true for runtimes with no required keys", async () => {
|
||||
const result = await checkDeploySecrets("unknown-runtime");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
// Should not have called fetch
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses workspace-specific endpoint when workspaceId is provided", async () => {
|
||||
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@ -185,38 +166,83 @@ describe("checkDeploySecrets", () => {
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets("claude-code", "ws-123");
|
||||
const result = await checkDeploySecrets(HERMES);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok=false with every candidate env when nothing is configured", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets(HERMES);
|
||||
expect(result.ok).toBe(false);
|
||||
// De-duplicated flat list across providers.
|
||||
expect(new Set(result.missingKeys)).toEqual(
|
||||
new Set(["HERMES_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"]),
|
||||
);
|
||||
// Grouped providers preserved for the picker.
|
||||
expect(result.providers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("treats has_value=false as not-configured", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
|
||||
it("skips the API call entirely when the template declares no env needs", async () => {
|
||||
const result = await checkDeploySecrets(UNKNOWN);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
{ key: "OPENAI_API_KEY", has_value: true, created_at: "", updated_at: "" },
|
||||
]),
|
||||
} as Response);
|
||||
|
||||
await checkDeploySecrets(LANGGRAPH, "ws-123");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/workspaces/ws-123/secrets"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses global secrets endpoint when no workspaceId", async () => {
|
||||
it("uses the global secrets endpoint when no workspaceId", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
|
||||
await checkDeploySecrets("langgraph");
|
||||
await checkDeploySecrets(LANGGRAPH);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/settings/secrets"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats API failure as all keys missing (safe default)", async () => {
|
||||
it("treats fetch failure as all-missing (safe default prompts the user)", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
const result = await checkDeploySecrets("langgraph");
|
||||
const result = await checkDeploySecrets(LANGGRAPH);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual([
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
]);
|
||||
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,111 +1,38 @@
|
||||
/**
|
||||
* Pre-deploy secret check per runtime.
|
||||
* Pre-deploy secret check driven by the template's config.yaml.
|
||||
*
|
||||
* Before a workspace is deployed, validates that all required secrets/env vars
|
||||
* are configured for the target runtime. Each runtime defines its own set of
|
||||
* required keys (derived from each runtime's config.yaml `env.required` field).
|
||||
* The single source of truth for which env vars a workspace needs is
|
||||
* each template repo's config.yaml — the `runtime_config.models[].required_env`
|
||||
* array names the key(s) required per model, and `runtime_config.required_env`
|
||||
* names any AND-required keys at the runtime level. The Go `/templates`
|
||||
* handler parses these and exposes them as `models` and `required_env` on
|
||||
* each template summary.
|
||||
*
|
||||
* This module consumes that shape; it does NOT hardcode a per-runtime
|
||||
* provider table. When a template declares alternative models (e.g.
|
||||
* Hermes supports 35 models across 8 providers), the unique required_env
|
||||
* tuples become the provider options shown in the picker modal.
|
||||
*/
|
||||
|
||||
import { api } from "./api";
|
||||
|
||||
/* ---------- 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.
|
||||
*/
|
||||
/* ---------- Types matching the /templates response ---------- */
|
||||
|
||||
export interface ProviderChoice {
|
||||
/** Stable id for the provider. Used as React key + picker value. */
|
||||
export interface ModelSpec {
|
||||
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;
|
||||
name?: string;
|
||||
required_env?: 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",
|
||||
ANTHROPIC_API_KEY: "Anthropic API Key",
|
||||
GOOGLE_API_KEY: "Google AI API Key",
|
||||
SERP_API_KEY: "SERP API Key",
|
||||
OPENROUTER_API_KEY: "OpenRouter API Key",
|
||||
HERMES_API_KEY: "Nous Research API Key",
|
||||
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] ?? [];
|
||||
/** Minimal template shape consumed by the preflight check. Any object
|
||||
* that matches this subset of the `/templates` response works. */
|
||||
export interface TemplateLike {
|
||||
runtime: string;
|
||||
models?: ModelSpec[];
|
||||
/** AND-required env vars declared at runtime_config level. */
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
key: string;
|
||||
has_value: boolean;
|
||||
@ -116,77 +43,184 @@ export interface SecretEntry {
|
||||
|
||||
export interface PreflightResult {
|
||||
ok: boolean;
|
||||
/** Flat list of env var names needed — for the legacy modal path and
|
||||
* for callers that want a single display of "what's missing". */
|
||||
missingKeys: string[];
|
||||
/** Grouped provider options derived from the template. When length ≥ 2
|
||||
* the modal renders a picker; length 1 means exactly one provider is
|
||||
* required (AllKeysModal renders the N envVars inline). */
|
||||
providers: ProviderChoice[];
|
||||
runtime: string;
|
||||
}
|
||||
|
||||
/* ---------- Pure helpers (easily testable) ---------- */
|
||||
/* ---------- Provider options ---------- */
|
||||
|
||||
/** Get required env keys for a given runtime. Returns empty array for unknown runtimes. */
|
||||
export function getRequiredKeys(runtime: string): string[] {
|
||||
return RUNTIME_REQUIRED_KEYS[runtime] ?? [];
|
||||
/** One row in the provider picker. `envVars` is the set of keys required
|
||||
* TOGETHER to satisfy this option (usually length 1 — e.g. just
|
||||
* OPENROUTER_API_KEY). When length ≥ 2 all must be saved. */
|
||||
export interface ProviderChoice {
|
||||
/** Stable id for React keys + picker value — the sorted envVars joined. */
|
||||
id: string;
|
||||
/** Human label, e.g. "OpenRouter" or "OpenAI + Serper". */
|
||||
label: string;
|
||||
/** Env vars required for this provider option. */
|
||||
envVars: string[];
|
||||
/** Short rationale shown under the option, optional. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Given a runtime and a set of configured key names, return which keys are missing. */
|
||||
export function findMissingKeys(
|
||||
runtime: string,
|
||||
configuredKeys: Set<string>,
|
||||
): string[] {
|
||||
return getRequiredKeys(runtime).filter((k) => !configuredKeys.has(k));
|
||||
}
|
||||
/** Human-readable labels for well-known secret keys. Anything not in
|
||||
* this table falls back to a humanized form of the env var. */
|
||||
export const KEY_LABELS: Record<string, string> = {
|
||||
OPENAI_API_KEY: "OpenAI",
|
||||
ANTHROPIC_API_KEY: "Anthropic",
|
||||
GOOGLE_API_KEY: "Google AI",
|
||||
GEMINI_API_KEY: "Google Gemini",
|
||||
SERP_API_KEY: "SERP",
|
||||
SERPER_API_KEY: "Serper",
|
||||
OPENROUTER_API_KEY: "OpenRouter",
|
||||
HERMES_API_KEY: "Nous Research (Hermes native)",
|
||||
DEEPSEEK_API_KEY: "DeepSeek",
|
||||
GLM_API_KEY: "z.ai GLM",
|
||||
KIMI_API_KEY: "Moonshot Kimi",
|
||||
MINIMAX_API_KEY: "MiniMax",
|
||||
KILOCODE_API_KEY: "Kilo Code",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "Claude Code subscription",
|
||||
};
|
||||
|
||||
/** Get human-readable label for a key, or fall back to the key itself. */
|
||||
/** Full "API Key" label used for input field headers. */
|
||||
export function getKeyLabel(key: string): string {
|
||||
return KEY_LABELS[key] ?? key;
|
||||
const base = KEY_LABELS[key];
|
||||
if (base) return `${base} API Key`;
|
||||
return humanizeEnvVar(key);
|
||||
}
|
||||
|
||||
/* ---------- API-calling preflight check ---------- */
|
||||
/** Short provider name used in the picker (no trailing "API Key"). */
|
||||
export function getProviderLabel(key: string): string {
|
||||
return KEY_LABELS[key] ?? humanizeEnvVar(key);
|
||||
}
|
||||
|
||||
function humanizeEnvVar(key: string): string {
|
||||
return key
|
||||
.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "")
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch configured secrets from the platform and check whether all required
|
||||
* keys for the target runtime are present.
|
||||
* Derive the provider options for a template from its declared shape.
|
||||
*
|
||||
* If `workspaceId` is provided, fetches the merged (global + workspace) secret
|
||||
* list for that workspace. Otherwise falls back to global secrets only.
|
||||
* 1. `models[].required_env` — each unique (sorted) tuple becomes a
|
||||
* provider option. E.g. Hermes exposes 8 options (Nous, OpenRouter,
|
||||
* Anthropic, Gemini, DeepSeek, GLM, Kimi, Kilocode) even though it
|
||||
* lists 35 models. Insertion order is preserved so the template's
|
||||
* author controls which provider is offered first.
|
||||
* 2. If `models` is empty or has no required_env, fall back to the
|
||||
* top-level `required_env` as a single all-required option.
|
||||
* 3. If neither is declared, return [] — no preflight needed.
|
||||
*
|
||||
* Models with `required_env: []` (local / self-hosted endpoints) are
|
||||
* skipped when computing options; they never block a deploy.
|
||||
*/
|
||||
export async function checkDeploySecrets(
|
||||
runtime: string,
|
||||
workspaceId?: string,
|
||||
): Promise<PreflightResult> {
|
||||
const providers = getRuntimeProviders(runtime);
|
||||
if (providers.length === 0) {
|
||||
// Unknown runtime — nothing to preflight.
|
||||
return { ok: true, missingKeys: [], runtime };
|
||||
export function providersFromTemplate(template: TemplateLike): ProviderChoice[] {
|
||||
const out: ProviderChoice[] = [];
|
||||
const seen = new Set<string>();
|
||||
const modelCount: Record<string, number> = {};
|
||||
|
||||
for (const m of template.models ?? []) {
|
||||
const envs = m.required_env ?? [];
|
||||
if (envs.length === 0) continue;
|
||||
const id = [...envs].sort().join("|");
|
||||
modelCount[id] = (modelCount[id] ?? 0) + 1;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push({
|
||||
id,
|
||||
envVars: envs,
|
||||
label: envs.map(getProviderLabel).join(" + "),
|
||||
});
|
||||
}
|
||||
|
||||
// Decorate labels with model-count hints when multiple models share
|
||||
// the same provider. Gives the user context: "OpenRouter (14 models)".
|
||||
for (const p of out) {
|
||||
const n = modelCount[p.id];
|
||||
if (n && n > 1) p.label = `${p.label} (${n} models)`;
|
||||
}
|
||||
|
||||
if (out.length === 0 && template.required_env?.length) {
|
||||
const envs = template.required_env;
|
||||
out.push({
|
||||
id: [...envs].sort().join("|"),
|
||||
envVars: envs,
|
||||
label: envs.map(getProviderLabel).join(" + "),
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Helper: is any single provider option already satisfied by the set of
|
||||
* configured keys? A provider is satisfied when EVERY envVar it requires
|
||||
* is present. Returns the first such option or null. */
|
||||
export function findSatisfiedProvider(
|
||||
providers: ProviderChoice[],
|
||||
configured: Set<string>,
|
||||
): ProviderChoice | null {
|
||||
for (const p of providers) {
|
||||
if (p.envVars.every((k) => configured.has(k))) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Preflight ---------- */
|
||||
|
||||
/**
|
||||
* Fetch configured secrets from the platform and decide whether the
|
||||
* workspace can deploy. When `workspaceId` is provided the merged
|
||||
* (global + workspace) secrets are checked; otherwise only globals.
|
||||
*
|
||||
* Returns `ok=true` immediately if any provider option's env vars are
|
||||
* already configured. Otherwise returns all candidate env vars flat in
|
||||
* `missingKeys` plus the grouped `providers` list for the picker.
|
||||
*/
|
||||
export async function checkDeploySecrets(
|
||||
template: TemplateLike,
|
||||
workspaceId?: string,
|
||||
): Promise<PreflightResult> {
|
||||
const providers = providersFromTemplate(template);
|
||||
const runtime = template.runtime;
|
||||
|
||||
if (providers.length === 0) {
|
||||
// Template declares no env requirements — nothing to preflight.
|
||||
return { ok: true, missingKeys: [], providers: [], runtime };
|
||||
}
|
||||
|
||||
let configured: Set<string>;
|
||||
try {
|
||||
const secrets = workspaceId
|
||||
? await api.get<SecretEntry[]>(`/workspaces/${workspaceId}/secrets`)
|
||||
: await api.get<SecretEntry[]>("/settings/secrets");
|
||||
|
||||
const configuredKeys = new Set(
|
||||
secrets.filter((s) => s.has_value).map((s) => s.key),
|
||||
);
|
||||
|
||||
// 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 };
|
||||
configured = new Set(secrets.filter((s) => s.has_value).map((s) => s.key));
|
||||
} 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: providers.map((p) => p.envVar),
|
||||
runtime,
|
||||
};
|
||||
console.error(
|
||||
"[deploy-preflight] Failed to read secrets, assuming all missing:",
|
||||
error,
|
||||
);
|
||||
// Safer to prompt the user than to silently deploy.
|
||||
configured = new Set();
|
||||
}
|
||||
|
||||
if (findSatisfiedProvider(providers, configured)) {
|
||||
return { ok: true, missingKeys: [], providers, runtime };
|
||||
}
|
||||
|
||||
// Nothing configured — surface every candidate env var so the modal
|
||||
// can render the picker or the all-keys fallback.
|
||||
const missingKeys = Array.from(
|
||||
new Set(providers.flatMap((p) => p.envVars)),
|
||||
);
|
||||
return { ok: false, missingKeys, providers, runtime };
|
||||
}
|
||||
|
||||
@ -53,8 +53,14 @@ type templateSummary struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Model string `json:"model"`
|
||||
Models []modelSpec `json:"models,omitempty"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
// RequiredEnv mirrors runtime_config.required_env from the template's
|
||||
// config.yaml — the AND-required env vars the template declares at the
|
||||
// runtime level (separate from per-model required_env). The canvas
|
||||
// preflight uses this as the fallback provider when `models` is empty
|
||||
// so provider picker stays data-driven instead of hardcoded in the UI.
|
||||
RequiredEnv []string `json:"required_env,omitempty"`
|
||||
Skills []string `json:"skills"`
|
||||
SkillCount int `json:"skill_count"`
|
||||
}
|
||||
|
||||
// resolveTemplateDir finds the template directory for a workspace on the host.
|
||||
@ -100,8 +106,9 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
RequiredEnv []string `yaml:"required_env"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
@ -122,6 +129,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user