Prompt for template provider env config #1846
@@ -23,6 +23,8 @@ interface Props {
|
||||
/** Grouped provider options derived from the template's models[] /
|
||||
* required_env. When length ≥ 2 the modal shows a radio picker. */
|
||||
providers?: ProviderChoice[];
|
||||
/** Optional keys to offer in the deploy modal without blocking Deploy. */
|
||||
optionalKeys?: string[];
|
||||
/** Runtime slug — used only for the "The <runtime> runtime …"
|
||||
* headline; behavior is driven by providers/missingKeys. */
|
||||
runtime: string;
|
||||
@@ -94,13 +96,13 @@ export function MissingKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -114,13 +116,13 @@ export function MissingKeysModal({
|
||||
<ProviderPickerModal
|
||||
open={open}
|
||||
providers={pickerProviders}
|
||||
optionalKeys={optionalKeys ?? []}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
modelSuggestions={modelSuggestions}
|
||||
models={models}
|
||||
initialModel={initialModel}
|
||||
title={title}
|
||||
@@ -138,11 +140,15 @@ export function MissingKeysModal({
|
||||
<AllKeysModal
|
||||
open={open}
|
||||
missingKeys={keys}
|
||||
optionalKeys={optionalKeys ?? []}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -170,13 +176,13 @@ export function providerIdForModel(
|
||||
function ProviderPickerModal({
|
||||
open,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -184,13 +190,13 @@ function ProviderPickerModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
providers: ProviderChoice[];
|
||||
optionalKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
models?: ModelSpec[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
@@ -250,16 +256,9 @@ function ProviderPickerModal({
|
||||
|
||||
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Legacy compat: map the selector value back into the old `selected`/
|
||||
// `model` shape for the rest of the modal body (footer copy, etc.).
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === selectorValue.providerId) ??
|
||||
providers[0],
|
||||
[providers, selectorValue.providerId],
|
||||
);
|
||||
const model = selectorValue.model;
|
||||
const showModelInput = catalog.length > 0;
|
||||
|
||||
@@ -282,7 +281,18 @@ function ProviderPickerModal({
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selectorValue.envVars, configuredKeys]);
|
||||
setOptionalEntries(
|
||||
optionalKeys
|
||||
.filter((key) => !selectorValue.envVars.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selectorValue.envVars, configuredKeys, optionalKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -336,6 +346,43 @@ function ProviderPickerModal({
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
const updateOptionalEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setOptionalEntries((prev) =>
|
||||
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveOptionalKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = optionalEntries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
updateOptionalEntry(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(),
|
||||
});
|
||||
}
|
||||
updateOptionalEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateOptionalEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
},
|
||||
[optionalEntries, updateOptionalEntry, workspaceId],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
// Portal to document.body for the same reason as
|
||||
// OrgImportPreflightModal — several callers (TemplatePalette,
|
||||
@@ -465,6 +512,62 @@ function ProviderPickerModal({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{optionalEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
{optionalEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Optional value for ${entry.key}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveOptionalKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveOptionalKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
@@ -512,21 +615,30 @@ function ProviderPickerModal({
|
||||
function AllKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
open: boolean;
|
||||
missingKeys: string[];
|
||||
optionalKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -535,13 +647,24 @@ function AllKeysModal({
|
||||
missingKeys.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: false,
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setOptionalEntries(
|
||||
optionalKeys
|
||||
.filter((key) => !missingKeys.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setGlobalError(null);
|
||||
}, [open, missingKeys]);
|
||||
}, [open, missingKeys, optionalKeys, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -591,6 +714,45 @@ function AllKeysModal({
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
const updateOptionalEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setOptionalEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveOptionalKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = optionalEntries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
|
||||
updateOptionalEntry(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(),
|
||||
});
|
||||
}
|
||||
updateOptionalEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateOptionalEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
},
|
||||
[optionalEntries, updateOptionalEntry, workspaceId],
|
||||
);
|
||||
|
||||
const handleAddKeysAndDeploy = useCallback(() => {
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
if (anySaving) {
|
||||
@@ -656,12 +818,16 @@ function AllKeysModal({
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
Missing API Keys
|
||||
{title ?? "Missing API Keys"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
{description ?? (
|
||||
<>
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -719,6 +885,62 @@ function AllKeysModal({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{optionalEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
{optionalEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Optional value for ${entry.key}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveOptionalKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveOptionalKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalError && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
{globalError}
|
||||
|
||||
@@ -402,6 +402,31 @@ describe("MissingKeysModal — add keys and deploy", () => {
|
||||
expect(onKeysAdded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows optional keys without blocking deploy", () => {
|
||||
const onKeysAdded = vi.fn();
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open={true}
|
||||
missingKeys={[]}
|
||||
optionalKeys={["GOOGLE_GSC_SITE"]}
|
||||
runtime="claude-code"
|
||||
title="Configure Workspace"
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Optional")).toBeTruthy();
|
||||
expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0);
|
||||
const deployBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Deploy",
|
||||
);
|
||||
expect(deployBtn).toBeTruthy();
|
||||
expect(deployBtn!.disabled).toBe(false);
|
||||
act(() => { fireEvent.click(deployBtn!); });
|
||||
expect(onKeysAdded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows global error when not all keys saved", async () => {
|
||||
const onKeysAdded = vi.fn();
|
||||
render(
|
||||
@@ -529,4 +554,4 @@ describe("MissingKeysModal — cancel and settings", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ vi.mock("@/components/MissingKeysModal", () => ({
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
configuredKeys?: Set<string>;
|
||||
optionalKeys?: string[];
|
||||
modelSuggestions?: string[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
@@ -77,6 +78,9 @@ vi.mock("@/components/MissingKeysModal", () => ({
|
||||
</span>
|
||||
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
|
||||
<span data-testid="modal-title">{props.title ?? ""}</span>
|
||||
<span data-testid="modal-optional-keys">
|
||||
{(props.optionalKeys ?? []).join(",")}
|
||||
</span>
|
||||
<button
|
||||
data-testid="modal-keys-added"
|
||||
onClick={() => props.onKeysAdded()}
|
||||
@@ -113,6 +117,7 @@ function makeTemplate(over: Partial<Template> = {}): Template {
|
||||
runtime: "claude-code",
|
||||
models: [],
|
||||
required_env: [],
|
||||
recommended_env: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -129,6 +134,7 @@ beforeEach(() => {
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
mockApiPost.mockResolvedValue({ id: "ws-new" });
|
||||
@@ -243,6 +249,7 @@ describe("useTemplateDeploy — preflight failure modes", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -271,6 +278,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -306,6 +314,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -359,6 +368,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -392,6 +402,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -420,6 +431,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -484,6 +496,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -499,6 +512,35 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens configure modal for optional env prompts even when no required provider key is missing", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate({
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
|
||||
}));
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("modal-optional-keys").textContent).toBe(
|
||||
"GOOGLE_GSC_SITE,GOOGLE_GA4_PROPERTY_ID",
|
||||
);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTemplateDeploy — POST failure", () => {
|
||||
|
||||
@@ -152,6 +152,7 @@ export function useTemplateDeploy(
|
||||
runtime,
|
||||
models: template.models,
|
||||
required_env: template.required_env,
|
||||
recommended_env: template.recommended_env,
|
||||
});
|
||||
} catch (e) {
|
||||
// Preflight network failure used to strand `deploying` — the
|
||||
@@ -165,7 +166,11 @@ export function useTemplateDeploy(
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
if (preflight.ok && preflight.providers.length === 0) {
|
||||
if (
|
||||
preflight.ok &&
|
||||
preflight.providers.length === 0 &&
|
||||
preflight.optionalKeys.length === 0
|
||||
) {
|
||||
await executeDeploy(template);
|
||||
return;
|
||||
}
|
||||
@@ -220,6 +225,7 @@ export function useTemplateDeploy(
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
optionalKeys={missingKeysInfo?.preflight.optionalKeys ?? []}
|
||||
providers={missingKeysInfo?.preflight.providers ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
configuredKeys={missingKeysInfo?.preflight.configuredKeys}
|
||||
|
||||
@@ -37,6 +37,11 @@ const CLAUDE_CODE: TemplateLike = {
|
||||
required_env: ["OPENAI_API_KEY"],
|
||||
};
|
||||
|
||||
const OPTIONAL_ONLY: TemplateLike = {
|
||||
runtime: "claude-code",
|
||||
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
|
||||
};
|
||||
|
||||
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -154,6 +159,7 @@ describe("checkDeploySecrets", () => {
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(result.optionalKeys).toEqual([]);
|
||||
expect(result.runtime).toBe("claude-code");
|
||||
});
|
||||
|
||||
@@ -184,6 +190,7 @@ describe("checkDeploySecrets", () => {
|
||||
);
|
||||
// Grouped providers preserved for the picker.
|
||||
expect(result.providers).toHaveLength(3);
|
||||
expect(result.optionalKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats has_value=false as not-configured", async () => {
|
||||
@@ -207,6 +214,22 @@ describe("checkDeploySecrets", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts optional-only env without treating it as missing", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
|
||||
const result = await checkDeploySecrets(OPTIONAL_ONLY);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(result.optionalKeys).toEqual([
|
||||
"GOOGLE_GSC_SITE",
|
||||
"GOOGLE_GA4_PROPERTY_ID",
|
||||
]);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -244,6 +267,7 @@ describe("checkDeploySecrets", () => {
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
|
||||
expect(result.optionalKeys).toEqual([]);
|
||||
// Empty Set on fetch failure — useTemplateDeploy relies on this
|
||||
// so the picker still opens with every entry rendered as input.
|
||||
expect(result.configuredKeys).toEqual(new Set());
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface TemplateLike {
|
||||
models?: ModelSpec[];
|
||||
/** AND-required env vars declared at runtime_config level. */
|
||||
required_env?: string[];
|
||||
/** Optional env vars declared at runtime_config level. */
|
||||
recommended_env?: string[];
|
||||
}
|
||||
|
||||
/** Full /templates response shape shared by TemplatePalette (sidebar)
|
||||
@@ -95,6 +97,8 @@ export interface PreflightResult {
|
||||
/** 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[];
|
||||
/** Optional env vars to offer in the modal without blocking deploy. */
|
||||
optionalKeys: 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). */
|
||||
@@ -247,12 +251,14 @@ export async function checkDeploySecrets(
|
||||
): Promise<PreflightResult> {
|
||||
const providers = providersFromTemplate(template);
|
||||
const runtime = template.runtime;
|
||||
const optionalKeys = Array.from(new Set(template.recommended_env ?? []));
|
||||
|
||||
if (providers.length === 0) {
|
||||
if (providers.length === 0 && optionalKeys.length === 0) {
|
||||
// Template declares no env requirements — nothing to preflight.
|
||||
return {
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
optionalKeys: [],
|
||||
providers: [],
|
||||
runtime,
|
||||
configuredKeys: new Set(),
|
||||
@@ -274,10 +280,11 @@ export async function checkDeploySecrets(
|
||||
configured = new Set();
|
||||
}
|
||||
|
||||
if (findSatisfiedProvider(providers, configured)) {
|
||||
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
|
||||
return {
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
optionalKeys,
|
||||
providers,
|
||||
runtime,
|
||||
configuredKeys: configured,
|
||||
@@ -292,6 +299,7 @@ export async function checkDeploySecrets(
|
||||
return {
|
||||
ok: false,
|
||||
missingKeys,
|
||||
optionalKeys,
|
||||
providers,
|
||||
runtime,
|
||||
configuredKeys: configured,
|
||||
|
||||
@@ -116,6 +116,10 @@ type templateSummary struct {
|
||||
// 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"`
|
||||
// RecommendedEnv mirrors runtime_config.recommended_env from the
|
||||
// template's config.yaml. Canvas prompts for these as non-blocking
|
||||
// optional secrets during template deploy.
|
||||
RecommendedEnv []string `json:"recommended_env,omitempty"`
|
||||
// Providers is the runtime's own list of supported provider slugs,
|
||||
// sourced from runtime_config.providers in the template's config.yaml.
|
||||
// The canvas Config tab surfaces this as the Provider override
|
||||
@@ -188,6 +192,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
RequiredEnv []string `yaml:"required_env"`
|
||||
RecommendedEnv []string `yaml:"recommended_env"`
|
||||
Providers []string `yaml:"providers"`
|
||||
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
|
||||
} `yaml:"runtime_config"`
|
||||
@@ -229,6 +234,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
|
||||
RecommendedEnv: raw.RuntimeConfig.RecommendedEnv,
|
||||
Providers: raw.RuntimeConfig.Providers,
|
||||
ProviderRegistry: raw.Providers,
|
||||
Skills: raw.Skills,
|
||||
|
||||
@@ -148,6 +148,7 @@ tier: 2
|
||||
runtime: hermes
|
||||
runtime_config:
|
||||
model: nous-hermes-3-70b
|
||||
recommended_env: [GOOGLE_GSC_SITE, GOOGLE_GA4_PROPERTY_ID]
|
||||
models:
|
||||
- id: nous-hermes-3-70b
|
||||
name: Nous Hermes 3 70B
|
||||
@@ -199,6 +200,11 @@ skills: []
|
||||
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
|
||||
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
|
||||
}
|
||||
if len(got.RecommendedEnv) != 2 ||
|
||||
got.RecommendedEnv[0] != "GOOGLE_GSC_SITE" ||
|
||||
got.RecommendedEnv[1] != "GOOGLE_GA4_PROPERTY_ID" {
|
||||
t.Errorf("RecommendedEnv: want [GOOGLE_GSC_SITE GOOGLE_GA4_PROPERTY_ID], got %+v", got.RecommendedEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplatesList_SurfacesProviders pins the Option B PR-5 wiring:
|
||||
|
||||
Reference in New Issue
Block a user