270 lines
9.7 KiB
TypeScript
270 lines
9.7 KiB
TypeScript
/**
|
|
* setup/controlplane.ts
|
|
*
|
|
* Initialises the Clawdie control plane:
|
|
* 1. Creates PostgreSQL tables (idempotent — IF NOT EXISTS)
|
|
* 2. Provisions 4 default agents (CEO, Sysadmin, DBA, Git Admin)
|
|
* 3. Creates the operator account
|
|
* 4. Seeds per-agent budgets
|
|
* 5. Copies operational skills → data/skills/
|
|
*
|
|
* Safe to re-run: all writes use ON CONFLICT DO NOTHING / DO UPDATE.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import pg from 'pg';
|
|
|
|
import {
|
|
ensureControlplaneAgentApiKey,
|
|
syncControlplaneAgentApiKeysFromDisk,
|
|
} from '../src/controlplane-agent-keys.js';
|
|
import {
|
|
BETTER_AUTH_SECRET,
|
|
BETTER_AUTH_URL,
|
|
CONTROLPLANE_API_PORT,
|
|
CONTROLPLANE_DASHBOARD_DIR,
|
|
CONTROLPLANE_HOST_LABEL,
|
|
CONTROLPLANE_INTERNAL_DOMAIN,
|
|
MEMORY_DB_URL,
|
|
PLATFORM_INTERNAL_BASE,
|
|
PLATFORM_PUBLIC_BASE,
|
|
TENANT_ID,
|
|
} from '../src/config.js';
|
|
import {
|
|
getDefaultAgents,
|
|
copySkills,
|
|
generatePassword,
|
|
getAgents,
|
|
getOperator,
|
|
runSchemaMigration,
|
|
upsertAgent,
|
|
upsertBudget,
|
|
upsertOperator,
|
|
} from '../src/controlplane-db.js';
|
|
import {
|
|
createControlplaneBootstrapAuth,
|
|
deriveControlplaneOperatorEmail,
|
|
ensureControlplaneBootstrapOperator,
|
|
runControlplaneBootstrapMigrations,
|
|
} from '../src/controlplane-auth-bootstrap.js';
|
|
import { readEnvFile } from '../src/env.js';
|
|
import { logger } from '../src/logger.js';
|
|
import { emitStatus } from './status.js';
|
|
|
|
const LOG = 'logs/setup.log';
|
|
|
|
function appendSetupLog(line: string): void {
|
|
try {
|
|
fs.mkdirSync('logs', { recursive: true });
|
|
fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${line}\n`);
|
|
} catch {
|
|
// log file is best-effort; never block setup on it
|
|
}
|
|
}
|
|
|
|
export async function run(_args: string[]): Promise<void> {
|
|
appendSetupLog('=== controlplane setup start ===');
|
|
const dbUrl = MEMORY_DB_URL;
|
|
if (!dbUrl) {
|
|
emitStatus('SETUP_CONTROLPLANE', {
|
|
STATUS: 'failed',
|
|
ERROR: 'MEMORY_DB_URL not set — cannot connect to PostgreSQL',
|
|
LOG,
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
const pool = new pg.Pool({ connectionString: dbUrl, max: 3 });
|
|
const envSecrets = readEnvFile([
|
|
'OPERATOR_PASSWORD',
|
|
'CONTROLPLANE_OPERATOR_EMAIL',
|
|
]);
|
|
|
|
try {
|
|
// ── 1. Schema migration ──────────────────────────────────────────
|
|
logger.info('Running control plane schema migration...');
|
|
appendSetupLog('schema migration: starting');
|
|
await runSchemaMigration(pool);
|
|
logger.info('Schema ready');
|
|
appendSetupLog('schema migration: ready');
|
|
await syncControlplaneAgentApiKeysFromDisk(pool);
|
|
|
|
// ── 2. Provision default agents ──────────────────────────────────
|
|
const defaultAgents = getDefaultAgents(TENANT_ID);
|
|
logger.info('Provisioning default agents...');
|
|
for (const agent of defaultAgents) {
|
|
await upsertAgent(pool, agent);
|
|
logger.info({ id: agent.id, role: agent.role }, 'Agent ready');
|
|
appendSetupLog(`agent ready: ${agent.id} (${agent.role})`);
|
|
}
|
|
|
|
// ── 3. Create operator account ───────────────────────────────────
|
|
const operatorName = (
|
|
process.env.CONTROLPLANE_NAME || CONTROLPLANE_HOST_LABEL
|
|
).trim();
|
|
|
|
const existing = await getOperator(pool, operatorName);
|
|
let operatorPassword: string;
|
|
|
|
if (existing) {
|
|
logger.info({ username: operatorName }, 'Operator already exists');
|
|
operatorPassword = '(existing — check OPERATOR_PASSWORD in .env)';
|
|
appendSetupLog(`operator exists: ${operatorName}`);
|
|
} else {
|
|
operatorPassword = generatePassword(32);
|
|
await upsertOperator(pool, operatorName, operatorPassword);
|
|
logger.info({ username: operatorName }, 'Operator created');
|
|
appendSetupLog(
|
|
`operator created: ${operatorName} (password written to .env, not logged)`,
|
|
);
|
|
|
|
// Write password to .env if present
|
|
const envPath = path.join(process.cwd(), '.env');
|
|
if (fs.existsSync(envPath)) {
|
|
let envContent = fs.readFileSync(envPath, 'utf-8');
|
|
if (envContent.includes('OPERATOR_PASSWORD=')) {
|
|
envContent = envContent.replace(
|
|
/^OPERATOR_PASSWORD=.*$/m,
|
|
`OPERATOR_PASSWORD=${operatorPassword}`,
|
|
);
|
|
} else {
|
|
envContent += `\nOPERATOR_PASSWORD=${operatorPassword}\n`;
|
|
}
|
|
fs.writeFileSync(envPath, envContent, { mode: 0o600 });
|
|
logger.info('Wrote OPERATOR_PASSWORD to .env');
|
|
}
|
|
}
|
|
|
|
const operatorEmail = deriveControlplaneOperatorEmail({
|
|
operatorName,
|
|
explicitEmail:
|
|
process.env.CONTROLPLANE_OPERATOR_EMAIL ||
|
|
envSecrets.CONTROLPLANE_OPERATOR_EMAIL,
|
|
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
|
|
publicBase: PLATFORM_PUBLIC_BASE || undefined,
|
|
});
|
|
|
|
const bootstrapPassword = existing
|
|
? (process.env.OPERATOR_PASSWORD || envSecrets.OPERATOR_PASSWORD || '').trim()
|
|
: operatorPassword;
|
|
let betterAuthStatus: 'created' | 'existing' | 'skipped' = 'skipped';
|
|
let betterAuthReason = '';
|
|
|
|
if (!BETTER_AUTH_SECRET.trim()) {
|
|
betterAuthReason =
|
|
'BETTER_AUTH_SECRET is unset; skipping Better Auth bootstrap.';
|
|
logger.info(betterAuthReason);
|
|
appendSetupLog(`better-auth bootstrap skipped: ${betterAuthReason}`);
|
|
} else if (!bootstrapPassword) {
|
|
betterAuthReason =
|
|
'OPERATOR_PASSWORD is unavailable for the existing operator; skipping Better Auth bootstrap.';
|
|
logger.warn(betterAuthReason);
|
|
appendSetupLog(`better-auth bootstrap skipped: ${betterAuthReason}`);
|
|
} else {
|
|
const bootstrapAuth = createControlplaneBootstrapAuth(pool, {
|
|
secret: BETTER_AUTH_SECRET,
|
|
baseURL: BETTER_AUTH_URL,
|
|
controlplaneApiPort: CONTROLPLANE_API_PORT,
|
|
internalBase: PLATFORM_INTERNAL_BASE,
|
|
internalDomain: CONTROLPLANE_INTERNAL_DOMAIN,
|
|
publicBase: PLATFORM_PUBLIC_BASE || undefined,
|
|
});
|
|
await runControlplaneBootstrapMigrations(bootstrapAuth);
|
|
betterAuthStatus = await ensureControlplaneBootstrapOperator(
|
|
bootstrapAuth,
|
|
{
|
|
name: operatorName,
|
|
email: operatorEmail,
|
|
password: bootstrapPassword,
|
|
},
|
|
);
|
|
appendSetupLog(
|
|
`better-auth bootstrap ${betterAuthStatus}: ${operatorEmail}`,
|
|
);
|
|
}
|
|
|
|
// ── 4. Seed budgets ──────────────────────────────────────────────
|
|
const dailyTokens = parseInt(
|
|
process.env.CONTROLPLANE_DAILY_TOKENS || '100000',
|
|
10,
|
|
);
|
|
|
|
for (const agent of defaultAgents) {
|
|
const agentTokens = Math.floor(
|
|
(dailyTokens * agent.budget_allocation_pct) / 100,
|
|
);
|
|
await upsertBudget(pool, agent.id, agentTokens);
|
|
logger.info({ agent: agent.id, tokens: agentTokens }, 'Budget seeded');
|
|
}
|
|
|
|
// ── 5. Copy skills ───────────────────────────────────────────────
|
|
const skillsSrc = path.join(process.cwd(), '.agent', 'skills');
|
|
const skillsDest = path.join(process.cwd(), 'data', 'skills');
|
|
|
|
copySkills(skillsSrc, skillsDest);
|
|
const skillCount = fs
|
|
.readdirSync(skillsDest, { withFileTypes: true })
|
|
.filter(
|
|
(e) =>
|
|
e.isDirectory() &&
|
|
fs.existsSync(path.join(skillsDest, e.name, 'SKILL.md')),
|
|
).length;
|
|
logger.info({ count: skillCount, dest: skillsDest }, 'Skills copied');
|
|
|
|
// ── Summary ──────────────────────────────────────────────────────
|
|
const agents = await getAgents(pool);
|
|
for (const agent of agents) {
|
|
await ensureControlplaneAgentApiKey(pool, agent.id);
|
|
}
|
|
|
|
const dashboardDir = CONTROLPLANE_DASHBOARD_DIR;
|
|
const dashboardUrl = `http://${CONTROLPLANE_INTERNAL_DOMAIN}:${process.env.CONTROLPLANE_PORT || 3100}/dashboard/`;
|
|
|
|
emitStatus('SETUP_CONTROLPLANE', {
|
|
STATUS: 'success',
|
|
AGENTS: agents.map((a) => a.id).join(', '),
|
|
OPERATOR: operatorName,
|
|
OPERATOR_EMAIL: operatorEmail,
|
|
OPERATOR_PASSWORD: existing
|
|
? '(unchanged)'
|
|
: '(printed to terminal — see console output, not logged)',
|
|
BETTER_AUTH_BOOTSTRAP: betterAuthStatus,
|
|
BETTER_AUTH_BOOTSTRAP_REASON: betterAuthReason || '(none)',
|
|
DAILY_TOKENS: dailyTokens,
|
|
SKILLS_COPIED: skillCount,
|
|
DASHBOARD: dashboardUrl,
|
|
DASHBOARD_DIR: dashboardDir,
|
|
LOG,
|
|
});
|
|
appendSetupLog(
|
|
`success: agents=${agents.length} operator=${operatorName} skills=${skillCount}`,
|
|
);
|
|
|
|
console.log('\n✓ Control plane ready');
|
|
console.log(` Agents: ${agents.map((a) => a.id).join(', ')}`);
|
|
console.log(` Operator: ${operatorName}`);
|
|
console.log(` Email: ${operatorEmail}`);
|
|
if (!existing) {
|
|
console.log(
|
|
` Password: ${operatorPassword} ← save this! (also stored in .env)`,
|
|
);
|
|
console.log(
|
|
` ⚠ do not redirect/tee this output — password is on stdout only`,
|
|
);
|
|
}
|
|
console.log(` Web auth: ${betterAuthStatus}`);
|
|
if (betterAuthReason) {
|
|
console.log(` Note: ${betterAuthReason}`);
|
|
}
|
|
console.log(` Skills: ${skillCount} copied to data/skills/`);
|
|
console.log(` Dashboard: ${dashboardUrl}`);
|
|
console.log(` Dash dir: ${dashboardDir}\n`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
emitStatus('SETUP_CONTROLPLANE', { STATUS: 'failed', ERROR: message, LOG });
|
|
throw error;
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|