#!/usr/bin/env node /** * dev — Project Hub CLI * * Usage: dev [args] * * Install: * chmod +x cli/dev.mjs * ln -sf "$(pwd)/cli/dev.mjs" ~/.local/bin/dev */ import { spawnSync } from 'child_process'; import { readFileSync, existsSync, realpathSync, readSync, } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; // ── Resolve project root (follows the symlink back to the real script) ────── const SCRIPT_REAL = realpathSync(fileURLToPath(import.meta.url)); const PROJECT_ROOT = dirname(dirname(SCRIPT_REAL)); // cli/dev.mjs → cli/ → root const SSH_KEY = join(homedir(), '.ssh', 'project-hub'); const REMOTE_HOST = 'project-hub'; // matches ~/.ssh/config entry const REMOTE_IP = '146.190.56.90'; // ── ANSI colour helpers ────────────────────────────────────────────────────── const A = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', }; const c = (col, s) => `${A[col]}${s}${A.reset}`; const log = (...a) => console.log(...a); const err = (msg, code = 1) => { console.error(c('red', `✗ ${msg}`)); process.exit(code); }; const ok = (msg) => log(c('green', `✓ ${msg}`)); const inf = (msg) => log(c('cyan', `→ ${msg}`)); const wrn = (msg) => log(c('yellow', `⚠ ${msg}`)); // ── .env loader ────────────────────────────────────────────────────────────── function loadEnv() { const p = join(PROJECT_ROOT, '.env'); if (!existsSync(p)) return {}; const env = {}; for (const raw of readFileSync(p, 'utf8').split('\n')) { const line = raw.split('#')[0].trim(); if (!line.includes('=')) continue; const [k, ...rest] = line.split('='); env[k.trim()] = rest.join('=').trim().replace(/^["']|["']$/g, ''); } return env; } // ── Spawn helpers ───────────────────────────────────────────────────────────── function run(cmd, args = [], { cwd = PROJECT_ROOT, allowFail = false, env: extraEnv = {} } = {}) { const r = spawnSync(cmd, args, { stdio: 'inherit', cwd, env: { ...process.env, ...extraEnv }, }); if (r.error) err(`spawn error running ${cmd}: ${r.error.message}`); if (r.status !== 0 && !allowFail) process.exit(r.status ?? 1); return r; } /** Non-interactive remote command. */ function remote(remoteCmd) { return run('ssh', [REMOTE_HOST, remoteCmd]); } /** Interactive SSH session (TTY allocated). */ function remoteInteractive(remoteCmd = '') { const args = ['-t', REMOTE_HOST]; if (remoteCmd) args.push(remoteCmd); return run('ssh', args); } /** Read one line from stdin synchronously (used for confirmations). */ function promptSync(question) { process.stdout.write(question); const buf = Buffer.alloc(512); const n = readSync(0, buf, 0, 512, null); return buf.slice(0, n).toString().trim(); } // ── Divider helper for remote output ───────────────────────────────────────── const DIVCMD = (label) => `printf '\\n${c('bold', label)}\\n' && echo '─────────────────────────────────────────'`; // ═══════════════════════════════════════════════════════════════════════════ // // COMMANDS // // ═══════════════════════════════════════════════════════════════════════════ // const CMD = { // ── Remote ───────────────────────────────────────────────────────────────── /** Open interactive SSH session. */ ssh() { inf(`Connecting to ${REMOTE_IP}…`); remoteInteractive(); }, /** Print server stats: uptime, memory, disk, Docker, open ports. */ 'server:status'() { inf('Fetching server status…'); // Keep remote commands simple — avoid complex shell quoting over SSH. const script = [ 'echo ""', 'echo "=== HOST ==="', 'hostname && uptime', 'echo ""', 'echo "=== MEMORY ==="', 'free -h', 'echo ""', 'echo "=== DISK (/) ==="', 'df -h /', 'echo ""', 'echo "=== DOCKER CONTAINERS ==="', 'docker ps 2>/dev/null || echo "(docker not installed — run: dev server:setup)"', 'echo ""', 'echo "=== LISTENING PORTS ==="', 'ss -tlnp 2>/dev/null', 'echo ""', ].join(' && '); remote(script); }, /** Tail container or journal logs. Usage: dev server:logs [service] */ 'server:logs'() { const svc = process.argv[3] || 'project-manager-api'; inf(`Tailing logs for: ${c('cyan', svc)}`); remote( `docker logs --tail=200 -f ${svc} 2>&1 || ` + `journalctl -u ${svc} -n 200 -f 2>/dev/null || ` + `echo "No logs found for '${svc}'"` ); }, /** Install Docker, UFW, create /opt/project-hub on the remote server. */ 'server:setup'() { inf('Setting up remote server…'); wrn('This will install Docker, Docker Compose, and configure UFW.'); const confirm = promptSync('Continue? (yes/no): '); if (confirm.toLowerCase() !== 'yes') { log('Aborted.'); return; } const script = [ 'export DEBIAN_FRONTEND=noninteractive', 'apt-get update -qq', 'apt-get install -y -qq ca-certificates curl gnupg lsb-release ufw git', // Add Docker's official GPG key and apt repo 'install -m 0755 -d /etc/apt/keyrings', 'curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg', 'chmod a+r /etc/apt/keyrings/docker.gpg', 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list', 'apt-get update -qq', 'apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin', 'systemctl enable --now docker', 'ufw allow OpenSSH', 'ufw allow 4000/tcp comment "project-hub API"', 'ufw allow 80/tcp', 'ufw allow 443/tcp', 'ufw --force enable', 'mkdir -p /opt/project-hub/data /opt/project-hub/uploads', 'echo "SETUP_DONE"', ].join(' && '); remote(script); ok('Server setup complete.'); log(c('dim', ' Next: run dev deploy to push the backend.')); }, /** Reboot the remote server (requires typed confirmation). */ 'server:reboot'() { wrn(`This will reboot ${REMOTE_IP}.`); const answer = promptSync('Type YES to confirm: '); if (answer !== 'YES') { log('Aborted.'); return; } remote('echo "Rebooting in 1s…" && sleep 1 && reboot'); ok('Reboot command sent. Server will be back in ~30s.'); }, /** * One-time server app initialisation: * - Clone repo to /opt/project-hub/src (or pull if already there) * - Configure git credentials so future pulls work without prompts * - Add pm.jiosii.com block to Caddyfile (idempotent) * - First production build + start via docker-compose.prod.yml * - Connect pm-network to Caddy & reload */ 'server:init-app'() { const env = loadEnv(); const giteaToken = env.GITEA_API_TOKEN; const giteaUser = (env.GITEA_USERNAME || 'ryan').split('@')[0]; if (!giteaToken) err('GITEA_API_TOKEN not found in .env'); inf('Initialising app on remote server…'); wrn('This will clone the repo, build Docker images, and update Caddy.'); const confirm = promptSync('Continue? (yes/no): '); if (confirm.toLowerCase() !== 'yes') { log('Aborted.'); return; } const repoUrl = `https://${giteaUser}:${giteaToken}@git.jiosii.com/${giteaUser}/Project-Manager.git`; // Step 1: Clone or pull the repo inf('Step 1/4 Clone / update source…'); remote( `if [ -d /opt/project-hub/src/.git ]; then` + ` git -C /opt/project-hub/src remote set-url origin '${repoUrl}' && ` + ` git -C /opt/project-hub/src pull origin main; ` + `else ` + ` git clone '${repoUrl}' /opt/project-hub/src; ` + `fi` ); // Step 2: First production build inf('Step 2/4 Building + starting containers (this may take a few minutes)…'); remote( 'cd /opt/project-hub/src && ' + 'docker compose -f docker-compose.prod.yml up -d --build --remove-orphans' ); // Step 3: Connect Caddy to the pm-network inf('Step 3/4 Connecting Caddy to pm-network…'); remote('docker network connect pm-network caddy 2>/dev/null || true'); // Step 4: Add pm.jiosii.com to Caddyfile (idempotent) and reload inf('Step 4/4 Updating Caddyfile and reloading…'); // Use individual echo statements grouped with { } — avoids nested heredoc issues remote( 'if ! grep -q "pm.jiosii.com" /opt/gitea-drone/Caddyfile; then ' + '{ ' + 'echo ""; ' + 'echo "# Project Manager - pm.jiosii.com"; ' + 'echo "pm.jiosii.com {"; ' + 'echo " @backend path /api/* /health /uploads/*"; ' + 'echo " handle @backend {"; ' + 'echo " reverse_proxy pm-backend:4000"; ' + 'echo " }"; ' + 'echo " handle {"; ' + 'echo " reverse_proxy pm-frontend:80"; ' + 'echo " }"; ' + 'echo " log {"; ' + 'echo " output file /var/log/caddy/pm.jiosii.com.log"; ' + 'echo " }"; ' + 'echo "}"; ' + 'echo "www.pm.jiosii.com {"; ' + 'echo " redir https://pm.jiosii.com{uri} permanent"; ' + 'echo "}"; ' + '} >> /opt/gitea-drone/Caddyfile; ' + 'echo "Caddyfile updated."; ' + 'else echo "pm.jiosii.com already in Caddyfile, skipping."; fi' ); remote('docker exec caddy caddy reload --config /etc/caddy/Caddyfile --force'); ok('Server app init complete.'); log(c('dim', ' ➜ Add DNS A record: pm.jiosii.com → 146.190.56.90')); log(c('dim', ' ➜ Then visit: https://pm.jiosii.com')); log(c('dim', ' ➜ Activate Drone: https://drone.jiosii.com (see below)')); }, // ── Deploy ───────────────────────────────────────────────────────────────── /** Pull latest code on server and rebuild production containers. */ deploy() { inf(`Deploying to ${REMOTE_IP}…`); remote([ 'cd /opt/project-hub/src', 'git pull origin main', 'docker compose -f docker-compose.prod.yml up -d --build --force-recreate --remove-orphans', 'docker network connect pm-network caddy 2>/dev/null || true', 'docker exec caddy caddy reload --config /etc/caddy/Caddyfile --force', 'docker image prune -f', 'echo "Deploy complete."', ].join(' && ')); ok('Deploy complete.'); log(c('dim', ' Live at https://pm.jiosii.com')); }, // ── Local Dev ────────────────────────────────────────────────────────────── /** Check local backend health endpoint. */ health() { const env = loadEnv(); const port = env.PORT_BACKEND || '4000'; const url = `http://localhost:${port}/health`; inf(`GET ${url}`); run('curl', ['-sS', '-m', '5', '--fail', '--show-error', url]); log(''); }, /** docker compose up then vite dev server. */ start() { inf('Starting local backend (Docker)…'); run('docker', ['compose', 'up', '-d']); inf('Starting frontend dev server…'); const env = loadEnv(); const port = env.PORT_FRONTEND_DEV || '5173'; run('npm', ['run', 'dev', '--prefix', 'frontend', '--', '--port', port]); }, /** Stop Docker containers + Vite. */ stop() { inf('Stopping local services…'); run('docker', ['compose', 'down'], { allowFail: true }); run('bash', ['-c', "lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill 2>/dev/null || true"], { allowFail: true }); run('bash', ['-c', "lsof -tiTCP:5174 -sTCP:LISTEN | xargs kill 2>/dev/null || true"], { allowFail: true }); ok('All local services stopped.'); }, /** Build frontend and sync to dist/. */ build() { inf('Building frontend…'); run('npm', ['run', 'build', '--prefix', 'frontend']); run('bash', ['-c', 'rm -rf dist && mkdir -p dist && cp -R frontend/dist/* dist/']); ok('Build complete → dist/'); }, /** Show project port ownership map. */ ports() { run('node', ['scripts/ports-report.cjs']); }, /** Run MVP health checks. */ test() { run('node', ['scripts/mvp-check.cjs']); }, /** Run wiring smoke test. */ 'test:wiring'() { run('node', ['scripts/wiring-smoke-test.cjs']); }, /** Run persistence self-test. */ 'test:persistence'() { run('node', ['scripts/persistence-self-test.cjs']); }, // ── Help ─────────────────────────────────────────────────────────────────── help() { log(''); log(c('bold', c('cyan', ' ⚡ dev — Project Hub CLI'))); log(c('dim', ` Project root: ${PROJECT_ROOT}`)); log(c('dim', ` Remote host: ${REMOTE_IP} (alias: project-hub)`)); log(''); const h = (label, commands) => { log(c('bold', ` ${label}`)); for (const [cmd, desc] of commands) log(` ${c('cyan', ('dev ' + cmd).padEnd(30))} ${c('dim', desc)}`); log(''); }; h('Remote Server', [ ['ssh', 'Open interactive SSH session'], ['server:status', 'Memory, disk, Docker containers, open ports'], ['server:logs [service]', 'Tail container logs (default: project-manager-api)'], ['server:setup', 'Install Docker + UFW, create /opt/project-hub'], ['server:init-app', 'Clone repo + first build + Caddy config (run once)'], ['server:reboot', 'Reboot remote server (requires typed confirmation)'], ['deploy', 'git pull + docker compose up --build on server'], ]); h('Local Dev', [ ['start', 'docker compose up + vite dev server'], ['stop', 'Stop Docker + kill Vite process'], ['build', 'Build frontend → dist/'], ['health', 'GET /health from local backend'], ['ports', 'Show project port ownership map'], ]); h('Tests', [ ['test', 'Run MVP health checks'], ['test:wiring', 'Run wiring smoke test (tasks, members, invites)'], ['test:persistence', 'Run persistence self-test'], ]); log(c('dim', ' Run any command with --help for details (future)')); log(''); }, }; // ── Dispatch ────────────────────────────────────────────────────────────────── const [, , subcmd = 'help', ...rest] = process.argv; if (CMD[subcmd]) { CMD[subcmd](); } else { err(`Unknown command: ${c('yellow', subcmd)}\n Run dev help to see available commands.`); }