diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5caee1f --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Delad token som BÅDE daemon och frontend måste skicka för att få ansluta till servern. +# Byt till något hemligt — den här skyddar fjärrstyrning av en agent som kan köra shell. +AGENT_HELM_TOKEN=change-me + +# --- server --- +PORT=8787 + +# --- daemon --- +SERVER_URL=ws://localhost:8787 +# Kommandot som körs i pty:n. Default: gemini. Sätt t.ex. AGENT_CMD=bash för att testa pipelinen. +AGENT_CMD=gemini +# AGENT_ARGS= +# AGENT_CWD= +# AGENT_TITLE= + +# --- frontend (Vite läser bara VITE_*-prefix) --- +VITE_SERVER_WS=ws://localhost:8787 +VITE_AGENT_HELM_TOKEN=change-me diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5406b1d --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Tåligare mot flakigt nätverk vid install (parsas till nummer av pnpm). +# OBS: sätt INTE fetch-timeout via --config på CLI:n — då skickas värdet som +# sträng och kraschar AbortSignal.timeout(). pnpm:s default-timeout är ok. +fetch-retries=5 +network-concurrency=8 diff --git a/package.json b/package.json new file mode 100644 index 0000000..aff931a --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "agent-helm", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev:server": "pnpm --filter @agent-helm/server dev", + "dev:daemon": "pnpm --filter @agent-helm/daemon dev", + "dev:web": "pnpm --filter @agent-helm/web dev", + "build": "pnpm -r build", + "typecheck": "pnpm -r --if-present typecheck" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 0000000..dd1ae5d --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1,23 @@ +{ + "name": "@agent-helm/daemon", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --env-file-if-exists=../../.env watch src/index.ts", + "start": "tsx --env-file-if-exists=../../.env src/index.ts", + "build": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@agent-helm/shared": "workspace:*", + "node-pty": "^1.0.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts new file mode 100644 index 0000000..fb8f5d8 --- /dev/null +++ b/packages/daemon/src/index.ts @@ -0,0 +1,101 @@ +/** + * agent-helm daemon ("appen"). + * + * Spawnar en agent (default: gemini) i en pty, strömmar pty-output utåt till + * control-plane-servern över WebSocket, och skriver tillbaka input som kommer + * från servern till pty:ns stdin. Återansluter automatiskt om servern går ner. + * + * Persistens (att daemonen själv överlever) är ett yttre lager: kör den i tmux + * eller som en Docker-container med restart-policy. Det hanteras inte här. + */ +import { spawn, type IPty } from "node-pty"; +import { WebSocket } from "ws"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared"; +import { parseMessage } from "@agent-helm/shared"; + +const TOKEN = process.env.AGENT_HELM_TOKEN ?? ""; +const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787"; +const AGENT_CMD = process.env.AGENT_CMD ?? "gemini"; +const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : []; +const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd(); +const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`; + +// Fast pty-storlek: undviker resize-churn när olika klienter (mobil/desktop) +// tittar samtidigt. Frontenden skalar/scrollar istället för att reflowa. +const COLS = 100; +const ROWS = 30; + +if (!TOKEN) { + console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön."); + process.exit(1); +} + +const sessionId = randomUUID(); + +const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, { + name: "xterm-256color", + cols: COLS, + rows: ROWS, + cwd: AGENT_CWD, + env: { ...process.env } as Record, +}); + +console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`); + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; + +function send(msg: DaemonMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); +} + +function connect(): void { + console.log(`[daemon] ansluter till ${SERVER_URL} ...`); + ws = new WebSocket(SERVER_URL); + + ws.on("open", () => { + console.log("[daemon] ansluten — registrerar session"); + send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS }); + }); + + ws.on("message", (raw) => { + const msg = parseMessage(raw.toString()); + if (msg?.type === "server:input") { + pty.write(msg.data); + } + }); + + ws.on("close", () => { + console.log("[daemon] anslutning stängd — återansluter om 2s"); + scheduleReconnect(); + }); + + ws.on("error", (err: Error) => { + console.error("[daemon] ws-fel:", err.message); + }); +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, 2000); +} + +pty.onData((data) => send({ type: "daemon:output", data })); + +pty.onExit(({ exitCode }) => { + console.log(`[daemon] agenten avslutades (kod ${exitCode})`); + send({ type: "daemon:exit", code: exitCode }); + setTimeout(() => process.exit(exitCode), 200); +}); + +process.on("SIGINT", () => { + pty.kill(); + process.exit(0); +}); + +connect(); diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json new file mode 100644 index 0000000..503c949 --- /dev/null +++ b/packages/daemon/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"], + "baseUrl": ".", + "paths": { + "@agent-helm/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..f0731ed --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@agent-helm/server", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --env-file-if-exists=../../.env watch src/index.ts", + "start": "tsx --env-file-if-exists=../../.env src/index.ts", + "build": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@agent-helm/shared": "workspace:*", + "@xterm/addon-serialize": "^0.13.0", + "@xterm/headless": "^5.5.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.12", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..cbef82d --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,204 @@ +/** + * agent-helm control-plane (körs på Pi5). + * + * - WebSocket-mux: både daemons och frontends ansluter hit. Rollen avgörs av + * det första meddelandet (daemon:hello / web:hello), båda kräver rätt token. + * - Session-registry: håller reda på vilken daemon som äger vilken session. + * - Scrollback: en headless xterm per session matas med all output, så en + * frontend som ansluter (eller en mobil som väcks) får en ögonblicksbild via + * SerializeAddon — inte en tom skärm. + * - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter. + */ +import http from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +import { Terminal } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import type { + DaemonHello, + DaemonMessage, + ServerToWeb, + SessionMeta, + WebMessage, +} from "@agent-helm/shared"; +import { parseMessage } from "@agent-helm/shared"; + +const TOKEN = process.env.AGENT_HELM_TOKEN ?? ""; +const PORT = Number(process.env.PORT ?? 8787); + +if (!TOKEN) { + console.error("[server] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön."); + process.exit(1); +} + +interface Session { + meta: SessionMeta; + daemon: WebSocket | null; + term: Terminal; + serializer: SerializeAddon; + subscribers: Set; +} + +type Role = + | { kind: "pending" } + | { kind: "daemon"; sessionId: string } + | { kind: "web" }; + +const sessions = new Map(); +const webClients = new Set(); +const roles = new WeakMap(); + +function sessionList(): SessionMeta[] { + return [...sessions.values()].map((s) => s.meta); +} + +function sendWeb(ws: WebSocket, msg: ServerToWeb): void { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); +} + +function broadcastSessions(): void { + const msg: ServerToWeb = { type: "server:sessions", sessions: sessionList() }; + for (const ws of webClients) sendWeb(ws, msg); +} + +function closeWithError(ws: WebSocket, message: string): void { + sendWeb(ws, { type: "server:error", message }); + ws.close(); +} + +function registerDaemon(ws: WebSocket, hello: DaemonHello): void { + roles.set(ws, { kind: "daemon", sessionId: hello.sessionId }); + + const existing = sessions.get(hello.sessionId); + if (existing) { + // Daemonen återansluter — behåll scrollback, byt ut socketen. + existing.daemon = ws; + existing.meta.status = "running"; + } else { + const term = new Terminal({ cols: hello.cols, rows: hello.rows, allowProposedApi: true }); + const serializer = new SerializeAddon(); + term.loadAddon(serializer); + sessions.set(hello.sessionId, { + meta: { + sessionId: hello.sessionId, + title: hello.title, + status: "running", + cols: hello.cols, + rows: hello.rows, + }, + daemon: ws, + term, + serializer, + subscribers: new Set(), + }); + } + console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.title})`); + broadcastSessions(); +} + +function handleHello(ws: WebSocket, text: string): void { + const msg = parseMessage(text); + if (!msg) return closeWithError(ws, "ogiltigt meddelande"); + + if (msg.type === "daemon:hello") { + if (msg.token !== TOKEN) return closeWithError(ws, "fel token"); + return registerDaemon(ws, msg); + } + if (msg.type === "web:hello") { + if (msg.token !== TOKEN) return closeWithError(ws, "fel token"); + roles.set(ws, { kind: "web" }); + webClients.add(ws); + sendWeb(ws, { type: "server:welcome" }); + sendWeb(ws, { type: "server:sessions", sessions: sessionList() }); + return; + } + return closeWithError(ws, "förväntade hello"); +} + +function handleDaemonMessage(sessionId: string, text: string): void { + const msg = parseMessage(text); + if (!msg) return; + const session = sessions.get(sessionId); + if (!session) return; + + if (msg.type === "daemon:output") { + session.term.write(msg.data); + const out: ServerToWeb = { type: "server:output", sessionId, data: msg.data }; + for (const sub of session.subscribers) sendWeb(sub, out); + } else if (msg.type === "daemon:exit") { + session.meta.status = "exited"; + session.meta.exitCode = msg.code; + for (const sub of session.subscribers) { + sendWeb(sub, { type: "server:exit", sessionId, code: msg.code }); + } + broadcastSessions(); + } +} + +function handleWebMessage(ws: WebSocket, text: string): void { + const msg = parseMessage(text); + if (!msg) return; + + if (msg.type === "web:subscribe") { + const session = sessions.get(msg.sessionId); + if (!session) return sendWeb(ws, { type: "server:error", message: "okänd session" }); + session.subscribers.add(ws); + sendWeb(ws, { + type: "server:snapshot", + sessionId: msg.sessionId, + data: session.serializer.serialize(), + meta: session.meta, + }); + } else if (msg.type === "web:input") { + const session = sessions.get(msg.sessionId); + if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return; + // Strukturerade hook-svar går INTE här — bara terminal-input (text/tangenter). + session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data })); + } +} + +function handleClose(ws: WebSocket): void { + const role = roles.get(ws); + roles.delete(ws); + if (!role) return; + + if (role.kind === "web") { + webClients.delete(ws); + for (const s of sessions.values()) s.subscribers.delete(ws); + } else if (role.kind === "daemon") { + const session = sessions.get(role.sessionId); + if (session && session.daemon === ws) { + session.daemon = null; // behåll session + scrollback ifall daemonen kommer tillbaka + console.log(`[server] daemon för ${role.sessionId} kopplade ner`); + } + } +} + +const server = http.createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, sessions: sessions.size })); + return; + } + res.writeHead(404); + res.end(); +}); + +const wss = new WebSocketServer({ server }); + +wss.on("connection", (ws) => { + roles.set(ws, { kind: "pending" }); + ws.on("message", (raw) => { + const role = roles.get(ws); + if (!role) return; + const text = raw.toString(); + if (role.kind === "pending") handleHello(ws, text); + else if (role.kind === "daemon") handleDaemonMessage(role.sessionId, text); + else handleWebMessage(ws, text); + }); + ws.on("close", () => handleClose(ws)); + ws.on("error", () => {}); +}); + +server.listen(PORT, () => { + console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..503c949 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"], + "baseUrl": ".", + "paths": { + "@agent-helm/shared": ["../shared/src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..e39334c --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agent-helm/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..92c943d --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,125 @@ +/** + * Delat WebSocket-protokoll mellan daemon, server och frontend. + * Allt skickas som JSON-strängar. Typerna nedan är enda källan till sanning + * för meddelandeformaten — importeras av alla tre paketen. + */ + +export type SessionStatus = "running" | "exited"; + +export interface SessionMeta { + sessionId: string; + title: string; + status: SessionStatus; + cols: number; + rows: number; + exitCode?: number; +} + +/* ----------------------------- Daemon -> Server ---------------------------- */ + +export interface DaemonHello { + type: "daemon:hello"; + token: string; + sessionId: string; + title: string; + cols: number; + rows: number; +} + +export interface DaemonOutput { + type: "daemon:output"; + /** Rå pty-output (utf8). */ + data: string; +} + +export interface DaemonExit { + type: "daemon:exit"; + code: number; +} + +export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit; + +/* ----------------------------- Server -> Daemon ---------------------------- */ + +export interface ServerInput { + type: "server:input"; + /** Text eller escape-sekvenser som ska skrivas till pty:ns stdin. */ + data: string; +} + +export type ServerToDaemon = ServerInput; + +/* ---------------------------- Frontend -> Server --------------------------- */ + +export interface WebHello { + type: "web:hello"; + token: string; +} + +export interface WebSubscribe { + type: "web:subscribe"; + sessionId: string; +} + +export interface WebInput { + type: "web:input"; + sessionId: string; + /** Text eller escape-sekvens (piltangenter, Enter, Ctrl-C ...). */ + data: string; +} + +export type WebMessage = WebHello | WebSubscribe | WebInput; + +/* ---------------------------- Server -> Frontend --------------------------- */ + +export interface ServerWelcome { + type: "server:welcome"; +} + +export interface ServerSessions { + type: "server:sessions"; + sessions: SessionMeta[]; +} + +export interface ServerSnapshot { + type: "server:snapshot"; + sessionId: string; + /** Serialiserad terminal-state (scrollback) så en ny klient ser var den är. */ + data: string; + meta: SessionMeta; +} + +export interface ServerOutput { + type: "server:output"; + sessionId: string; + data: string; +} + +export interface ServerSessionExit { + type: "server:exit"; + sessionId: string; + code: number; +} + +export interface ServerError { + type: "server:error"; + message: string; +} + +export type ServerToWeb = + | ServerWelcome + | ServerSessions + | ServerSnapshot + | ServerOutput + | ServerSessionExit + | ServerError; + +/* --------------------------------- Helpers --------------------------------- */ + +export function parseMessage(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..b753182 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": [] + }, + "include": ["src"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..23579cd --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,12 @@ + + + + + + agent-helm + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..80e7c08 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@agent-helm/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@agent-helm/shared": "workspace:*", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue new file mode 100644 index 0000000..a5b751f --- /dev/null +++ b/packages/web/src/App.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/packages/web/src/components/TerminalView.vue b/packages/web/src/components/TerminalView.vue new file mode 100644 index 0000000..d2894eb --- /dev/null +++ b/packages/web/src/components/TerminalView.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/packages/web/src/lib/helm.ts b/packages/web/src/lib/helm.ts new file mode 100644 index 0000000..54e91d1 --- /dev/null +++ b/packages/web/src/lib/helm.ts @@ -0,0 +1,90 @@ +import { ref } from "vue"; +import type { ServerToWeb, SessionMeta, WebMessage } from "@agent-helm/shared"; + +/** Callbacks som terminal-vyn registrerar för den session den visar. */ +export interface HelmHandlers { + onSnapshot?: (sessionId: string, data: string, meta: SessionMeta) => void; + onOutput?: (sessionId: string, data: string) => void; + onExit?: (sessionId: string, code: number) => void; +} + +export function createHelmClient() { + const connected = ref(false); + const connecting = ref(false); + const error = ref(null); + const sessions = ref([]); + const handlers: HelmHandlers = {}; + + let ws: WebSocket | null = null; + + function send(msg: WebMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); + } + + function connect(url: string, token: string): void { + error.value = null; + connecting.value = true; + ws = new WebSocket(url); + + ws.onopen = () => send({ type: "web:hello", token }); + + ws.onmessage = (ev) => { + let msg: ServerToWeb; + try { + msg = JSON.parse(ev.data as string) as ServerToWeb; + } catch { + return; + } + switch (msg.type) { + case "server:welcome": + connected.value = true; + connecting.value = false; + break; + case "server:sessions": + sessions.value = msg.sessions; + break; + case "server:snapshot": + handlers.onSnapshot?.(msg.sessionId, msg.data, msg.meta); + break; + case "server:output": + handlers.onOutput?.(msg.sessionId, msg.data); + break; + case "server:exit": + sessions.value = sessions.value.map((s) => + s.sessionId === msg.sessionId ? { ...s, status: "exited", exitCode: msg.code } : s, + ); + handlers.onExit?.(msg.sessionId, msg.code); + break; + case "server:error": + error.value = msg.message; + connecting.value = false; + break; + } + }; + + ws.onclose = () => { + connected.value = false; + connecting.value = false; + }; + ws.onerror = () => { + error.value = "Kunde inte ansluta till servern."; + connecting.value = false; + }; + } + + function subscribe(sessionId: string): void { + send({ type: "web:subscribe", sessionId }); + } + function sendInput(sessionId: string, data: string): void { + send({ type: "web:input", sessionId, data }); + } + function disconnect(): void { + ws?.close(); + ws = null; + connected.value = false; + } + + return { connected, connecting, error, sessions, handlers, connect, disconnect, subscribe, sendInput }; +} + +export type HelmClient = ReturnType; diff --git a/packages/web/src/main.ts b/packages/web/src/main.ts new file mode 100644 index 0000000..de275e7 --- /dev/null +++ b/packages/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; + +createApp(App).mount("#app"); diff --git a/packages/web/src/style.css b/packages/web/src/style.css new file mode 100644 index 0000000..63e44dd --- /dev/null +++ b/packages/web/src/style.css @@ -0,0 +1,31 @@ +:root { + --bg: #0b0e14; + --panel: #11151f; + --panel-2: #161b29; + --border: #232a3b; + --text: #d6dcea; + --muted: #8a93a8; + --accent: #5aa6ff; + --green: #43d17a; + --red: #ff6b6b; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #app { + height: 100%; + margin: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; +} + +button { + font: inherit; + cursor: pointer; +} diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/packages/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..7824e18 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@agent-helm/shared": ["../shared/src/index.ts"] + }, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..611b288 --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { fileURLToPath } from "node:url"; + +// Pekar @agent-helm/shared direkt på TS-källan och låter Vite transpilera den +// som vanlig app-kod (slipper bygg-steg för shared i den tunna slicen). +const sharedSrc = fileURLToPath(new URL("../shared/src/index.ts", import.meta.url)); +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); + +export default defineConfig({ + plugins: [vue()], + envDir: repoRoot, + resolve: { + alias: { + "@agent-helm/shared": sharedSrc, + }, + }, + server: { + host: true, + fs: { allow: [repoRoot] }, + }, +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..91ad1a7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - "packages/*" + +# pnpm 10+ blockar native build-scripts som standard. Tillåt dem vi behöver: +# node-pty kompilerar sin binär, esbuild hämtar sin plattformsbinär (vite/tsx). +onlyBuiltDependencies: + - esbuild + - node-pty diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..db42233 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true + } +}