@ottocode/server 0.1.177 → 0.1.179

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.177",
3
+ "version": "0.1.179",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@ottocode/sdk": "0.1.177",
33
- "@ottocode/database": "0.1.177",
32
+ "@ottocode/sdk": "0.1.179",
33
+ "@ottocode/database": "0.1.179",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { registerResearchRoutes } from './routes/research.ts';
19
19
  import { registerSessionApprovalRoute } from './routes/session-approval.ts';
20
20
  import { registerSetuRoutes } from './routes/setu.ts';
21
21
  import { registerAuthRoutes } from './routes/auth.ts';
22
+ import { registerTunnelRoutes } from './routes/tunnel.ts';
22
23
  import type { AgentConfigEntry } from './runtime/agent/registry.ts';
23
24
 
24
25
  const globalTerminalManager = new TerminalManager();
@@ -74,6 +75,7 @@ function initApp() {
74
75
  registerResearchRoutes(app);
75
76
  registerSetuRoutes(app);
76
77
  registerAuthRoutes(app);
78
+ registerTunnelRoutes(app);
77
79
 
78
80
  return app;
79
81
  }
@@ -145,6 +147,7 @@ export function createStandaloneApp(_config?: StandaloneAppConfig) {
145
147
  registerResearchRoutes(honoApp);
146
148
  registerSetuRoutes(honoApp);
147
149
  registerAuthRoutes(honoApp);
150
+ registerTunnelRoutes(honoApp);
148
151
 
149
152
  return honoApp;
150
153
  }
@@ -244,6 +247,7 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
244
247
  registerResearchRoutes(honoApp);
245
248
  registerSetuRoutes(honoApp);
246
249
  registerAuthRoutes(honoApp);
250
+ registerTunnelRoutes(honoApp);
247
251
 
248
252
  return honoApp;
249
253
  }
@@ -279,3 +283,6 @@ export {
279
283
  isTraceEnabled,
280
284
  } from './runtime/debug/state.ts';
281
285
  export { logger } from '@ottocode/sdk';
286
+
287
+ // Export server state management
288
+ export { setServerPort, getServerPort, getServerInfo } from './state.ts';
@@ -529,7 +529,10 @@ export function registerAuthRoutes(app: Hono) {
529
529
  if (!sessionId || !copilotDeviceSessions.has(sessionId)) {
530
530
  return c.json({ error: 'Session expired or invalid' }, 400);
531
531
  }
532
- const session = copilotDeviceSessions.get(sessionId)!;
532
+ const session = copilotDeviceSessions.get(sessionId);
533
+ if (!session) {
534
+ return c.json({ error: 'Session expired or invalid' }, 400);
535
+ }
533
536
  const result = await pollForCopilotTokenOnce(session.deviceCode);
534
537
  if (result.status === 'complete') {
535
538
  copilotDeviceSessions.delete(sessionId);
@@ -52,12 +52,7 @@ async function listFilesWithRg(
52
52
  const rgBin = await resolveBinary('rg');
53
53
 
54
54
  return new Promise((resolve) => {
55
- const args = [
56
- '--files',
57
- '--hidden',
58
- '--glob', '!.git/',
59
- '--sort', 'path',
60
- ];
55
+ const args = ['--files', '--hidden', '--glob', '!.git/', '--sort', 'path'];
61
56
 
62
57
  const proc = spawn(rgBin, args, { cwd: projectRoot });
63
58
  let stdout = '';
@@ -73,7 +68,10 @@ async function listFilesWithRg(
73
68
 
74
69
  proc.on('close', (code) => {
75
70
  if (code !== 0 && code !== 1) {
76
- logger.warn('rg --files failed, falling back', { stderr } as Record<string, unknown>);
71
+ logger.warn('rg --files failed, falling back', { stderr } as Record<
72
+ string,
73
+ unknown
74
+ >);
77
75
  resolve({ files: [], truncated: false });
78
76
  return;
79
77
  }
@@ -196,7 +194,10 @@ async function traverseDirectory(
196
194
  }
197
195
  }
198
196
  } catch (err) {
199
- logger.warn(`Failed to read directory ${dir}:`, err as Record<string, unknown>);
197
+ logger.warn(
198
+ `Failed to read directory ${dir}:`,
199
+ err as Record<string, unknown>,
200
+ );
200
201
  }
201
202
 
202
203
  return { files: collected, truncated: false };
@@ -1,5 +1,12 @@
1
1
  import type { Hono } from 'hono';
2
+ import { getServerInfo } from '../state.ts';
2
3
 
3
4
  export function registerRootRoutes(app: Hono) {
4
5
  app.get('/', (c) => c.text('otto server running'));
6
+
7
+ app.get('/v1/server/info', (c) => {
8
+ return c.json({
9
+ ...getServerInfo(),
10
+ });
11
+ });
5
12
  }
@@ -19,7 +19,8 @@ import {
19
19
  type TopupMethod,
20
20
  } from '../runtime/topup/manager.ts';
21
21
 
22
- const SETU_BASE_URL = process.env.SETU_BASE_URL || 'https://api.setu.ottocode.io';
22
+ const SETU_BASE_URL =
23
+ process.env.SETU_BASE_URL || 'https://api.setu.ottocode.io';
23
24
 
24
25
  function getSetuBaseUrl(): string {
25
26
  return SETU_BASE_URL.endsWith('/')
@@ -0,0 +1,238 @@
1
+ import type { Hono } from 'hono';
2
+ import { streamSSE } from 'hono/streaming';
3
+ import {
4
+ OttoTunnel,
5
+ isTunnelBinaryInstalled,
6
+ generateQRCode,
7
+ killStaleTunnels,
8
+ logger,
9
+ } from '@ottocode/sdk';
10
+ import { getServerPort } from '../state.ts';
11
+
12
+ let activeTunnel: OttoTunnel | null = null;
13
+ let tunnelUrl: string | null = null;
14
+ let tunnelStatus: 'idle' | 'starting' | 'connected' | 'error' = 'idle';
15
+ let tunnelError: string | null = null;
16
+ let progressMessage: string | null = null;
17
+
18
+ export function registerTunnelRoutes(app: Hono) {
19
+ app.get('/v1/tunnel/status', async (c) => {
20
+ const binaryInstalled = await isTunnelBinaryInstalled();
21
+
22
+ return c.json({
23
+ status: tunnelStatus,
24
+ url: tunnelUrl,
25
+ error: tunnelError,
26
+ binaryInstalled,
27
+ isRunning: activeTunnel?.isRunning ?? false,
28
+ });
29
+ });
30
+
31
+ app.post('/v1/tunnel/start', async (c) => {
32
+ if (activeTunnel?.isRunning) {
33
+ return c.json({
34
+ ok: true,
35
+ url: tunnelUrl,
36
+ message: 'Tunnel already running',
37
+ });
38
+ }
39
+
40
+ try {
41
+ const body = await c.req.json().catch(() => ({}));
42
+ let port = body.port;
43
+
44
+ // Use server's known port if not explicitly provided
45
+ if (!port) {
46
+ port = getServerPort() || 9100;
47
+ }
48
+
49
+ logger.debug('Starting tunnel on port:', port);
50
+
51
+ // Kill any stale tunnel processes first
52
+ await killStaleTunnels();
53
+
54
+ tunnelStatus = 'starting';
55
+ tunnelError = null;
56
+ progressMessage = 'Initializing...';
57
+
58
+ activeTunnel = new OttoTunnel();
59
+
60
+ const url = await activeTunnel.start(port, (msg) => {
61
+ progressMessage = msg;
62
+ logger.debug('Tunnel progress:', msg);
63
+ });
64
+
65
+ tunnelUrl = url;
66
+ tunnelStatus = 'connected';
67
+ progressMessage = null;
68
+
69
+ activeTunnel.on('error', (err) => {
70
+ logger.error('Tunnel error:', err);
71
+ tunnelError = err.message;
72
+ tunnelStatus = 'error';
73
+ });
74
+
75
+ activeTunnel.on('exit', () => {
76
+ tunnelStatus = 'idle';
77
+ tunnelUrl = null;
78
+ activeTunnel = null;
79
+ });
80
+
81
+ return c.json({
82
+ ok: true,
83
+ url: tunnelUrl,
84
+ message: 'Tunnel started',
85
+ });
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ tunnelStatus = 'error';
89
+ tunnelError = message;
90
+ progressMessage = null;
91
+
92
+ logger.error('Failed to start tunnel:', error);
93
+ return c.json({ ok: false, error: message }, 500);
94
+ }
95
+ });
96
+
97
+ app.post('/v1/tunnel/register', async (c) => {
98
+ try {
99
+ const body = await c.req.json().catch(() => ({}));
100
+ const { url } = body;
101
+
102
+ if (!url) {
103
+ return c.json({ ok: false, error: 'URL is required' }, 400);
104
+ }
105
+
106
+ tunnelUrl = url;
107
+ tunnelStatus = 'connected';
108
+ tunnelError = null;
109
+ progressMessage = null;
110
+
111
+ logger.debug('External tunnel registered:', url);
112
+
113
+ return c.json({
114
+ ok: true,
115
+ url: tunnelUrl,
116
+ message: 'External tunnel registered',
117
+ });
118
+ } catch (error) {
119
+ const message = error instanceof Error ? error.message : String(error);
120
+ logger.error('Failed to register external tunnel:', error);
121
+ return c.json({ ok: false, error: message }, 500);
122
+ }
123
+ });
124
+
125
+ app.post('/v1/tunnel/stop', async (c) => {
126
+ if (!activeTunnel) {
127
+ return c.json({ ok: true, message: 'No tunnel running' });
128
+ }
129
+
130
+ try {
131
+ activeTunnel.stop();
132
+ activeTunnel = null;
133
+ tunnelUrl = null;
134
+ tunnelStatus = 'idle';
135
+ tunnelError = null;
136
+
137
+ return c.json({ ok: true, message: 'Tunnel stopped' });
138
+ } catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ return c.json({ ok: false, error: message }, 500);
141
+ }
142
+ });
143
+
144
+ app.get('/v1/tunnel/qr', async (c) => {
145
+ if (!tunnelUrl) {
146
+ return c.json({ ok: false, error: 'No tunnel URL available' }, 400);
147
+ }
148
+
149
+ try {
150
+ const qrCode = await generateQRCode(tunnelUrl);
151
+ return c.json({
152
+ ok: true,
153
+ url: tunnelUrl,
154
+ qrCode,
155
+ });
156
+ } catch (error) {
157
+ const message = error instanceof Error ? error.message : String(error);
158
+ return c.json({ ok: false, error: message }, 500);
159
+ }
160
+ });
161
+
162
+ app.get('/v1/tunnel/stream', async (c) => {
163
+ return streamSSE(c, async (stream) => {
164
+ const sendEvent = async (data: Record<string, unknown>) => {
165
+ try {
166
+ await stream.write(`data: ${JSON.stringify(data)}\n\n`);
167
+ } catch (error) {
168
+ logger.error('SSE error writing event', error);
169
+ }
170
+ };
171
+
172
+ await sendEvent({
173
+ type: 'status',
174
+ status: tunnelStatus,
175
+ url: tunnelUrl,
176
+ error: tunnelError,
177
+ progress: progressMessage,
178
+ });
179
+
180
+ const interval = setInterval(async () => {
181
+ await sendEvent({
182
+ type: 'status',
183
+ status: tunnelStatus,
184
+ url: tunnelUrl,
185
+ error: tunnelError,
186
+ progress: progressMessage,
187
+ });
188
+ }, 1000);
189
+
190
+ const onAbort = () => {
191
+ clearInterval(interval);
192
+ stream.close();
193
+ };
194
+
195
+ c.req.raw.signal.addEventListener('abort', onAbort, { once: true });
196
+
197
+ await new Promise<void>((resolve) => {
198
+ c.req.raw.signal.addEventListener('abort', () => resolve(), {
199
+ once: true,
200
+ });
201
+ });
202
+
203
+ clearInterval(interval);
204
+ });
205
+ });
206
+ }
207
+
208
+ export function stopActiveTunnel() {
209
+ if (activeTunnel) {
210
+ activeTunnel.stop();
211
+ activeTunnel = null;
212
+ tunnelUrl = null;
213
+ tunnelStatus = 'idle';
214
+ }
215
+ }
216
+
217
+ export function setExternalTunnel(tunnel: OttoTunnel, url: string) {
218
+ activeTunnel = tunnel;
219
+ tunnelUrl = url;
220
+ tunnelStatus = 'connected';
221
+ tunnelError = null;
222
+ progressMessage = null;
223
+
224
+ tunnel.on('error', (err) => {
225
+ tunnelError = err.message;
226
+ tunnelStatus = 'error';
227
+ });
228
+
229
+ tunnel.on('exit', () => {
230
+ tunnelStatus = 'idle';
231
+ tunnelUrl = null;
232
+ activeTunnel = null;
233
+ });
234
+ }
235
+
236
+ export function getActiveTunnelUrl(): string | null {
237
+ return tunnelUrl;
238
+ }
@@ -52,7 +52,7 @@ export function createErrorHandler(
52
52
  '';
53
53
 
54
54
  // Also check error message for the exact fiat selection message
55
- const errorMessage =
55
+ const _errorMessage =
56
56
  (errObj?.message as string) ??
57
57
  ((errObj?.error as Record<string, unknown>)?.message as string) ??
58
58
  ((
package/src/state.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Server state - tracks runtime information like the server's port
3
+ * This is the single source of truth for server configuration
4
+ */
5
+
6
+ let serverPort: number | null = null;
7
+
8
+ export function setServerPort(port: number): void {
9
+ serverPort = port;
10
+ }
11
+
12
+ export function getServerPort(): number | null {
13
+ return serverPort;
14
+ }
15
+
16
+ export function getServerInfo(): { port: number | null } {
17
+ return { port: serverPort };
18
+ }