Add interactive PTY Chat tab with xterm.js terminal emulator
Some checks failed
CI / lint-and-test (push) Successful in 33s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled

Browser-based interactive terminal sessions with agent CLIs via
WebSocket + node-pty. Supports full TUI rendering (colors, cursor,
ctrl-c) through xterm.js in the browser.

Architecture: xterm.js ←WebSocket→ pty-server.js ←PTY→ agent CLI

- Extract shared buildAgentEnv() from executor into agent-env.ts
- Add internal /api/agents/[id]/env endpoint for PTY server
- Add pty-server.js (WebSocket + node-pty, max 3 sessions, 2hr cleanup)
- Add custom server.js wrapping Next.js with WebSocket upgrade
- Add ChatTab component with agent selector and terminal
- Wire CHAT tab into dashboard nav and render
- Configure serverExternalPackages for node-pty
- Update Dockerfile with build tools and custom server
- Bump k8s memory limit 1Gi → 2Gi for PTY sessions
This commit is contained in:
Julia McGhee
2026-03-21 20:42:58 +00:00
parent f45fa64855
commit 7bb091d4b3
12 changed files with 672 additions and 38 deletions

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps FROM base AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat python3 make gcc g++ linux-headers
WORKDIR /app WORKDIR /app
# Copy workspace root config + relevant package.jsons for install # Copy workspace root config + relevant package.jsons for install
@@ -40,7 +40,14 @@ RUN mkdir -p /data/harness && chown nextjs:nodejs /data/harness
COPY --from=builder /app/apps/harness/public ./apps/harness/public COPY --from=builder /app/apps/harness/public ./apps/harness/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/static ./apps/harness/.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/harness/.next/static ./apps/harness/.next/static
# PTY server dependencies + custom server
COPY --from=builder /app/node_modules/.pnpm/node-pty*/node_modules/node-pty ./node_modules/node-pty
COPY --from=builder /app/node_modules/.pnpm/ws*/node_modules/ws ./node_modules/ws
COPY apps/harness/server.js ./server.js
COPY apps/harness/pty-server.js ./pty-server.js
USER nextjs USER nextjs
EXPOSE 3100 EXPOSE 3100
ENV PORT=3100 ENV PORT=3100
CMD ["node", "apps/harness/server.js"] CMD ["node", "server.js"]

View File

@@ -47,7 +47,7 @@ spec:
memory: 256Mi memory: 256Mi
cpu: 100m cpu: 100m
limits: limits:
memory: 1Gi memory: 2Gi
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /api/health path: /api/health

View File

@@ -2,6 +2,7 @@
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
instrumentationHook: true, instrumentationHook: true,
serverExternalPackages: ["node-pty"],
}; };
module.exports = nextConfig; module.exports = nextConfig;

View File

@@ -11,17 +11,22 @@
}, },
"dependencies": { "dependencies": {
"@homelab/db": "workspace:^", "@homelab/db": "workspace:^",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"next": "^15.1.0", "next": "^15.1.0",
"node-pty": "^1.1.0",
"postgres": "^3.4.0", "postgres": "^3.4.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"ws": "^8.20.0",
"yaml": "^2.7.0" "yaml": "^2.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }

169
apps/harness/pty-server.js Normal file
View File

@@ -0,0 +1,169 @@
// @ts-check
const pty = require("node-pty");
const { WebSocketServer } = require("ws");
const MAX_SESSIONS = 3;
const MAX_SESSION_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
/** @type {Map<string, { pty: import("node-pty").IPty, ws: import("ws").WebSocket, createdAt: number }>} */
const sessions = new Map();
/**
* @param {import("http").Server} server
*/
function attachPtyWebSocket(server) {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
if (url.pathname !== "/ws/pty") return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", async (ws, req) => {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const agentId = url.searchParams.get("agentId");
const cols = parseInt(url.searchParams.get("cols") || "80", 10);
const rows = parseInt(url.searchParams.get("rows") || "24", 10);
if (!agentId) {
ws.send(JSON.stringify({ type: "error", message: "agentId required" }));
ws.close();
return;
}
if (sessions.size >= MAX_SESSIONS) {
ws.send(
JSON.stringify({
type: "error",
message: `max ${MAX_SESSIONS} concurrent sessions`,
}),
);
ws.close();
return;
}
// Fetch agent config + env from the Next.js API
const port = process.env.PORT || 3100;
let config;
try {
const res = await fetch(
`http://127.0.0.1:${port}/api/agents/${encodeURIComponent(agentId)}/env`,
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
ws.send(
JSON.stringify({
type: "error",
message: body.error || `agent lookup failed: ${res.status}`,
}),
);
ws.close();
return;
}
config = await res.json();
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to fetch agent config: ${err.message}`,
}),
);
ws.close();
return;
}
const sessionId = `${agentId}-${Date.now()}`;
let term;
try {
term = pty.spawn(config.command, config.args, {
name: "xterm-256color",
cols,
rows,
cwd: config.workDir,
env: config.env,
});
} catch (err) {
ws.send(
JSON.stringify({
type: "error",
message: `failed to spawn PTY: ${err.message}`,
}),
);
ws.close();
return;
}
sessions.set(sessionId, { pty: term, ws, createdAt: Date.now() });
ws.send(JSON.stringify({ type: "connected", sessionId }));
term.onData((data) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
});
term.onExit(({ exitCode }) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "exit", code: exitCode }));
ws.close();
}
sessions.delete(sessionId);
});
ws.on("message", (data) => {
const msg = data.toString();
// Try to parse as JSON control message
if (msg.startsWith("{")) {
try {
const ctrl = JSON.parse(msg);
if (ctrl.type === "resize" && ctrl.cols && ctrl.rows) {
term.resize(ctrl.cols, ctrl.rows);
return;
}
} catch {
// Not JSON, treat as terminal input
}
}
term.write(msg);
});
ws.on("close", () => {
try {
term.kill();
} catch {
// Already dead
}
sessions.delete(sessionId);
});
});
// Periodic cleanup of stale sessions
setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.createdAt > MAX_SESSION_AGE_MS) {
try {
session.pty.kill();
} catch {
// ignore
}
try {
session.ws.close();
} catch {
// ignore
}
sessions.delete(id);
}
}
}, CLEANUP_INTERVAL_MS);
return wss;
}
module.exports = { attachPtyWebSocket };

24
apps/harness/server.js Normal file
View File

@@ -0,0 +1,24 @@
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const { attachPtyWebSocket } = require("./pty-server");
const dev = process.env.NODE_ENV !== "production";
const hostname = process.env.HOSTNAME || "0.0.0.0";
const port = parseInt(process.env.PORT || "3100", 10);
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url || "/", true);
handle(req, res, parsedUrl);
});
attachPtyWebSocket(server);
server.listen(port, hostname, () => {
console.log(`> Harness ready on http://${hostname}:${port}`);
});
});

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { getAgentConfig } from "@/lib/agents";
import { buildAgentEnv, buildInteractiveCommand } from "@/lib/agent-env";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// Only allow localhost access
const forwarded = _request.headers.get("x-forwarded-for");
const host = _request.headers.get("host") ?? "";
const isLocal =
!forwarded &&
(host.startsWith("localhost") || host.startsWith("127.0.0.1"));
if (!isLocal) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
const { id } = await params;
const config = await getAgentConfig(id);
if (!config) {
return NextResponse.json({ error: "agent not found" }, { status: 404 });
}
const env = await buildAgentEnv(config);
const { command, args } = buildInteractiveCommand(config);
// Strip process.env inherited values — only send what we explicitly set
const cleanEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) cleanEnv[k] = v;
}
return NextResponse.json({
command,
args,
env: cleanEnv,
workDir: process.env.HARNESS_WORK_DIR || "/data/harness",
});
}

View File

@@ -0,0 +1,304 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
tokens,
Label,
Mono,
Btn,
Panel,
PanelHeader,
SearchableDropdown,
DropdownOption,
} from "./harness-design-system";
type SessionState = "idle" | "connecting" | "connected" | "disconnected";
interface Agent {
id: string;
name: string;
runtime: string;
modelId: string;
}
export default function ChatTab({ mobile }: { mobile: boolean }) {
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [state, setState] = useState<SessionState>("idle");
const termRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitRef = useRef<any>(null);
const wsRef = useRef<WebSocket | null>(null);
// Fetch agents list
useEffect(() => {
fetch("/api/agents")
.then((r) => r.json())
.then((data) => {
setAgents(data.configs || []);
})
.catch(() => {});
}, []);
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setState("disconnected");
}, []);
const connect = useCallback(async () => {
if (!selectedAgentId || !termRef.current) return;
setState("connecting");
// Dynamic import xterm (SSR-safe)
const { Terminal } = await import("@xterm/xterm");
const { FitAddon } = await import("@xterm/addon-fit");
// CSS is imported at module level below
await import("@xterm/xterm/css/xterm.css" as any);
// Clean up previous terminal
if (xtermRef.current) {
xtermRef.current.dispose();
}
termRef.current.innerHTML = "";
const term = new Terminal({
cursorBlink: true,
fontFamily: tokens.font.mono,
fontSize: 14,
theme: {
background: tokens.color.bg1,
foreground: tokens.color.text0,
cursor: tokens.color.accent,
selectionBackground: "#374151",
},
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(termRef.current);
fit.fit();
xtermRef.current = term;
fitRef.current = fit;
const cols = term.cols;
const rows = term.rows;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${proto}//${location.host}/ws/pty?agentId=${encodeURIComponent(selectedAgentId)}&cols=${cols}&rows=${rows}`,
);
wsRef.current = ws;
ws.onopen = () => {
setState("connected");
};
ws.onmessage = (e) => {
const data = e.data;
// Check for JSON control messages
if (typeof data === "string" && data.startsWith("{")) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
term.writeln(`\r\n\x1b[90m[session exited with code ${msg.code}]\x1b[0m`);
setState("disconnected");
return;
}
if (msg.type === "error") {
term.writeln(`\r\n\x1b[31m[error: ${msg.message}]\x1b[0m`);
setState("disconnected");
return;
}
if (msg.type === "connected") {
return;
}
} catch {
// Not JSON, render as terminal output
}
}
term.write(data);
};
ws.onclose = () => {
setState("disconnected");
};
ws.onerror = () => {
setState("disconnected");
};
// Terminal → WebSocket
term.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize handling
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
}, [selectedAgentId]);
// ResizeObserver for terminal container
useEffect(() => {
const el = termRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
if (fitRef.current) {
try {
fitRef.current.fit();
} catch {
// Terminal may not be ready
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (wsRef.current) wsRef.current.close();
if (xtermRef.current) xtermRef.current.dispose();
};
}, []);
const agentOptions: DropdownOption[] = agents.map((a) => ({
value: a.id,
label: `${a.name}`,
sub: `${a.runtime} · ${a.modelId}`,
}));
const statusColor =
state === "connected"
? tokens.color.pass
: state === "connecting"
? tokens.color.warn
: state === "disconnected"
? tokens.color.fail
: tokens.color.muted;
const statusLabel =
state === "connected"
? "CONNECTED"
: state === "connecting"
? "CONNECTING"
: state === "disconnected"
? "DISCONNECTED"
: "IDLE";
return (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
padding: mobile ? tokens.space[3] : tokens.space[5],
gap: tokens.space[4],
overflow: "hidden",
}}
>
{/* Header bar */}
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[4],
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 240, flex: mobile ? 1 : undefined }}>
<SearchableDropdown
options={agentOptions}
value={selectedAgentId}
onChange={(v) => setSelectedAgentId(Array.isArray(v) ? v[0] : v)}
placeholder="Select agent..."
/>
</div>
{state === "connected" ? (
<Btn onClick={disconnect} style={{ background: tokens.color.failDim, borderColor: tokens.color.fail }}>
DISCONNECT
</Btn>
) : (
<Btn
onClick={connect}
disabled={!selectedAgentId || state === "connecting"}
>
{state === "connecting" ? "CONNECTING..." : "CONNECT"}
</Btn>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[2],
marginLeft: "auto",
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: statusColor,
boxShadow: state === "connected" ? tokens.color.accentGlow : undefined,
}}
/>
<Label color={statusColor} style={{ fontSize: tokens.size.xs }}>
{statusLabel}
</Label>
</div>
</div>
{/* Terminal area */}
<div
style={{
flex: 1,
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border0}`,
borderRadius: 6,
overflow: "hidden",
position: "relative",
minHeight: 0,
}}
>
<div
ref={termRef}
style={{
width: "100%",
height: "100%",
padding: 4,
}}
/>
{state === "idle" && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text3}>
Select an agent and click CONNECT to start an interactive session
</Mono>
</div>
)}
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import {
Btn, Input, Textarea, EvalPip, IterDot, PathCrumb, BackBtn, Btn, Input, Textarea, EvalPip, IterDot, PathCrumb, BackBtn,
SearchableDropdown, DropdownOption, SearchableDropdown, DropdownOption,
} from "./harness-design-system"; } from "./harness-design-system";
import ChatTab from "./chat-tab";
// ─── TYPES ────────────────────────────────────────────────────────────────────── // ─── TYPES ──────────────────────────────────────────────────────────────────────
@@ -1639,6 +1640,7 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "LOOPS", icon: "⟳", label: "LOOPS" }, { id: "LOOPS", icon: "⟳", label: "LOOPS" },
{ id: "CHAT", icon: "▸", label: "CHAT" },
{ id: "PROJECTS", icon: "◫", label: "PROJECTS" }, { id: "PROJECTS", icon: "◫", label: "PROJECTS" },
{ id: "MODELS", icon: "◈", label: "MODELS" }, { id: "MODELS", icon: "◈", label: "MODELS" },
{ id: "KNOWLEDGE", icon: "≡", label: "DOCS" }, { id: "KNOWLEDGE", icon: "≡", label: "DOCS" },
@@ -1672,7 +1674,7 @@ function BottomNav({ activeTab, setActiveTab, tasks }: { activeTab: string; setA
function TopBar({ activeTab, setActiveTab, tasks, mobile }: { activeTab: string; setActiveTab: (t: string) => void; tasks: Task[]; mobile: boolean }) { function TopBar({ activeTab, setActiveTab, tasks, mobile }: { activeTab: string; setActiveTab: (t: string) => void; tasks: Task[]; mobile: boolean }) {
const running = tasks.filter(t => t.status === "running").length; const running = tasks.filter(t => t.status === "running").length;
const pending = tasks.filter(t => t.status === "pending").length; const pending = tasks.filter(t => t.status === "pending").length;
const tabs = ["LOOPS", "PROJECTS", "MODELS", "KNOWLEDGE", "HISTORY", "NEW TASK"]; const tabs = ["LOOPS", "CHAT", "PROJECTS", "MODELS", "KNOWLEDGE", "HISTORY", "NEW TASK"];
return ( return (
<div style={{ borderBottom: `1px solid ${tokens.color.border0}`, display: "flex", alignItems: "stretch", height: 48, flexShrink: 0, background: tokens.color.bg0 }}> <div style={{ borderBottom: `1px solid ${tokens.color.border0}`, display: "flex", alignItems: "stretch", height: 48, flexShrink: 0, background: tokens.color.bg0 }}>
@@ -1760,6 +1762,7 @@ export default function HarnessDashboard() {
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}> <div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{activeTab === "LOOPS" && <LoopsTab tasks={tasks} setTasks={setTasks} mobile={mobile} />} {activeTab === "LOOPS" && <LoopsTab tasks={tasks} setTasks={setTasks} mobile={mobile} />}
{activeTab === "CHAT" && <ChatTab mobile={mobile} />}
{activeTab === "PROJECTS" && <ProjectsTab projects={projects} setProjects={setProjects} mobile={mobile} />} {activeTab === "PROJECTS" && <ProjectsTab projects={projects} setProjects={setProjects} mobile={mobile} />}
{activeTab === "MODELS" && <ModelsTab mobile={mobile} />} {activeTab === "MODELS" && <ModelsTab mobile={mobile} />}
{activeTab === "KNOWLEDGE" && <KnowledgeTab docs={knowledgeDocs} mobile={mobile} />} {activeTab === "KNOWLEDGE" && <KnowledgeTab docs={knowledgeDocs} mobile={mobile} />}

View File

@@ -0,0 +1,55 @@
import { AgentConfig, AGENT_RUNTIMES } from "./agents";
import { getRawCredentialsByProvider, Provider } from "./credentials";
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
google: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
};
export async function buildAgentEnv(
config: AgentConfig,
): Promise<NodeJS.ProcessEnv> {
const env: NodeJS.ProcessEnv = { ...process.env, TERM: "xterm-256color" };
const providersToInject =
config.runtime === "opencode"
? Object.keys(PROVIDER_ENV_VARS)
: [config.provider];
for (const provider of providersToInject) {
const envVar = PROVIDER_ENV_VARS[provider];
if (!envVar) continue;
const creds = await getRawCredentialsByProvider(provider as Provider);
if (creds.length > 0) {
env[envVar] = creds[0].token;
}
}
const ghCreds = await getRawCredentialsByProvider("github" as Provider);
if (ghCreds.length > 0) {
env.GITHUB_TOKEN = ghCreds[0].token;
env.GH_TOKEN = ghCreds[0].token;
}
if (config.env) {
Object.assign(env, config.env);
}
return env;
}
export function buildInteractiveCommand(
config: AgentConfig,
): { command: string; args: string[] } {
const runtime = AGENT_RUNTIMES[config.runtime];
const args: string[] = [];
if (runtime.modelFlag && config.modelId) {
args.push(runtime.modelFlag, config.modelId);
}
return { command: runtime.cliCommand, args };
}

View File

@@ -1,18 +1,10 @@
import { spawn, ChildProcess } from "node:child_process"; import { spawn, ChildProcess } from "node:child_process";
import { getAgentConfig, buildAgentCommand, AGENT_RUNTIMES } from "./agents"; import { getAgentConfig, buildAgentCommand, AGENT_RUNTIMES } from "./agents";
import { getRawCredentialsByProvider, Provider } from "./credentials"; import { buildAgentEnv } from "./agent-env";
import { ExecutionResult } from "./types"; import { ExecutionResult } from "./types";
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
google: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"opencode-zen": "OPENCODE_ZEN_API_KEY",
};
const TOKEN_PATTERNS: Record<string, { input: RegExp; output: RegExp }> = { const TOKEN_PATTERNS: Record<string, { input: RegExp; output: RegExp }> = {
"claude-code": { "claude-code": {
input: /input[_\s]tokens?[:\s]+(\d[\d,]*)/i, input: /input[_\s]tokens?[:\s]+(\d[\d,]*)/i,
@@ -50,31 +42,7 @@ export async function executeAgent(opts: {
const command = args[0]; const command = args[0];
const commandArgs = args.slice(1); const commandArgs = args.slice(1);
const env: NodeJS.ProcessEnv = { ...process.env }; const env = await buildAgentEnv(config);
const providersToInject =
config.runtime === "opencode"
? Object.keys(PROVIDER_ENV_VARS)
: [config.provider];
for (const provider of providersToInject) {
const envVar = PROVIDER_ENV_VARS[provider];
if (!envVar) continue;
const creds = await getRawCredentialsByProvider(provider as Provider);
if (creds.length > 0) {
env[envVar] = creds[0].token;
}
}
const ghCreds = await getRawCredentialsByProvider("github" as Provider);
if (ghCreds.length > 0) {
env.GITHUB_TOKEN = ghCreds[0].token;
env.GH_TOKEN = ghCreds[0].token;
}
if (config.env) {
Object.assign(env, config.env);
}
const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const timeout = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const startTime = Date.now(); const startTime = Date.now();

58
pnpm-lock.yaml generated
View File

@@ -75,12 +75,21 @@ importers:
'@homelab/db': '@homelab/db':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/db version: link:../../packages/db
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
drizzle-orm: drizzle-orm:
specifier: ^0.36.0 specifier: ^0.36.0
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
next: next:
specifier: ^15.1.0 specifier: ^15.1.0
version: 15.5.14(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 15.5.14(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
node-pty:
specifier: ^1.1.0
version: 1.1.0
postgres: postgres:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.8 version: 3.4.8
@@ -90,6 +99,9 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
ws:
specifier: ^8.20.0
version: 8.20.0
yaml: yaml:
specifier: ^2.7.0 specifier: ^2.7.0
version: 2.8.2 version: 2.8.2
@@ -103,6 +115,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
@@ -1755,6 +1770,9 @@ packages:
'@types/tedious@4.0.14': '@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.57.1': '@typescript-eslint/eslint-plugin@8.57.1':
resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1909,6 +1927,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
accepts@1.3.8: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3108,6 +3132,9 @@ packages:
sass: sass:
optional: true optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -3121,6 +3148,9 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3761,6 +3791,18 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
ws@8.20.0:
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -5248,6 +5290,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.15 '@types/node': 22.19.15
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.15
'@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -5398,6 +5444,10 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1': '@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true optional: true
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@@ -6735,6 +6785,8 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-exports-info@1.6.0: node-exports-info@1.6.0:
@@ -6750,6 +6802,10 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -7569,6 +7625,8 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
ws@8.20.0: {}
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}