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" ;
2026-07-24 00:16:39 +00:00
const PHP = "/usr/bin/php" ;
2026-07-23 20:53:32 +00:00
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 23:44:50 +00:00
// ---- 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 } ) ) ;
} ) ;
}
2026-07-24 00:16:39 +00:00
// ---- 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 ;
}
2026-07-24 02:45:53 +00:00
function nowStamp ( ) {
const d = new Date ( ) ;
const p = ( n ) => String ( n ) . padStart ( 2 , "0" ) ;
return ` ${ d . getFullYear ( ) } - ${ p ( d . getMonth ( ) + 1 ) } - ${ p ( d . getDate ( ) ) } ${ p ( d . getHours ( ) ) } : ${ p ( d . getMinutes ( ) ) } ` ;
}
2026-07-24 00:16:39 +00:00
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 ( "" ) ;
}
2026-07-24 02:45:53 +00:00
p . push (
"" ,
"After you finish the change, log it to the public changelog using your `changelog` skill:" ,
"- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice." ,
` - Use the timestamp ' ${ nowStamp ( ) } ' and attribute it to 'Website Designer Agent' (the 5th array element). ` ,
"- Choose the right type (added / updated / fixed / removed)." ,
"- Do NOT run the reseed or the build — the console does that for you." ,
"- If you ended up making no change to the site, do not add a changelog entry." ,
"" ,
"Keep the change scoped to what's asked and leave the working tree with only your intended edits." ,
) ;
2026-07-24 00:16:39 +00:00
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.
2026-07-24 00:20:52 +00:00
// Uploaded assets under app/public/media/ are expected (they're this task's
// images) and get committed with it, so they don't count as "dirty".
2026-07-24 00:16:39 +00:00
const pre = await git ( [ "status" , "--porcelain" , "--" , ... GIT _SCOPE ] ) ;
2026-07-24 00:20:52 +00:00
const dirty = pre . tail
. split ( "\n" )
. map ( ( l ) => l . slice ( 3 ) . trim ( ) ) // strip the "XY " status prefix
. filter ( Boolean )
. filter ( ( p ) => ! p . startsWith ( "app/public/media/" ) ) ;
if ( dirty . length ) {
2026-07-24 00:16:39 +00:00
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 ) } ) ;
}
2026-07-24 02:45:53 +00:00
// The agent logs to the changelog by editing api/cli/seed-changelog.php
// (it has no shell). If it did, reseed cja_changelog so the build picks up
// the new entry — the changelog page reads the DB at build time.
if ( /seed-changelog\.php/ . test ( status . tail ) ) {
await phpCli ( [ "api/cli/seed-changelog.php" ] ) ;
}
2026-07-24 00:16:39 +00:00
// 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 ;
}
}
2026-07-24 03:30:13 +00:00
// ---- QA agent ----------------------------------------------------------------
// A deterministic HTTP crawler tests the live static site (links, images, forms,
// API, SEO/meta), stores findings via the qa-*.php CLI, and a thin LLM step
// writes a plain-English summary. Read-only against the site — it never changes
// anything; fixes only happen when the admin clicks "Create fix task".
const QA _BASE = "https://carlosarias.co" ;
const QA _UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)" ;
const QA _STATIC _ROUTES = [
"/" , "/about" , "/services" , "/services/website-design" ,
"/projects" , "/blog" , "/contact" , "/changelog" , "/resume" , "/faq" ,
] ;
let qaRunning = false ;
async function probe ( url , { method = "GET" , readBody = false , timeout = 12000 , headers = { } , body = null } = { } ) {
const ctrl = new AbortController ( ) ;
const t = setTimeout ( ( ) => ctrl . abort ( ) , timeout ) ;
try {
const r = await fetch ( url , {
method , redirect : "follow" , signal : ctrl . signal ,
headers : { "user-agent" : QA _UA , ... headers } , body ,
} ) ;
let text = null ;
if ( readBody ) text = await r . text ( ) ;
else { try { await r . body ? . cancel ( ) ; } catch { } }
return { status : r . status , ok : r . ok , finalUrl : r . url , text } ;
} catch ( e ) {
return { status : 0 , ok : false , error : e . name === "AbortError" ? "timeout" : ( e . message || "network error" ) } ;
} finally { clearTimeout ( t ) ; }
}
async function pMap ( items , concurrency , fn ) {
const out = [ ] ; let i = 0 ;
const workers = Array . from ( { length : Math . min ( concurrency , items . length || 1 ) } , async ( ) => {
while ( i < items . length ) { const idx = i ++ ; out [ idx ] = await fn ( items [ idx ] , idx ) ; }
} ) ;
await Promise . all ( workers ) ;
return out ;
}
const qaNorm = ( p ) => { p = String ( p ) . split ( "#" ) [ 0 ] . split ( "?" ) [ 0 ] ; if ( p . length > 1 ) p = p . replace ( /\/+$/ , "" ) ; return p || "/" ; } ;
const qaAbs = ( href , pagePath ) => { try { return new URL ( href , QA _BASE + pagePath ) . href ; } catch { return null ; } } ;
const qaGrabAll = ( re , html ) => [ ... String ( html ) . matchAll ( re ) ] . map ( ( m ) => m [ 1 ] ) ;
const qaFirst = ( re , html ) => { const m = String ( html ) . match ( re ) ; return m ? m [ 1 ] . trim ( ) : "" ; } ;
function qaImgsNoAlt ( html ) {
2026-07-24 10:20:14 +00:00
// An image "has alt" if it carries an alt attribute at all — including an
// empty one (alt="" or a bare `alt`), which is the valid, deliberate signal
// for a decorative image. Only a total absence of alt is a finding.
const hasAlt = ( tag ) => / \ salt ( \ s *= | [ \ s > \ / ] ) / i . test ( tag ) ;
2026-07-24 03:30:13 +00:00
return [ ... String ( html ) . matchAll ( /<img\b[^>]*>/gi ) ] . map ( ( m ) => m [ 0 ] )
2026-07-24 10:20:14 +00:00
. filter ( ( tag ) => ! hasAlt ( tag ) )
2026-07-24 03:30:13 +00:00
. map ( ( tag ) => ( tag . match ( /\bsrc=["']([^"']+)["']/i ) || [ ] ) [ 1 ] )
. filter ( Boolean ) ;
}
async function runQa ( ) {
const findings = [ ] ;
const add = ( check _type , severity , url , detail , fix _hint = null ) =>
findings . push ( { check _type , severity , url , detail , fix _hint } ) ;
// ---- route set: static + DB-driven slugs -------------------------------
let dynamic = [ ] ;
try { dynamic = JSON . parse ( ( await phpCli ( [ "api/cli/qa-routes.php" ] ) ) . out || "[]" ) ; } catch { }
const routes = [ ... new Set ( [ ... QA _STATIC _ROUTES , ... dynamic ] . map ( qaNorm ) ) ] ;
// ---- fetch every page, parse HTML --------------------------------------
const pages = await pMap ( routes , 6 , async ( path ) => ( { path , r : await probe ( QA _BASE + path , { readBody : true } ) } ) ) ;
const fetched = new Map ( ) ; // normPath -> ok
const titles = new Map ( ) ; // title -> [paths]
const internal = new Set ( ) ; // internal target URLs
const images = new Set ( ) ; // image URLs
const external = new Map ( ) ; // external URL -> page it was found on
for ( const { path , r } of pages ) {
fetched . set ( path , r . ok ) ;
if ( ! r . ok ) {
add ( "page" , "error" , path , ` Returns ${ r . status || r . error } instead of 200. ` ,
` ${ path } returns ${ r . status || r . error } instead of 200. Investigate why the page fails to render and fix it. ` ) ;
continue ;
}
const html = r . text || "" ;
const title = qaFirst ( /<title[^>]*>([^<]*)<\/title>/i , html ) ;
const desc = qaFirst ( /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i , html ) ;
const canon = qaFirst ( /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i , html ) ;
if ( ! title ) add ( "seo" , "warning" , path , "Missing <title>." , ` ${ path } has no <title>. Add a page-specific title via its BaseLayout props. ` ) ;
else { if ( ! titles . has ( title ) ) titles . set ( title , [ ] ) ; titles . get ( title ) . push ( path ) ; }
if ( ! desc ) add ( "seo" , "warning" , path , "Missing meta description." , ` ${ path } has no meta description. Add a page-specific description via its BaseLayout props. ` ) ;
if ( canon ) { try { const h = new URL ( canon ) . host ; if ( h && h !== "carlosarias.co" ) add ( "seo" , "error" , path , ` Canonical points to ${ h } . ` , ` ${ path } canonical points to ${ h } instead of carlosarias.co. Fix the site URL / canonical. ` ) ; } catch { } }
if ( ! /<html[^>]+lang=/i . test ( html ) ) add ( "a11y" , "warning" , path , "<html> has no lang attribute." , ` ${ path } <html> tag has no lang attribute. Add lang="en". ` ) ;
for ( const href of qaGrabAll ( /<a\b[^>]*\bhref=["']([^"']+)["']/gi , html ) ) {
if ( /^(mailto:|tel:|javascript:|#|data:)/i . test ( href ) ) continue ;
const u = qaAbs ( href , path ) ; if ( ! u ) continue ;
const noHash = u . split ( "#" ) [ 0 ] ;
if ( noHash . startsWith ( QA _BASE ) ) internal . add ( noHash ) ;
else if ( /^https?:\/\//i . test ( noHash ) && ! external . has ( noHash ) ) external . set ( noHash , path ) ;
}
for ( const src of qaGrabAll ( /<img\b[^>]*\bsrc=["']([^"']+)["']/gi , html ) ) { const u = qaAbs ( src , path ) ; if ( u && /^https?:/i . test ( u ) ) images . add ( u . split ( "#" ) [ 0 ] ) ; }
for ( const src of qaImgsNoAlt ( html ) ) add ( "a11y" , "warning" , path , ` Image without alt: ${ src } ` , ` On ${ path } , the image " ${ src } " has no alt text. Add descriptive alt text. ` ) ;
}
// duplicate titles across pages
for ( const [ title , paths ] of titles ) {
if ( paths . length > 1 ) add ( "seo" , "warning" , paths . join ( ", " ) , ` ${ paths . length } pages share the title " ${ title } ". ` ,
` These pages share one <title> (" ${ title } "): ${ paths . join ( ", " ) } . Give each a distinct, page-specific title. ` ) ;
}
// ---- internal links not already fetched --------------------------------
const internalPaths = [ ... new Set ( [ ... internal ] . map ( ( u ) => qaNorm ( u . replace ( QA _BASE , "" ) || "/" ) ) ) ] ;
const toCheck = internalPaths . filter ( ( p ) => ! fetched . has ( p ) ) ;
await pMap ( toCheck , 8 , async ( p ) => {
const r = await probe ( QA _BASE + p , { } ) ;
if ( ! r . ok ) add ( "link" , "error" , p , ` Broken internal link ( ${ r . status || r . error } ). ` ,
` An internal link points to ${ p } , which returns ${ r . status || r . error } . Find that link in the page source and fix the URL or remove the link. ` ) ;
} ) ;
// ---- images ------------------------------------------------------------
await pMap ( [ ... images ] , 8 , async ( u ) => {
const r = await probe ( u , { } ) ;
if ( ! r . ok ) add ( "image" , "error" , u , ` Image returns ${ r . status || r . error } . ` ,
` The image ${ u } returns ${ r . status || r . error } . Fix the image path or replace the image. ` ) ;
} ) ;
// ---- external links (tolerant: only clear 404/410/DNS failures) ---------
await pMap ( [ ... external . keys ( ) ] , 6 , async ( u ) => {
const r = await probe ( u , { method : "GET" , timeout : 12000 } ) ;
const clearlyBad = r . status === 404 || r . status === 410 || ( r . status === 0 && r . error && r . error !== "timeout" ) ;
if ( clearlyBad ) add ( "external" , "warning" , u , ` External link may be broken ( ${ r . status || r . error } ); found on ${ external . get ( u ) } . ` ,
` The external link ${ u } (on ${ external . get ( u ) } ) appears broken ( ${ r . status || r . error } ). Verify it and update or remove it. ` ) ;
} ) ;
// ---- contact form: two non-polluting probes ----------------------------
const cHeaders = { "content-type" : "application/json" , origin : QA _BASE , referer : QA _BASE + "/contact" } ;
const hp = await probe ( QA _BASE + "/api/contact/submit" , {
method : "POST" , readBody : true , headers : cHeaders ,
body : JSON . stringify ( { name : "QA Bot" , email : "qa@carlosarias.co" , subject : "other" , message : "QA honeypot probe — please ignore." , company : "qa-honeypot" } ) ,
} ) ;
if ( hp . status !== 200 ) add ( "form" , "error" , "/api/contact/submit" , ` Contact honeypot probe returned ${ hp . status || hp . error } (expected 200). ` ,
` POST /api/contact/submit returned ${ hp . status || hp . error } instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php. ` ) ;
const val = await probe ( QA _BASE + "/api/contact/submit" , {
method : "POST" , readBody : true , headers : cHeaders ,
body : JSON . stringify ( { name : "QA" , email : "qa@carlosarias.co" , subject : "other" , message : "hi" } ) ,
} ) ;
if ( val . status !== 422 ) add ( "form" , "warning" , "/api/contact/submit" , ` Validation probe returned ${ val . status || val . error } (expected 422 for a too-short message). ` ,
` POST /api/contact/submit did not reject an invalid submission (got ${ val . status || val . error } , expected 422). Server-side validation may be off. ` ) ;
// ---- API health --------------------------------------------------------
const health = await probe ( QA _BASE + "/api/health" , { readBody : true } ) ;
// The API wraps responses in a {ok, data, error} envelope, so db is at data.db.
let dbOk = false ; try { const j = JSON . parse ( health . text || "{}" ) ; dbOk = ( j . data ? . db ? ? j . db ) === "connected" ; } catch { }
if ( health . status !== 200 || ! dbOk ) add ( "health" , "error" , "/api/health" , ` Status ${ health . status || health . error } , db ${ dbOk ? "connected" : "not connected" } . ` ,
` /api/health returned ${ health . status || health . error } ${ dbOk ? "" : " and the database is not connected" } . The API or database may be down. ` ) ;
// ---- sitemap coverage --------------------------------------------------
const sm = await probe ( QA _BASE + "/sitemap.xml" , { readBody : true } ) ;
if ( sm . ok ) {
const locs = new Set ( qaGrabAll ( /<loc>([^<]+)<\/loc>/gi , sm . text || "" ) . map ( ( l ) => qaNorm ( l . replace ( QA _BASE , "" ) ) ) ) ;
for ( const p of routes ) if ( ! locs . has ( p ) ) add ( "sitemap" , "warning" , p , "Not listed in sitemap.xml." ,
` ${ p } is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it. ` ) ;
} else {
add ( "sitemap" , "warning" , "/sitemap.xml" , ` sitemap.xml returned ${ sm . status || sm . error } . ` , ` /sitemap.xml is unreachable ( ${ sm . status || sm . error } ). Check app/src/pages/sitemap.xml.js. ` ) ;
}
const counts = { error : 0 , warning : 0 , info : 0 , pages : pages . length , links : toCheck . length , images : images . size , external : external . size } ;
for ( const f of findings ) counts [ f . severity ] = ( counts [ f . severity ] || 0 ) + 1 ;
return { findings , counts } ;
}
// Thin LLM step: a plain-English run summary via the qa skill (best-effort).
function qaTriage ( findings , counts ) {
const top = [
... findings . filter ( ( f ) => f . severity === "error" ) . slice ( 0 , 12 ) ,
... findings . filter ( ( f ) => f . severity === "warning" ) . slice ( 0 , 12 ) ,
] ;
const lines = top . map ( ( f ) => ` - [ ${ f . severity } ] ${ f . check _type } ${ f . url } : ${ f . detail } ` ) . join ( "\n" ) || "(no issues found)" ;
const fallback = ` ${ counts . error } error(s) and ${ counts . warning } warning(s) across ${ counts . pages } pages. ` ;
const ask = [
"You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill." ,
` Counts: ${ counts . error } errors, ${ counts . warning } warnings across ${ counts . pages } pages. ` ,
"Top findings:" , lines ,
"" ,
"Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary." ,
] . join ( "\n" ) ;
return new Promise ( ( resolve ) => {
const child = spawn ( CLAUDE , [ "-p" , ask , "--output-format" , "json" , "--allowedTools" , "Read Skill" , "--model" , DEFAULT _MODEL ] , { cwd : REPO , env : agentEnv ( ) } ) ;
let out = "" ;
child . stdout . on ( "data" , ( d ) => ( out += d ) ) ;
child . on ( "close" , ( ) => {
let s = "" ;
try { const e = JSON . parse ( out ) ; if ( typeof e . result === "string" ) s = e . result . trim ( ) . slice ( 0 , 800 ) ; } catch { }
resolve ( s || fallback ) ;
} ) ;
child . on ( "error" , ( ) => resolve ( fallback ) ) ;
} ) ;
}
async function runQaFlow ( trigger ) {
if ( qaRunning ) return ;
qaRunning = true ;
let runId = 0 ;
try {
const start = await phpCli ( [ "api/cli/qa-start.php" , ` --trigger= ${ trigger } ` ] ) ;
runId = JSON . parse ( start . out || "{}" ) . run _id || 0 ;
if ( ! runId ) throw new Error ( "could not open a QA run" ) ;
const { findings , counts } = await runQa ( ) ;
2026-07-24 09:47:29 +00:00
// Triage (an LLM call) only when it's worth it: a manual run, or a
// scheduled heartbeat that actually found errors. Clean heartbeats get a
// cheap templated summary — no tokens spent when the site is fine.
const summary = ( trigger === "manual" || counts . error > 0 )
? await qaTriage ( findings , counts )
: ` ${ counts . error } error(s) and ${ counts . warning } warning(s) across ${ counts . pages } pages. ` ;
2026-07-24 03:30:13 +00:00
const tmp = ` /tmp/qa- ${ runId } .json ` ;
writeFileSync ( tmp , JSON . stringify ( findings ) ) ;
await phpCli ( [ "api/cli/qa-finish.php" , ` --run= ${ runId } ` , "--status=done" , ` --summary= ${ summary } ` , ` --counts= ${ JSON . stringify ( counts ) } ` , ` --findings-file= ${ tmp } ` ] ) ;
rmSync ( tmp , { force : true } ) ;
} catch ( e ) {
if ( runId ) await phpCli ( [ "api/cli/qa-finish.php" , ` --run= ${ runId } ` , "--status=failed" , ` --summary=QA run failed: ${ String ( ( e && e . message ) || e ) . slice ( 0 , 180 ) } ` ] ) ;
} finally {
qaRunning = 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 23:44:50 +00:00
// 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 ) ;
}
2026-07-24 00:16:39 +00:00
// 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 } ) ;
}
2026-07-24 03:30:13 +00:00
// Kick a QA run: crawl the live site, store findings. Read-only; returns
// immediately. The admin polls /api/adminqa/runs for status.
if ( path === "/qa/run" && method === "POST" ) {
if ( qaRunning ) return json ( res , 429 , { error : "A QA run is already in progress." } ) ;
const body = await readBody ( req ) ;
const trigger = body . trigger === "scheduled" ? "scheduled" : "manual" ;
runQaFlow ( trigger ) ; // fire-and-forget
return json ( res , 200 , { ok : true } ) ;
}
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 } ` ) ;
} ) ;
2026-07-24 09:47:29 +00:00
// ---- QA heartbeat ------------------------------------------------------------
// The runner is long-lived, so the QA heartbeat is just an in-process timer —
// no cron, no extra units. Runs a scheduled QA pass every QA_HEARTBEAT_MIN
// minutes (0 disables), plus one pulse shortly after boot. runQaFlow self-guards
// against overlap and is read-only against the live site.
const QA _HEARTBEAT _MIN = Number ( process . env . QA _HEARTBEAT _MIN || 60 ) ;
if ( QA _HEARTBEAT _MIN > 0 ) {
setTimeout ( ( ) => runQaFlow ( "scheduled" ) , 90_000 ) ; // first pulse ~90s after boot
setInterval ( ( ) => runQaFlow ( "scheduled" ) , QA _HEARTBEAT _MIN * 60_000 ) ;
console . log ( ` [qa] heartbeat every ${ QA _HEARTBEAT _MIN } min ` ) ;
}