Skip to content
Merged
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
19 changes: 19 additions & 0 deletions packages/cli/bin/tps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const cli = meow(
follow: { type: "boolean", default: false },
lines: { type: "number" },
transport: { type: "string" },
port: { type: "number" },
autoPrune: { type: "boolean", default: false },
prune: { type: "boolean", default: false },
staleMinutes: { type: "number" },
Expand Down Expand Up @@ -848,6 +849,24 @@ async function main() {
}
break;
}
case "service": {
const action = rest[0] as "register" | "list" | "remove" | undefined;
if (!action || !["register", "list", "remove"].includes(action)) {
console.error("Usage:\n tps service register <name> <url> [--port <local-port>] [--desc <text>]\n tps service list [--json]\n tps service remove <name>");
process.exit(1);
}
const { runService } = await import("../src/commands/service.js");
await runService({
action,
name: action === "register" ? rest[1] : rest[1],
url: action === "register" ? rest[2] : undefined,
port: cli.flags.port ? Number(cli.flags.port) : undefined,
desc: cli.flags.desc as string | undefined,
json: cli.flags.json as boolean | undefined,
});
break;
}

case "memory": {
// ops-31.2: reflect + consolidate (ops-31.1 governance commands come with PR #67)
const action = rest[0] as "reflect" | "consolidate" | undefined;
Expand Down
29 changes: 23 additions & 6 deletions packages/cli/src/commands/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { spawn } from "node:child_process";
import { generateKeyPair, loadKeyPair, saveKeyPair } from "../utils/identity.js";
import { listenForHost, listenForJoin } from "../utils/noise-ik-transport.js";
import { listenForHostWs, listenForJoinWs } from "../utils/ws-noise-transport.js";
import { MailDeliverBodySchema, MSG_MAIL_DELIVER, MSG_MAIL_ACK, MSG_HEARTBEAT } from "../utils/wire-mail.js";
import { startFlairProxy } from "../utils/flair-proxy.js";
import { MailDeliverBodySchema, MSG_MAIL_DELIVER, MSG_MAIL_ACK, MSG_HEARTBEAT, MSG_JOIN_COMPLETE, JoinCompleteBodySchema } from "../utils/wire-mail.js";
import { startServiceProxies, type ServiceProxySet } from "../utils/service-proxy-branch.js";
import { sendMessage } from "../utils/mail.js";
import { drainOutbox, queueOutboxMessage } from "../utils/outbox.js";
import { clearBranchState, writeBranchState } from "../utils/connection-state.js";
Expand Down Expand Up @@ -244,7 +244,7 @@ async function runStart(): Promise<void> {

const localAgentId = getLocalAgentId();

let flairProxy: { close: () => void } | null = null;
let serviceProxies: ServiceProxySet | null = null;

const onMessage = async (msg: TpsMessage, channel: TransportChannel) => {
if (activeHostChannel !== channel) {
Expand All @@ -268,6 +268,21 @@ async function runStart(): Promise<void> {
logLine("SYNC", "Heartbeat received — drained outbox");
return;
}

// OPS-122: host advertises services on join — start local proxies
if (msg.type === MSG_JOIN_COMPLETE) {
const parsed = JoinCompleteBodySchema.safeParse(msg.body);
const services = parsed.success ? (parsed.data.services ?? []) : [];
if (services.length > 0) {
try { serviceProxies?.close(); } catch {}
serviceProxies = startServiceProxies(services, channel);
for (const h of serviceProxies.handles) {
logLine("PROXY", `Service '${h.name}' proxied on 127.0.0.1:${h.port}`);
}
}
return;
}

if (msg.type !== MSG_MAIL_DELIVER) return;
const parsed = MailDeliverBodySchema.safeParse(msg.body);
if (!parsed.success) {
Expand Down Expand Up @@ -333,8 +348,9 @@ async function runStart(): Promise<void> {

server.onConnection((channel) => {
activeHostChannel = channel;
try { flairProxy?.close(); } catch {}
flairProxy = startFlairProxy(9927, channel);
// Close any previous proxies — new connection, new proxies (host will resend MSG_JOIN_COMPLETE)
try { serviceProxies?.close(); } catch {}
serviceProxies = null;
});

const outboxNewDir = join(process.env.HOME || homedir(), ".tps", "outbox", "new");
Expand All @@ -354,7 +370,8 @@ async function runStart(): Promise<void> {
const onShutdown = async () => {
logLine("STOPPED", "Signal received");
try { outboxWatcher.close(); } catch {}
try { flairProxy?.close(); } catch {}
try { serviceProxies?.close(); } catch {}
serviceProxies = null;
activeHostChannel = null;
clearBranchState();
try { await server.close(); } catch {}
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/office.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { provisionTeam } from "../utils/provision.js";
import { parseOfficeManifest } from "../schema/manifest.js";
import { connectAndKeepAlive, startRelay, syncRemoteBranch } from "../utils/relay.js";
import { MSG_JOIN_COMPLETE, MSG_MAIL_DELIVER } from "../utils/wire-mail.js";
import { ensureDefaultServices, listServices } from "../utils/service-registry.js";
import { WsNoiseTransport } from "../utils/ws-noise-transport.js";
import { branchRoot as sharedBranchRoot, resolveTeamId, workspacePath as sharedWorkspacePath } from "../utils/workspace.js";
import { runOfficeManager, OFFICE_READY_MARKER, loadWorkspaceManifest } from "./office-manager.js";
Expand Down Expand Up @@ -595,6 +596,12 @@ export async function runOffice(args: OfficeArgs): Promise<void> {

console.log(`Noise_IK handshake OK — branch fingerprint verified: ${token.fingerprint}`);

// Ensure default services (e.g. flair) are in the registry before advertising
ensureDefaultServices();
const advertisedServices = listServices()
.filter((s) => s.localPort)
.map((s) => ({ name: s.name, localPort: s.localPort!, description: s.description }));

await channel.send({
type: MSG_JOIN_COMPLETE,
seq: 0,
Expand All @@ -603,6 +610,7 @@ export async function runOffice(args: OfficeArgs): Promise<void> {
hostPubkey: Buffer.from(hostKp.encryption.publicKey).toString("base64url"),
hostFingerprint: fingerprint(hostKp.encryption.publicKey),
hostId: process.env.TPS_HOST_ID || "host",
services: advertisedServices,
},
});

Expand Down
92 changes: 92 additions & 0 deletions packages/cli/src/commands/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* tps service — manage the branch service proxy registry
*
* OPS-122 Phase 1
*
* Usage:
* tps service register <name> <url> [--port <local-port>] [--desc <text>]
* tps service list [--json]
* tps service remove <name>
*/

import {
registerService,
removeService,
listServices,
validateServiceName,
} from "../utils/service-registry.js";

export interface ServiceArgs {
action: "register" | "list" | "remove";
name?: string;
url?: string;
port?: number;
desc?: string;
json?: boolean;
}

export async function runService(args: ServiceArgs): Promise<void> {
switch (args.action) {
case "register": {
const name = args.name?.trim();
const url = args.url?.trim();
if (!name) { console.error("Usage: tps service register <name> <url>"); process.exitCode = 1; return; }
if (!url) { console.error("Usage: tps service register <name> <url>"); process.exitCode = 1; return; }

try {
registerService(name, url, {
...(args.port ? { localPort: args.port } : {}),
...(args.desc ? { description: args.desc } : {}),
});
console.log(`✅ Service '${name}' registered → ${url}`);
if (args.port) console.log(` Local proxy port: ${args.port}`);
} catch (err: any) {
console.error(`Error: ${err.message}`);
process.exitCode = 1;
}
return;
}

case "list": {
const services = listServices();
if (args.json) {
console.log(JSON.stringify(services, null, 2));
return;
}
if (services.length === 0) {
console.log("No services registered. Use: tps service register <name> <url>");
return;
}
console.log("Registered services:\n");
for (const svc of services) {
const port = svc.localPort ? ` (local port: ${svc.localPort})` : "";
const desc = svc.description ? ` ${svc.description}` : "";
console.log(` ${svc.name.padEnd(16)} ${svc.url}${port}${desc}`);
}
return;
}

case "remove": {
const name = args.name?.trim();
if (!name) { console.error("Usage: tps service remove <name>"); process.exitCode = 1; return; }
try {
validateServiceName(name);
const removed = removeService(name);
if (removed) {
console.log(`✅ Service '${name}' removed`);
} else {
console.log(`Service '${name}' not found`);
process.exitCode = 1;
}
} catch (err: any) {
console.error(`Error: ${err.message}`);
process.exitCode = 1;
}
return;
}

default:
console.error("Usage: tps service <register|list|remove>");
process.exitCode = 1;
}
}
85 changes: 0 additions & 85 deletions packages/cli/src/utils/flair-proxy-host.ts

This file was deleted.

Loading
Loading