2026-07-23 20:53:32 +00:00
|
|
|
#!/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/<jobId>. 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 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: "" }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 || "/" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 22:55:32 +00:00
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 20:53:32 +00:00
|
|
|
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}`);
|
|
|
|
|
});
|