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
645 changes: 645 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
],
"license": "MIT",
"dependencies": {
"@fastify/cors": "^11.2.0",
"commander": "^14.0.0",
"cron-parser": "^5.5.0",
"execa": "^9.5.2",
"fastify": "^5.7.4",
"nanoid": "^5.1.5",
"proper-lockfile": "^4.1.2",
"write-file-atomic": "^7.0.0",
Expand Down
20 changes: 20 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,18 @@ program
await installDashboardCommand(options);
});

program
.command('serve')
.description('Start the ppg API server')
.option('-p, --port <number>', 'Port to listen on', parsePort, 3100)
.option('-H, --host <address>', 'Host to bind to', '127.0.0.1')
.option('--token <secret>', 'Bearer token for authentication')
.option('--json', 'Output as JSON')
.action(async (options) => {
const { serveCommand } = await import('./commands/serve.js');
await serveCommand(options);
});

const cronCmd = program.command('cron').description('Manage scheduled runs');

cronCmd
Expand Down Expand Up @@ -372,6 +384,14 @@ function parsePositiveInt(optionName: string) {
};
}

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

async function main() {
try {
await program.parseAsync(process.argv);
Expand Down
23 changes: 23 additions & 0 deletions src/commands/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getRepoRoot } from '../core/worktree.js';
import { requireManifest } from '../core/manifest.js';
import { startServer } from '../server/index.js';

export interface ServeCommandOptions {
port: number;
host: string;
token?: string;
json?: boolean;
}

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

await startServer({
projectRoot,
port: options.port,
host: options.host,
token: options.token,
json: options.json,
});
}
Comment on lines +12 to +23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

--daemon is declared but not implemented.

ServeCommandOptions includes daemon (Line 29), but serveCommand ignores it (Line 39-Line 45). The CLI advertises daemon mode, so this currently behaves as a silent no-op.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/serve.ts` around lines 33 - 46, serveCommand currently ignores
ServeCommandOptions.daemon; update it to honor the flag by launching the server
in background when options.daemon is true. Implement by either (A) passing
daemon through to startServer (add a daemon parameter to startServer and
propagate it from serveCommand) or (B) spawning a detached child process that
re-runs the CLI with the same args (use child_process.spawn with detached: true,
stdio: 'ignore', unref()) and exit the parent process. Ensure you reference and
modify ServeCommandOptions, serveCommand, and startServer (or the new helper
that spawns the detached process) so the CLI's --daemon behavior is actually
implemented.

5 changes: 3 additions & 2 deletions src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import * as tmux from '../core/tmux.js';
import type { Manifest } from '../types/manifest.js';

vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
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
10 changes: 10 additions & 0 deletions src/lib/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
globalPromptsDir,
globalTemplatesDir,
globalSwarmsDir,
serveStatePath,
servePidPath,
} from './paths.js';

const ROOT = '/tmp/project';
Expand Down Expand Up @@ -104,4 +106,12 @@ describe('paths', () => {
test('globalSwarmsDir', () => {
expect(globalSwarmsDir()).toBe(path.join(os.homedir(), '.ppg', 'swarms'));
});

test('serveStatePath', () => {
expect(serveStatePath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve.json'));
});

test('servePidPath', () => {
expect(servePidPath(ROOT)).toBe(path.join(ROOT, '.ppg', 'serve.pid'));
});
});
8 changes: 8 additions & 0 deletions src/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,11 @@ export function worktreeBaseDir(projectRoot: string): string {
export function worktreePath(projectRoot: string, id: string): string {
return path.join(worktreeBaseDir(projectRoot), id);
}

export function serveStatePath(projectRoot: string): string {
return path.join(ppgDir(projectRoot), 'serve.json');
}

export function servePidPath(projectRoot: string): string {
return path.join(ppgDir(projectRoot), 'serve.pid');
}
71 changes: 71 additions & 0 deletions src/server/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import os from 'node:os';
import { detectLanAddress, timingSafeTokenMatch } from './index.js';

describe('detectLanAddress', () => {
afterEach(() => {
vi.restoreAllMocks();
});

test('given interfaces with a non-internal IPv4 address, should return it', () => {
vi.spyOn(os, 'networkInterfaces').mockReturnValue({
lo0: [
{ address: '127.0.0.1', family: 'IPv4', internal: true, netmask: '255.0.0.0', mac: '00:00:00:00:00:00', cidr: '127.0.0.1/8' },
],
en0: [
{ address: 'fe80::1', family: 'IPv6', internal: false, netmask: 'ffff:ffff:ffff:ffff::', mac: 'aa:bb:cc:dd:ee:ff', cidr: 'fe80::1/64', scopeid: 1 },
{ address: '192.168.1.42', family: 'IPv4', internal: false, netmask: '255.255.255.0', mac: 'aa:bb:cc:dd:ee:ff', cidr: '192.168.1.42/24' },
],
});
expect(detectLanAddress()).toBe('192.168.1.42');
});

test('given only internal interfaces, should return undefined', () => {
vi.spyOn(os, 'networkInterfaces').mockReturnValue({
lo0: [
{ address: '127.0.0.1', family: 'IPv4', internal: true, netmask: '255.0.0.0', mac: '00:00:00:00:00:00', cidr: '127.0.0.1/8' },
],
});
expect(detectLanAddress()).toBeUndefined();
});

test('given empty interfaces, should return undefined', () => {
vi.spyOn(os, 'networkInterfaces').mockReturnValue({});
expect(detectLanAddress()).toBeUndefined();
});
});

describe('timingSafeTokenMatch', () => {
const token = 'my-secret-token';

test('given matching bearer token, should return true', () => {
expect(timingSafeTokenMatch(`Bearer ${token}`, token)).toBe(true);
});

test('given wrong token, should return false', () => {
expect(timingSafeTokenMatch('Bearer wrong-token!', token)).toBe(false);
});

test('given missing header, should return false', () => {
expect(timingSafeTokenMatch(undefined, token)).toBe(false);
});

test('given empty header, should return false', () => {
expect(timingSafeTokenMatch('', token)).toBe(false);
});

test('given header with different length, should return false', () => {
expect(timingSafeTokenMatch('Bearer short', token)).toBe(false);
});

test('given header with same char length but different byte length, should return false', () => {
const unicodeHeader = `Bearer ${'é'.repeat(token.length)}`;
expect(() => timingSafeTokenMatch(unicodeHeader, token)).not.toThrow();
expect(timingSafeTokenMatch(unicodeHeader, token)).toBe(false);
});

test('given raw token without Bearer prefix, should return false', () => {
const padded = token.padEnd(`Bearer ${token}`.length, 'x');
expect(timingSafeTokenMatch(padded, token)).toBe(false);
});
});
150 changes: 150 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import { createRequire } from 'node:module';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { serveStatePath, servePidPath } from '../lib/paths.js';
import { info, success } from '../lib/output.js';

const require = createRequire(import.meta.url);
const PACKAGE_JSON_PATHS = ['../../package.json', '../package.json'] as const;

function getPackageVersion(): string {
for (const packageJsonPath of PACKAGE_JSON_PATHS) {
try {
const pkg = require(packageJsonPath) as { version?: unknown };
if (typeof pkg.version === 'string') return pkg.version;
} catch {
// Fall through and try alternate path.
}
}
throw new Error('Unable to resolve package version');
}

const packageVersion = getPackageVersion();

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

export interface ServeState {
pid: number;
port: number;
host: string;
lanAddress?: string;
startedAt: string;
version: string;
}

export function detectLanAddress(): string | undefined {
const interfaces = os.networkInterfaces();
for (const addrs of Object.values(interfaces)) {
if (!addrs) continue;
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
return addr.address;
}
}
}
return undefined;
}

export function timingSafeTokenMatch(header: string | undefined, expected: string): boolean {
const expectedValue = `Bearer ${expected}`;
if (!header || header.length !== expectedValue.length) return false;
const headerBuffer = Buffer.from(header);
const expectedBuffer = Buffer.from(expectedValue);
if (headerBuffer.length !== expectedBuffer.length) return false;
return crypto.timingSafeEqual(
headerBuffer,
expectedBuffer,
);
}

async function writeStateFile(projectRoot: string, state: ServeState): Promise<void> {
const statePath = serveStatePath(projectRoot);
await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
}

async function writePidFile(projectRoot: string, pid: number): Promise<void> {
const pidPath = servePidPath(projectRoot);
await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 });
}
Comment on lines +69 to +77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden file permissions for existing serve.json/serve.pid.

Line 44 and Line 49 pass mode: 0o600, but this only applies on file creation. If files already exist, prior permissions may persist.

Suggested fix
 async function writeStateFile(projectRoot: string, state: ServeState): Promise<void> {
   const statePath = serveStatePath(projectRoot);
   await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
+  await fs.chmod(statePath, 0o600);
 }
 
 async function writePidFile(projectRoot: string, pid: number): Promise<void> {
   const pidPath = servePidPath(projectRoot);
   await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 });
+  await fs.chmod(pidPath, 0o600);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/index.ts` around lines 42 - 50, The writeStateFile and
writePidFile functions only pass mode: 0o600 to fs.writeFile which affects
permissions on creation but does not change existing file permissions; after
writing the file (statePath and pidPath) call fs.chmod(statePath, 0o600) and
fs.chmod(pidPath, 0o600) respectively (await the calls and handle errors as
appropriate) to ensure existing files are hardened to 0o600 regardless of prior
permissions.


async function removeStateFiles(projectRoot: string): Promise<void> {
for (const filePath of [serveStatePath(projectRoot), servePidPath(projectRoot)]) {
try {
await fs.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
}

export async function startServer(options: ServeOptions): Promise<void> {
const { projectRoot, port, host, token, json } = options;

const app = Fastify({ logger: false });

await app.register(cors, { origin: true });

if (token) {
app.addHook('onRequest', async (request, reply) => {
if (request.url === '/health') return;
if (!timingSafeTokenMatch(request.headers.authorization, token)) {
return reply.code(401).send({ error: 'Unauthorized' });
}
});
}

app.get('/health', async () => {
return {
status: 'ok',
uptime: process.uptime(),
version: packageVersion,
};
});

const lanAddress = detectLanAddress();

const shutdown = async (signal: string) => {
if (!json) info(`Received ${signal}, shutting down...`);
await removeStateFiles(projectRoot);
await app.close();
process.exit(0);
};

process.on('SIGTERM', () => { shutdown('SIGTERM').catch(() => process.exit(1)); });
process.on('SIGINT', () => { shutdown('SIGINT').catch(() => process.exit(1)); });

await app.listen({ port, host });

const state: ServeState = {
pid: process.pid,
port,
host,
lanAddress,
startedAt: new Date().toISOString(),
version: packageVersion,
};

await writeStateFile(projectRoot, state);
await writePidFile(projectRoot, process.pid);

if (json) {
console.log(JSON.stringify(state));
} else {
success(`Server listening on http://${host}:${port}`);
if (lanAddress) {
info(`LAN address: http://${lanAddress}:${port}`);
}
if (token) {
info('Bearer token authentication enabled');
}
}
Comment on lines +139 to +149
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Startup flow is missing the QR code output required by the issue objective.

The startup output currently prints text/JSON only; no QR payload is generated/displayed for token + connection URL.

If you want, I can draft a minimal QR integration patch (terminal QR + JSON-safe behavior under --json).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/index.ts` around lines 113 - 123, Startup output currently omits
the QR payload; when not running with --json and a token exists you should
generate and display a terminal QR for the connection URL+token and include the
QR payload in the printed state when --json is used. In practice, update the
block that checks json/host/port/token (variables: json, host, port, token,
lanAddress, state) so that: if json is true, add a qr_payload field to state
(the URL including token and LAN address variant if present) and
JSON.stringify(state) as before; if json is false and token is present, call the
terminal QR generator (e.g., a utility function you add like
renderTerminalQr(qr_payload)) to print the QR after the success/info logs and
also info() the plain URL; ensure behavior remains unchanged when token is falsy
(no QR generation) and that LAN address URL is used when lanAddress is present.

}