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 += ` \n CLAUDE_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 : "" } ) ) ;
} ) ;
}
2026-07-23 23:23:33 +00:00
// ---- 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 } ) ) ;
} ) ;
}
2026-07-23 20:53:32 +00:00
// ---- 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 } ) ;
}
2026-07-23 23:23:33 +00:00
// 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 ) ;
}
2026-07-23 20:53:32 +00:00
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 } ` ) ;
} ) ;