forked from molecule-ai/molecule-core
feat(canvas+org): env preflight, EmptyState parity, shared useTemplateDeploy hook
Builds on #2061. Three internally-cohesive sub-features; easiest to read in order. ## 1. Org-level env preflight Server - `OrgTemplate` + `OrgWorkspace` gain `required_env: string[]` and `recommended_env: string[]` YAML fields. - `GET /org/templates` walks the tree and returns the tree-union (deduped, sorted) of both. `collectOrgEnv` dedup prefers required when the same key is declared at both tiers. - `POST /org/import` preflights against `global_secrets` WHERE `octet_length(encrypted_value) > 0` (empty-value rows used to be counted as "configured" and the per-container preflight still failed at start time). 412 Precondition Failed + `missing_env` list when required keys are absent. `force=true` bypasses with an audit log line. DB lookup failure now returns 500 (was: silent fall-through that defeated the guard). Env-var NAMES validated against `^[A-Z][A-Z0-9_]{0,127}$` so a malicious template can't ship pathological names into the UI or DB. Canvas - New `OrgImportPreflightModal`: red "Required" section (blocking) and yellow "Recommended" section (non-blocking, import stays enabled, shows live missing-count next to the Import button). - Per-key password input → `PUT /settings/secrets` → strike-through on save. Functional `setDrafts` throughout (no stale-closure clobbers on rapid successive saves). `useEffect` seed keyed on a sorted-join string signature so a parent re-render with a new array identity doesn't clobber typed inputs. - `TemplatePalette.handleImport` branches: zero env declarations → straight to import; any declarations → fetch configured global secret keys, open the modal. Tests (Go): `TestCollectOrgEnv_*` (5) cover union-across-levels, required-wins-over-recommended (including same-struct), dedup, empty, invalid-name rejection. ## 2. EmptyState parity with TemplatePalette The "Deploy your first agent" grid used to call `POST /workspaces` with no preflight while the sidebar palette ran `checkDeploySecrets` + `MissingKeysModal` first. Same template deployed two different ways → first-run users saw containers boot in `failed` state without guidance. Now both surfaces share one preflight + modal handshake. EmptyState's previous `interface Template` dropped `runtime`, `models`, and `required_env` — silently discarding exactly the fields the preflight needs. `Template` now lives in `deploy-preflight.ts` and is imported from there by both surfaces. ## 3. useTemplateDeploy hook With the preflight + modal wiring now duplicated across EmptyState + TemplatePalette + (going forward) any third surface, extracted the pattern into `canvas/src/hooks/useTemplateDeploy.tsx`: const { deploy, deploying, error, modal } = useTemplateDeploy({ canvasCoords: ..., // optional, default random onDeployed: (id) => ..., }); Closes three drift surfaces that the duplication had created: - `resolveRuntime` id→runtime fallback table (moved to `deploy-preflight.ts`). EmptyState had a narrower fallback that would have silently disagreed with the palette on any future id needing a non-identity mapping. - `checkDeploySecrets` call signature. One owner. - `MissingKeysModal` JSX wiring. One owner. Narrow try/catch around `checkDeploySecrets` so a preflight network failure clears `deploying` and surfaces via `setError` instead of stranding the button forever. `modal: ReactNode` (not a `renderModal()` function) — the previous memoization bought nothing since consumers called it inline every render. Named `MissingKeysInfo` interface for the state shape. ## 4. Viewport auto-fit user-pan gate fix During org deploy the canvas was meant to pan+zoom to follow each arriving workspace (`molecule:fit-deploying-org` event → debounced fitView). In practice the fit stayed stuck on wherever the first fit landed. Root cause: React Flow v12 fires `onMoveEnd` with a truthy `event` at the END of a programmatic `fitView` animation. The original "respect-user-pan" gate stamped `userPannedAtRef` in `onMoveEnd`, so our own fit completing looked like a user pan, and every subsequent auto-fit short-circuited for the rest of the deploy. Fix: stop trusting `onMoveEnd` for user-intent detection. Register explicit `wheel` + `pointerdown` listeners on `document` with capture phase and `target.closest('.react-flow__pane')` filter. Capture-phase immunity to `stopPropagation`; pane-filter rejects toolbar / modal / side-panel clicks (the old `window` fallback caught those). `onMoveEnd` simplified to only drive the debounced viewport save. Also: fit event dispatched on root arrivals (not just children), so the canvas centers on the just-landed root immediately instead of waiting ~2s for the first child. Animation 600ms → 400ms so successive per-arrival fits don't pile up visually. End-state fit stays at 1200ms — intentional asymmetry ("settling" vs "tracking"), documented in code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a34121d451
commit
5adc8a74d5
@ -1,27 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { OrgTemplatesSection } from "./TemplatePalette";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TIER_CONFIG } from "@/lib/design-tokens";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tier: number;
|
||||
model: string;
|
||||
skills: string[];
|
||||
skill_count: number;
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deploying, setDeploying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blankCreating, setBlankCreating] = useState(false);
|
||||
const [blankError, setBlankError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@ -31,48 +23,56 @@ export function EmptyState() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const deploy = async (template: Template) => {
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: template.name,
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
canvas: { x: 200, y: 150 },
|
||||
});
|
||||
// Auto-select the new workspace and open chat
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(ws.id);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Deploy failed");
|
||||
} finally {
|
||||
setDeploying(null);
|
||||
}
|
||||
};
|
||||
// Canvas fills in a visible "center-ish" spot on a fresh tenant so
|
||||
// the user doesn't have to pan to find their new workspace. Fixed
|
||||
// (200, 150) instead of the sidebar's random placement because the
|
||||
// canvas is guaranteed empty when this component mounts.
|
||||
const firstDeployCoords = useCallback(() => ({ x: 200, y: 150 }), []);
|
||||
|
||||
// After the POST succeeds, auto-select the new workspace and flip
|
||||
// the panel to Chat. This is a UX flourish that only makes sense
|
||||
// on first deploy (the canvas is empty so the selection can't
|
||||
// surprise anyone); the sidebar intentionally skips this step.
|
||||
// 500 ms delay so React Flow has a frame to render the new node
|
||||
// before it receives focus.
|
||||
const handleDeployed = useCallback((workspaceId: string) => {
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(workspaceId);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const { deploy, deploying, error, modal } = useTemplateDeploy({
|
||||
canvasCoords: firstDeployCoords,
|
||||
onDeployed: handleDeployed,
|
||||
});
|
||||
|
||||
// "Create blank" bypasses templates entirely — no preflight, no
|
||||
// modal, just POST /workspaces with a default name and tier.
|
||||
// Deliberately NOT routed through useTemplateDeploy because it
|
||||
// has no `template.id` to deploy against.
|
||||
const createBlank = async () => {
|
||||
setDeploying("blank");
|
||||
setError(null);
|
||||
setBlankCreating(true);
|
||||
setBlankError(null);
|
||||
try {
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: "My First Agent",
|
||||
tier: 2,
|
||||
canvas: { x: 200, y: 150 },
|
||||
canvas: firstDeployCoords(),
|
||||
});
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(ws.id);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
handleDeployed(ws.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Create failed");
|
||||
setBlankError(e instanceof Error ? e.message : "Create failed");
|
||||
} finally {
|
||||
setDeploying(null);
|
||||
setBlankCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Any active gesture locks every button so the user can't fire a
|
||||
// second POST while the first is still in flight.
|
||||
const anyDeploying = !!deploying || blankCreating;
|
||||
const displayError = error ?? blankError;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-[1] overflow-y-auto py-8">
|
||||
<div className="relative max-w-2xl w-full rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
|
||||
@ -112,8 +112,8 @@ export function EmptyState() {
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
onClick={() => void deploy(t)}
|
||||
disabled={anyDeploying}
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@ -143,10 +143,10 @@ export function EmptyState() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={createBlank}
|
||||
disabled={!!deploying}
|
||||
disabled={anyDeploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
{deploying === "blank" ? "Creating..." : "+ Create blank workspace"}
|
||||
{blankCreating ? "Creating..." : "+ Create blank workspace"}
|
||||
</button>
|
||||
|
||||
{/* Org templates — instantiate a whole team in one click */}
|
||||
@ -154,12 +154,17 @@ export function EmptyState() {
|
||||
<OrgTemplatesSection />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{displayError && (
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing-keys preflight modal — owned by useTemplateDeploy,
|
||||
shared with TemplatePalette. Rendered inline here so it
|
||||
overlays this card naturally. */}
|
||||
{modal}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-5 pt-4 border-t border-zinc-800/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-400">
|
||||
|
||||
329
canvas/src/components/OrgImportPreflightModal.tsx
Normal file
329
canvas/src/components/OrgImportPreflightModal.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createSecret } from "@/lib/api/secrets";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Display name of the org template — headline only. */
|
||||
orgName: string;
|
||||
/** Total workspace count so the header can read "12 workspaces". */
|
||||
workspaceCount: number;
|
||||
/** Env vars the server has declared MUST be set as global secrets.
|
||||
* Import is disabled until every entry here is configured. */
|
||||
requiredEnv: string[];
|
||||
/** Env vars the server suggests — import can proceed without them,
|
||||
* but the user sees them listed so they can decide. */
|
||||
recommendedEnv: string[];
|
||||
/** Names of env vars already configured globally. Used to strike
|
||||
* through entries the user has already set up in another
|
||||
* session. Passed in rather than queried inside the modal so the
|
||||
* parent can refresh after each save without prop-driven effects. */
|
||||
configuredKeys: Set<string>;
|
||||
/** Called after a successful secret save so the parent can refresh
|
||||
* `configuredKeys`. */
|
||||
onSecretSaved: () => void;
|
||||
/** User clicked Import with all required envs satisfied. */
|
||||
onProceed: () => void;
|
||||
/** User dismissed the modal. Import is NOT fired. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OrgImportPreflightModal
|
||||
* -----------------------
|
||||
* Two-tier env preflight before POST /org/import:
|
||||
*
|
||||
* - REQUIRED section (red, blocking) — every entry MUST be configured
|
||||
* globally before the Import button enables. Matches the server-
|
||||
* side preflight that would 412 the import anyway.
|
||||
*
|
||||
* - RECOMMENDED section (yellow, non-blocking) — listed so the user
|
||||
* can add them if they want the full experience, but the Import
|
||||
* button stays enabled regardless.
|
||||
*
|
||||
* Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets)
|
||||
* because org-level templates deploy shared resources. Per-workspace
|
||||
* overrides still work via the Config tab on an individual node
|
||||
* after import. The modal does NOT enable Import the moment a key is
|
||||
* typed — only after it saves successfully (so a half-entered token
|
||||
* can't proceed and then fail at container-start time instead).
|
||||
*/
|
||||
export function OrgImportPreflightModal({
|
||||
open,
|
||||
orgName,
|
||||
workspaceCount,
|
||||
requiredEnv,
|
||||
recommendedEnv,
|
||||
configuredKeys,
|
||||
onSecretSaved,
|
||||
onProceed,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
|
||||
|
||||
// Seed a draft entry per declared key the first time the modal
|
||||
// opens. Entries persist across `configuredKeys` changes so a mid-
|
||||
// save recheck doesn't wipe what the user typed.
|
||||
//
|
||||
// Dep: dervie a STABLE string from the env-name lists rather than
|
||||
// the array refs themselves. The parent computes
|
||||
// `preflight.org.required_env ?? []`, which produces a fresh []
|
||||
// identity on every re-render (e.g. when refreshConfiguredKeys
|
||||
// bumps state); depending on the array refs would re-fire the
|
||||
// effect on every parent render and mask any future edit that
|
||||
// drops the `if (!next[k])` guard as a silent input-reset bug.
|
||||
const envKeysSignature = useMemo(
|
||||
() => [...requiredEnv, ...recommendedEnv].sort().join("|"),
|
||||
[requiredEnv, recommendedEnv],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const k of [...requiredEnv, ...recommendedEnv]) {
|
||||
if (!next[k]) {
|
||||
next[k] = { key: k, value: "", saving: false, error: null };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, envKeysSignature]);
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => requiredEnv.filter((k) => !configuredKeys.has(k)),
|
||||
[requiredEnv, configuredKeys],
|
||||
);
|
||||
const missingRecommended = useMemo(
|
||||
() => recommendedEnv.filter((k) => !configuredKeys.has(k)),
|
||||
[recommendedEnv, configuredKeys],
|
||||
);
|
||||
const canProceed = missingRequired.length === 0;
|
||||
|
||||
const saveOne = useCallback(
|
||||
async (key: string) => {
|
||||
// Functional setter throughout so two near-simultaneous saves
|
||||
// don't have the second one's call see a stale snapshot captured
|
||||
// before the first save's setState landed. Read the current
|
||||
// value AND write the `saving` flag in a single transition
|
||||
// rather than reading from closure-scoped `drafts`.
|
||||
let startValue = "";
|
||||
setDrafts((d) => {
|
||||
const current = d[key];
|
||||
if (!current || !current.value.trim()) return d;
|
||||
startValue = current.value;
|
||||
return { ...d, [key]: { ...current, saving: true, error: null } };
|
||||
});
|
||||
if (!startValue.trim()) return;
|
||||
try {
|
||||
await createSecret("global", key, startValue);
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[key]: { ...d[key], value: "", saving: false, error: null },
|
||||
}));
|
||||
// Let the parent refresh configuredKeys so the strike-through
|
||||
// updates and canProceed recomputes.
|
||||
onSecretSaved();
|
||||
} catch (e) {
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[key]: {
|
||||
...d[key],
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Save failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[onSecretSaved],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="org-preflight-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[85vh] overflow-auto rounded-xl bg-zinc-900 border border-zinc-700 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 py-4 border-b border-zinc-800">
|
||||
<h2 id="org-preflight-title" className="text-sm font-semibold text-zinc-100">
|
||||
Deploy {orgName}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-zinc-500">
|
||||
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
||||
Review the credentials needed before import.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="p-5 space-y-5">
|
||||
{requiredEnv.length > 0 && (
|
||||
<EnvList
|
||||
tone="required"
|
||||
title="Required"
|
||||
subtitle="Import is blocked until every key below is saved globally."
|
||||
entries={requiredEnv}
|
||||
configuredKeys={configuredKeys}
|
||||
drafts={drafts}
|
||||
onChange={(key, value) =>
|
||||
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
||||
}
|
||||
onSave={saveOne}
|
||||
/>
|
||||
)}
|
||||
{recommendedEnv.length > 0 && (
|
||||
<EnvList
|
||||
tone="recommended"
|
||||
title="Recommended"
|
||||
subtitle="Not required, but some features degrade without them. Add them now for the best experience."
|
||||
entries={recommendedEnv}
|
||||
configuredKeys={configuredKeys}
|
||||
drafts={drafts}
|
||||
onChange={(key, value) =>
|
||||
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
||||
}
|
||||
onSave={saveOne}
|
||||
/>
|
||||
)}
|
||||
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
|
||||
<p className="text-[12px] text-zinc-400">
|
||||
No additional credentials required for this template.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-[11px] rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{missingRecommended.length > 0 && canProceed && (
|
||||
<span className="text-[10px] text-amber-400/90">
|
||||
{missingRecommended.length} recommended key
|
||||
{missingRecommended.length === 1 ? "" : "s"} still unset
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-blue-600 hover:bg-blue-500 text-white disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnvListProps {
|
||||
tone: "required" | "recommended";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
entries: string[];
|
||||
configuredKeys: Set<string>;
|
||||
drafts: Record<string, DraftEntry>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSave: (key: string) => void;
|
||||
}
|
||||
|
||||
function EnvList({
|
||||
tone,
|
||||
title,
|
||||
subtitle,
|
||||
entries,
|
||||
configuredKeys,
|
||||
drafts,
|
||||
onChange,
|
||||
onSave,
|
||||
}: EnvListProps) {
|
||||
const accent =
|
||||
tone === "required"
|
||||
? "border-red-800/60 bg-red-950/20"
|
||||
: "border-amber-800/50 bg-amber-950/15";
|
||||
const headerColor =
|
||||
tone === "required" ? "text-red-300" : "text-amber-300";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${accent} p-3`}>
|
||||
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-0.5 mb-2 text-[10px] text-zinc-400">{subtitle}</p>
|
||||
<ul className="space-y-2">
|
||||
{entries.map((k) => {
|
||||
const configured = configuredKeys.has(k);
|
||||
const d = drafts[k];
|
||||
return (
|
||||
<li
|
||||
key={k}
|
||||
className="flex items-center gap-2 rounded bg-zinc-900/70 border border-zinc-800 px-2 py-1.5"
|
||||
>
|
||||
<code
|
||||
className={`text-[11px] font-mono flex-1 ${
|
||||
configured ? "text-zinc-500 line-through" : "text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{k}
|
||||
</code>
|
||||
{configured ? (
|
||||
<span className="text-[10px] text-emerald-400">✓ set</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={`Value for ${k}`}
|
||||
placeholder="paste value"
|
||||
value={d?.value ?? ""}
|
||||
onChange={(e) => onChange(k, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSave(k);
|
||||
}
|
||||
}}
|
||||
disabled={d?.saving}
|
||||
className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(k)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{d?.error && (
|
||||
<span className="text-[9px] text-red-400 basis-full pl-1">
|
||||
{d.error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,32 +4,36 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { WorkspaceData } from "@/store/socket";
|
||||
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
|
||||
import { MissingKeysModal } from "./MissingKeysModal";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
||||
import { OrgImportPreflightModal } from "./OrgImportPreflightModal";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { showToast } from "./Toaster";
|
||||
import { TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { listSecrets } from "@/lib/api/secrets";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
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;
|
||||
}
|
||||
|
||||
// `Template` type and `resolveRuntime` helper now live in
|
||||
// `@/lib/deploy-preflight` so EmptyState can import the same ones. Was
|
||||
// redeclared here + a narrower redeclaration in EmptyState; the
|
||||
// narrower one dropped `runtime`, `models`, `required_env`, which is
|
||||
// exactly the data the preflight needs. See reviewer's "runtime
|
||||
// fallback drift" note — single source of truth closes the drift.
|
||||
export interface OrgTemplate {
|
||||
dir: string;
|
||||
name: string;
|
||||
description: string;
|
||||
workspaces: number;
|
||||
/** Env vars that MUST be set as global secrets before the org can
|
||||
* import. Server refuses the import with 412 if any are missing;
|
||||
* the canvas preflights against /secrets/list to avoid the round
|
||||
* trip. Aggregated from org-level + every workspace in the tree. */
|
||||
required_env?: string[];
|
||||
/** "Nice-to-have" tier. Import proceeds without them but features
|
||||
* may degrade — a channel's webhook posts get dropped, a fallback
|
||||
* LLM isn't available, etc. Surfaced to the user as a non-blocking
|
||||
* warning with an "add now" affordance. */
|
||||
recommended_env?: string[];
|
||||
}
|
||||
|
||||
/** Fetch the list of org templates from the platform. Returns [] on error
|
||||
@ -91,6 +95,14 @@ export function OrgTemplatesSection() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Preflight modal state. `preflight` is non-null when the user
|
||||
// clicked Import on an org with declared required/recommended envs
|
||||
// and we're waiting for them to confirm; null otherwise (direct
|
||||
// import path for orgs with zero env requirements).
|
||||
const [preflight, setPreflight] = useState<{
|
||||
org: OrgTemplate;
|
||||
configuredKeys: Set<string>;
|
||||
} | null>(null);
|
||||
// Collapsed by default — org templates are multi-workspace imports
|
||||
// that most new users don't reach for first. Keeping them
|
||||
// expand-on-demand frees ~400 px of vertical space for the
|
||||
@ -109,7 +121,25 @@ export function OrgTemplatesSection() {
|
||||
loadOrgs();
|
||||
}, [loadOrgs]);
|
||||
|
||||
const handleImport = async (org: OrgTemplate) => {
|
||||
/** Fetch the set of global secret KEYS that are already configured.
|
||||
* Used to strike through already-set entries in the preflight modal
|
||||
* and to decide whether the import needs the modal at all. */
|
||||
const loadConfiguredKeys = useCallback(async (): Promise<Set<string>> => {
|
||||
try {
|
||||
const secrets = await listSecrets("global");
|
||||
return new Set(secrets.map((s) => s.name));
|
||||
} catch {
|
||||
// Secrets endpoint unreachable → assume nothing configured.
|
||||
// The server will refuse the import with 412 and the user
|
||||
// retries; safer than letting the import fly blind.
|
||||
return new Set();
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Actually run the import. Split out so both the "no preflight
|
||||
* needed" fast path and the "preflight modal approved" path can
|
||||
* share the fetch + hydrate + toast sequence. */
|
||||
const doImport = useCallback(async (org: OrgTemplate) => {
|
||||
setImporting(org.dir);
|
||||
setError(null);
|
||||
try {
|
||||
@ -149,7 +179,45 @@ export function OrgTemplatesSection() {
|
||||
} finally {
|
||||
setImporting(null);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Entry point for the Import button. Two paths:
|
||||
*
|
||||
* 1. No env declared by the template (required_env + recommended_env
|
||||
* both empty) → fire doImport directly. Matches the pre-preflight
|
||||
* behaviour for existing templates.
|
||||
*
|
||||
* 2. Any env declared → load the configured-keys set and open the
|
||||
* preflight modal. doImport runs only when the user clicks
|
||||
* Import inside the modal, which is gated to "required envs all
|
||||
* configured" by the modal itself. */
|
||||
const handleImport = useCallback(async (org: OrgTemplate) => {
|
||||
const hasEnvDeclarations =
|
||||
(org.required_env && org.required_env.length > 0) ||
|
||||
(org.recommended_env && org.recommended_env.length > 0);
|
||||
if (!hasEnvDeclarations) {
|
||||
void doImport(org);
|
||||
return;
|
||||
}
|
||||
// Flip the button to its "Importing…" state while the secrets
|
||||
// lookup runs — on a tenant with 500+ global secrets the round
|
||||
// trip can be > 200 ms and the user otherwise gets zero visual
|
||||
// feedback after clicking. Cleared on modal close / error.
|
||||
setImporting(org.dir);
|
||||
try {
|
||||
const configuredKeys = await loadConfiguredKeys();
|
||||
setPreflight({ org, configuredKeys });
|
||||
} finally {
|
||||
setImporting(null);
|
||||
}
|
||||
}, [doImport, loadConfiguredKeys]);
|
||||
|
||||
/** Called by the preflight modal after a successful key save so the
|
||||
* strike-through re-renders and canProceed recomputes. */
|
||||
const refreshConfiguredKeys = useCallback(async () => {
|
||||
const keys = await loadConfiguredKeys();
|
||||
setPreflight((prev) => (prev ? { ...prev, configuredKeys: keys } : prev));
|
||||
}, [loadConfiguredKeys]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="org-templates-section">
|
||||
@ -238,6 +306,24 @@ export function OrgTemplatesSection() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflight && (
|
||||
<OrgImportPreflightModal
|
||||
open
|
||||
orgName={preflight.org.name || preflight.org.dir}
|
||||
workspaceCount={preflight.org.workspaces}
|
||||
requiredEnv={preflight.org.required_env ?? []}
|
||||
recommendedEnv={preflight.org.recommended_env ?? []}
|
||||
configuredKeys={preflight.configuredKeys}
|
||||
onSecretSaved={refreshConfiguredKeys}
|
||||
onProceed={() => {
|
||||
const org = preflight.org;
|
||||
setPreflight(null);
|
||||
void doImport(org);
|
||||
}}
|
||||
onCancel={() => setPreflight(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -335,14 +421,6 @@ export function TemplatePalette() {
|
||||
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Missing keys modal state
|
||||
const [missingKeysInfo, setMissingKeysInfo] = useState<{
|
||||
template: Template;
|
||||
preflight: PreflightResult;
|
||||
} | null>(null);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -360,65 +438,15 @@ export function TemplatePalette() {
|
||||
if (open) loadTemplates();
|
||||
}, [open, loadTemplates]);
|
||||
|
||||
/** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */
|
||||
const resolveRuntime = (templateId: string): string => {
|
||||
const runtimeMap: Record<string, string> = {
|
||||
langgraph: "langgraph",
|
||||
"claude-code-default": "claude-code",
|
||||
openclaw: "openclaw",
|
||||
deepagents: "deepagents",
|
||||
crewai: "crewai",
|
||||
autogen: "autogen",
|
||||
};
|
||||
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
|
||||
};
|
||||
|
||||
/** Actually execute the deploy API call */
|
||||
const executeDeploy = useCallback(async (template: Template) => {
|
||||
setCreating(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post("/workspaces", {
|
||||
name: template.name,
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
canvas: {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
},
|
||||
});
|
||||
setCreating(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to deploy");
|
||||
setCreating(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Pre-deploy check: validate secrets before deploying */
|
||||
const handleDeploy = async (template: Template) => {
|
||||
setCreating(template.id);
|
||||
setError(null);
|
||||
|
||||
// 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
|
||||
setMissingKeysInfo({ template, preflight });
|
||||
setCreating(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// All keys present — deploy directly
|
||||
await executeDeploy(template);
|
||||
};
|
||||
// Preflight + POST + modal wiring moved into useTemplateDeploy so
|
||||
// this component and EmptyState use one implementation. The sidebar
|
||||
// uses the hook's default random canvas placement (no override) —
|
||||
// an already-populated canvas shouldn't have new deploys stacking on
|
||||
// a single fixed point. No post-deploy side effect either: the
|
||||
// palette is operator-triggered, so auto-selecting would yank
|
||||
// focus off whatever the user was already looking at.
|
||||
const { deploy: handleDeploy, deploying: creating, error, modal } =
|
||||
useTemplateDeploy();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -442,21 +470,9 @@ export function TemplatePalette() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Missing Keys Modal */}
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
providers={missingKeysInfo?.preflight.providers ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
onKeysAdded={() => {
|
||||
if (missingKeysInfo) {
|
||||
const template = missingKeysInfo.template;
|
||||
setMissingKeysInfo(null);
|
||||
executeDeploy(template);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMissingKeysInfo(null)}
|
||||
/>
|
||||
{/* Missing-keys modal — rendered by the shared hook. Same
|
||||
instance shape used by EmptyState. */}
|
||||
{modal}
|
||||
|
||||
{/* Sidebar */}
|
||||
{open && (
|
||||
@ -499,7 +515,7 @@ export function TemplatePalette() {
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => handleDeploy(t)}
|
||||
onClick={() => void handleDeploy(t)}
|
||||
disabled={isDeploying}
|
||||
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800/40 disabled:hover:border-zinc-700/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
|
||||
@ -31,11 +31,16 @@ export function useCanvasViewport() {
|
||||
// render so we can detect the boundary when the last one finishes
|
||||
// and auto-fit the viewport around the whole tree.
|
||||
const hadProvisioningRef = useRef(false);
|
||||
// Respect-user-pan gate for the deploy-time auto-fit: whenever the
|
||||
// user moves the canvas (onMoveEnd stamps userPannedAtRef), we
|
||||
// compare against the last auto-fit timestamp; if the user moved
|
||||
// AFTER the last auto-fit, the auto-fit handler bails out for the
|
||||
// rest of this deploy cycle.
|
||||
// Respect-user-pan gate for the deploy-time auto-fit. Earlier
|
||||
// revisions tried to detect user pans via `onMoveEnd`, but React
|
||||
// Flow v12 fires that callback with a truthy event at the END of
|
||||
// a programmatic fitView animation — so the first auto-fit we
|
||||
// triggered would immediately look like a user pan and block
|
||||
// every subsequent fit for the rest of the deploy, leaving the
|
||||
// viewport stuck wherever the first fit landed. Now we stamp
|
||||
// this ref ONLY on wheel / pointerdown / touchstart on the
|
||||
// React Flow pane itself (see the effect below), which are
|
||||
// unambiguous user-gesture signals.
|
||||
const userPannedAtRef = useRef<number | null>(null);
|
||||
const lastAutoFitAtRef = useRef(0);
|
||||
|
||||
@ -47,6 +52,35 @@ export function useCanvasViewport() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// User-gesture listeners for the respect-user-pan gate. Listens on
|
||||
// `document` with capture phase and filters to events whose target
|
||||
// lies inside the React Flow pane — this avoids a mount-order race
|
||||
// (`.react-flow__pane` may not exist when the hook first runs if
|
||||
// RF is behind a Suspense boundary) AND keeps clicks on the
|
||||
// toolbar / modals / side panel from stamping user-pan-intent.
|
||||
// Capture phase runs before target-phase `stopPropagation` so a
|
||||
// handler elsewhere can't swallow the signal. `pointerdown` covers
|
||||
// mouse + touch + pen on every modern browser — no separate
|
||||
// `touchstart` listener needed (would double-stamp on mobile).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const stamp = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target?.closest?.(".react-flow__pane")) return;
|
||||
userPannedAtRef.current = Date.now();
|
||||
};
|
||||
const opts: AddEventListenerOptions = { passive: true, capture: true };
|
||||
const targets: Array<keyof DocumentEventMap> = ["wheel", "pointerdown"];
|
||||
for (const ev of targets) {
|
||||
document.addEventListener(ev, stamp, opts);
|
||||
}
|
||||
return () => {
|
||||
for (const ev of targets) {
|
||||
document.removeEventListener(ev, stamp, opts);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-fit the viewport once all workspaces finish provisioning. Org
|
||||
// imports land dozens of new nodes off-screen; without a follow-up
|
||||
// fit, the user has to manually pan + zoom to find what they just
|
||||
@ -105,6 +139,11 @@ export function useCanvasViewport() {
|
||||
// fitView zooms to that smaller-than-real rectangle.
|
||||
autoFitTimerRef.current = setTimeout(() => {
|
||||
fitView({
|
||||
// Deliberately SLOWER than the in-flight tracking fits
|
||||
// (400ms). The asymmetry reads as "settling" on the
|
||||
// finished org rather than "tracking" another arrival,
|
||||
// which is the intended UX for the "deploy done" moment.
|
||||
// Don't normalize these two durations to the same value.
|
||||
duration: 1200,
|
||||
// Match the deploy-time fit padding (0.45) so end-state
|
||||
// and in-flight state use the same framing — otherwise
|
||||
@ -184,7 +223,11 @@ export function useCanvasViewport() {
|
||||
if (subtree.length === 0) return;
|
||||
fitView({
|
||||
nodes: subtree.map((id) => ({ id })),
|
||||
duration: 600,
|
||||
// Short animation — server paces children ~2s apart, so a
|
||||
// 400ms fit animation reads as "smoothly tracked" rather
|
||||
// than "constantly lurching". Longer durations (the earlier
|
||||
// 600ms) start to overlap if the user re-triggers deploys.
|
||||
duration: 400,
|
||||
// Generous padding so the right-hand Communications panel,
|
||||
// bottom-left Legend, and bottom-right "New Workspace"
|
||||
// button don't cover the outer cards. React Flow padding
|
||||
@ -204,9 +247,12 @@ export function useCanvasViewport() {
|
||||
};
|
||||
const handler = (e: Event) => {
|
||||
const { rootId } = (e as CustomEvent<{ rootId: string }>).detail;
|
||||
// Keep the most recently-requested root — if the user triggers
|
||||
// imports on two different orgs back-to-back, the later one
|
||||
// wins the viewport, which matches user intent.
|
||||
// Keep the most recently-requested root. Back-to-back imports
|
||||
// on two different orgs (rare — user would have to click
|
||||
// Import twice within 500ms) "later wins" the viewport rather
|
||||
// than ping-ponging between them. If this becomes a real
|
||||
// pattern we'd flush the pending fit synchronously when
|
||||
// `rootId` changes, rather than resetting the timer.
|
||||
pendingFitRootRef.current = rootId;
|
||||
clearTimeout(autoFitTimerRef.current);
|
||||
autoFitTimerRef.current = setTimeout(runFit, 500);
|
||||
@ -251,16 +297,12 @@ export function useCanvasViewport() {
|
||||
}, [fitBounds]);
|
||||
|
||||
const onMoveEnd = useCallback(
|
||||
(event: unknown, vp: { x: number; y: number; zoom: number }) => {
|
||||
// Stamp user-pan timestamp only when the move was actually
|
||||
// initiated by the user (mouse / trackpad / keyboard). React
|
||||
// Flow also fires onMoveEnd for programmatic fitView() calls
|
||||
// — `event` is null in that case, which would otherwise
|
||||
// defeat the respect-user-pan gate by making every auto-fit
|
||||
// look like a user move.
|
||||
if (event !== null) {
|
||||
userPannedAtRef.current = Date.now();
|
||||
}
|
||||
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
|
||||
// User-pan detection moved to the wheel/pointerdown listener
|
||||
// above — onMoveEnd fires for programmatic fitView too, which
|
||||
// made this callback an unreliable source for user-intent
|
||||
// tracking. This now only handles the debounced viewport
|
||||
// save so a reload lands the user back where they were.
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveViewport(vp.x, vp.y, vp.zoom);
|
||||
|
||||
170
canvas/src/hooks/useTemplateDeploy.tsx
Normal file
170
canvas/src/hooks/useTemplateDeploy.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
checkDeploySecrets,
|
||||
resolveRuntime,
|
||||
type PreflightResult,
|
||||
type Template,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import { MissingKeysModal } from "@/components/MissingKeysModal";
|
||||
|
||||
/**
|
||||
* useTemplateDeploy — shared preflight + POST + modal wiring for
|
||||
* every surface that deploys a workspace from a template.
|
||||
*
|
||||
* Owns: `checkDeploySecrets` call, `MissingKeysModal` render, the
|
||||
* `POST /workspaces` that follows, and per-template `deploying`
|
||||
* state. Returns `modal` as a `ReactNode` ready to place inline.
|
||||
*
|
||||
* Why a hook rather than two copies: the runtime-fallback table
|
||||
* (`resolveRuntime`) and the preflight wiring were previously
|
||||
* copy-pasted between TemplatePalette and EmptyState. When the
|
||||
* copies drifted (palette had the full id-to-runtime map,
|
||||
* empty-state had only the `-default` strip), the two surfaces
|
||||
* could silently disagree on future templates that need a
|
||||
* non-identity mapping. Single owner closes the drift surface.
|
||||
*/
|
||||
export interface UseTemplateDeployOptions {
|
||||
/** Compute canvas coords for the new workspace. Called once per
|
||||
* successful deploy. Defaults to random coords in the [100, 500] ×
|
||||
* [100, 400] band, matching the sidebar palette's historical
|
||||
* placement. Override for surfaces that want deterministic
|
||||
* placement (e.g. EmptyState's first-deploy "center-ish" target). */
|
||||
canvasCoords?: () => { x: number; y: number };
|
||||
|
||||
/** Optional post-deploy side effect — passed the id of the new
|
||||
* workspace. EmptyState uses this to auto-select the node and
|
||||
* flip the side panel to Chat so a fresh tenant sees something
|
||||
* useful. */
|
||||
onDeployed?: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
/** Paired template + preflight result carried through the "user
|
||||
* clicked deploy → modal opens → keys saved → retry" loop. Named
|
||||
* so the `useState` generic and any future signature change have
|
||||
* a single place to track. */
|
||||
interface MissingKeysInfo {
|
||||
template: Template;
|
||||
preflight: PreflightResult;
|
||||
}
|
||||
|
||||
export interface UseTemplateDeployResult {
|
||||
/** Template id currently being deployed (incl. the preflight
|
||||
* network call), or null when idle. Callers pass this to disable
|
||||
* the relevant button and show a spinner. */
|
||||
deploying: string | null;
|
||||
|
||||
/** Last deploy error message, or null. Cleared on next `deploy`
|
||||
* call. */
|
||||
error: string | null;
|
||||
|
||||
/** Kick off a deploy. Opens the missing-keys modal if preflight
|
||||
* returns not-ok; otherwise fires POST /workspaces directly. */
|
||||
deploy: (template: Template) => Promise<void>;
|
||||
|
||||
/** The missing-keys modal, ready to place inline. Always non-null
|
||||
* (the underlying component self-gates on `open`), so the caller
|
||||
* can drop `{modal}` anywhere without conditionals. */
|
||||
modal: ReactNode;
|
||||
}
|
||||
|
||||
export function useTemplateDeploy(
|
||||
options: UseTemplateDeployOptions = {},
|
||||
): UseTemplateDeployResult {
|
||||
const [deploying, setDeploying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [missingKeysInfo, setMissingKeysInfo] = useState<MissingKeysInfo | null>(null);
|
||||
|
||||
const { canvasCoords, onDeployed } = options;
|
||||
|
||||
/** Actually execute the POST /workspaces call. Split from `deploy`
|
||||
* so the "modal → keys added → retry" path can reuse it without
|
||||
* re-running preflight (the user just proved the keys are now set). */
|
||||
const executeDeploy = useCallback(
|
||||
async (template: Template) => {
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
const coords = canvasCoords
|
||||
? canvasCoords()
|
||||
: {
|
||||
x: Math.random() * 400 + 100,
|
||||
y: Math.random() * 300 + 100,
|
||||
};
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: template.name,
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
canvas: coords,
|
||||
});
|
||||
onDeployed?.(ws.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Deploy failed");
|
||||
} finally {
|
||||
setDeploying(null);
|
||||
}
|
||||
},
|
||||
[canvasCoords, onDeployed],
|
||||
);
|
||||
|
||||
const deploy = useCallback(
|
||||
async (template: Template) => {
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
let preflight: PreflightResult;
|
||||
try {
|
||||
const runtime = template.runtime ?? resolveRuntime(template.id);
|
||||
preflight = await checkDeploySecrets({
|
||||
runtime,
|
||||
models: template.models,
|
||||
required_env: template.required_env,
|
||||
});
|
||||
} catch (e) {
|
||||
// Preflight network failure used to strand `deploying` — the
|
||||
// button stayed disabled forever because the throw bypassed
|
||||
// the setDeploying(null) in the non-ok branch below. Any
|
||||
// future refactor that drops this try block will regress the
|
||||
// same way; keep it narrow around just the preflight call
|
||||
// so a successful preflight still lets executeDeploy own
|
||||
// its own error path.
|
||||
setError(e instanceof Error ? e.message : "Preflight check failed");
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
if (!preflight.ok) {
|
||||
setMissingKeysInfo({ template, preflight });
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
await executeDeploy(template);
|
||||
},
|
||||
[executeDeploy],
|
||||
);
|
||||
|
||||
// No useCallback here — consumers call this on every render anyway
|
||||
// (it's placed inline in JSX), and useCallback's deps would
|
||||
// invalidate on every state change, making the memoisation a wash.
|
||||
// Plain ReactNode is simpler and equally performant.
|
||||
const modal: ReactNode = (
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
providers={missingKeysInfo?.preflight.providers ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
onKeysAdded={() => {
|
||||
if (missingKeysInfo) {
|
||||
const template = missingKeysInfo.template;
|
||||
setMissingKeysInfo(null);
|
||||
// Intentional fire-and-forget — executeDeploy manages
|
||||
// its own error state via setError.
|
||||
void executeDeploy(template);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMissingKeysInfo(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
return { deploying, error, deploy, modal };
|
||||
}
|
||||
@ -33,6 +33,46 @@ export interface TemplateLike {
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
/** Full /templates response shape shared by TemplatePalette (sidebar)
|
||||
* and EmptyState (welcome grid). Was previously re-declared in each
|
||||
* with subtly different fields — EmptyState's narrower shape silently
|
||||
* dropped `runtime`, `models`, and `required_env`, so the preflight
|
||||
* couldn't see provider alternatives the template declared. Keep this
|
||||
* the single source of truth. */
|
||||
export interface Template extends TemplateLike {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tier: number;
|
||||
model: string;
|
||||
skills: string[];
|
||||
skill_count: number;
|
||||
}
|
||||
|
||||
/** Map from a template id to the runtime name the per-workspace
|
||||
* preflight expects. Used only when the server's `/templates`
|
||||
* response predates the `runtime` field on the summary (legacy
|
||||
* installs) — modern responses carry it verbatim. Strip `-default`
|
||||
* for the claude-code template and identity-map everything else
|
||||
* that matches our current runtime registry.
|
||||
*
|
||||
* Lives in the preflight module (not TemplatePalette) so EmptyState
|
||||
* uses the SAME fallback table. A previous duplication in both call
|
||||
* sites left EmptyState with only the `-default` suffix strip, which
|
||||
* would silently disagree with TemplatePalette on templates whose
|
||||
* id needs a non-identity mapping. */
|
||||
export function resolveRuntime(templateId: string): string {
|
||||
const runtimeMap: Record<string, string> = {
|
||||
langgraph: "langgraph",
|
||||
"claude-code-default": "claude-code",
|
||||
openclaw: "openclaw",
|
||||
deepagents: "deepagents",
|
||||
crewai: "crewai",
|
||||
autogen: "autogen",
|
||||
};
|
||||
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
|
||||
}
|
||||
|
||||
export interface SecretEntry {
|
||||
key: string;
|
||||
has_value: boolean;
|
||||
|
||||
@ -288,10 +288,17 @@ export function handleCanvasEvent(
|
||||
// which aborts the fit when the user moved after the last
|
||||
// auto-fit). Event name matches the existing handler in
|
||||
// useCanvasViewport that knows how to compute subtree bounds.
|
||||
if (parentIdRaw && typeof window !== "undefined") {
|
||||
//
|
||||
// Fire for roots too (not just children) so the canvas
|
||||
// centers on the just-landed root immediately instead of
|
||||
// waiting for the first child to arrive ~2s later. The
|
||||
// viewport hook walks UP to find the true root, so passing
|
||||
// the node's own id when there's no parent is equivalent
|
||||
// to passing the root.
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("molecule:fit-deploying-org", {
|
||||
detail: { rootId: parentIdRaw },
|
||||
detail: { rootId: parentIdRaw ?? msg.workspace_id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,6 +189,20 @@ type OrgTemplate struct {
|
||||
// GlobalMemories is a list of org-wide memories seeded as GLOBAL scope
|
||||
// on the first root workspace (PM) during org import. Issue #1050.
|
||||
GlobalMemories []models.MemorySeed `yaml:"global_memories" json:"global_memories"`
|
||||
// RequiredEnv is the union of env vars WHICH MUST be set globally (or
|
||||
// on every workspace in the subtree that needs them) before import
|
||||
// will succeed. Declared at the org level for shared creds, also
|
||||
// extensible per-workspace via OrgWorkspace.RequiredEnv for team-
|
||||
// scoped credentials (e.g. LEGAL_VAULT_TOKEN only matters if the Legal
|
||||
// subtree is part of this template). The canvas preflights both.
|
||||
RequiredEnv []string `yaml:"required_env" json:"required_env"`
|
||||
// RecommendedEnv is the "nice-to-have" tier — import still succeeds
|
||||
// without them, but features degrade. The canvas shows them as a
|
||||
// yellow warning ("add now for best experience") rather than a
|
||||
// blocking red. Example: SLACK_WEBHOOK_URL for a team whose agents
|
||||
// occasionally post status updates, or ANTHROPIC_API_KEY when an
|
||||
// agent might fall back to claude from its primary provider.
|
||||
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
|
||||
}
|
||||
|
||||
type OrgDefaults struct {
|
||||
@ -295,7 +309,16 @@ type OrgWorkspace struct {
|
||||
X float64 `yaml:"x" json:"x"`
|
||||
Y float64 `yaml:"y" json:"y"`
|
||||
} `yaml:"canvas" json:"canvas"`
|
||||
Children []OrgWorkspace `yaml:"children" json:"children"`
|
||||
// RequiredEnv / RecommendedEnv declared at the workspace level
|
||||
// narrow down what a specific team needs beyond the org-wide union.
|
||||
// When GET /org/templates walks the tree, these flow up into
|
||||
// OrgTemplate.RequiredEnv / RecommendedEnv. A workspace's subtree
|
||||
// inherits: a parent declaring ANTHROPIC_API_KEY as required
|
||||
// means every descendant considers it required too (no override
|
||||
// needed at each leaf).
|
||||
RequiredEnv []string `yaml:"required_env" json:"required_env"`
|
||||
RecommendedEnv []string `yaml:"recommended_env" json:"recommended_env"`
|
||||
Children []OrgWorkspace `yaml:"children" json:"children"`
|
||||
}
|
||||
|
||||
// ListTemplates handles GET /org/templates — lists available org templates.
|
||||
@ -354,11 +377,18 @@ func (h *OrgHandler) ListTemplates(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
count := countWorkspaces(tmpl.Workspaces)
|
||||
// Walk the tree to collect required + recommended env union.
|
||||
// Canvas uses these to render a preflight modal BEFORE firing
|
||||
// the import — saves the user from a 15-workspace import that
|
||||
// dies one container at a time on missing creds.
|
||||
required, recommended := collectOrgEnv(&tmpl)
|
||||
templates = append(templates, map[string]interface{}{
|
||||
"dir": e.Name(),
|
||||
"name": tmpl.Name,
|
||||
"description": tmpl.Description,
|
||||
"workspaces": count,
|
||||
"dir": e.Name(),
|
||||
"name": tmpl.Name,
|
||||
"description": tmpl.Description,
|
||||
"workspaces": count,
|
||||
"required_env": required,
|
||||
"recommended_env": recommended,
|
||||
})
|
||||
}
|
||||
|
||||
@ -370,6 +400,13 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
var body struct {
|
||||
Dir string `json:"dir"` // org template directory name
|
||||
Template OrgTemplate `json:"template"` // or inline template
|
||||
// Force skips the required-env preflight. Used by tooling
|
||||
// that already computed the preflight client-side and wants
|
||||
// to proceed despite missing creds (usually because the
|
||||
// user explicitly acknowledged the tradeoff). Default behavior
|
||||
// refuses the import with a 412 and the missing-key list so
|
||||
// the canvas can surface them in its preflight modal.
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
@ -415,6 +452,55 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Required-env preflight — refuses import when any required_env is
|
||||
// missing from global_secrets (unless `force: true` overrides). The
|
||||
// canvas runs the same check client-side against GET /org/templates
|
||||
// output and shows a modal so users set keys before clicking Import;
|
||||
// this server-side check is the authoritative guard in case a caller
|
||||
// bypasses the UI (CLI, API clients, etc.). 412 Precondition Failed
|
||||
// carries the missing-key list so tooling can render the same
|
||||
// add-key flow.
|
||||
required, _ := collectOrgEnv(&tmpl)
|
||||
if body.Force {
|
||||
// Log the bypass so a post-incident search can find who
|
||||
// imported an org with missing creds. The common audit flow
|
||||
// treats log.Printf at INFO as the low-cost trail for
|
||||
// explicit-override actions — keeps force as a supported
|
||||
// knob but makes it investigable.
|
||||
log.Printf("Org import: force=true bypass — template=%q, required_env=%v", tmpl.Name, required)
|
||||
} else if len(required) > 0 {
|
||||
ctx := c.Request.Context()
|
||||
configured, err := loadConfiguredGlobalSecretKeys(ctx)
|
||||
if err != nil {
|
||||
// Fail closed. Previously this fell through and imported
|
||||
// anyway, defeating the preflight for exactly the case
|
||||
// it's meant to cover. A DB hiccup should look like a
|
||||
// retryable 500, not a silent green light for an import
|
||||
// that will fail at container-start time on every node.
|
||||
log.Printf("Org import preflight: global secrets lookup failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "could not verify required environment variables; try again or pass force=true to override",
|
||||
})
|
||||
return
|
||||
}
|
||||
var missing []string
|
||||
for _, k := range required {
|
||||
if _, ok := configured[k]; !ok {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
c.JSON(http.StatusPreconditionFailed, gin.H{
|
||||
"error": "missing required environment variables",
|
||||
"missing_env": missing,
|
||||
"required_env": required,
|
||||
"template": tmpl.Name,
|
||||
"suggestion": "set these as global secrets (POST /settings/secrets) or pass force=true to override",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
results := []map[string]interface{}{}
|
||||
var createErr error
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -540,6 +542,104 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
return nil
|
||||
}
|
||||
|
||||
// envVarNamePattern guards template-supplied env var names against
|
||||
// pathological inputs. A malicious template could ship
|
||||
// required_env: ["'; DROP …"] or whitespace-only entries that would
|
||||
// flow through collectOrgEnv → into the 412 response body and,
|
||||
// worse, into the modal's PUT /settings/secrets input. Schema
|
||||
// already has `key TEXT NOT NULL UNIQUE` and our queries are
|
||||
// parameterised so SQL injection isn't the threat — the real risks
|
||||
// are UI rendering weirdness (newlines, NUL bytes, zero-width chars)
|
||||
// and downstream env-var semantics (POSIX requires uppercase +
|
||||
// underscore + digit). A strict regex filters both classes of
|
||||
// problem at a single choke point.
|
||||
var envVarNamePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
|
||||
|
||||
// collectOrgEnv walks the whole template tree and returns the union of
|
||||
// required_env and recommended_env declared anywhere — at the org
|
||||
// level, on root workspaces, or on any nested child. Deduplicates so
|
||||
// the canvas sees a clean set. An env var declared as required at ANY
|
||||
// level wins over recommended (required is strictly stricter).
|
||||
// Entries that fail envVarNamePattern are dropped with a log line so
|
||||
// a bad template surfaces in operator logs without breaking import.
|
||||
func collectOrgEnv(tmpl *OrgTemplate) (required, recommended []string) {
|
||||
req := map[string]struct{}{}
|
||||
rec := map[string]struct{}{}
|
||||
accept := func(into map[string]struct{}, src []string, where string) {
|
||||
for _, k := range src {
|
||||
if !envVarNamePattern.MatchString(k) {
|
||||
if k != "" {
|
||||
log.Printf("collectOrgEnv: rejecting invalid env var name %q from %s (must match %s)", k, where, envVarNamePattern)
|
||||
}
|
||||
continue
|
||||
}
|
||||
into[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
accept(req, tmpl.RequiredEnv, "template root")
|
||||
accept(rec, tmpl.RecommendedEnv, "template root")
|
||||
var walk func([]OrgWorkspace)
|
||||
walk = func(ws []OrgWorkspace) {
|
||||
for _, w := range ws {
|
||||
accept(req, w.RequiredEnv, "workspace "+w.Name)
|
||||
accept(rec, w.RecommendedEnv, "workspace "+w.Name)
|
||||
walk(w.Children)
|
||||
}
|
||||
}
|
||||
walk(tmpl.Workspaces)
|
||||
// Required wins — a key recommended at one layer and required at
|
||||
// another surfaces only on the required side.
|
||||
for k := range req {
|
||||
delete(rec, k)
|
||||
}
|
||||
required = make([]string, 0, len(req))
|
||||
for k := range req {
|
||||
required = append(required, k)
|
||||
}
|
||||
recommended = make([]string, 0, len(rec))
|
||||
for k := range rec {
|
||||
recommended = append(recommended, k)
|
||||
}
|
||||
sort.Strings(required)
|
||||
sort.Strings(recommended)
|
||||
return required, recommended
|
||||
}
|
||||
|
||||
// loadConfiguredGlobalSecretKeys returns the set of key names present
|
||||
// in global_secrets WHERE the encrypted_value is non-empty. Filtering
|
||||
// on the payload size catches the failure mode where a row was
|
||||
// upserted with an empty value (historical rows predating the
|
||||
// binding:"required" guard on SetGlobal, or a future direct SQL
|
||||
// path that skips it) — the preflight would otherwise report the
|
||||
// key as "configured" and the per-container preflight would still
|
||||
// fail at start time, defeating the whole feature.
|
||||
// The LIMIT is a sanity cap: at realistic tenant sizes (< 1k
|
||||
// secrets) it's a no-op; at pathological sizes it stops one slow
|
||||
// query from wedging org imports. A hit gets logged so operators
|
||||
// can investigate.
|
||||
const globalSecretsPreflightLimit = 10000
|
||||
|
||||
func loadConfiguredGlobalSecretKeys(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT key FROM global_secrets WHERE octet_length(encrypted_value) > 0 LIMIT $1`,
|
||||
globalSecretsPreflightLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if scanErr := rows.Scan(&k); scanErr == nil && k != "" {
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(out) == globalSecretsPreflightLimit {
|
||||
log.Printf("loadConfiguredGlobalSecretKeys: hit LIMIT %d — org-import preflight may be incomplete", globalSecretsPreflightLimit)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func countWorkspaces(workspaces []OrgWorkspace) int {
|
||||
count := len(workspaces)
|
||||
for _, ws := range workspaces {
|
||||
|
||||
@ -650,3 +650,145 @@ func TestOrgImport_ScheduleComputeError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Org env-preflight aggregation (collectOrgEnv)
|
||||
// ============================================================================
|
||||
|
||||
func TestCollectOrgEnv_UnionAcrossLevels(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []string{"ANTHROPIC_API_KEY"},
|
||||
RecommendedEnv: []string{"SLACK_WEBHOOK_URL"},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Root",
|
||||
RequiredEnv: []string{"GITHUB_TOKEN"},
|
||||
Children: []OrgWorkspace{
|
||||
{
|
||||
Name: "Leaf",
|
||||
RequiredEnv: []string{"OPENROUTER_API_KEY"},
|
||||
RecommendedEnv: []string{"DISCORD_WEBHOOK_URL"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
// Required is the union of top-level + root + leaf.
|
||||
wantReq := []string{"ANTHROPIC_API_KEY", "GITHUB_TOKEN", "OPENROUTER_API_KEY"}
|
||||
if !stringSlicesEqual(req, wantReq) {
|
||||
t.Errorf("required mismatch: got %v, want %v", req, wantReq)
|
||||
}
|
||||
wantRec := []string{"DISCORD_WEBHOOK_URL", "SLACK_WEBHOOK_URL"}
|
||||
if !stringSlicesEqual(rec, wantRec) {
|
||||
t.Errorf("recommended mismatch: got %v, want %v", rec, wantRec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_RequiredWinsOverRecommended(t *testing.T) {
|
||||
// Same key declared at one layer as recommended and another as
|
||||
// required MUST surface only on the required side — a required
|
||||
// declaration is strictly stricter than a recommended one, and
|
||||
// listing it in both tiers would confuse the preflight modal.
|
||||
tmpl := &OrgTemplate{
|
||||
RecommendedEnv: []string{"API_KEY"},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{Name: "X", RequiredEnv: []string{"API_KEY"}},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0] != "API_KEY" {
|
||||
t.Errorf("required should contain API_KEY, got %v", req)
|
||||
}
|
||||
for _, k := range rec {
|
||||
if k == "API_KEY" {
|
||||
t.Errorf("API_KEY must not appear in recommended once required elsewhere")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_Dedup(t *testing.T) {
|
||||
// Same key declared twice at different levels should appear once.
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []string{"K", "K"},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{Name: "A", RequiredEnv: []string{"K"}},
|
||||
{Name: "B", RequiredEnv: []string{"K"}, Children: []OrgWorkspace{
|
||||
{Name: "C", RequiredEnv: []string{"K"}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0] != "K" {
|
||||
t.Errorf("dedup failed: got %v, want [K]", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_Empty(t *testing.T) {
|
||||
tmpl := &OrgTemplate{}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 0 || len(rec) != 0 {
|
||||
t.Errorf("empty template should produce empty slices, got req=%v rec=%v", req, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// stringSlicesEqual checks ordered equality — collectOrgEnv sorts its
|
||||
// output so callers can do deterministic comparisons.
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_RequiredWinsOnSameStruct(t *testing.T) {
|
||||
// The same key declared required AND recommended on the SAME
|
||||
// workspace node (rare but legal to parse) must still dedup
|
||||
// correctly and end up required-only.
|
||||
tmpl := &OrgTemplate{
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "X",
|
||||
RequiredEnv: []string{"API_KEY"},
|
||||
RecommendedEnv: []string{"API_KEY"},
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0] != "API_KEY" {
|
||||
t.Errorf("required should contain API_KEY once, got %v", req)
|
||||
}
|
||||
for _, k := range rec {
|
||||
if k == "API_KEY" {
|
||||
t.Errorf("API_KEY must not appear in recommended when also required on same struct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_RejectsInvalidNames(t *testing.T) {
|
||||
// Names failing envVarNamePattern (lowercase, traversal, whitespace,
|
||||
// shell metachars) must be dropped silently — the log line is not
|
||||
// asserted here; the output slice assertion is enough to prove the
|
||||
// filter fires.
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []string{
|
||||
"VALID_ONE",
|
||||
"lowercase_bad",
|
||||
"../../etc/passwd",
|
||||
"name with spaces",
|
||||
"WITH-DASH",
|
||||
"'; DROP TABLE users;--",
|
||||
"",
|
||||
"A", // single char — still valid per regex
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if !stringSlicesEqual(req, []string{"A", "VALID_ONE"}) {
|
||||
t.Errorf("expected only valid names, got %v", req)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user