#!/usr/bin/env node /** * AstroAgent console runner. * * Runs AS the confined `carlos-arias-agent` user (systemd User=), binds * 127.0.0.1 only, and is reached exclusively through nginx, which gates every * request with auth_request against the PHP admin session. If a request lands * here, the operator is already an authenticated admin — this process does not * re-check that; its job is orchestration. * * Contract (matches app/src/components/DevConsole.astro): * GET /devconsole/ping -> { authed, hasToken } * POST /devconsole/run {message,page,...} -> { conversationId } (async) * GET /devconsole/stream?conversationId=... -> SSE: text|tool|preview|published|error|done * POST /devconsole/publish {conversationId} -> promotes preview, git commit * POST /devconsole/discard {conversationId} -> reverts working tree * POST /devconsole/auth {token} -> update the Claude token * POST /devconsole/logout -> (admin logout is PHP; no-op here) * * Safety model: * - The AGENT only edits files (tools: Read/Write/Edit/Glob/Grep/WebSearch, * NO Bash). This runner — not the agent — runs every build and git op. * - Every /run edits the working tree, then builds an ISOLATED preview to * public-preview/. Nothing is live. * - /publish builds to public/, verifies the build passed, then git-commits * (a rollback point). /discard does `git checkout -- .`. * - One job at a time (single admin); a lock serialises run/publish/discard. * * Node built-ins only — no dependencies. */ import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "..", ".."); // /www/wwwroot/CarlosAriasPersonal const APP = join(REPO, "app"); const PREVIEW_DIR = join(REPO, "public-preview"); const ENV_FILE = join(REPO, "agents", ".env"); const CLAUDE = "/usr/local/bin/claude"; const PHP = "/usr/bin/php"; const PORT = Number(process.env.ADMIN_PORT || 3011); const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch"; const DEFAULT_MODEL = "claude-sonnet-4-6"; // ---- env (.env) -------------------------------------------------------------- function loadEnv() { const out = {}; if (existsSync(ENV_FILE)) { for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m) out[m[1]] = m[2]; } } return out; } function saveToken(token) { let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : ""; if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) { s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`); } else { s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`; } writeFileSync(ENV_FILE, s, { mode: 0o600 }); } // ---- job state --------------------------------------------------------------- /** conversationId -> job */ const jobs = new Map(); let busy = false; // single-flight lock function newJob(page) { const conversationId = randomUUID(); const job = { conversationId, jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, sessionId: null, // claude session, for --resume follow-ups page: page || "/", events: [], // buffered for late SSE subscribers clients: new Set(), // active SSE responses done: false, }; jobs.set(conversationId, job); return job; } function emit(job, ev) { job.events.push(ev); const line = `data: ${JSON.stringify(ev)}\n\n`; for (const res of job.clients) { try { res.write(line); } catch {} } } // ---- prompt wrapper ---------------------------------------------------------- function buildPrompt({ message, page, selections }) { const parts = []; parts.push( "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).", "Make ONLY the change the operator asks for. Keep everything on-brand:", "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css", "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).", "Do NOT run builds or git commands — the console builds and publishes for you.", "", ); if (page && page !== "/") { parts.push(`The operator is on the page: ${page}`); parts.push( "Its source is almost certainly under app/src/pages (find it with Glob/Grep).", "", ); } else if (page === "/") { parts.push("The operator is on the homepage (app/src/pages/index.astro).", ""); } if (Array.isArray(selections) && selections.length) { parts.push("They selected these element(s) on the page:"); for (const s of selections) { const tag = s.tag || s.selector || "element"; parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`); } parts.push(""); } parts.push("Request:", message); return parts.join("\n"); } // ---- run the agent, then build a preview ------------------------------------- function agentEnv() { const env = loadEnv(); return { ...process.env, HOME: process.env.HOME || "/var/lib/carlos-arias-agent", PATH: "/usr/local/bin:/usr/bin:/bin", CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "", }; } function runAgent(job, { message, page, selections, model }) { const prompt = buildPrompt({ message, page, selections }); const args = [ "-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", AGENT_TOOLS, "--model", model || DEFAULT_MODEL, ]; if (job.sessionId) args.push("--resume", job.sessionId); const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); let buf = ""; child.stdout.on("data", (chunk) => { buf += chunk.toString(); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } handleStreamMsg(job, msg); } }); child.stderr.on("data", (d) => { const t = d.toString().trim(); if (t) emit(job, { kind: "tool", text: t.slice(0, 200) }); }); child.on("close", async (code) => { if (code !== 0) { emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." }); emit(job, { type: "done" }); job.done = true; busy = false; return; } await buildPreview(job); busy = false; }); child.on("error", (err) => { emit(job, { type: "error", text: `Could not start the agent: ${err.message}` }); emit(job, { type: "done" }); job.done = true; busy = false; }); } function handleStreamMsg(job, msg) { // capture the resumable session id if (msg.session_id) job.sessionId = msg.session_id; const content = msg?.message?.content; if (Array.isArray(content)) { for (const block of content) { if (block.type === "text" && block.text) emit(job, { kind: "text", text: block.text }); else if (block.type === "tool_use") { const label = block.name === "Edit" || block.name === "Write" ? `${block.name} ${shortPath(block.input?.file_path)}` : block.name; emit(job, { kind: "tool", text: label }); } } } if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) { emit(job, { kind: "text", text: msg.result.trim() }); } } function shortPath(p) { if (!p) return ""; return String(p).replace(REPO + "/", ""); } // ---- builds ------------------------------------------------------------------ function build(extraEnv) { return new Promise((res) => { const child = spawn("npm", ["run", "build"], { cwd: APP, env: { ...agentEnv(), ...extraEnv }, }); let tail = ""; const grab = (d) => { tail = (tail + d.toString()).slice(-4000); }; child.stdout.on("data", grab); child.stderr.on("data", grab); child.on("close", (code) => res({ ok: code === 0, tail })); child.on("error", () => res({ ok: false, tail: "build failed to start" })); }); } async function buildPreview(job) { emit(job, { kind: "tool", text: "building preview…" }); const out = join("..", "public-preview", job.jobId); const base = `/_preview/${job.jobId}`; const { ok, tail } = await build({ PREVIEW_OUT: out, PREVIEW_BASE: base }); if (!ok) { emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." }); // revert the agent's edits so the working tree stays clean await git(["checkout", "--", "app", "brand", "api/db", "api/cli"]); emit(job, { type: "done" }); job.done = true; return; } const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : ""; emit(job, { type: "preview", url: `${base}/${target}` }); emit(job, { type: "done" }); job.done = true; } // ---- git --------------------------------------------------------------------- function git(args) { return new Promise((res) => { const child = spawn("git", args, { cwd: REPO, env: agentEnv() }); let tail = ""; child.stdout.on("data", (d) => (tail += d)); child.stderr.on("data", (d) => (tail += d)); child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() })); child.on("error", () => res({ ok: false, tail: "" })); }); } // ---- one-shot copy drafting -------------------------------------------------- // Turn a rough draft + prompt into polished project copy. No tools, no file // writes, no preview — just returns text for the admin to review before saving. function stripFence(t) { const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/); return (m ? m[1] : t).trim(); } function draftCopy({ name, category, draft, prompt }) { const ask = [ "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.", "", `Project name: ${name}`, category ? `Category: ${category}` : "", draft ? `The author's rough draft / notes:\n${draft}` : "", prompt ? `The author's instructions:\n${prompt}` : "", "", "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:", '- "summary": one sentence (<=160 chars) for the projects list.', '- "lede": one punchy opening line for the top of the project page.', '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.', ].filter(Boolean).join("\n"); return new Promise((resolve) => { const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", ""]; const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", () => { try { const envelope = JSON.parse(out); const text = typeof envelope.result === "string" ? envelope.result : ""; const obj = JSON.parse(stripFence(text)); resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" }); } catch { resolve({ ok: false }); } }); child.on("error", () => resolve({ ok: false })); }); } // ---- Web Designer: build a full structured project from a brief -------------- // Reads the uploaded images and authors a COMPLETE cja_projects record (copy + // skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns // structured data the server validates; it never mutates the repo. const MEDIA_RE = /^\/media\/[\w.-]+$/; const MAX_BUILD_IMAGES = 8; function buildProject({ name, category, draft, prompt, images, instagram }) { // keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO) const srcs = (Array.isArray(images) ? images : []) .map((im) => String(im?.src || "")) .filter((s) => MEDIA_RE.test(s)); const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES); if (srcs.length > MAX_BUILD_IMAGES) { console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`); } const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : ""; const ask = [ "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.", "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.", "", "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.", "", "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:", "- header: title + one-line lede", "- facts rail: kind (project type), period (timeline label), role", "- metrics: a few outcome stats, each {value, label, note?}", "- gallery: your caption for each image below", "- body: the case study", "- skills: grouped disciplines, each {group, items[]}", "- stack: technologies used, as plain strings", "- categories: 1-3 short tags", "", `Project name: ${name}`, category ? `Category hint: ${category}` : "", instagram ? "There is an Instagram reel for this project." : "", draft ? `The author's rough draft / notes:\n${draft}` : "", prompt ? `The author's special instructions (follow these):\n${prompt}` : "", "", paths.length ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}` : "No images were uploaded.", "", "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.", "", "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:", '- "summary": one sentence (<=160 chars) for the projects list.', '- "lede": one punchy opening line.', '- "body": the Markdown case study.', '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").', '- "period": a timeline label, e.g. "2024 — present" (or "").', '- "role": Carlos\'s role on the project (or "").', '- "categories": array of 1-3 short strings.', '- "stack": array of technology strings.', '- "skills": array of {"group": string, "items": [string, ...]}.', '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.', `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`, `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`, ].filter(Boolean).join("\n"); return new Promise((resolve) => { const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"]; const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", () => { try { const envelope = JSON.parse(out); const text = typeof envelope.result === "string" ? envelope.result : ""; const obj = JSON.parse(stripFence(text)); // Re-zip the gallery server-side: trust OUR srcs, take the model's // caption/alt by index. The model never dictates a file path. const caps = Array.isArray(obj.captions) ? obj.captions : []; const alts = Array.isArray(obj.alts) ? obj.alts : []; const gallery = useSrcs.map((src, i) => ({ src, alt: String(alts[i] || "").slice(0, 200), caption: String(caps[i] || "").slice(0, 120), })); const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []); resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "", kind: obj.kind || "", period: obj.period || "", role: obj.role || "", categories: strArr(obj.categories), stack: strArr(obj.stack), skills: Array.isArray(obj.skills) ? obj.skills : [], metrics: Array.isArray(obj.metrics) ? obj.metrics : [], gallery, }); } catch { resolve({ ok: false }); } }); child.on("error", () => resolve({ ok: false })); }); } // ---- Web Designer task queue ------------------------------------------------- // A durable design queue (cja_tasks) drained one task at a time. Each task runs // the full-builder agent on any page (skills + brief), then auto-publishes: // build must pass, then a scoped git commit. A failing task reverts itself and // the queue moves on. The runner has no DB driver, so it claims/finishes rows // via the api/cli/tasks-*.php helpers. const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; let draining = false; function phpCli(args) { return new Promise((resolve) => { const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() }); let out = "", err = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("close", () => resolve({ out, err })); child.on("error", () => resolve({ out: "", err: "php spawn failed" })); }); } async function claimNextTask() { const { out } = await phpCli(["api/cli/tasks-next.php"]); try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; } catch { return null; } } async function finishTask(id, status, result) { await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]); } function liveUrl(target) { if (!target) return "/"; if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, ""); return target; } function buildDesignPrompt(task) { let assets = { images: [], videos: [] }; try { assets = JSON.parse(task.assets || "{}") || {}; } catch {} const images = Array.isArray(assets.images) ? assets.images : []; const videos = Array.isArray(assets.videos) ? assets.videos : []; const target = task.target_page || "/"; const isNew = target.startsWith("new:"); const slug = isNew ? target.slice(4).replace(/^\/+/, "") : ""; const p = []; p.push( "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.", "You are a real designer: make considered, on-brand design decisions, not just literal edits.", "", "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.", "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.", "Do NOT run builds or git — the console builds and publishes for you.", "", ); if (isNew) { p.push( `TASK: create a NEW page at /${slug}.`, `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`, "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.", "", ); } else { p.push( `TASK: work on the existing page ${target}.`, "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.", "", ); } p.push("What to do:", task.prompt || "(no instructions given)", ""); if (task.draft && String(task.draft).trim()) { p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, ""); } if (images.length) { p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:"); for (const im of images) { p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`); } p.push(""); } if (videos.length) { p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):"); for (const v of videos) p.push(`- ${v}`); p.push(""); } p.push("Keep the change scoped to what's asked and leave the working tree with only your intended edits."); return p.join("\n"); } async function runDesignTask(task) { const prompt = buildDesignPrompt(task); // Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree // would get swept into this task's commit. Refuse rather than clobber WIP. const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); if (pre.tail.trim() !== "") { return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." }; } return new Promise((resolve) => { const args = [ "-p", prompt, "--output-format", "json", "--allowedTools", "Read Write Edit Glob Grep WebSearch Skill", "--model", DEFAULT_MODEL, ]; const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.on("close", async () => { let summary = ""; try { const env = JSON.parse(out); if (typeof env.result === "string") summary = env.result.trim().slice(0, 500); } catch {} // Any source changes to publish? const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]); const changed = status.tail.trim() !== ""; if (!changed) { return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) }); } // Build must pass before anything is committed. const built = await build({}); if (!built.ok) { await git(["checkout", "--", ...GIT_SCOPE]); await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added return resolve({ ok: false, error: "The change broke the build, so it was reverted." }); } await git(["add", "--", ...GIT_SCOPE]); const title = (task.title || "task").toString().slice(0, 120); await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]); const head = await git(["rev-parse", "--short", "HEAD"]); resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) }); }); child.on("error", () => resolve({ ok: false, error: "Could not start the agent." })); }); } // Drain the queue sequentially. Shares the global `busy` lock so it never runs // concurrently with the in-page console. Re-entrant-safe via `draining`. async function drainTasks() { if (draining) return; draining = true; try { for (;;) { if (busy) break; // in-page console is mid-edit; a later kick resumes the queue const task = await claimNextTask(); if (!task) break; busy = true; let res; try { res = await runDesignTask(task); } catch (e) { res = { ok: false, error: String((e && e.message) || e) }; } busy = false; await finishTask( task.task_id, res.ok ? "published" : "failed", res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" }, ); } } finally { draining = false; } } // ---- request helpers --------------------------------------------------------- function json(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(body); } function readBody(req) { return new Promise((res) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { res(b ? JSON.parse(b) : {}); } catch { res({}); } }); }); } // ---- server ------------------------------------------------------------------ const server = createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const path = url.pathname.replace(/^\/devconsole/, "") || "/"; const method = req.method || "GET"; try { if (path === "/ping") { const env = loadEnv(); return json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) }); } if (path === "/auth" && method === "POST") { const { token } = await readBody(req); if (!token || !/^sk-ant-/.test(token)) { return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." }); } saveToken(token.trim()); return json(res, 200, { ok: true }); } if (path === "/logout" && method === "POST") { return json(res, 200, { ok: true }); // admin session is cleared by PHP } if (path === "/run" && method === "POST") { if (busy) return json(res, 429, { error: "A change is already in progress — let it finish." }); const body = await readBody(req); const message = String(body.message || "").trim(); if (!message) return json(res, 400, { error: "Say what you'd like changed." }); const env = loadEnv(); if (!env.CLAUDE_CODE_OAUTH_TOKEN) { return json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." }); } // Continue an existing conversation, or start a new one. let job = body.conversationId && jobs.get(body.conversationId); if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } else job = newJob(body.page); busy = true; runAgent(job, { message, page: body.page, selections: body.selections, model: body.model }); return json(res, 200, { conversationId: job.conversationId }); } // Draft project copy from a rough note + prompt (used by the New Project form). if (path === "/draft" && method === "POST") { if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); const body = await readBody(req); const name = String(body.name || "").trim(); if (!name) return json(res, 400, { error: "Add a project name first." }); const env = loadEnv(); if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); busy = true; const d = await draftCopy(body); busy = false; if (!d.ok) return json(res, 500, { error: "Couldn't generate a draft — try again." }); return json(res, 200, d); } // Web Designer: build a full structured project from a brief + images. if (path === "/build-project" && method === "POST") { if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); const body = await readBody(req); const name = String(body.name || "").trim(); if (!name) return json(res, 400, { error: "Add a project name first." }); const env = loadEnv(); if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); busy = true; const d = await buildProject(body); busy = false; if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." }); return json(res, 200, d); } // Kick the Web Designer task queue: drain any queued design tasks. Returns // immediately; the admin UI polls /api/admintasks/list for status. if (path === "/tasks/run" && method === "POST") { const env = loadEnv(); if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." }); drainTasks(); // fire-and-forget return json(res, 200, { ok: true, draining, busy }); } if (path === "/stream") { const id = url.searchParams.get("conversationId"); const job = id && jobs.get(id); if (!job) return json(res, 404, { error: "unknown conversation" }); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); // replay buffered, then stream live for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`); if (job.done) return res.end(); job.clients.add(res); req.on("close", () => job.clients.delete(res)); return; } if (path === "/publish" && method === "POST") { if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); const { conversationId } = await readBody(req); const job = conversationId && jobs.get(conversationId); if (!job) return json(res, 404, { error: "Nothing to publish." }); busy = true; const built = await build({}); // real build to public/ if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed — not published." }); } await git(["add", "--", "app", "brand", "api/db", "api/cli"]); const summary = (job.page && job.page !== "/" ? job.page : "homepage"); await git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]); rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); busy = false; return json(res, 200, { ok: true, live: job.page || "/" }); } // Rebuild the live site — used after structured content edits (projects // gallery, etc.) that changed the DB rather than files. Builds public/ and // commits any new media files (a no-op commit is fine). if (path === "/rebuild" && method === "POST") { if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); busy = true; const built = await build({}); if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); } await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]); await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit busy = false; return json(res, 200, { ok: true }); } if (path === "/discard" && method === "POST") { if (busy) return json(res, 429, { error: "Busy — try again in a moment." }); const { conversationId } = await readBody(req); const job = conversationId && jobs.get(conversationId); busy = true; const CONTENT = ["app", "brand", "api/db", "api/cli"]; await git(["checkout", "--", ...CONTENT]); await git(["clean", "-fd", ...CONTENT]); if (job) rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true }); busy = false; if (job) jobs.delete(job.conversationId); return json(res, 200, { ok: true }); } return json(res, 404, { error: "not found" }); } catch (err) { return json(res, 500, { error: err.message }); } }); if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true }); server.listen(PORT, "127.0.0.1", () => { console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`); });