Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
"execa": "^9.5.2",
"nanoid": "^5.1.5",
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"write-file-atomic": "^7.0.0",
"yaml": "^2.7.1"
},
"devDependencies": {
"@types/node": "^22.13.4",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"typescript": "^5.7.3",
Expand Down
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,18 @@ worktreeCmd
await worktreeCreateCommand(options);
});

program
.command('serve')
.description('Start the API server with TLS and display pairing QR code')
.option('-p, --port <number>', 'Port to listen on', parsePort, 7700)
.option('-H, --host <address>', 'Host to bind to', '0.0.0.0')
.option('--daemon', 'Run in daemon mode (suppress QR code)')
.option('--json', 'Output as JSON')
.action(async (options) => {
const { serveCommand } = await import('./commands/serve.js');
await serveCommand(options);
});

program
.command('ui')
.alias('dashboard')
Expand Down Expand Up @@ -372,6 +384,14 @@ function parsePositiveInt(optionName: string) {
};
}

function parsePort(v: string): number {
const port = Number(v);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('--port must be an integer between 1 and 65535');
}
return port;
}

async function main() {
try {
await program.parseAsync(process.argv);
Expand Down
67 changes: 67 additions & 0 deletions src/commands/serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, test, expect } from 'vitest';

import { buildPairingUrl, getLocalIp, verifyToken } from './serve.js';

describe('buildPairingUrl', () => {
test('given valid params, should encode all fields into ppg:// URL', () => {
const url = buildPairingUrl({
host: '192.168.1.10',
port: 7700,
fingerprint: 'AA:BB:CC',
token: 'test-token-123',
});

expect(url).toContain('ppg://connect');
expect(url).toContain('host=192.168.1.10');
expect(url).toContain('port=7700');
expect(url).toContain('ca=AA%3ABB%3ACC');
expect(url).toContain('token=test-token-123');
});

test('given special characters in token, should URL-encode them', () => {
const url = buildPairingUrl({
host: '10.0.0.1',
port: 8080,
fingerprint: 'DE:AD:BE:EF',
token: 'a+b/c=d',
});

expect(url).toContain('token=a%2Bb%2Fc%3Dd');
});
});

describe('getLocalIp', () => {
test('should return a non-empty string', () => {
const ip = getLocalIp();
expect(ip).toBeTruthy();
expect(typeof ip).toBe('string');
});

test('should return a valid IPv4 address', () => {
const ip = getLocalIp();
const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
expect(ip).toMatch(ipv4Pattern);
});
});

describe('verifyToken', () => {
test('given matching tokens, should return true', () => {
expect(verifyToken('correct-token', 'correct-token')).toBe(true);
});

test('given different tokens of same length, should return false', () => {
expect(verifyToken('aaaa-bbbb-cccc', 'xxxx-yyyy-zzzz')).toBe(false);
});

test('given different length tokens, should return false', () => {
expect(verifyToken('short', 'much-longer-token')).toBe(false);
});

test('given empty provided token, should return false', () => {
expect(verifyToken('', 'expected-token')).toBe(false);
});

test('given both empty, should return true', () => {
expect(verifyToken('', '')).toBe(true);
});
});
130 changes: 130 additions & 0 deletions src/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import os from 'node:os';
import { createServer } from 'node:https';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import qrcode from 'qrcode-terminal';
import { getRepoRoot } from '../core/worktree.js';
import { requireManifest } from '../core/manifest.js';
import { ensureTlsCerts } from '../core/tls.js';
import { output, info, success } from '../lib/output.js';

export interface ServeOptions {
port: number;
host: string;
daemon?: boolean;
json?: boolean;
}

export function buildPairingUrl(params: {
host: string;
port: number;
fingerprint: string;
token: string;
}): string {
const { host, port, fingerprint, token } = params;
const url = new URL('ppg://connect');
url.searchParams.set('host', host);
url.searchParams.set('port', String(port));
url.searchParams.set('ca', fingerprint);
url.searchParams.set('token', token);
return url.toString();
}

export function getLocalIp(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name] ?? []) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return '127.0.0.1';
}

export function verifyToken(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

function generateToken(): string {
return randomBytes(32).toString('base64url');
}

function displayQrCode(pairingUrl: string): Promise<void> {
return new Promise<void>((resolve) => {
qrcode.generate(pairingUrl, { small: true }, (code: string) => {
console.log('');
console.log(code);
resolve();
});
});
}

export async function serveCommand(options: ServeOptions): Promise<void> {
const projectRoot = await getRepoRoot();
await requireManifest(projectRoot);

const { port, host } = options;
const isDaemon = options.daemon ?? false;
const isInteractive = process.stdout.isTTY && !isDaemon;

const tls = await ensureTlsCerts(projectRoot);
const token = generateToken();

const displayHost = host === '0.0.0.0' ? getLocalIp() : host;
const pairingUrl = buildPairingUrl({
host: displayHost,
port,
fingerprint: tls.fingerprint,
token,
});

const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const authHeader = req.headers.authorization ?? '';
const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!verifyToken(provided, token)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
});

await new Promise<void>((resolve, reject) => {
server.on('error', reject);
server.listen(port, host, () => resolve());
});

if (options.json) {
output({
status: 'listening',
host: displayHost,
port,
token,
fingerprint: tls.fingerprint,
pairingUrl,
}, true);
} else {
success(`Server listening on https://${displayHost}:${port}`);
info(`Token: ${token}`);

if (isInteractive) {
info('Scan QR code to pair:');
await displayQrCode(pairingUrl);
info(`Pairing URL: ${pairingUrl}`);
}
}

// Keep running until killed
await new Promise<void>((resolve) => {
const shutdown = () => {
server.close(() => resolve());
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
});
}
5 changes: 3 additions & 2 deletions src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest.
import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import type { Manifest } from '../types/manifest.js';
import * as tmux from '../core/tmux.js';

vi.mock('node:fs/promises', async () => {
Expand Down Expand Up @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession);
const mockedCreateWindow = vi.mocked(tmux.createWindow);
const mockedSplitPane = vi.mocked(tmux.splitPane);

function createManifest(tmuxWindow = '') {
function createManifest(tmuxWindow = ''): Manifest {
return {
version: 1 as const,
projectRoot: '/tmp/repo',
Expand All @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') {
}

describe('spawnCommand', () => {
let manifestState = createManifest();
let manifestState: Manifest = createManifest();
let nextAgent = 1;
let nextSession = 1;

Expand Down
Loading