diff --git a/.env.secrets.example b/.env.secrets.example index 4f095e2..6512009 100644 --- a/.env.secrets.example +++ b/.env.secrets.example @@ -1,12 +1,5 @@ -# Required for Namecheap API DNS automation script. -# Keep real values in .env.secrets (already gitignored). +# Local-only secrets (kept out of git). +# Copy to `.env.secrets` and add only what you need for your environment. -NAMECHEAP_API_KEY=replace-me -NAMECHEAP_API_USER=replace-me -NAMECHEAP_USERNAME=replace-me -NAMECHEAP_CLIENT_IP=replace-me -NAMECHEAP_DOMAIN=yappybara.dev - -# Optional: defaults to obsecurus.github.io -GITHUB_PAGES_USER=obsecurus -# GITHUB_PAGES_CNAME_TARGET=obsecurus.github.io +# Example: +# ANTHROPIC_API_KEY=sk-ant-... diff --git a/.gitignore b/.gitignore index b6c44b4..72b0a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ archive/ # Devcontainer local cert material .devcontainer/certs/ + +# Local-only helper scripts and secrets +.local/ diff --git a/CLAUDE.md b/CLAUDE.md index 3798e0f..73cfb35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,6 @@ make interview-test - `site/` is deployed by `.github/workflows/pages.yml` - custom domain target: `yappybara.dev` -- DNS automation script: `scripts/configure-namecheap-github-pages.mjs` ## Release posture diff --git a/README.md b/README.md index 36fa269..9ba631e 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,6 @@ The public site is in `site/` and deploys via `.github/workflows/pages.yml`. Setup guide: - `docs/github-pages.md` -## Namecheap DNS automation - -Use the existing script to configure apex + `www` for GitHub Pages: - -```bash -node scripts/configure-namecheap-github-pages.mjs -node scripts/configure-namecheap-github-pages.mjs --apply -``` - -Required secrets are documented in `.env.secrets.example`. - ## Repository layout ```text diff --git a/docs/github-pages.md b/docs/github-pages.md index 8790dd6..ef3b146 100644 --- a/docs/github-pages.md +++ b/docs/github-pages.md @@ -24,7 +24,7 @@ After Pages is enabled: 1. In `Settings -> Pages`, set custom domain to `yappybara.dev` 2. Enable HTTPS once DNS has propagated -## 3) Namecheap DNS records +## 3) DNS records For GitHub Pages custom domain support: @@ -34,39 +34,11 @@ For GitHub Pages custom domain support: - `@ A 185.199.111.153` - `www CNAME obsecurus.github.io` -## 4) DNS automation script - -Dry run: - -```bash -node scripts/configure-namecheap-github-pages.mjs -``` - -Apply: - -```bash -node scripts/configure-namecheap-github-pages.mjs --apply -``` - -Required `.env.secrets` values: - -- `NAMECHEAP_API_KEY` -- `NAMECHEAP_API_USER` -- `NAMECHEAP_USERNAME` -- `NAMECHEAP_CLIENT_IP` -- `NAMECHEAP_DOMAIN` (defaults to `yappybara.dev`) - -Optional: - -- `GITHUB_PAGES_USER` (defaults to `obsecurus`) -- `GITHUB_PAGES_CNAME_TARGET` (defaults to `${GITHUB_PAGES_USER}.github.io`) - -## 5) Verify +## 4) Verify - Pages URL: `https://obsecurus.github.io/yappybara/` - Custom domain: `https://yappybara.dev` If custom domain is still pending: - verify DNS records, -- verify Namecheap API whitelist for `NAMECHEAP_CLIENT_IP`, - wait for DNS propagation. diff --git a/interview-app/public/index.html b/interview-app/public/index.html index 444d517..c9af34a 100644 --- a/interview-app/public/index.html +++ b/interview-app/public/index.html @@ -2249,6 +2249,10 @@

Parent Supervision Required

lastSpokenEndedAt: 0, }; +const PREVIEW_BLOCK_MATCH_RE = /---\s*PREVIEW\s*---([\s\S]*?)---\s*END_PREVIEW\s*---/i; +const PREVIEW_BLOCK_STRIP_RE = /---\s*PREVIEW\s*---[\s\S]*?---\s*END_PREVIEW\s*---/gi; +const BUILD_PHASE_BLOCK_STRIP_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---[\s\S]*?---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi; + // ===== DOM refs ===== const $chat = document.getElementById('chat'); const $input = document.getElementById('text-input'); @@ -3388,9 +3392,9 @@

Parent Supervision Required

if (buildPollTimer) return Promise.resolve(); // Strip markdown + emoji for speech (emoji still shows in chat, just not spoken) const clean = text - .replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '') + .replace(BUILD_PHASE_BLOCK_STRIP_RE, '') // Avoid speaking preview boilerplate; STT can hear it and create loops. - .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, '') + .replace(PREVIEW_BLOCK_STRIP_RE, '') .replace(/---INTERVIEW_COMPLETE---[\s\S]*---END_INTERVIEW---/g, 'I\'ve put together a summary of your app idea!') .replace(/[#*_`\[\]()>]/g, '') .replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '') @@ -3635,10 +3639,10 @@

Parent Supervision Required

function sanitizeAiDisplayText(text) { if (!text) return text; // Never show build internals in chat bubbles. - let cleaned = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ''); + let cleaned = text.replace(BUILD_PHASE_BLOCK_STRIP_RE, ''); // Protect preview HTML from text sanitization so tags remain valid. const previewBlocks = []; - cleaned = cleaned.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, (match) => { + cleaned = cleaned.replace(PREVIEW_BLOCK_STRIP_RE, (match) => { previewBlocks.push(match); return `__PREVIEW_${previewBlocks.length - 1}__`; }); @@ -3703,9 +3707,9 @@

Parent Supervision Required

} else if (role === 'ai') { // Extract and render preview blocks let displayText = text; - const previewMatch = text.match(/---PREVIEW---([\s\S]*?)---END_PREVIEW---/); + const previewMatch = text.match(PREVIEW_BLOCK_MATCH_RE); if (previewMatch) { - displayText = text.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/, '').trim(); + displayText = text.replace(PREVIEW_BLOCK_STRIP_RE, '').trim(); } // Highlight the question so the kid knows what to answer @@ -3853,6 +3857,27 @@

Meet Your Build Crew!

} // ===== API Communication ===== +async function syncBuildProgressFromServer() { + try { + const resp = await fetch('/api/build-status'); + if (!resp.ok) return false; + const data = await resp.json(); + + if (data.status === 'building') { + switchTab('dash'); + startBuildPolling(); + return true; + } + + if (data.status === 'done' && data.previewUrl) { + updateBuildState('done', data.previewUrl.replace(/^\//, '')); + } + } catch (err) { + // Ignore transient polling errors and keep chat flow moving. + } + return false; +} + async function sendMessage(text, imageInfo) { if (state.isWaiting) return; if (!text && !imageInfo) return; @@ -3930,15 +3955,19 @@

Meet Your Build Crew!

setBartMood('excited'); setTimeout(() => { if (bartMood === 'excited') setBartMood('idle'); }, 500); addMessage('ai', aiText); - await speak(aiText); - // If build phase was triggered, switch to Dash tab + let buildStarted = false; if (data.buildPhase) { - addMessage('ai', 'Dash is on it! Switch to the Dash tab to watch the progress and have fun while you wait!'); - setTimeout(() => { - switchTab('dash'); - startBuildPolling(); - }, 1500); + addSystemMessage('Dash is on it! Opening Dash so you can watch the build.'); + switchTab('dash'); + startBuildPolling(); + buildStarted = true; + } else { + buildStarted = await syncBuildProgressFromServer(); + } + + if (!buildStarted) { + await speak(aiText); } } catch (err) { hideTyping(); @@ -5447,8 +5476,22 @@

What You Built

const sysMsg = document.querySelector('#chat .message.system'); if (sysMsg) sysMsg.remove(); addMessage('ai', chatData.response); - await speak(chatData.response); - if (state.recognition) { + + let buildStarted = false; + if (chatData.buildPhase) { + addSystemMessage('Dash is on it! Opening Dash so you can watch the build.'); + switchTab('dash'); + startBuildPolling(); + buildStarted = true; + } else { + buildStarted = await syncBuildProgressFromServer(); + } + + if (!buildStarted) { + await speak(chatData.response); + } + + if (state.recognition && !buildPollTimer) { state.micActive = true; startListening(); } @@ -5475,6 +5518,10 @@

What You Built

// Update build state const buildStatus = data.buildStatus || 'idle'; updateBuildState(buildStatus, data.buildPath); + if (buildStatus === 'building') { + switchTab('dash'); + startBuildPolling(); + } renderDeployStatus(data.deployRequest || null); enterAppMode(); @@ -5819,7 +5866,20 @@

What You Built

refreshParentGuide(); state.messages.push({ role: 'assistant', content: data.response }); addMessage('ai', data.response); - await speak(data.response); + + let buildStarted = false; + if (data.buildPhase) { + addSystemMessage('Dash is on it! Opening Dash so you can watch the build.'); + switchTab('dash'); + startBuildPolling(); + buildStarted = true; + } else { + buildStarted = await syncBuildProgressFromServer(); + } + + if (!buildStarted) { + await speak(data.response); + } } catch(e) { hideTyping(); } state.isWaiting = false; setBartMood('idle'); diff --git a/interview-app/safety.js b/interview-app/safety.js index d9622f4..d7ad070 100644 --- a/interview-app/safety.js +++ b/interview-app/safety.js @@ -105,6 +105,9 @@ export function sanitizePreviewHtml(html) { return cleaned; } +const PREVIEW_BLOCK_RE = /---\s*PREVIEW\s*---([\s\S]*?)---\s*END_PREVIEW\s*---/gi; +const BUILD_PHASE_BLOCK_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---([\s\S]*?)---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi; + // Hard guardrail — runs on every Bart response before it reaches the kid export function sanitizeBartResponse(text) { if (!text) return text; @@ -115,11 +118,11 @@ export function sanitizeBartResponse(text) { // (they contain HTML/spec content which has legit tech words) const previewBlocks = []; const buildBlocks = []; - let cleaned = text.replace(/---PREVIEW---([\s\S]*?)---END_PREVIEW---/g, (match) => { + let cleaned = text.replace(PREVIEW_BLOCK_RE, (match) => { previewBlocks.push(match); return `__PREVIEW_${previewBlocks.length - 1}__`; }); - cleaned = cleaned.replace(/---BUILD_PHASE---([\s\S]*?)---END_BUILD_PHASE---/g, (match) => { + cleaned = cleaned.replace(BUILD_PHASE_BLOCK_RE, (match) => { buildBlocks.push(match); return `__BUILD_${buildBlocks.length - 1}__`; }); diff --git a/interview-app/server.js b/interview-app/server.js index 1c4b0ae..629f983 100644 --- a/interview-app/server.js +++ b/interview-app/server.js @@ -31,6 +31,9 @@ if (BUILDS_DIR !== LEGACY_BUILDS_DIR) { } const BUILD_NUDGE_MINUTES = 1; // After this many minutes, nudge Bart to build +const PREVIEW_BLOCK_RE = /---\s*PREVIEW\s*---[\s\S]*?---\s*END_PREVIEW\s*---/gi; +const BUILD_PHASE_BLOCK_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---([\s\S]*?)---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/i; +const BUILD_PHASE_BLOCK_RE_GLOBAL = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---[\s\S]*?---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi; const REQUESTED_ASSIST_MODE = (process.env.YAPPY_MODE || 'auto').toLowerCase(); const VALID_ASSIST_MODES = new Set(['auto', 'claude', 'demo']); const REQUESTED_BILLING_MODE = (process.env.YAPPY_BILLING_MODE || 'subscription').toLowerCase(); @@ -672,8 +675,8 @@ function userMessageTexts(messages) { function cleanIdeaText(raw) { return String(raw || '') // Remove preview/build payloads if they somehow appear in message history. - .replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, ' ') - .replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ' ') + .replace(PREVIEW_BLOCK_RE, ' ') + .replace(BUILD_PHASE_BLOCK_RE_GLOBAL, ' ') // Remove html/xml tags to avoid parsing attribute values as app names. .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') @@ -1224,11 +1227,11 @@ app.post('/api/chat', async (req, res) => { }); // Detect build phase trigger - const buildMatch = responseText.match(/---BUILD_PHASE---([\s\S]*?)---END_BUILD_PHASE---/); + const buildMatch = responseText.match(BUILD_PHASE_BLOCK_RE); const currentBuildStatus = activeApp.buildStatus || 'idle'; if (buildMatch && (currentBuildStatus === 'idle' || currentBuildStatus === 'done' || currentBuildStatus === 'failed')) { const spec = buildMatch[1].trim(); - const displayText = responseText.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/, '').trim(); + const displayText = responseText.replace(BUILD_PHASE_BLOCK_RE_GLOBAL, '').trim(); logAction('build_phase', { spec: spec.slice(0, 500), appId: activeApp.id }); updateActiveApp({ buildStatus: 'building', spec }); diff --git a/interview-app/test.js b/interview-app/test.js index 8975f42..0a884ce 100644 --- a/interview-app/test.js +++ b/interview-app/test.js @@ -582,6 +582,19 @@ Idea: Build with HTML and API calls. assert.ok(cleaned.includes('---END_BUILD_PHASE---')); }); + it('restores flexible build phase marker variants', () => { + const raw = `Ready to build! + +--- build-phase --- +App Name: Rocket Painter +Idea: Paint stars and rockets. +--- end build-phase ---`; + const cleaned = sanitizeBartResponse(raw); + assert.ok(cleaned.includes('--- build-phase ---')); + assert.ok(cleaned.includes('App Name: Rocket Painter')); + assert.ok(cleaned.includes('--- end build-phase ---')); + }); + it('does not alter interview complete payload', () => { const raw = `---INTERVIEW_COMPLETE--- # Dream App @@ -766,6 +779,54 @@ describe('API Endpoints (integration — requires server)', () => { assert.equal(r.status, 400); }); + it('POST /api/chat triggers build phase and publishes build status', async (t) => { + if (skipIfNoServer()) return t.skip('Server not available'); + + const createResp = await fetch(`${BASE}/api/apps`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Build Trigger Test' }), + }); + const { id } = await createResp.json(); + await fetch(`${BASE}/api/apps/${id}/activate`, { method: 'PUT' }); + + const messages = []; + let buildTriggered = false; + + for (let i = 1; i <= 7; i++) { + messages.push({ role: 'user', content: `Turn ${i}: I want a fun drawing app for kids.` }); + const chatResp = await fetch(`${BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }), + }); + assert.equal(chatResp.status, 200); + const chatData = await chatResp.json(); + messages.push({ role: 'assistant', content: chatData.response }); + if (chatData.buildPhase) { + buildTriggered = true; + break; + } + } + + assert.equal(buildTriggered, true, 'Expected build phase to trigger in demo mode'); + + let buildStatus = null; + let previewUrl = null; + for (let i = 0; i < 15; i++) { + const statusResp = await fetch(`${BASE}/api/build-status`); + assert.equal(statusResp.status, 200); + const statusData = await statusResp.json(); + buildStatus = statusData.status; + previewUrl = statusData.previewUrl || null; + if (buildStatus === 'done' || buildStatus === 'failed') break; + await new Promise(r => setTimeout(r, 200)); + } + + assert.equal(buildStatus, 'done'); + assert.ok(previewUrl && previewUrl.startsWith('/builds/')); + }); + it('GET /api/build-status returns idle when no active app', async (t) => { if (skipIfNoServer()) return t.skip('Server not available'); await fetch(`${BASE}/api/start-over`, { method: 'POST' }); diff --git a/scripts/configure-namecheap-github-pages.mjs b/scripts/configure-namecheap-github-pages.mjs deleted file mode 100755 index 2ba98d9..0000000 --- a/scripts/configure-namecheap-github-pages.mjs +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env node - -import fs from 'fs'; -import path from 'path'; -import process from 'process'; - -const args = new Set(process.argv.slice(2)); -const shouldApply = args.has('--apply'); - -const envFromFile = readEnvFile(path.resolve(process.cwd(), '.env.secrets')); -const env = { ...envFromFile, ...process.env }; - -const apiKey = normalizeSecret(env.NAMECHEAP_API_KEY || ''); -const apiUser = normalizeSecret(env.NAMECHEAP_API_USER || env.NAMECHEAP_USERNAME || ''); -const userName = normalizeSecret(env.NAMECHEAP_USERNAME || apiUser); -const clientIp = normalizeSecret(env.NAMECHEAP_CLIENT_IP || ''); -const domain = normalizeSecret(env.NAMECHEAP_DOMAIN || env.DOMAIN || 'yappybara.dev'); -const githubPagesTarget = normalizeSecret( - env.GITHUB_PAGES_CNAME_TARGET || `${normalizeSecret(env.GITHUB_PAGES_USER || 'obsecurus')}.github.io` -); - -const missing = []; -if (!apiKey) missing.push('NAMECHEAP_API_KEY'); -if (!apiUser) missing.push('NAMECHEAP_API_USER or NAMECHEAP_USERNAME'); -if (!userName) missing.push('NAMECHEAP_USERNAME'); -if (!clientIp) missing.push('NAMECHEAP_CLIENT_IP'); - -if (missing.length > 0) { - console.error('Missing required env values:'); - for (const key of missing) console.error(`- ${key}`); - console.error(''); - console.error('Add them to .env.secrets or export them in your shell, then rerun.'); - process.exit(1); -} - -const domainParts = splitDomain(domain); -if (!domainParts) { - console.error(`Could not parse domain: ${domain}`); - process.exit(1); -} - -const desiredRootRecords = [ - { Name: '@', Type: 'A', Address: '185.199.108.153', TTL: '1800' }, - { Name: '@', Type: 'A', Address: '185.199.109.153', TTL: '1800' }, - { Name: '@', Type: 'A', Address: '185.199.110.153', TTL: '1800' }, - { Name: '@', Type: 'A', Address: '185.199.111.153', TTL: '1800' }, - { Name: 'www', Type: 'CNAME', Address: githubPagesTarget, TTL: '1800' }, -]; - -const apiBase = 'https://api.namecheap.com/xml.response'; - -try { - const currentHostsXml = await callNamecheap('namecheap.domains.dns.getHosts', { - SLD: domainParts.sld, - TLD: domainParts.tld, - }); - - const currentHosts = parseHostsFromXml(currentHostsXml); - - const preservedHosts = currentHosts.filter((record) => { - const name = record.Name.toLowerCase(); - return name !== '@' && name !== 'www'; - }); - - const finalHosts = [...desiredRootRecords, ...preservedHosts]; - - console.log(`Planned DNS records for ${domain}:`); - for (const record of finalHosts) { - console.log(`- ${record.Name} ${record.Type} ${record.Address} (TTL ${record.TTL || '1800'})`); - } - - if (!shouldApply) { - console.log(''); - console.log('Dry run only. Re-run with --apply to push these records to Namecheap.'); - process.exit(0); - } - - const setParams = { - SLD: domainParts.sld, - TLD: domainParts.tld, - }; - - finalHosts.forEach((record, index) => { - const i = index + 1; - setParams[`HostName${i}`] = record.Name; - setParams[`RecordType${i}`] = record.Type; - setParams[`Address${i}`] = record.Address; - setParams[`TTL${i}`] = record.TTL || '1800'; - if (record.Type === 'MX') { - setParams[`MXPref${i}`] = record.MXPref || '10'; - } - }); - - const setHostsXml = await callNamecheap('namecheap.domains.dns.setHosts', setParams); - const ok = /IsSuccess="true"/i.test(setHostsXml) || /]*Status="OK"/i.test(setHostsXml); - - if (!ok) { - console.error('Namecheap responded, but success could not be confirmed.'); - process.exit(1); - } - - console.log(''); - console.log(`DNS update pushed for ${domain}.`); - console.log('Next: verify the custom domain in GitHub Pages settings.'); -} catch (error) { - console.error(`Namecheap API request failed: ${error.message}`); - process.exit(1); -} - -async function callNamecheap(command, extraParams) { - const query = new URLSearchParams({ - ApiUser: apiUser, - ApiKey: apiKey, - UserName: userName, - ClientIp: clientIp, - Command: command, - ...extraParams, - }); - - const response = await fetch(`${apiBase}?${query.toString()}`); - const text = await response.text(); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const apiError = text.match(/]*>([\s\S]*?)<\/Error>/i); - if (apiError) { - throw new Error(apiError[1].replace(/\s+/g, ' ').trim()); - } - - return text; -} - -function splitDomain(raw) { - const parts = raw.trim().toLowerCase().split('.').filter(Boolean); - if (parts.length < 2) return null; - return { - sld: parts[0], - tld: parts.slice(1).join('.'), - }; -} - -function parseHostsFromXml(xml) { - const hostTags = [...xml.matchAll(/]+?)\/?\s*>/gi)]; - const records = []; - - for (const [, attrBlob] of hostTags) { - const attrs = Object.fromEntries([...attrBlob.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); - - if (!attrs.Name || !attrs.Type || !attrs.Address) continue; - records.push({ - Name: attrs.Name, - Type: attrs.Type, - Address: attrs.Address, - TTL: attrs.TTL || '1800', - MXPref: attrs.MXPref || '10', - }); - } - - return records; -} - -function readEnvFile(filePath) { - const result = {}; - if (!fs.existsSync(filePath)) return result; - - const content = fs.readFileSync(filePath, 'utf8'); - for (const line of content.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const eqIndex = trimmed.indexOf('='); - if (eqIndex <= 0) continue; - - const key = trimmed.slice(0, eqIndex).trim(); - const value = trimmed.slice(eqIndex + 1).trim(); - result[key] = normalizeSecret(value); - } - return result; -} - -function normalizeSecret(value) { - let output = String(value || '').trim(); - output = output.replace(/^["'“”‘’]+/, '').replace(/["'“”‘’]+$/, ''); - return output; -} diff --git a/site/index.html b/site/index.html index d8ea410..abcea29 100644 --- a/site/index.html +++ b/site/index.html @@ -81,7 +81,7 @@

Public release posture

  • MIT licensed
  • CI-verified test suite
  • -
  • Domain automation script for Namecheap
  • +
  • Registrar DNS setup docs included