-
Notifications
You must be signed in to change notification settings - Fork 1
feat: create ppg serve command and Fastify server scaffold #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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, | ||
| }); | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden file permissions for existing Line 44 and Line 49 pass 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 |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--daemonis declared but not implemented.ServeCommandOptionsincludesdaemon(Line 29), butserveCommandignores it (Line 39-Line 45). The CLI advertises daemon mode, so this currently behaves as a silent no-op.🤖 Prompt for AI Agents