@roamcode.ai/server 1.0.0

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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth.d.ts +63 -0
  3. package/dist/auth.js +133 -0
  4. package/dist/claude-auth-service.d.ts +76 -0
  5. package/dist/claude-auth-service.js +217 -0
  6. package/dist/claude-latest-service.d.ts +34 -0
  7. package/dist/claude-latest-service.js +61 -0
  8. package/dist/config.d.ts +78 -0
  9. package/dist/config.js +59 -0
  10. package/dist/data-dir.d.ts +42 -0
  11. package/dist/data-dir.js +70 -0
  12. package/dist/diag.d.ts +43 -0
  13. package/dist/diag.js +83 -0
  14. package/dist/fs-service.d.ts +90 -0
  15. package/dist/fs-service.js +290 -0
  16. package/dist/index.d.ts +82 -0
  17. package/dist/index.js +44 -0
  18. package/dist/managed-runtime.d.ts +51 -0
  19. package/dist/managed-runtime.js +411 -0
  20. package/dist/managed-update-helper.d.ts +2 -0
  21. package/dist/managed-update-helper.js +34 -0
  22. package/dist/mcp-send.d.ts +33 -0
  23. package/dist/mcp-send.js +107 -0
  24. package/dist/origin-check.d.ts +37 -0
  25. package/dist/origin-check.js +101 -0
  26. package/dist/pane-status.d.ts +61 -0
  27. package/dist/pane-status.js +145 -0
  28. package/dist/providers/claude-metadata-service.d.ts +58 -0
  29. package/dist/providers/claude-metadata-service.js +352 -0
  30. package/dist/providers/claude-provider.d.ts +11 -0
  31. package/dist/providers/claude-provider.js +166 -0
  32. package/dist/providers/codex-activity.d.ts +21 -0
  33. package/dist/providers/codex-activity.js +122 -0
  34. package/dist/providers/codex-app-server-client.d.ts +90 -0
  35. package/dist/providers/codex-app-server-client.js +485 -0
  36. package/dist/providers/codex-latest-service.d.ts +50 -0
  37. package/dist/providers/codex-latest-service.js +174 -0
  38. package/dist/providers/codex-metadata-service.d.ts +161 -0
  39. package/dist/providers/codex-metadata-service.js +686 -0
  40. package/dist/providers/codex-profile-client.d.ts +16 -0
  41. package/dist/providers/codex-profile-client.js +52 -0
  42. package/dist/providers/codex-profile-security.d.ts +23 -0
  43. package/dist/providers/codex-profile-security.js +161 -0
  44. package/dist/providers/codex-provider.d.ts +15 -0
  45. package/dist/providers/codex-provider.js +174 -0
  46. package/dist/providers/codex-thread-coordinator.d.ts +18 -0
  47. package/dist/providers/codex-thread-coordinator.js +93 -0
  48. package/dist/providers/codex-thread-persistence.d.ts +9 -0
  49. package/dist/providers/codex-thread-persistence.js +45 -0
  50. package/dist/providers/codex-thread-resolver.d.ts +59 -0
  51. package/dist/providers/codex-thread-resolver.js +322 -0
  52. package/dist/providers/options.d.ts +7 -0
  53. package/dist/providers/options.js +155 -0
  54. package/dist/providers/provider-artifacts.d.ts +3 -0
  55. package/dist/providers/provider-artifacts.js +30 -0
  56. package/dist/providers/registry.d.ts +7 -0
  57. package/dist/providers/registry.js +23 -0
  58. package/dist/providers/types.d.ts +95 -0
  59. package/dist/providers/types.js +8 -0
  60. package/dist/push-dispatch.d.ts +81 -0
  61. package/dist/push-dispatch.js +100 -0
  62. package/dist/push-store.d.ts +25 -0
  63. package/dist/push-store.js +79 -0
  64. package/dist/rate-limit.d.ts +52 -0
  65. package/dist/rate-limit.js +72 -0
  66. package/dist/server-config.d.ts +60 -0
  67. package/dist/server-config.js +77 -0
  68. package/dist/service-install.d.ts +60 -0
  69. package/dist/service-install.js +221 -0
  70. package/dist/session-defaults.d.ts +26 -0
  71. package/dist/session-defaults.js +60 -0
  72. package/dist/session-store.d.ts +81 -0
  73. package/dist/session-store.js +654 -0
  74. package/dist/start.d.ts +31 -0
  75. package/dist/start.js +372 -0
  76. package/dist/static-routes.d.ts +101 -0
  77. package/dist/static-routes.js +188 -0
  78. package/dist/terminal-capability.d.ts +5 -0
  79. package/dist/terminal-capability.js +27 -0
  80. package/dist/terminal-manager.d.ts +224 -0
  81. package/dist/terminal-manager.js +917 -0
  82. package/dist/terminal-process.d.ts +85 -0
  83. package/dist/terminal-process.js +238 -0
  84. package/dist/terminal-shared.d.ts +36 -0
  85. package/dist/terminal-shared.js +43 -0
  86. package/dist/tmux-list.d.ts +11 -0
  87. package/dist/tmux-list.js +39 -0
  88. package/dist/transport.d.ts +123 -0
  89. package/dist/transport.js +1559 -0
  90. package/dist/updater.d.ts +161 -0
  91. package/dist/updater.js +451 -0
  92. package/dist/usage-service.d.ts +118 -0
  93. package/dist/usage-service.js +173 -0
  94. package/dist/vapid.d.ts +17 -0
  95. package/dist/vapid.js +31 -0
  96. package/dist/web-push-send.d.ts +20 -0
  97. package/dist/web-push-send.js +21 -0
  98. package/dist/ws-ticket.d.ts +47 -0
  99. package/dist/ws-ticket.js +62 -0
  100. package/package.json +55 -0
package/dist/start.js ADDED
@@ -0,0 +1,372 @@
1
+ import { pathToFileURL, fileURLToPath } from "node:url";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { createServer } from "./transport.js";
5
+ import { loadServerConfig, assertConfigAllowsStart, isLoopbackAddress } from "./server-config.js";
6
+ import { ensureDataDir, resolveAccessToken } from "./data-dir.js";
7
+ import { openSessionStore } from "./session-store.js";
8
+ import { resolveVapidKeys } from "./vapid.js";
9
+ import { openPushStore } from "./push-store.js";
10
+ import { createWebPushSend } from "./web-push-send.js";
11
+ import { createPushDispatcher } from "./push-dispatch.js";
12
+ import { createUsageService } from "./usage-service.js";
13
+ import { createClaudeAuthService } from "./claude-auth-service.js";
14
+ import { createClaudeLatestService } from "./claude-latest-service.js";
15
+ import { createClaudeVersionProbe, defaultRunClaudeVersion } from "./diag.js";
16
+ import { classifierVersionWarning } from "./pane-status.js";
17
+ import { ProviderRegistry } from "./providers/registry.js";
18
+ import { createClaudeProvider } from "./providers/claude-provider.js";
19
+ import { createCodexProvider } from "./providers/codex-provider.js";
20
+ import { CodexAppServerClient } from "./providers/codex-app-server-client.js";
21
+ import { CodexMetadataService } from "./providers/codex-metadata-service.js";
22
+ import { ClaudeMetadataService, createClaudeMetadataRunner } from "./providers/claude-metadata-service.js";
23
+ import { createCodexProfileClientLifecycle } from "./providers/codex-profile-client.js";
24
+ import { createCodexThreadInventory, CodexThreadResolver } from "./providers/codex-thread-resolver.js";
25
+ import { CodexLatestService } from "./providers/codex-latest-service.js";
26
+ import { execFile } from "node:child_process";
27
+ import { createUpdater } from "./updater.js";
28
+ export function providerPreflightWarning(name, availability) {
29
+ if (availability.terminalAvailable)
30
+ return undefined;
31
+ return (`\n⚠ ${name} CLI not found or not runnable — new ${name} sessions will FAIL until this is fixed.\n` +
32
+ ` Install ${name} and make sure its executable is on this server's PATH, then authenticate it on the host.\n`);
33
+ }
34
+ export async function runProviderPreflight(providers, warn = (message) => console.warn(message)) {
35
+ await Promise.all(providers.map(async (provider) => {
36
+ let availability;
37
+ try {
38
+ availability = await provider.probe();
39
+ }
40
+ catch {
41
+ availability = { terminalAvailable: false, metadataAvailable: false };
42
+ }
43
+ const message = providerPreflightWarning(provider.name, availability);
44
+ if (message)
45
+ warn(message);
46
+ }));
47
+ }
48
+ /**
49
+ * STARTUP PREFLIGHT (#7): format a prominent, actionable boot warning when `claude --version` couldn't
50
+ * run. The server still boots (sessions just won't start until the operator fixes it) — this is purely
51
+ * to surface WHY in the logs immediately, like the better-sqlite3 fallback warning. Returns `undefined`
52
+ * when claude is available (no warning). PURE so it's unit-testable without spawning.
53
+ */
54
+ export function claudePreflightWarning(availability) {
55
+ if (availability.available)
56
+ return undefined;
57
+ return ("\n⚠ `claude` CLI not found or not runnable — new sessions will FAIL until this is fixed.\n" +
58
+ " Install Claude Code and make sure `claude` is on this server's PATH, then authenticate by\n" +
59
+ " running `claude` once in a terminal on the host (there is no remote login).\n" +
60
+ " (If it IS installed, the service's PATH may not include it — see the README troubleshooting.)\n");
61
+ }
62
+ /**
63
+ * Run the startup preflight: best-effort probe `claude --version` and print {@link claudePreflightWarning}
64
+ * if it's missing/failing. NEVER throws and NEVER blocks boot — a hung/slow claude can't stall startup
65
+ * (the probe is short-timeout-guarded). Injectable probe + log sink so it's testable without a spawn.
66
+ */
67
+ export async function runClaudePreflight(probe, warn = (m) => console.warn(m)) {
68
+ let availability;
69
+ try {
70
+ availability = await probe.get();
71
+ }
72
+ catch {
73
+ availability = { available: false }; // a thrown probe is treated as unavailable (defensive; probe never throws)
74
+ }
75
+ const message = claudePreflightWarning(availability);
76
+ if (message)
77
+ warn(message);
78
+ }
79
+ export async function startServer(env = process.env) {
80
+ const config = loadServerConfig(env);
81
+ // First-run token (spec §9): use ACCESS_TOKEN if set, else the persisted token, else generate.
82
+ // EXPLICIT OPT-OUT: NO_TOKEN=1 keeps the Plan-3 tokenless dev path (no token generated/stored/
83
+ // required) and only RUNS on a loopback bind (assertConfigAllowsStart enforces that below).
84
+ // SECURITY: a token is auto-generated only when the bind is loopback OR a token is already
85
+ // configured/persisted. A FIRST non-loopback bind with no token is left tokenless so
86
+ // assertConfigAllowsStart refuses to start — we never silently mint a secret for a public bind.
87
+ ensureDataDir(config.dataDir);
88
+ const loopback = isLoopbackAddress(config.bindAddress);
89
+ let token;
90
+ let generated = false;
91
+ const tokenless = env.NO_TOKEN === "1";
92
+ const mayResolveToken = !tokenless && (loopback || config.accessToken !== undefined);
93
+ if (mayResolveToken) {
94
+ const resolved = resolveAccessToken({ configured: config.accessToken, dataDir: config.dataDir });
95
+ token = resolved.token;
96
+ generated = resolved.generated;
97
+ config.accessToken = token;
98
+ }
99
+ assertConfigAllowsStart(config); // refuses a non-loopback bind that still has no token
100
+ const store = openSessionStore({ dbPath: join(config.dataDir, "sessions.db") });
101
+ // LOUD store-fallback warning: the store silently falls back to a non-durable in-memory Map when the
102
+ // native better-sqlite3 module can't load. That means sessions are LOST on every restart (incl. the OTA
103
+ // restart) — a silent data-durability footgun. Warn prominently + actionably so an operator notices and
104
+ // rebuilds the native module. `storeMode` is threaded to /diag for fleet observability.
105
+ const storeMode = store.mode;
106
+ if (storeMode === "memory-fallback") {
107
+ console.warn("\n⚠ better-sqlite3 failed to load — sessions are NOT persisted across restarts (every restart, " +
108
+ "including an OTA update, starts empty).\n" +
109
+ " Rebuild the native module: pnpm -C packages/server rebuild better-sqlite3\n" +
110
+ " (or reinstall with native builds allowed: pnpm install && pnpm approve-builds better-sqlite3)\n");
111
+ }
112
+ // STARTUP PREFLIGHT (#7): one cached `claude --version` probe SHARED by the boot preflight below and the
113
+ // authed GET /diag (injected into createServer), so we spawn at most once. The probe is short-timeout-
114
+ // guarded and never throws.
115
+ const claudeVersionProbe = createClaudeVersionProbe({
116
+ run: defaultRunClaudeVersion(config.claude.claudeBin, env),
117
+ });
118
+ // The provider-labelled dual preflight is launched after both adapters are constructed below. This cached
119
+ // Claude probe is still shared with diagnostics and the classifier guard, so no duplicate spawn occurs.
120
+ // CLASSIFIER VERSION GUARD: the pane-status markers driving the rail's working/blocked/idle are tied to
121
+ // Claude Code's ENGLISH TUI strings (see pane-status.ts CLASSIFIER_TESTED_UP_TO). If the installed claude
122
+ // is NEWER than the version they were verified against, log ONE warning so a reworded TUI degrading every
123
+ // status to "idle" isn't a silent mystery. Shares the cached probe above (no extra spawn); never throws.
124
+ void claudeVersionProbe
125
+ .get()
126
+ .then((availability) => {
127
+ const warning = classifierVersionWarning(availability.version);
128
+ if (warning)
129
+ console.warn(`[roamcode] ⚠ ${warning}`);
130
+ })
131
+ .catch(() => {
132
+ /* the probe never rejects; defensive so the guard can never affect boot */
133
+ });
134
+ // Web Push (spec §1): VAPID keypair (persisted) + subscription store.
135
+ const vapid = resolveVapidKeys({ dataDir: config.dataDir });
136
+ const pushStore = openPushStore({ dbPath: join(config.dataDir, "push.db") });
137
+ // Away-from-desk dispatcher: fans "claude needs you / finished / sent a file / has a question" pushes out
138
+ // to every matching subscription (pruning dead ones on 404/410). The VAPID subject is a mailto:/https: URL
139
+ // the push service can contact (web-push REQUIRES it) — from ROAMCODE_VAPID_SUBJECT, else a sane
140
+ // default. Wrapped so a misconfigured subject (or a web-push init throw) DISABLES push rather than killing
141
+ // boot — an always-on server should keep serving even if the nice-to-have notifications can't send.
142
+ const vapidSubject = (env.ROAMCODE_VAPID_SUBJECT ?? env.REMOTE_CODER_VAPID_SUBJECT)?.trim() || "mailto:roamcode@localhost";
143
+ let pushDispatcher;
144
+ try {
145
+ pushDispatcher = createPushDispatcher({
146
+ pushStore,
147
+ send: createWebPushSend({ vapid, subject: vapidSubject }),
148
+ log: (m) => console.warn(`[roamcode] ${m}`),
149
+ });
150
+ }
151
+ catch (err) {
152
+ console.warn(`[roamcode] ⚠ web push disabled (${err.message}) — set a valid ROAMCODE_VAPID_SUBJECT`);
153
+ }
154
+ // The PWA is served from packages/web/dist when it exists (one-origin deploy). Guard the path:
155
+ // @fastify/static throws at register if `root` is missing (e.g. a dev tree with no `vite build` yet).
156
+ const candidateWebDir = env.WEB_DIR ?? defaultWebDir();
157
+ const webDir = candidateWebDir && existsSync(candidateWebDir) ? candidateWebDir : undefined;
158
+ // Claude usage bars (GET /usage): spawn `claude /usage` with the SAME claude bin the chat uses and
159
+ // the server's env (login session → subscription auth resolves). The service TTL-caches the parsed
160
+ // result so the rail's poll is cheap; a spawn/parse failure degrades to null (the UI hides the bars).
161
+ const usage = createUsageService({ claudeBin: config.claude.claudeBin, env });
162
+ // In-app Claude re-authentication (GET/POST /auth/*): wraps `claude auth login` so an expired server-side
163
+ // Claude login can be fixed from the app instead of SSHing in. Same claude bin + env as the terminal spawns.
164
+ const claudeAuth = createClaudeAuthService({ claudeBin: config.claude.claudeBin, env });
165
+ // Update awareness (GET /claude/version): the newest published claude version (npm dist-tag), TTL-cached,
166
+ // compared client-side against each session's spawn-time version to show a subtle "update available" hint.
167
+ const claudeLatest = createClaudeLatestService();
168
+ // Auxiliary model discovery is lazy: construction does not spawn Claude, and failures only degrade
169
+ // metadata routes or add a compatibility warning to session creation.
170
+ const claudeMetadata = new ClaudeMetadataService(createClaudeMetadataRunner({
171
+ claudeBin: config.claude.claudeBin,
172
+ cwd: config.fsRoot,
173
+ env,
174
+ }));
175
+ // Stable-only auxiliary app-server. The lazy RPC wrapper starts it only when a metadata route or exact
176
+ // thread-inventory probe is used; terminal construction never depends on successful initialization.
177
+ const codexClient = new CodexAppServerClient({ codexBin: config.codexBin, env });
178
+ const codexRpc = {
179
+ request: async (method, params, schema) => {
180
+ await codexClient.start();
181
+ return codexClient.request(method, params, schema);
182
+ },
183
+ onNotification: (listener) => codexClient.onNotification(listener),
184
+ };
185
+ const profileClient = createCodexProfileClientLifecycle({ codexBin: config.codexBin, env });
186
+ const codexMetadata = new CodexMetadataService(codexRpc, {
187
+ ...(env.CODEX_HOME ? { codexHome: env.CODEX_HOME } : {}),
188
+ profileClient,
189
+ });
190
+ const codexCapabilityInventory = createCodexThreadInventory(codexRpc, { cwd: config.fsRoot });
191
+ const codexCapabilityProbe = {
192
+ get: () => codexMetadata.probeCapabilities(config.fsRoot, codexCapabilityInventory),
193
+ };
194
+ const codexLatest = new CodexLatestService({
195
+ runVersion: (args, options) => new Promise((resolve, reject) => {
196
+ execFile(config.codexBin, [...args], { env, timeout: options.timeoutMs, maxBuffer: options.maxOutputBytes, windowsHide: true }, (error, stdout, stderr) => {
197
+ if (error && error.code === "ENOENT") {
198
+ reject(error);
199
+ return;
200
+ }
201
+ const errorCode = error && typeof error.code === "number" ? error.code : error ? 1 : 0;
202
+ resolve({
203
+ code: errorCode,
204
+ stdout: String(stdout),
205
+ stderr: String(stderr),
206
+ });
207
+ });
208
+ }),
209
+ detectProvenance: () => "unknown",
210
+ fetchNpmLatest: async (_packageName, options) => {
211
+ const response = await fetch("https://registry.npmjs.org/@openai%2Fcodex/latest", {
212
+ signal: AbortSignal.timeout(options.timeoutMs),
213
+ });
214
+ const text = await response.text();
215
+ if (!response.ok || Buffer.byteLength(text) > options.maxResponseBytes)
216
+ throw new Error("unavailable");
217
+ const parsed = JSON.parse(text);
218
+ if (typeof parsed.version !== "string")
219
+ throw new Error("unavailable");
220
+ return parsed.version;
221
+ },
222
+ });
223
+ const providers = new ProviderRegistry([
224
+ createClaudeProvider({
225
+ claudeBin: config.claude.claudeBin,
226
+ env,
227
+ probe: async () => {
228
+ const availability = await claudeVersionProbe.get();
229
+ return {
230
+ terminalAvailable: availability.available,
231
+ metadataAvailable: availability.available,
232
+ ...(availability.version ? { version: availability.version } : {}),
233
+ };
234
+ },
235
+ }),
236
+ createCodexProvider({
237
+ codexBin: config.codexBin,
238
+ env,
239
+ validateProfile: codexMetadata.validateProfile,
240
+ probe: async () => {
241
+ try {
242
+ const version = await codexLatest.getVersion();
243
+ return { terminalAvailable: true, metadataAvailable: false, version: version.installed };
244
+ }
245
+ catch {
246
+ return { terminalAvailable: false, metadataAvailable: false };
247
+ }
248
+ },
249
+ }),
250
+ ]);
251
+ void runProviderPreflight(providers.list().map((provider) => ({ name: provider.displayName, probe: () => provider.probe() })));
252
+ const result = createServer(config, {
253
+ store,
254
+ pushStore,
255
+ pushDispatcher,
256
+ webDir,
257
+ vapidPublicKey: vapid.publicKey,
258
+ usage,
259
+ claudeAuth,
260
+ claudeLatest,
261
+ storeMode,
262
+ // Share the boot-preflight probe with /diag so claude is spawned at most once for both.
263
+ claudeVersionProbe,
264
+ providers,
265
+ claudeMetadata,
266
+ codexMetadata,
267
+ codexCapabilityProbe,
268
+ codexLatest,
269
+ updater: createUpdater({ dataDir: config.dataDir, env }),
270
+ codexThreadResolver: (cwd) => new CodexThreadResolver({ inventory: createCodexThreadInventory(codexRpc, { cwd }) }),
271
+ disposeProviders: () => codexClient.stop(),
272
+ });
273
+ // LOUD boot warning when terminal mode is off (tmux/node-pty unavailable) — the server still serves, but
274
+ // EVERY session fails to start, and the cause is otherwise silent. Mirrors the claude/sqlite warnings.
275
+ if (!result.terminalAvailable) {
276
+ console.warn("[roamcode] ⚠ terminal sessions are DISABLED — tmux and/or node-pty is unavailable. Install tmux " +
277
+ "(macOS: brew install tmux; Debian/Ubuntu: apt install tmux) and ensure node-pty built, then restart.");
278
+ }
279
+ const url = await result.app.listen({ port: config.port, host: config.bindAddress });
280
+ // mcp-send wiring: now that listen() resolved the real port, give the terminal manager the LOOPBACK base
281
+ // URL (the spawned mcp-send.js POSTs back to 127.0.0.1, never the public bind), this deploy's token, and
282
+ // the resolved path to dist/mcp-send.js. Every terminal spawn then loads the send server so claude can
283
+ // deliver files/images to the terminal. The script path is resolved relative to THIS module so it works
284
+ // wherever the server is installed.
285
+ const { port: listenPort } = result.app.server.address();
286
+ const attachConfig = {
287
+ baseUrl: `http://127.0.0.1:${listenPort}`,
288
+ token: token ?? "",
289
+ mcpScriptPath: fileURLToPath(new URL("./mcp-send.js", import.meta.url)),
290
+ // Per-session 0600 mcp-config-<id>.json files are written into the data dir (mode 0700), keeping
291
+ // the access token out of every process's argv.
292
+ dataDir: config.dataDir,
293
+ };
294
+ // Terminal sessions → the terminal's claude gets send_image/send_file too.
295
+ result.terminalManager.setAttachConfig(attachConfig);
296
+ // Now that rehydrate (adopt survivors) + setAttachConfig (dataDir) have both run, delete leaked
297
+ // per-session mcp-config-<id>.json files (they carry the token) whose session no longer exists.
298
+ const swept = result.terminalManager.sweepStaleMcpConfigs();
299
+ if (swept > 0)
300
+ console.log(`swept ${swept} stale mcp-config file(s)`);
301
+ // LIVE STATUS MONITOR — re-derive every running session's working-vs-awaiting flag from its rendered tmux
302
+ // pane every ~2.5s (universal + hook-free; see TerminalManager.refreshActivity). This is what makes the
303
+ // session rail's statuses actually track reality, for OLD sessions too. capture-pane is READ-ONLY so it can
304
+ // never disturb a live session; a re-entrancy guard stops a slow sweep from stacking tmux spawns; unref'd so
305
+ // it never holds the process open.
306
+ let activityBusy = false;
307
+ const activityTimer = setInterval(() => {
308
+ if (activityBusy)
309
+ return;
310
+ activityBusy = true;
311
+ void result.terminalManager
312
+ .refreshActivity()
313
+ .catch(() => {
314
+ /* the monitor is best-effort — a sweep failure must never crash the server */
315
+ })
316
+ .finally(() => {
317
+ activityBusy = false;
318
+ });
319
+ }, 2500);
320
+ if (typeof activityTimer.unref === "function")
321
+ activityTimer.unref();
322
+ return { ...result, url, token, tokenGenerated: generated };
323
+ }
324
+ /** Default PWA location relative to @roamcode.ai/server/dist. This resolves to sibling @roamcode.ai/web/dist
325
+ * in both the workspace and npm's scoped node_modules layout. */
326
+ function defaultWebDir() {
327
+ // Keep this path math in sync with tsup's outDir; the release boot smoke catches packaging drift.
328
+ const here = fileURLToPath(new URL(".", import.meta.url));
329
+ return join(here, "..", "..", "web", "dist");
330
+ }
331
+ /** Install process-wide crash guards so a stray unhandled rejection or a listener-less EventEmitter
332
+ * `error` (e.g. a write-after-teardown on a dying claude child, or a detached updater spawn failure)
333
+ * LOGS instead of taking the whole server down — for an always-on self-hosted server, staying up beats
334
+ * crashing. Install ONCE at a process entry (never in startServer, which tests call repeatedly). */
335
+ export function installCrashGuards() {
336
+ process.on("unhandledRejection", (reason) => {
337
+ const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
338
+ console.error(`[roamcode] unhandled rejection (kept serving): ${msg}`);
339
+ });
340
+ process.on("uncaughtException", (err) => {
341
+ console.error(`[roamcode] uncaught exception (kept serving): ${err.stack ?? err.message}`);
342
+ });
343
+ }
344
+ // Run when executed directly (node dist/start.js), not when imported.
345
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
346
+ startServer()
347
+ .then(({ app, url, token, tokenGenerated }) => {
348
+ console.log(`roamcode server listening on ${url}`);
349
+ if (tokenGenerated && token) {
350
+ console.log(`\n Access token (generated, stored in the data dir):\n ${token}\n Open: ${url}/?token=${token}\n`);
351
+ }
352
+ else if (!token) {
353
+ console.log(` (NO_TOKEN tokenless loopback dev mode — no access token required)`);
354
+ }
355
+ // Graceful shutdown: app.close() fires the onClose hook, which stops every live session
356
+ // (and its child `claude`), so a deployment leaves no orphaned processes.
357
+ const shutdown = (signal) => {
358
+ console.log(`received ${signal}, shutting down`);
359
+ app
360
+ .close()
361
+ .then(() => process.exit(0))
362
+ .catch(() => process.exit(0));
363
+ };
364
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
365
+ process.on("SIGINT", () => shutdown("SIGINT"));
366
+ installCrashGuards();
367
+ })
368
+ .catch((err) => {
369
+ console.error(`roamcode server failed to start: ${err.message}`);
370
+ process.exit(1);
371
+ });
372
+ }
@@ -0,0 +1,101 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ /**
3
+ * Server-side mirror of the web SW's `apiNavigationDenylist` (packages/web/src/pwa/sw-exclusions.ts),
4
+ * EXTENDED with /health, /push and the /ws suffix. A request whose path matches one of these is a
5
+ * live API/WS/health/push route — the SPA navigation fallback must NOT serve index.html for it (it
6
+ * must hit the real handler), and the auth gate must NOT treat it as a public static asset.
7
+ *
8
+ * SYNC INVARIANT: this denylist (and `isPublicForRequest` below) decides which requests skip the
9
+ * token gate, while Fastify's find-my-way router decides which handler a request reaches. The two
10
+ * MUST agree on path normalization, or a request can look public to the gate yet reach a protected
11
+ * handler (an auth bypass). We gate on the DECODED path (`pathForGate`) to match the router's
12
+ * percent-decoding, and we reject encoded separators (`hasEncodedSep`). If Fastify is ever
13
+ * configured with `caseSensitive:false` or `ignoreDuplicateSlashes:true`, this gate must apply the
14
+ * SAME case/slash normalization first, or those options will silently reopen the bypass.
15
+ */
16
+ export declare const API_PATH_DENYLIST: RegExp[];
17
+ /**
18
+ * The EXPLICIT public-shell allowlist — the only paths the auth gate lets a REGISTERED route serve
19
+ * without a token. It must cover exactly what an unauthenticated PWA boot needs (the login screen has to
20
+ * render before a token exists): the root shell, the Vite asset dir, and the top-level bundle files
21
+ * (index.html, sw.js, manifest.webmanifest, icons — matched by extension so a new icon the web build adds
22
+ * doesn't need a server change). Everything else — including any FUTURE route nobody remembered to gate —
23
+ * is token-gated by default (see the transport preHandler).
24
+ *
25
+ * SAFE because only static handlers live at these shapes: every API route is extensionless and no API
26
+ * route sits at `/` or under `/assets/`. Keep it that way — an API route named like a top-level static
27
+ * file (e.g. `/report.json`) would be silently public.
28
+ */
29
+ export declare const SHELL_PATH_ALLOWLIST: RegExp[];
30
+ /** True for the static PWA shell paths above (the auth gate's explicit allowlist). */
31
+ export declare function isShellPath(path: string): boolean;
32
+ /**
33
+ * Normalize a raw request URL to the path the FASTIFY ROUTER will route, for the auth gate.
34
+ * find-my-way routes the percent-DECODED path, so we must gate on the decoded path too — otherwise
35
+ * `GET /%73essions` (`%73`=`s`) looks public to a raw-path gate but routes to `/sessions`.
36
+ * A malformed escape (decodeURIComponent throws) falls back to the raw path (still gated by the
37
+ * caller's encoded-separator check), never crashing the gate.
38
+ */
39
+ export declare function pathForGate(rawUrl: string): string;
40
+ /**
41
+ * True if the RAW (pre-decode) path carries an encoded slash or backslash (`%2f`/`%2F`/`%5c`/`%5C`).
42
+ * find-my-way's handling of encoded slashes can desync from a single `decodeURIComponent`, and any
43
+ * request that needs an encoded separator to look public is inherently suspicious — so we treat such
44
+ * requests as NON-public (gated) regardless of what they decode to.
45
+ */
46
+ export declare function hasEncodedSep(rawUrl: string): boolean;
47
+ /**
48
+ * The auth-boundary decision for a real request: a request is public (skips the token gate / may get
49
+ * the SPA shell) IFF it has no encoded separator AND its decoded path is a public static/shell path.
50
+ * Both the preHandler bypass (transport.ts) and the SPA `setNotFoundHandler` MUST use this, so the
51
+ * gate and the router agree on what is reachable.
52
+ */
53
+ export declare function isPublicForRequest(rawUrl: string): boolean;
54
+ /**
55
+ * True for the PUBLIC static shell (HTML/JS/CSS/icons/manifest/sw + SPA navigations) — served WITHOUT
56
+ * a token so the login screen can load and THEN authenticate. A path is public iff it is on the shell
57
+ * allowlist, OR it is an extensionless NAVIGATION path (an SPA client route like `/login` gets
58
+ * index.html on a hard refresh) outside the reserved API namespace. (The served bundle carries no
59
+ * secret: the token lives only in the browser localStorage.)
60
+ *
61
+ * NOTE this predicate alone is NOT the auth boundary for routes: the transport preHandler is
62
+ * DEFAULT-DENY — it only honors the navigation half of this when the request matched NO registered
63
+ * route (fastify's `is404`), so a real handler can never hide behind an extensionless path. The
64
+ * navigation half here also drives the SPA fallback in {@link registerStatic}'s notFound handler.
65
+ *
66
+ * INVARIANT: a built shell asset must NEVER live at a path starting `/sessions`, `/fs`, `/ws`,
67
+ * `/health`, or `/push`. This holds for the Vite build (assets are emitted under `/assets/`, plus
68
+ * root `/index.html`, `/icon-*.svg`, `/manifest.webmanifest`, `/sw.js`) — none collide with the
69
+ * denylist. If a future asset were ever emitted under one of those prefixes, this default-deny would
70
+ * wrongly 401 a real asset to an unauthenticated shell load (the shell would fail to boot). Keep the
71
+ * Vite output prefixes clear of the denylist, or special-case the asset path here.
72
+ */
73
+ export declare function isPublicPath(path: string): boolean;
74
+ /**
75
+ * True if the path looks like a request for a static FILE — it lives under `/assets/` or its last
76
+ * segment has an extension (e.g. `.js`/`.css`/`.png`/`.svg`). A file request that reaches the
77
+ * notFound handler means the file does NOT exist on disk, so it must 404 — it must NOT fall back to
78
+ * index.html. Serving the HTML shell for a missing `.js` makes the browser block the module script
79
+ * (MIME mismatch → blank page) and can POISON a service-worker precache (HTML cached as the JS
80
+ * entry). Only extensionless navigation paths (client routes like `/`, `/login`) get the SPA shell.
81
+ *
82
+ * Operational note: `@fastify/static` with `wildcard:false` globs the dist and registers one route
83
+ * per file AT STARTUP, so after rebuilding the web bundle (new content-hashed filenames) the server
84
+ * must be RESTARTED for the new assets to get routes — otherwise they reach this handler and (now)
85
+ * correctly 404 instead of silently serving the shell.
86
+ */
87
+ export declare function looksLikeAssetRequest(path: string): boolean;
88
+ export interface RegisterStaticOptions {
89
+ /** Absolute path to the built PWA (packages/web/dist). */
90
+ webDir: string;
91
+ }
92
+ /**
93
+ * Serve the built PWA at `/` with an SPA fallback: a GET NAVIGATION (extensionless, public, non-API)
94
+ * that no static file matched returns index.html, so client routes (e.g. /login) work on a hard
95
+ * refresh. A request that looks like a missing FILE (`/assets/*`, or any `*.ext`) is served LIVE from
96
+ * disk when the file exists under webDir but @fastify/static's startup glob missed it (a build that
97
+ * landed after this server started — an OTA window / manual rebuild); otherwise it returns 404, never
98
+ * the shell — so a missing asset fails loudly instead of poisoning a browser/SW cache with HTML. An
99
+ * unknown /sessions/... still 404/401s from the real handlers / the gate.
100
+ */
101
+ export declare function registerStatic(app: FastifyInstance, opts: RegisterStaticOptions): void;