← back to Dot Palette

server.js

98 lines

#!/usr/bin/env node
// dot-palette — tiny local server behind the always-on-top color palette.
// One endpoint: POST /api/setdot {color,label?} -> resolve the CURRENT iTerm2
// pane's tty via AppleScript, then call the dot engine so the ~/.claude/tab-dots
// registry (what dot-screen-router reads) stays the single source of truth.
// Zero deps (Node built-ins only). $0 local — reads/sets only your own tabs.
'use strict';

const http = require('http');
const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');

// TEST SEAM (inert in production): the Electron shell + launchd NEVER set these
// env vars, so the live palette always uses the real engine + resolves the real
// iTerm2 tty. selftest.js sets them to run the full request path against a mock
// engine on an ephemeral port WITHOUT repainting any real tab. Doctrine: a checkable
// seam guarded so a scheduled/production launch can never trip it (CLAUDE.md TK-11431).
const ENGINE = process.env.DOTPALETTE_ENGINE || `${process.env.HOME}/Projects/terminal-status/terminal_status.py`;
const ENGINE_BIN = process.env.DOTPALETTE_ENGINE_BIN || 'python3';
const TTY_OVERRIDE = process.env.DOTPALETTE_TTY_OVERRIDE || '';   // test-only; empty in prod
// The six status colours the engine accepts (COLORS in terminal_status.py).
const COLORS = ['green', 'yellow', 'orange', 'purple', 'lightblue', 'pink'];

function run(cmd, args, timeoutMs = 8000) {
  return new Promise((resolve) => {
    execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
      resolve({ err, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
    });
  });
}

// The crux: ask iTerm2 for ITS current session's tty (the tab Steve last used),
// NOT the OS frontmost app — so a non-activating palette click still targets the
// right pane even though iTerm2 may not be the active application at click time.
async function frontTty() {
  if (TTY_OVERRIDE) return /^\/dev\/ttys\d+$/.test(TTY_OVERRIDE) ? TTY_OVERRIDE : '';
  const script = 'tell application "iTerm2" to get tty of current session of current window';
  const { stdout } = await run('osascript', ['-e', script], 5000);
  return /^\/dev\/ttys\d+$/.test(stdout) ? stdout : '';
}

async function setDot(color, label) {
  if (!COLORS.includes(color)) return { ok: false, error: `bad color: ${color}` };
  const tty = await frontTty();
  if (!tty) return { ok: false, error: 'no current iTerm2 session (is iTerm2 open?)' };
  const args = [ENGINE, 'set', color];
  if (label) args.push(String(label).slice(0, 120));
  args.push('--tty', tty);
  const { err, stdout, stderr } = await run(ENGINE_BIN, args, 8000);
  return { ok: !err, tty, color, out: stdout, error: err ? (stderr || String(err)).slice(0, 200) : null };
}

function send(res, code, body, type = 'application/json') {
  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
}

const server = http.createServer(async (req, res) => {
  try {
    const url = new URL(req.url, 'http://x');
    if (url.pathname === '/health') {
      return send(res, 200, { ok: true, port: server.address() && server.address().port });
    }
    if (url.pathname === '/api/setdot' && req.method === 'POST') {
      let raw = '';
      req.on('data', c => (raw += c));
      req.on('end', async () => {
        let color = '', label = '';
        try { const j = JSON.parse(raw); color = j.color; label = j.label || ''; } catch {}
        send(res, 200, await setDot(color, label));
      });
      return;
    }
    if (url.pathname === '/' || url.pathname === '/index.html') {
      return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'), 'text/html');
    }
    send(res, 404, { error: 'not found' });
  } catch (e) {
    send(res, 500, { error: String(e) });
  }
});

function listen(port, tries = 20) {
  server.once('error', (e) => {
    if (e.code === 'EADDRINUSE' && tries > 0) return listen(port + 1, tries - 1);
    throw e;
  });
  server.listen(port, '127.0.0.1', () => {
    const p = server.address().port;
    // DOTPALETTE_PORT_FILE is a test seam (inert in prod) so selftest.js never
    // clobbers the live palette's .port that the Electron shell reads.
    fs.writeFileSync(process.env.DOTPALETTE_PORT_FILE || path.join(__dirname, '.port'), String(p));
    console.log(`dot-palette on http://127.0.0.1:${p}`);
  });
}
listen(parseInt(process.env.PORT || '9791', 10));