main
John Lauer Publish 1.0.1 923fe69 1mo ago
#!/usr/bin/env node
// svcwatch CLI.
//
// Output contract: every response starts with OK: or ERROR:, and every error
// carries at least one Hint: naming the exact next command.
//
// The dock bar runs `svcwatch serve --print-url` as a launch step and waits for
// that process to EXIT before reading its stdout. So serve must self-background:
// it spawns the server detached, waits for the server to answer, prints the
// direct URL, and returns. A serve that stays in the foreground blocks the exec
// for the whole timeout, gets killed, and the dock reports "started, but <url>
// never answered".

import { spawn, execFile } from 'child_process';
import { readFileSync, readdirSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const DIR = dirname(fileURLToPath(import.meta.url));
const SLUG = 'svcwatch';
const VERSION = JSON.parse(readFileSync(join(DIR, 'package.json'), 'utf8')).version;
const INSTANCES = join(process.env.HOME, '.adom', 'instances', SLUG);

// Strip color when stdout is not a TTY.
const TTY = process.stdout.isTTY;
const dim = s => (TTY ? `\x1b[2m${s}\x1b[0m` : s);

const argv = process.argv.slice(2);
const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';
function flag(name) { return argv.includes(name); }
function val(name, fallback = null) {
  const i = argv.indexOf(name);
  return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fallback;
}

const AI_THREAD = val('--ai-thread', process.env.ADOM_AI_THREAD || 'dock');

function ok(msg) { console.log('OK: ' + msg); }
function hint(msg) { console.log('Hint: ' + msg); }
function fail(msg, ...hints) {
  console.error('ERROR: ' + msg);
  for (const h of hints) console.error('Hint: ' + h);
  process.exit(1);
}

function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }

function instances() {
  const out = [];
  if (!existsSync(INSTANCES)) return out;
  for (const f of readdirSync(INSTANCES)) {
    if (!f.endsWith('.json')) continue;
    const p = join(INSTANCES, f);
    let e;
    try { e = JSON.parse(readFileSync(p, 'utf8')); } catch { continue; }
    if (!e.pid || !alive(e.pid)) { try { unlinkSync(p); } catch {} continue; }
    out.push(e);
  }
  return out;
}

async function probe(port, ms = 1200) {
  try {
    const c = new AbortController();
    const t = setTimeout(() => c.abort(), ms);
    const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: c.signal });
    clearTimeout(t);
    return r.ok ? await r.json() : null;
  } catch { return null; }
}

async function liveInstance() {
  for (const e of instances()) {
    if (await probe(e.port)) return e;
  }
  return null;
}

// The direct URL, never the :7380/proxy/<port>/ form. The workspace is local
// WSL2 with mirrored networking, so a distro port IS a Windows localhost port.
// 127.0.0.1 rather than the name localhost: IPv6-first resolution costs about
// 800ms per request on Windows.
const directUrl = port => `http://127.0.0.1:${port}/`;

// The proxy URL is what a webview OUTSIDE the container must load. A webview
// cannot reach 127.0.0.1 in here.
function proxyUrl(port) {
  const t = process.env.VSCODE_PROXY_URI;
  return t ? t.replace('{{port}}', String(port)) : directUrl(port);
}

async function waitForPort(port, budgetMs = 30000) {
  const deadline = Date.now() + budgetMs;
  while (Date.now() < deadline) {
    const h = await probe(port);
    if (h) return h;
    await new Promise(r => setTimeout(r, 400));
  }
  return null;
}

async function startDetached() {
  mkdirSync(INSTANCES, { recursive: true });
  const port = val('--port', 'auto');
  const child = spawn(process.execPath, [
    join(DIR, 'server.js'), 'serve', '--ai-thread', AI_THREAD, '--port', port,
  ], { detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
  child.unref();

  // The server picks its own port when asked for auto, so discover it through
  // the registry rather than guessing.
  const deadline = Date.now() + 30000;
  while (Date.now() < deadline) {
    const e = (instances()).find(i => i.pid === child.pid);
    if (e) {
      const h = await waitForPort(e.port, 15000);
      if (h) return e;
    }
    await new Promise(r => setTimeout(r, 300));
  }
  return null;
}

// Once serving, the app takes over the tab the dock pre-opened for it.
function claimDockWebview(port) {
  const name = process.env.ADOM_DOCK_WEBVIEW;
  if (!name) return;
  execFile('adom-cli', [
    'hydrogen', 'webview', 'open-or-refresh',
    '--name', name, '--url', proxyUrl(port),
  ], () => {});
}

async function main() {
  switch (cmd) {

    case 'serve': {
      // Reuse instead of respawn: a second instance would fight for the same
      // registry entry and leave a zombie behind.
      let inst = await liveInstance();
      if (!inst) inst = await startDetached();
      if (!inst) {
        fail('svcwatch did not come up within 30 seconds.',
             'Run it in the foreground to see why: node ' + join(DIR, 'server.js') + ' serve --port 8901',
             'Check the watchdog state directory exists: ls /home/adom/.watchdog-state');
      }
      claimDockWebview(inst.port);
      if (flag('--print-url')) {
        // The dock scrapes the first https?://\S+ on stdout. Keep it on its
        // own line and on stdout, never stderr.
        console.log(directUrl(inst.port));
        return;
      }
      ok(`svcwatch v${VERSION} serving on port ${inst.port}`);
      console.log('URL: ' + directUrl(inst.port));
      console.log('Webview URL: ' + proxyUrl(inst.port));
      return;
    }

    case 'show': {
      let inst = await liveInstance();
      if (!inst) inst = await startDetached();
      if (!inst) fail('Could not start svcwatch.', 'Try: svcwatch serve');
      const name = process.env.ADOM_DOCK_WEBVIEW || 'Service Containers';
      execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh',
        '--name', name, '--url', proxyUrl(inst.port),
        '--panel-id', val('--panel-id', '') || 'f11c55d1-1007-4520-9769-d8f3b2c0a333',
      ], (err) => {
        if (err) {
          console.error('ERROR: could not open the webview tab.');
          console.error('Hint: find a pane id with: adom-cli hydrogen workspace tabs');
          process.exit(1);
        }
        ok(`Service Containers tab open on port ${inst.port}`);
      });
      return;
    }

    case 'status': {
      const inst = await liveInstance();
      if (!inst) {
        fail('svcwatch is not running.', 'Start it with: svcwatch serve');
      }
      const h = await probe(inst.port, 4000);
      const c = h.counts;
      ok(`${c.ok}/${c.total} services healthy` +
         (c.down ? `, ${c.down} down` : '') +
         (c.pending ? `, ${c.pending} watching` : ''));
      console.log(dim(`port ${inst.port}  thread "${inst.thread}"  v${inst.version}` +
        (h.sweepAgeS != null ? `  watchdog wrote ${h.sweepAgeS}s ago` : '')));
      if (c.down) hint('Open the dashboard to see which: svcwatch show');
      return;
    }

    case 'health': {
      const inst = await liveInstance();
      if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');
      ok(`svcwatch v${inst.version} answering on port ${inst.port}`);
      return;
    }

    case 'ls': {
      const list = instances();
      if (!list.length) { ok('no svcwatch instances running'); return; }
      for (const e of list) {
        const idleM = Math.round((Date.now() - new Date(e.lastActivity).getTime()) / 60000);
        const drift = e.version !== VERSION
          ? `  WARN: runs ${e.version}, disk has ${VERSION}` : '';
        console.log(`port ${e.port}  thread "${e.thread}"  idle ${idleM}m  v${e.version}${drift}`);
      }
      ok(`${list.length} instance${list.length === 1 ? '' : 's'}`);
      return;
    }

    case 'stop': {
      const list = instances();
      if (!list.length) { ok('nothing to stop'); return; }
      for (const e of list) {
        try {
          await fetch(`http://127.0.0.1:${e.port}/shutdown`, { method: 'POST' });
        } catch {}
      }
      ok(`stopped ${list.length} instance${list.length === 1 ? '' : 's'}`);
      return;
    }

    // Drive the page the same way a person would, so the AI can walk every
    // interaction and assert the result.
    case 'ui': {
      const inst = await liveInstance();
      if (!inst) fail('svcwatch is not running.', 'Start it with: svcwatch serve');
      const sub = argv[1];
      const base = `http://127.0.0.1:${inst.port}`;
      const post = async (path, body) => {
        const r = await fetch(base + path, {
          method: 'POST', headers: { 'content-type': 'application/json' },
          body: JSON.stringify(body),
        });
        return { status: r.status, json: await r.json().catch(() => ({})) };
      };

      if (sub === 'toast') {
        // Drop each --flag AND the value that follows it, or "--kind success"
        // leaves the word "success" stuck on the end of the message.
        const words = [];
        for (let i = 2; i < argv.length; i++) {
          if (argv[i].startsWith('--')) { i++; continue; }
          words.push(argv[i]);
        }
        const message = words.join(' ');
        if (!message) fail('No message given.', 'Try: svcwatch ui toast "sweep finished"');
        await post('/api/toast', { kind: val('--kind', 'info'), message });
        ok(`toast raised: ${message}`);
        return;
      }
      if (sub === 'sweep') {
        const r = await post('/api/sweep', {});
        if (r.status !== 200) fail('Sweep failed.', 'Read the log: tail /tmp/service-watchdog.log');
        ok(`sweep complete: ${r.json.counts.ok}/${r.json.counts.total} healthy`);
        return;
      }
      if (sub === 'restart') {
        const label = argv[2];
        const reason = val('--reason');
        if (!label) fail('No service given.', 'Try: svcwatch ui restart oauth --reason "recovering the gateway"');
        if (!reason) {
          fail('Restarting a shared service needs a written reason.',
               `Try: svcwatch ui restart ${label} --reason "why you are restarting it"`);
        }
        const r = await post('/api/restart', { label, reason });
        if (r.status !== 200) {
          fail(r.json.error || 'Restart failed.',
               r.json.hint || 'List valid names with: svcwatch status');
        }
        ok(`${r.json.service} restart dispatched`);
        return;
      }
      fail(`Unknown ui command: ${sub || '(none)'}`,
           'Try: svcwatch ui toast "..." | svcwatch ui sweep | svcwatch ui restart <service> --reason "..."');
      return;
    }

    default: {
      console.log(`svcwatch v${VERSION}: the Adom service container dashboard`);
      console.log('');
      console.log('  svcwatch serve [--print-url]     start the server (self-backgrounds)');
      console.log('  svcwatch show                    open or refresh the Services webview tab');
      console.log('  svcwatch status                  one-line health of every service container');
      console.log('  svcwatch health                  is svcwatch itself up');
      console.log('  svcwatch ls                      running instances, with version drift');
      console.log('  svcwatch stop                    shut every instance down');
      console.log('  svcwatch ui toast "..."          raise a toast on the page');
      console.log('  svcwatch ui sweep                run a watchdog sweep now');
      console.log('  svcwatch ui restart <svc> --reason "..."   restart one service');
      return;
    }
  }
}

main().catch(e => fail(e.message, 'This is a bug in svcwatch. Run with: node cli.js ' + cmd));