ci: add Drone pipeline, production Docker setup, frontend Dockerfile
Some checks failed
continuous-integration/drone/push Build encountered an error

This commit is contained in:
Ryan Lancaster
2026-03-18 17:00:31 -04:00
parent 4a7b3061ed
commit 82d7dfcc14
5 changed files with 523 additions and 0 deletions

386
cli/dev.mjs Executable file
View File

@@ -0,0 +1,386 @@
#!/usr/bin/env node
/**
* dev — Project Hub CLI
*
* Usage: dev <command> [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.`);
}