← back to Studio Zero
server.js
232 lines
// Studio Zero — guided AI YouTube channel builder.
// Zero-dep Node http server: static host + POST /api/generate.
// Provider layer (SZ_PROVIDER): 'cli' (Claude Max plan via the local `claude` CLI, $0 marginal),
// 'api' (metered Anthropic HTTP API), or 'auto' (cli if the CLI is present, else api).
// Every path falls back to a canned demo deliverable so the prototype is always clickable.
const http = require('http'), fs = require('fs'), path = require('path'), https = require('https');
const os = require('os'), crypto = require('crypto'), { spawn, execSync } = require('child_process');
// --- Optional HTTP Basic-Auth gate (DEFAULT OFF) ---------------------------------
// Staged for a future public launch. While SZ_BASIC_AUTH is unset/0/off the app is wide
// open exactly as before (firewalled, internal-only) — byte-for-byte unchanged, so it never
// affects the current :9761 service or the /deploy /api/config smoke test. Flip it on
// (=1/on/true/yes) and EVERY request — including /api/* and the health check — requires
// Basic-Auth. Creds default admin/DW2024!, overridable via SZ_BASIC_USER / SZ_BASIC_PASS.
// Zero new dependencies (crypto is built-in); credential compare is constant-time.
const AUTH_ON = /^(1|on|true|yes)$/i.test(process.env.SZ_BASIC_AUTH || '');
const AUTH_USER = process.env.SZ_BASIC_USER || 'admin';
const AUTH_PASS = process.env.SZ_BASIC_PASS || 'DW2024!';
function tseq(a, b) { // constant-time string compare
const ab = Buffer.from(String(a)), bb = Buffer.from(String(b));
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function authOK(req) {
if (!AUTH_ON) return true;
const m = (req.headers['authorization'] || '').match(/^Basic\s+(.+)$/i);
if (!m) return false;
let dec = ''; try { dec = Buffer.from(m[1], 'base64').toString('utf8'); } catch (_) { return false; }
const i = dec.indexOf(':');
if (i < 0) return false;
const uok = tseq(dec.slice(0, i), AUTH_USER); // evaluate both (no short-circuit) to
const pok = tseq(dec.slice(i + 1), AUTH_PASS); // avoid leaking which half was wrong
return uok && pok;
}
const PORT = parseInt(process.env.PORT || '0', 10);
const DIR = __dirname;
const MODEL = process.env.SZ_MODEL || 'claude-sonnet-4-6'; // API label / default
const CLI_MODEL = process.env.SZ_CLI_MODEL || 'sonnet'; // alias the `claude` CLI accepts
const MAX_TOKENS = parseInt(process.env.SZ_MAX_TOKENS || '1600', 10);
// Provider selection. Default: prefer the Max-plan CLI (Steve's directive) when it's on PATH.
function cliAvailable() {
try { execSync('command -v claude', { stdio: 'ignore' }); return true; } catch (_) { return false; }
}
const HAS_CLI = cliAvailable();
let PROVIDER = (process.env.SZ_PROVIDER || 'auto').toLowerCase();
if (PROVIDER === 'auto') PROVIDER = HAS_CLI ? 'cli' : 'api';
// Anthropic per-Mtoken rates (USD) — used for the metered API path + the "equivalent" figure.
const RATES = {
'claude-sonnet-4-6': { in: 3, out: 15 },
'claude-opus-4-8': { in: 15, out: 75 },
'claude-haiku-4-5': { in: 1, out: 5 },
};
function rate(m) { return RATES[m] || RATES['claude-sonnet-4-6']; }
function costOf(model, usage) {
const r = rate(model);
const i = (usage && usage.input_tokens || 0) / 1e6 * r.in;
const o = (usage && usage.output_tokens || 0) / 1e6 * r.out;
return +(i + o).toFixed(5);
}
// Resolve the Anthropic API key at runtime WITHOUT copying or committing it
// (only used by the 'api' provider): env first, then project-local .env, then master secrets .env.
function resolveKey() {
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
const candidates = [
path.join(DIR, '.env'),
path.join(os.homedir(), 'Projects', 'secrets-manager', '.env'),
];
for (const f of candidates) {
try {
const m = fs.readFileSync(f, 'utf8').match(/^ANTHROPIC_API_KEY=(.+)$/m);
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
} catch (_) {}
}
return '';
}
const HAS_KEY = !!resolveKey();
function loadPrompts() {
try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'prompts.json'), 'utf8')); }
catch { return { steps: [] }; }
}
// --- Provider: Claude Max plan via the local `claude` CLI (subscription, $0 marginal) ---
// Spawns lean: API key stripped (forces Max-plan/subscription auth), no MCP, scratch cwd.
function callClaudeCLI(prompt) {
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_AUTH_TOKEN; // force the Max-plan subscription
const args = ['-p', prompt, '--output-format', 'json', '--model', CLI_MODEL, '--strict-mcp-config'];
const cp = spawn('claude', args, { env, cwd: os.tmpdir() });
let out = '', err = '';
cp.stdout.on('data', d => out += d);
cp.stderr.on('data', d => err += d);
const timer = setTimeout(() => { cp.kill('SIGKILL'); reject(new Error('cli timeout')); }, 150000);
cp.on('error', e => { clearTimeout(timer); reject(e); });
cp.on('close', () => {
clearTimeout(timer);
try {
const j = JSON.parse(out.trim());
const arr = Array.isArray(j) ? j : [j];
const r = arr.find(x => x && x.type === 'result') || arr[arr.length - 1] || j;
if (r.is_error) return reject(new Error(r.result || r.subtype || 'cli error'));
const model = r.modelUsage ? Object.keys(r.modelUsage)[0] : (MODEL);
resolve({ text: r.result || '', usage: r.usage, equiv: r.total_cost_usd || null, model, maxPlan: true });
} catch (e) { reject(new Error('cli parse: ' + (err.split('\n')[0] || e.message))); }
});
});
}
// --- Provider: metered Anthropic HTTP API ---
function callAnthropic(prompt) {
return new Promise((resolve, reject) => {
const key = resolveKey();
if (!key) return reject(new Error('no-key'));
const body = JSON.stringify({ model: MODEL, max_tokens: MAX_TOKENS, messages: [{ role: 'user', content: prompt }] });
const req = https.request({
hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST',
headers: { 'content-type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-length': Buffer.byteLength(body) },
}, (r) => {
let d = ''; r.on('data', c => d += c);
r.on('end', () => {
try {
const j = JSON.parse(d);
if (r.statusCode >= 400) return reject(new Error(j.error && j.error.message || ('HTTP ' + r.statusCode)));
const text = (j.content || []).filter(b => b.type === 'text').map(b => b.text).join('\n');
resolve({ text, usage: j.usage, model: j.model || MODEL, maxPlan: false, cost: costOf(j.model || MODEL, j.usage) });
} catch (e) { reject(e); }
});
});
req.on('error', reject);
req.setTimeout(120000, () => req.destroy(new Error('timeout')));
req.write(body); req.end();
});
}
async function generate(prompt) {
if (PROVIDER === 'cli' && HAS_CLI) return await callClaudeCLI(prompt);
return await callAnthropic(prompt);
}
// Demo-mode deliverable so the prototype is fully clickable with no provider.
function demoOutput(stepKey) {
const canned = {
niche: "### Top 10 niches (demo)\n\n| Niche | Avg CPM | Difficulty | Beyond AdSense | 3-word concept |\n|---|---|---|---|---|\n| AI tools for solopreneurs | $18–28 | Medium | Affiliate, courses, SaaS | \"Automate. Ship. Profit.\" |\n| Personal finance / FIRE | $22–35 | Medium | Affiliate, coaching, books | \"Money. Freedom. Calm.\" |\n| B2B software reviews | $25–40 | High | Sponsorships, affiliate | \"Software. Reviewed. Honestly.\" |\n| Home lab / self-hosting | $12–20 | Medium | Affiliate, Patreon | \"Own. Your. Data.\" |\n| Career / interview prep | $15–24 | Low | Courses, resume services | \"Land. The. Offer.\" |\n\n*(Demo output — enable a provider to generate the live, full 10-niche analysis.)*",
identity: "### Channel identity (demo)\n\n**Names:** Zero-to-Scale · The Quiet Engine · Solo Signal · Compound Studio · Nightshift Labs\n\n**Tagline:** *Build the machine that builds your channel.*\n\n**Persona:** 28–40, time-poor operator who wants leverage, not another hobby.\n\n**Pillars:** (1) Systems teardown (2) Tool-of-the-week (3) Case-study builds (4) Mindset / operating cadence.\n\n**Unique mechanism:** the \"one-input, seven-outputs\" content engine — every build becomes a week of content.\n\n*(Demo output — enable a provider for the live version.)*",
roadmap: "### 90-day roadmap (demo)\n\n1. How I Built an AI YouTube Channel in 90 Days\n2. The $0 Faceless Channel Stack\n3. 7 Prompts That Replace a Content Team\n4. Why Posting Daily Is Killing Your Channel\n5. The One-Input, Seven-Output System\n6. I Let Claude Plan 90 Days of Content\n7. Faceless Channel Mistakes That Cost Me Months\n8. The Thumbnail Formula That Doubled My CTR\n9. How to Monetize Before 1,000 Subs\n10. My Exact Upload Schedule (Copy It)\n11. Turning One Video Into a Week of Content\n12. The 90-Day Authority Ladder\n\n*(Demo output — enable a provider for the full calendar with schedule + SEO/virality priority.)*",
script: "### Script (demo)\n\n**[0:00 HOOK]** \"Everyone told you to post daily. That advice is quietly killing new channels — here's the math.\"\n\n**[Agitation]** …\n**[Solution]** …\n**[3 insights]** …\n**[CTA]** …\n**[End-screen tease]** \"Next video: the 7-output content engine.\"\n\n*(Demo output — enable a provider for the full script.)*",
seo: "### SEO + thumbnail package (demo)\n\n**Thumbnails:** (A) shocked-face + \"$0 → $10K\" (B) split before/after (C) big red arrow on a dashboard.\n\n**Title (<60):** I Built an AI YouTube Channel in 90 Days (Real Numbers)\n\n**Tags:** ai youtube, faceless channel, youtube automation, … *(10 total)*\n\n*(Demo output — enable a provider for the full package.)*",
monetization: "### Monetization stack (demo)\n\n**Affiliate:** tool stack you already use · **Digital product:** the exact template pack · **Sponsorship pitch:** 6-line cold template · **Lead magnet:** \"The 7-Prompt Channel Kit\" → email list.\n\n*(Demo output — enable a provider for the full roadmap.)*",
repurpose: "### Repurposing engine (demo)\n\n**X thread** · **LinkedIn post** · **3 Shorts hooks** · **Newsletter** · **Pinterest desc** — all generated from your script.\n\n*(Demo output — enable a provider for the full multi-platform pack.)*",
};
return canned[stepKey] || "*(Demo output — enable a provider to generate live deliverables.)*";
}
function send(res, code, obj) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(obj));
}
// Is a live provider actually usable right now?
const LIVE = (PROVIDER === 'cli' && HAS_CLI) || (PROVIDER === 'api' && HAS_KEY);
const server = http.createServer((req, res) => {
if (!authOK(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Studio Zero", charset="UTF-8"', 'Content-Type': 'text/plain' });
return res.end('Authentication required');
}
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/config') {
return send(res, 200, {
liveMode: LIVE, provider: LIVE ? PROVIDER : 'demo',
maxPlan: PROVIDER === 'cli' && HAS_CLI,
model: PROVIDER === 'cli' ? (CLI_MODEL + ' (Max plan)') : MODEL,
maxTokens: MAX_TOKENS, rate: rate(MODEL),
});
}
if (u.pathname === '/api/prompts') {
return send(res, 200, loadPrompts());
}
if (u.pathname === '/api/generate' && req.method === 'POST') {
let raw = ''; req.on('data', c => raw += c);
req.on('end', async () => {
let body; try { body = JSON.parse(raw || '{}'); } catch { return send(res, 400, { error: 'bad json' }); }
const prompt = (body.prompt || '').toString().slice(0, 20000);
const stepKey = (body.stepKey || '').toString();
if (!prompt) return send(res, 400, { error: 'missing prompt' });
const r = rate(MODEL);
const est = +(((prompt.length / 4) / 1e6 * r.in) + (MAX_TOKENS / 1e6 * r.out)).toFixed(5);
if (!LIVE) {
return send(res, 200, { text: demoOutput(stepKey), demo: true, provider: 'demo', cost: 0, estCost: 0, model: 'demo' });
}
try {
const out = await generate(prompt);
// Max-plan CLI: marginal spend is $0; report the metered-equivalent separately.
const cost = out.maxPlan ? 0 : (out.cost != null ? out.cost : costOf(out.model, out.usage));
return send(res, 200, {
text: out.text, demo: false, provider: out.maxPlan ? 'cli' : 'api', maxPlan: !!out.maxPlan,
usage: out.usage, cost, equiv: out.equiv || null, estCost: est, model: out.model,
});
} catch (e) {
// Graceful fallback to demo so the prototype never hard-fails mid-preview.
// `fallback:true` is the honesty flag: distinguishes "a LIVE call just failed and
// this is substitute canned content" from an intentional demo-mode response — the
// client raises a persistent banner so fabricated demo is never mistaken for a real result.
return send(res, 200, { text: demoOutput(stepKey), demo: true, fallback: true, provider: 'demo', cost: 0, estCost: est, model: 'demo', warning: 'live call failed: ' + e.message });
}
});
return;
}
// static
const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
const fp = path.join(DIR, 'public', path.basename(f));
if (fs.existsSync(fp)) {
const ext = path.extname(fp).toLowerCase();
const ct = ext === '.html' ? 'text/html' : ext === '.js' ? 'text/javascript' : ext === '.css' ? 'text/css' : ext === '.json' ? 'application/json' : 'text/plain';
res.writeHead(200, { 'Content-Type': ct }); return res.end(fs.readFileSync(fp));
}
res.writeHead(404); res.end('not found');
});
server.listen(PORT, function () {
const mode = LIVE ? (PROVIDER === 'cli' ? 'LIVE · Max plan (' + CLI_MODEL + ')' : 'LIVE · API ' + MODEL) : 'DEMO mode';
const auth = AUTH_ON ? ' · BasicAuth ON (' + AUTH_USER + ')' : ' · no auth (internal)';
console.log('[studio-zero] http://localhost:' + this.address().port + ' (' + mode + auth + ')');
});