@townco/cli 0.1.117 → 0.1.118
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -8,6 +8,7 @@ declare const _default: {
|
|
|
8
8
|
readonly prompt: string | undefined;
|
|
9
9
|
readonly port: number | undefined;
|
|
10
10
|
readonly noSession: true | undefined;
|
|
11
|
+
readonly proxy: true | undefined;
|
|
11
12
|
}, ["matched", string] | ["parsing", {
|
|
12
13
|
readonly command: "run";
|
|
13
14
|
readonly name: import("@optique/core").ValueParserResult<string> | undefined;
|
|
@@ -17,6 +18,7 @@ declare const _default: {
|
|
|
17
18
|
readonly prompt: [import("@optique/core").ValueParserResult<string> | undefined] | undefined;
|
|
18
19
|
readonly port: [import("@optique/core").ValueParserResult<number> | undefined] | undefined;
|
|
19
20
|
readonly noSession: [import("@optique/core").ValueParserResult<true> | undefined] | undefined;
|
|
21
|
+
readonly proxy: [import("@optique/core").ValueParserResult<true> | undefined] | undefined;
|
|
20
22
|
}] | undefined>;
|
|
21
23
|
impl: (def: {
|
|
22
24
|
readonly command: "run";
|
|
@@ -27,6 +29,7 @@ declare const _default: {
|
|
|
27
29
|
readonly prompt: string | undefined;
|
|
28
30
|
readonly port: number | undefined;
|
|
29
31
|
readonly noSession: true | undefined;
|
|
32
|
+
readonly proxy: true | undefined;
|
|
30
33
|
}) => unknown;
|
|
31
34
|
};
|
|
32
35
|
export default _default;
|
|
@@ -12,14 +12,16 @@ export default createCommand({
|
|
|
12
12
|
prompt: optional(argument(string({ metavar: "PROMPT" }))),
|
|
13
13
|
port: optional(option("-p", "--port", integer())),
|
|
14
14
|
noSession: optional(flag("--no-session")),
|
|
15
|
+
proxy: optional(flag("--proxy")),
|
|
15
16
|
}), { brief: message `Run an agent.` }),
|
|
16
|
-
impl: async ({ name, http, gui, cli, prompt, port, noSession }) => {
|
|
17
|
+
impl: async ({ name, http, gui, cli, prompt, port, noSession, proxy }) => {
|
|
17
18
|
const options = {
|
|
18
19
|
name,
|
|
19
20
|
http: http === true,
|
|
20
21
|
gui: gui === true,
|
|
21
22
|
cli: cli === true,
|
|
22
23
|
noSession: noSession === true,
|
|
24
|
+
proxy: proxy === true,
|
|
23
25
|
};
|
|
24
26
|
if (prompt !== null && prompt !== undefined) {
|
|
25
27
|
options.prompt = prompt;
|
package/dist/commands/run.d.ts
CHANGED
package/dist/commands/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
13
13
|
import { LogsPane } from "../components/LogsPane.js";
|
|
14
14
|
import { TabbedOutput } from "../components/TabbedOutput.js";
|
|
15
15
|
import { ensurePortAvailable } from "../lib/port-utils.js";
|
|
16
|
+
import { createProxyServer } from "../lib/proxy-server.js";
|
|
16
17
|
function TuiRunner({ agentPath, workingDir, agentName, noSession, onExit, }) {
|
|
17
18
|
const [client, setClient] = useState(null);
|
|
18
19
|
const [error, setError] = useState(null);
|
|
@@ -82,15 +83,15 @@ function TuiRunner({ agentPath, workingDir, agentName, noSession, onExit, }) {
|
|
|
82
83
|
], [client, error, workingDir]);
|
|
83
84
|
return (_jsx(TabbedOutput, { processes: [], customTabs: customTabs, onExit: onExit }));
|
|
84
85
|
}
|
|
85
|
-
function GuiRunner({ agentProcess, guiProcess, agentPort, agentPath, logger, onExit, }) {
|
|
86
|
+
function GuiRunner({ agentProcess, guiProcess, agentPort, browserPort, agentPath, logger, onExit, }) {
|
|
86
87
|
const browserOpenedRef = useRef(false);
|
|
87
|
-
const handlePortDetected = useCallback((processIndex,
|
|
88
|
+
const handlePortDetected = useCallback((processIndex, _port) => {
|
|
88
89
|
// Process index 1 is the GUI process
|
|
89
90
|
if (processIndex === 1) {
|
|
90
|
-
// Open browser once we know the
|
|
91
|
+
// Open browser once we know the GUI is ready
|
|
91
92
|
if (!browserOpenedRef.current) {
|
|
92
93
|
browserOpenedRef.current = true;
|
|
93
|
-
const guiUrl = `http://localhost:${
|
|
94
|
+
const guiUrl = `http://localhost:${browserPort}`;
|
|
94
95
|
logger.info("Opening browser", { url: guiUrl });
|
|
95
96
|
open(guiUrl).catch((error) => {
|
|
96
97
|
logger.warn("Could not automatically open browser", {
|
|
@@ -100,7 +101,7 @@ function GuiRunner({ agentProcess, guiProcess, agentPort, agentPath, logger, onE
|
|
|
100
101
|
});
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
|
-
}, [logger]);
|
|
104
|
+
}, [logger, browserPort]);
|
|
104
105
|
// Memoize processes array based only on actual process objects and initial ports
|
|
105
106
|
// Don't include guiPort as dependency to prevent re-creating array when port is detected
|
|
106
107
|
// TabbedOutput will update the displayed port internally via onPortDetected callback
|
|
@@ -374,7 +375,7 @@ async function runCliMode(options) {
|
|
|
374
375
|
}
|
|
375
376
|
}
|
|
376
377
|
export async function runCommand(options) {
|
|
377
|
-
const { name, http = false, gui = false, cli = false, prompt, port = 3100, noSession = false, } = options;
|
|
378
|
+
const { name, http = false, gui = false, cli = false, prompt, port = 3100, noSession = false, proxy = false, } = options;
|
|
378
379
|
// Check if we're inside a Town project
|
|
379
380
|
const projectRoot = await isInsideTownProject();
|
|
380
381
|
if (projectRoot === null) {
|
|
@@ -512,9 +513,15 @@ export async function runCommand(options) {
|
|
|
512
513
|
}
|
|
513
514
|
// Ensure agent and GUI ports are available (debugger ports already checked above)
|
|
514
515
|
const guiPort = 5173;
|
|
516
|
+
const proxyPort = process.env.TOWN_AGENT_PROXY_PORT
|
|
517
|
+
? Number.parseInt(process.env.TOWN_AGENT_PROXY_PORT, 10)
|
|
518
|
+
: 3500;
|
|
515
519
|
try {
|
|
516
520
|
await ensurePortAvailable(port, "agent HTTP server");
|
|
517
521
|
await ensurePortAvailable(guiPort, "GUI dev server");
|
|
522
|
+
if (proxy) {
|
|
523
|
+
await ensurePortAvailable(proxyPort, "reverse proxy");
|
|
524
|
+
}
|
|
518
525
|
}
|
|
519
526
|
catch (error) {
|
|
520
527
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -524,10 +531,18 @@ export async function runCommand(options) {
|
|
|
524
531
|
logger.info("Starting GUI mode", {
|
|
525
532
|
agentPort: port,
|
|
526
533
|
guiPort,
|
|
534
|
+
...(proxy ? { proxyPort } : {}),
|
|
527
535
|
});
|
|
528
536
|
console.log(`Starting agent "${name}" with GUI...`);
|
|
529
|
-
|
|
530
|
-
|
|
537
|
+
if (proxy) {
|
|
538
|
+
console.log(`Proxy server running on port ${proxyPort}`);
|
|
539
|
+
console.log(` - GUI: http://localhost:${proxyPort}/`);
|
|
540
|
+
console.log(` - Agent API: http://localhost:${proxyPort}/rpc\n`);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
console.log(`GUI: http://localhost:${guiPort}/`);
|
|
544
|
+
console.log(`Agent API: http://localhost:${port}/rpc\n`);
|
|
545
|
+
}
|
|
531
546
|
// Set stdin to raw mode for Ink
|
|
532
547
|
if (process.stdin.isTTY) {
|
|
533
548
|
process.stdin.setRawMode(true);
|
|
@@ -551,13 +566,17 @@ export async function runCommand(options) {
|
|
|
551
566
|
if (process.env.BIND_HOST) {
|
|
552
567
|
viteArgs.push("--host", process.env.BIND_HOST);
|
|
553
568
|
}
|
|
569
|
+
// When proxy is enabled, GUI talks to proxy; otherwise directly to agent
|
|
570
|
+
const agentUrl = proxy
|
|
571
|
+
? `http://localhost:${proxyPort}`
|
|
572
|
+
: `http://localhost:${port}`;
|
|
554
573
|
const guiProcess = spawn("bunx", viteArgs, {
|
|
555
574
|
cwd: guiPath,
|
|
556
575
|
stdio: ["ignore", "pipe", "pipe"], // Pipe stdout/stderr for capture
|
|
557
576
|
env: {
|
|
558
577
|
...process.env,
|
|
559
578
|
...configEnvVars,
|
|
560
|
-
VITE_AGENT_URL:
|
|
579
|
+
VITE_AGENT_URL: process.env.EXT_HOST ?? agentUrl,
|
|
561
580
|
VITE_DEBUGGER_URL: `http://localhost:${debuggerUiPort}`,
|
|
562
581
|
// If agent uses library MCP, pass LIBRARY_API_URL to GUI for auth
|
|
563
582
|
...(usesLibraryMcp &&
|
|
@@ -567,12 +586,32 @@ export async function runCommand(options) {
|
|
|
567
586
|
}),
|
|
568
587
|
},
|
|
569
588
|
});
|
|
570
|
-
//
|
|
589
|
+
// Start the reverse proxy server if enabled
|
|
590
|
+
const proxyServer = proxy
|
|
591
|
+
? createProxyServer({
|
|
592
|
+
port: proxyPort,
|
|
593
|
+
vitePort: guiPort,
|
|
594
|
+
agentPort: port,
|
|
595
|
+
basicAuthUser: process.env.BASIC_AUTH_USER,
|
|
596
|
+
basicAuthPass: process.env.BASIC_AUTH_PASS,
|
|
597
|
+
logger,
|
|
598
|
+
})
|
|
599
|
+
: null;
|
|
600
|
+
// Setup cleanup handlers for agent, GUI, and proxy processes
|
|
571
601
|
let isCleaningUp = false;
|
|
572
602
|
const cleanupProcesses = () => {
|
|
573
603
|
if (isCleaningUp)
|
|
574
604
|
return;
|
|
575
605
|
isCleaningUp = true;
|
|
606
|
+
// Stop proxy server if running
|
|
607
|
+
if (proxyServer) {
|
|
608
|
+
try {
|
|
609
|
+
proxyServer.stop();
|
|
610
|
+
}
|
|
611
|
+
catch (_e) {
|
|
612
|
+
// Proxy may already be stopped
|
|
613
|
+
}
|
|
614
|
+
}
|
|
576
615
|
// Kill child processes - since they're not detached, they're in our process group
|
|
577
616
|
try {
|
|
578
617
|
agentProcess.kill("SIGTERM");
|
|
@@ -600,7 +639,9 @@ export async function runCommand(options) {
|
|
|
600
639
|
cleanupDebugger?.();
|
|
601
640
|
};
|
|
602
641
|
// Render the tabbed UI with dynamic port detection
|
|
603
|
-
|
|
642
|
+
// Browser opens to proxy port if enabled, otherwise to GUI port
|
|
643
|
+
const browserPort = proxy ? proxyPort : guiPort;
|
|
644
|
+
const { waitUntilExit } = render(_jsx(GuiRunner, { agentProcess: agentProcess, guiProcess: guiProcess, agentPort: port, browserPort: browserPort, agentPath: agentPath, logger: logger, onExit: () => {
|
|
604
645
|
cleanupProcesses();
|
|
605
646
|
} }));
|
|
606
647
|
// Register signal handlers AFTER Ink render to ensure they take precedence
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import type { createLogger } from "@townco/core";
|
|
3
|
+
export interface ProxyServerOptions {
|
|
4
|
+
port: number;
|
|
5
|
+
vitePort: number;
|
|
6
|
+
agentPort: number;
|
|
7
|
+
basicAuthUser: string | undefined;
|
|
8
|
+
basicAuthPass: string | undefined;
|
|
9
|
+
logger: ReturnType<typeof createLogger>;
|
|
10
|
+
}
|
|
11
|
+
export interface ProxyServer {
|
|
12
|
+
server: http.Server;
|
|
13
|
+
stop: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare function createProxyServer(options: ProxyServerOptions): ProxyServer;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import httpProxy from "http-proxy";
|
|
4
|
+
// Agent routes that should be proxied to the agent server
|
|
5
|
+
const AGENT_ROUTES = [
|
|
6
|
+
"/rpc",
|
|
7
|
+
"/events",
|
|
8
|
+
"/health",
|
|
9
|
+
"/sessions",
|
|
10
|
+
"/sandbox",
|
|
11
|
+
"/static",
|
|
12
|
+
"/logs",
|
|
13
|
+
];
|
|
14
|
+
function timingSafeEqual(a, b) {
|
|
15
|
+
const aBuf = Buffer.from(a);
|
|
16
|
+
const bBuf = Buffer.from(b);
|
|
17
|
+
// timingSafeEqual requires equal lengths, so pad the shorter buffer
|
|
18
|
+
const maxLen = Math.max(aBuf.length, bBuf.length);
|
|
19
|
+
const paddedA = Buffer.alloc(maxLen);
|
|
20
|
+
const paddedB = Buffer.alloc(maxLen);
|
|
21
|
+
aBuf.copy(paddedA);
|
|
22
|
+
bBuf.copy(paddedB);
|
|
23
|
+
// Always do both comparisons to avoid leaking length information via timing
|
|
24
|
+
const lengthsMatch = aBuf.length === bBuf.length;
|
|
25
|
+
const contentsMatch = crypto.timingSafeEqual(paddedA, paddedB);
|
|
26
|
+
return lengthsMatch && contentsMatch;
|
|
27
|
+
}
|
|
28
|
+
function getTarget(pathname, options) {
|
|
29
|
+
// Check for agent routes
|
|
30
|
+
for (const route of AGENT_ROUTES) {
|
|
31
|
+
if (pathname === route || pathname.startsWith(`${route}/`)) {
|
|
32
|
+
return `http://localhost:${options.agentPort}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Default: Vite server
|
|
36
|
+
return `http://localhost:${options.vitePort}`;
|
|
37
|
+
}
|
|
38
|
+
export function createProxyServer(options) {
|
|
39
|
+
const { port, basicAuthUser, basicAuthPass, logger } = options;
|
|
40
|
+
const proxy = httpProxy.createProxyServer({
|
|
41
|
+
ws: true,
|
|
42
|
+
changeOrigin: true,
|
|
43
|
+
// Don't add X-Forwarded headers
|
|
44
|
+
xfwd: false,
|
|
45
|
+
});
|
|
46
|
+
// Handle proxy errors
|
|
47
|
+
proxy.on("error", (err, req, res) => {
|
|
48
|
+
logger.error("Proxy error", { error: err.message, url: req.url });
|
|
49
|
+
if (res && "writeHead" in res) {
|
|
50
|
+
res.writeHead(502, {
|
|
51
|
+
"Content-Type": "text/plain",
|
|
52
|
+
});
|
|
53
|
+
res.end("Bad Gateway");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Build expected auth header if auth is configured
|
|
57
|
+
const expectedAuth = basicAuthUser && basicAuthPass
|
|
58
|
+
? `Basic ${Buffer.from(`${basicAuthUser}:${basicAuthPass}`).toString("base64")}`
|
|
59
|
+
: null;
|
|
60
|
+
const server = http.createServer((req, res) => {
|
|
61
|
+
// Basic auth check
|
|
62
|
+
if (expectedAuth) {
|
|
63
|
+
const authHeader = req.headers.authorization ?? "";
|
|
64
|
+
if (!timingSafeEqual(authHeader, expectedAuth)) {
|
|
65
|
+
res.statusCode = 401;
|
|
66
|
+
res.setHeader("WWW-Authenticate", "Basic");
|
|
67
|
+
res.end("Unauthorized");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
72
|
+
const target = getTarget(url.pathname, options);
|
|
73
|
+
// Proxy the request
|
|
74
|
+
proxy.web(req, res, { target });
|
|
75
|
+
});
|
|
76
|
+
// Handle WebSocket upgrades (for Vite HMR)
|
|
77
|
+
server.on("upgrade", (req, socket, head) => {
|
|
78
|
+
// WebSocket upgrades go to Vite for HMR
|
|
79
|
+
// Note: We don't do auth on WebSocket upgrades since Vite HMR is internal
|
|
80
|
+
const viteTarget = `http://localhost:${options.vitePort}`;
|
|
81
|
+
proxy.ws(req, socket, head, { target: viteTarget });
|
|
82
|
+
});
|
|
83
|
+
server.on("error", (err) => {
|
|
84
|
+
logger.error("Proxy server error", { error: err.message, port });
|
|
85
|
+
});
|
|
86
|
+
server.listen(port, () => {
|
|
87
|
+
logger.info("Proxy server started", { port });
|
|
88
|
+
});
|
|
89
|
+
const stop = () => {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
proxy.close();
|
|
92
|
+
server.close(() => resolve());
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
return { server, stop };
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@townco/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.118",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"town": "./dist/index.js"
|
|
@@ -15,22 +15,23 @@
|
|
|
15
15
|
"build": "tsgo"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"@townco/tsconfig": "0.1.
|
|
18
|
+
"@townco/tsconfig": "0.1.110",
|
|
19
19
|
"@types/archiver": "^7.0.0",
|
|
20
20
|
"@types/bun": "^1.3.1",
|
|
21
|
+
"@types/http-proxy": "^1.17.14",
|
|
21
22
|
"@types/ignore-walk": "^4.0.3",
|
|
22
23
|
"@types/react": "^19.2.2"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@optique/core": "^0.6.2",
|
|
26
27
|
"@optique/run": "^0.6.2",
|
|
27
|
-
"@townco/agent": "0.1.
|
|
28
|
-
"@townco/apiclient": "0.0.
|
|
29
|
-
"@townco/core": "0.0.
|
|
30
|
-
"@townco/debugger": "0.1.
|
|
31
|
-
"@townco/env": "0.1.
|
|
32
|
-
"@townco/secret": "0.1.
|
|
33
|
-
"@townco/ui": "0.1.
|
|
28
|
+
"@townco/agent": "0.1.121",
|
|
29
|
+
"@townco/apiclient": "0.0.33",
|
|
30
|
+
"@townco/core": "0.0.91",
|
|
31
|
+
"@townco/debugger": "0.1.69",
|
|
32
|
+
"@townco/env": "0.1.63",
|
|
33
|
+
"@townco/secret": "0.1.113",
|
|
34
|
+
"@townco/ui": "0.1.113",
|
|
34
35
|
"@trpc/client": "^11.7.2",
|
|
35
36
|
"archiver": "^7.0.1",
|
|
36
37
|
"eventsource": "^4.1.0",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"open": "^10.2.0",
|
|
42
43
|
"superjson": "^2.2.5",
|
|
43
44
|
"ts-pattern": "^5.9.0",
|
|
45
|
+
"http-proxy": "^1.18.1",
|
|
44
46
|
"zod": "^4.1.12"
|
|
45
47
|
},
|
|
46
48
|
"peerDependencies": {
|