mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 15:26:24 +00:00
71a7550f48
Git servers (forge_hosts) become full credential owners: - an encrypted forge token (Fernet, HANDLER_SECRET_KEY) stored per server and never returned by the API (has_token flag only); used automatically by every project on that host and addressable as db:host:<hostname> — the reserved db: credential scheme is now live - a per-server ed25519 SSH deploy key: generated server-side, public half shown in the dashboard to paste into the forge, private half encrypted at rest and materialized 0600 only in the control container (GIT_SSH_COMMAND / core.sshCommand) Project registration gets a git-server mode: pick a registered server, type owner/name, and the API derives the remote (ssh when the server has a deploy key, https otherwise), computes root_dir under PROJECTS_ROOT, and enqueues a new 'sync' command the worker executes (clone, or ff-only pull). Spawn always pulls first, so runs start from the remote's latest state; POST /projects/:p/sync and 'handler sync' re-pull on demand. Schedules: recurring agent spawns (prefix, prompt, interval, role). The worker fires due schedules as ordinary queued spawn commands with timestamped agent names, so runs are fresh stateless agents and appear in the Activity audit trail; missed intervals collapse into one catch-up run. Dashboard: Git Servers pane shows the SSH public key (copy button) and takes a write-only token; Repositories gains the server-first add form and a Pull now button; new Schedules pane. Rebuilt static export. Also restores the missing frontend/lib (api client + format helpers) the components import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
208 lines
7.1 KiB
TypeScript
208 lines
7.1 KiB
TypeScript
/* Git Servers — one entry per forge host, carrying the server's own credentials:
|
|
* a forge token (stored encrypted, write-only) and an SSH deploy key whose public half
|
|
* is shown here to paste into the forge. Projects on a configured server need no
|
|
* per-repo credentials. */
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useDashboard } from "@/components/store";
|
|
import { Badge, Button, Card, Input, Select } from "@/components/ui";
|
|
import type { Host } from "@/lib/api";
|
|
|
|
const FORGE_OPTS = [
|
|
{ value: "github", label: "github" },
|
|
{ value: "gitlab", label: "gitlab" },
|
|
{ value: "gitea", label: "gitea" },
|
|
{ value: "forgejo", label: "forgejo" },
|
|
{ value: "bitbucket", label: "bitbucket" },
|
|
];
|
|
|
|
const empty = {
|
|
hostname: "",
|
|
forge_type: "github",
|
|
token_env_var: "",
|
|
base_url: "",
|
|
token: "",
|
|
generate_ssh_key: true,
|
|
};
|
|
|
|
function PublicKey({ value }: { value: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const copy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
/* clipboard unavailable (http) — the key is selectable below */
|
|
}
|
|
};
|
|
return (
|
|
<div style={{ marginTop: 10 }}>
|
|
<div className="hstack" style={{ justifyContent: "space-between" }}>
|
|
<span className="eyebrow">SSH public key — add it to the forge (deploy key)</span>
|
|
<Button size="sm" variant="secondary" onClick={copy}>
|
|
{copied ? "Copied" : "Copy"}
|
|
</Button>
|
|
</div>
|
|
<pre
|
|
className="mono"
|
|
style={{
|
|
fontSize: "var(--text-xs)",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-all",
|
|
margin: "6px 0 0",
|
|
padding: 8,
|
|
border: "1px solid var(--border-default)",
|
|
borderRadius: 6,
|
|
userSelect: "all",
|
|
}}
|
|
>
|
|
{value}
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GitServersSection() {
|
|
const s = useDashboard();
|
|
const [form, setForm] = useState(empty);
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
const reset = () => {
|
|
setForm(empty);
|
|
setEditing(false);
|
|
};
|
|
|
|
const save = async () => {
|
|
const ok = editing ? await s.updateHost(form.hostname, form) : await s.createHost(form);
|
|
if (ok) reset();
|
|
};
|
|
|
|
const edit = (h: Host) => {
|
|
setForm({
|
|
hostname: h.hostname,
|
|
forge_type: h.forge_type,
|
|
token_env_var: h.token_env_var ?? "",
|
|
base_url: h.base_url ?? "",
|
|
token: "",
|
|
generate_ssh_key: false,
|
|
});
|
|
setEditing(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="section-head">
|
|
<div className="section-title">Git Servers</div>
|
|
<div className="section-desc">
|
|
Each server carries its own credentials: a forge token (encrypted at rest, used by
|
|
agents' <span className="mono">forge</span> + git) and an SSH deploy key — paste
|
|
the public key into the forge. New repositories are added by picking a server and
|
|
typing owner/name.
|
|
</div>
|
|
</div>
|
|
<div className="section-body">
|
|
<Card>
|
|
<div className="card-head" style={{ marginBottom: 14 }}>
|
|
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
|
{editing ? `Edit server · ${form.hostname}` : "Add a git server"}
|
|
</span>
|
|
</div>
|
|
<div className="form-grid">
|
|
<Input
|
|
label="Hostname"
|
|
value={form.hostname}
|
|
onChange={(v) => setForm({ ...form, hostname: v })}
|
|
placeholder="github.com"
|
|
disabled={editing}
|
|
/>
|
|
<Select
|
|
label="Type"
|
|
value={form.forge_type}
|
|
onChange={(v) => setForm({ ...form, forge_type: v })}
|
|
options={FORGE_OPTS}
|
|
/>
|
|
<Input
|
|
label={editing ? "Forge token (blank = keep current)" : "Forge token"}
|
|
type="password"
|
|
value={form.token}
|
|
onChange={(v) => setForm({ ...form, token: v })}
|
|
placeholder="stored encrypted; used by forge + git"
|
|
/>
|
|
<Input
|
|
label="Base URL (optional)"
|
|
value={form.base_url}
|
|
onChange={(v) => setForm({ ...form, base_url: v })}
|
|
placeholder="https://git.corp.internal:8443"
|
|
/>
|
|
<Input
|
|
label="Token env var override (optional)"
|
|
value={form.token_env_var}
|
|
onChange={(v) => setForm({ ...form, token_env_var: v })}
|
|
placeholder="GITEA_TOKEN"
|
|
/>
|
|
<label className="field">
|
|
<span className="field-label">SSH deploy key</span>
|
|
<label className="hstack" style={{ gap: 8, cursor: "pointer" }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.generate_ssh_key}
|
|
onChange={(e) => setForm({ ...form, generate_ssh_key: e.target.checked })}
|
|
/>
|
|
<span style={{ fontSize: "var(--text-sm)" }}>
|
|
{editing ? "Regenerate keypair (replaces the current key)" : "Generate a keypair"}
|
|
</span>
|
|
</label>
|
|
</label>
|
|
</div>
|
|
<div className="hstack mt14">
|
|
<Button variant="primary" disabled={s.cmd.busy || !form.hostname.trim()} onClick={save}>
|
|
{editing ? "Save changes" : "Add server"}
|
|
</Button>
|
|
{editing && (
|
|
<Button variant="ghost" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{s.hosts.length === 0 && (
|
|
<div className="empty">No git servers registered (built-in host map still applies).</div>
|
|
)}
|
|
|
|
{s.hosts.map((h) => (
|
|
<Card key={h.hostname}>
|
|
<div className="card-head">
|
|
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
|
|
{h.hostname}
|
|
</span>
|
|
<div className="hstack">
|
|
<Badge tone="info">{h.forge_type}</Badge>
|
|
<Badge tone={h.has_token ? "success" : "neutral"}>
|
|
{h.has_token ? "token stored" : "no token"}
|
|
</Badge>
|
|
<Badge tone={h.ssh_public_key ? "success" : "neutral"}>
|
|
{h.ssh_public_key ? "ssh key" : "no ssh key"}
|
|
</Badge>
|
|
<Button size="sm" variant="secondary" onClick={() => edit(h)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="danger" onClick={() => s.deleteHost(h.hostname)}>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
|
token env {h.token_env_var || "—"}
|
|
{h.base_url ? ` · ${h.base_url}` : ""}
|
|
</div>
|
|
{h.ssh_public_key && <PublicKey value={h.ssh_public_key} />}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|