@roamcode.ai/server 1.0.0 → 1.0.2

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 (99) hide show
  1. package/dist/index.d.ts +2406 -82
  2. package/dist/index.js +8324 -44
  3. package/dist/managed-update-helper.js +631 -26
  4. package/dist/mcp-send.js +76 -90
  5. package/dist/start.d.ts +591 -9
  6. package/dist/start.js +7629 -353
  7. package/package.json +3 -3
  8. package/dist/auth.d.ts +0 -63
  9. package/dist/auth.js +0 -133
  10. package/dist/claude-auth-service.d.ts +0 -76
  11. package/dist/claude-auth-service.js +0 -217
  12. package/dist/claude-latest-service.d.ts +0 -34
  13. package/dist/claude-latest-service.js +0 -61
  14. package/dist/config.d.ts +0 -78
  15. package/dist/config.js +0 -59
  16. package/dist/data-dir.d.ts +0 -42
  17. package/dist/data-dir.js +0 -70
  18. package/dist/diag.d.ts +0 -43
  19. package/dist/diag.js +0 -83
  20. package/dist/fs-service.d.ts +0 -90
  21. package/dist/fs-service.js +0 -290
  22. package/dist/managed-runtime.d.ts +0 -51
  23. package/dist/managed-runtime.js +0 -411
  24. package/dist/managed-update-helper.d.ts +0 -2
  25. package/dist/mcp-send.d.ts +0 -33
  26. package/dist/origin-check.d.ts +0 -37
  27. package/dist/origin-check.js +0 -101
  28. package/dist/pane-status.d.ts +0 -61
  29. package/dist/pane-status.js +0 -145
  30. package/dist/providers/claude-metadata-service.d.ts +0 -58
  31. package/dist/providers/claude-metadata-service.js +0 -352
  32. package/dist/providers/claude-provider.d.ts +0 -11
  33. package/dist/providers/claude-provider.js +0 -166
  34. package/dist/providers/codex-activity.d.ts +0 -21
  35. package/dist/providers/codex-activity.js +0 -122
  36. package/dist/providers/codex-app-server-client.d.ts +0 -90
  37. package/dist/providers/codex-app-server-client.js +0 -485
  38. package/dist/providers/codex-latest-service.d.ts +0 -50
  39. package/dist/providers/codex-latest-service.js +0 -174
  40. package/dist/providers/codex-metadata-service.d.ts +0 -161
  41. package/dist/providers/codex-metadata-service.js +0 -686
  42. package/dist/providers/codex-profile-client.d.ts +0 -16
  43. package/dist/providers/codex-profile-client.js +0 -52
  44. package/dist/providers/codex-profile-security.d.ts +0 -23
  45. package/dist/providers/codex-profile-security.js +0 -161
  46. package/dist/providers/codex-provider.d.ts +0 -15
  47. package/dist/providers/codex-provider.js +0 -174
  48. package/dist/providers/codex-thread-coordinator.d.ts +0 -18
  49. package/dist/providers/codex-thread-coordinator.js +0 -93
  50. package/dist/providers/codex-thread-persistence.d.ts +0 -9
  51. package/dist/providers/codex-thread-persistence.js +0 -45
  52. package/dist/providers/codex-thread-resolver.d.ts +0 -59
  53. package/dist/providers/codex-thread-resolver.js +0 -322
  54. package/dist/providers/options.d.ts +0 -7
  55. package/dist/providers/options.js +0 -155
  56. package/dist/providers/provider-artifacts.d.ts +0 -3
  57. package/dist/providers/provider-artifacts.js +0 -30
  58. package/dist/providers/registry.d.ts +0 -7
  59. package/dist/providers/registry.js +0 -23
  60. package/dist/providers/types.d.ts +0 -95
  61. package/dist/providers/types.js +0 -8
  62. package/dist/push-dispatch.d.ts +0 -81
  63. package/dist/push-dispatch.js +0 -100
  64. package/dist/push-store.d.ts +0 -25
  65. package/dist/push-store.js +0 -79
  66. package/dist/rate-limit.d.ts +0 -52
  67. package/dist/rate-limit.js +0 -72
  68. package/dist/server-config.d.ts +0 -60
  69. package/dist/server-config.js +0 -77
  70. package/dist/service-install.d.ts +0 -60
  71. package/dist/service-install.js +0 -221
  72. package/dist/session-defaults.d.ts +0 -26
  73. package/dist/session-defaults.js +0 -60
  74. package/dist/session-store.d.ts +0 -81
  75. package/dist/session-store.js +0 -654
  76. package/dist/static-routes.d.ts +0 -101
  77. package/dist/static-routes.js +0 -188
  78. package/dist/terminal-capability.d.ts +0 -5
  79. package/dist/terminal-capability.js +0 -27
  80. package/dist/terminal-manager.d.ts +0 -224
  81. package/dist/terminal-manager.js +0 -917
  82. package/dist/terminal-process.d.ts +0 -85
  83. package/dist/terminal-process.js +0 -238
  84. package/dist/terminal-shared.d.ts +0 -36
  85. package/dist/terminal-shared.js +0 -43
  86. package/dist/tmux-list.d.ts +0 -11
  87. package/dist/tmux-list.js +0 -39
  88. package/dist/transport.d.ts +0 -123
  89. package/dist/transport.js +0 -1559
  90. package/dist/updater.d.ts +0 -161
  91. package/dist/updater.js +0 -451
  92. package/dist/usage-service.d.ts +0 -118
  93. package/dist/usage-service.js +0 -173
  94. package/dist/vapid.d.ts +0 -17
  95. package/dist/vapid.js +0 -31
  96. package/dist/web-push-send.d.ts +0 -20
  97. package/dist/web-push-send.js +0 -21
  98. package/dist/ws-ticket.d.ts +0 -47
  99. package/dist/ws-ticket.js +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roamcode.ai/server",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Host-native server for RoamCode",
5
5
  "homepage": "https://roamcode.ai",
6
6
  "bugs": "https://github.com/burakgon/roamcode/issues",
@@ -35,12 +35,12 @@
35
35
  "@fastify/static": "^9.3.0",
36
36
  "@fastify/websocket": "^11.3.0",
37
37
  "@modelcontextprotocol/sdk": "^1.29.0",
38
- "better-sqlite3": "^12.11.1",
38
+ "better-sqlite3": "12.11.1",
39
39
  "fastify": "^5.10.0",
40
40
  "node-pty": "1.1.0",
41
41
  "web-push": "^3.6.7",
42
42
  "zod": "^4.4.3",
43
- "@roamcode.ai/web": "1.0.0"
43
+ "@roamcode.ai/web": "1.0.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/better-sqlite3": "^7.6.13",
package/dist/auth.d.ts DELETED
@@ -1,63 +0,0 @@
1
- export declare function extractBearerToken(authorizationHeader: string | undefined): string | undefined;
2
- export interface AuthGateOptions {
3
- token?: string;
4
- /** Consecutive failures from one client before it is locked out. Default 10. */
5
- maxFailures?: number;
6
- /** Lockout duration in ms. Default 60000. */
7
- lockoutMs?: number;
8
- /**
9
- * Grace window (ms) after {@link AuthGate.rotateToken} during which the PREVIOUS token is still
10
- * accepted, so a callback already in flight with the old token (e.g. a running mcp-send subprocess that
11
- * captured RC_TOKEN at spawn) survives the rotation instead of 401-ing mid-conversation. Default 60000.
12
- */
13
- graceMs?: number;
14
- /** Injectable clock for tests. Default Date.now. */
15
- now?: () => number;
16
- }
17
- export type AuthCheckResult = {
18
- ok: true;
19
- } | {
20
- ok: false;
21
- reason: "locked" | "invalid" | "missing-token-config";
22
- };
23
- /**
24
- * Verifies a bearer access token in constant time and applies a per-client
25
- * lockout after repeated failures. Pure / I/O-free so it is unit-testable;
26
- * Task 8 wires it as a single global Fastify `preHandler` that also gates the
27
- * WebSocket upgrade.
28
- *
29
- * Proxy lockout caveat: the lockout is keyed by `clientKey` (`request.ip`).
30
- * Behind a reverse proxy (Caddy / Cloudflare) `request.ip` is the proxy's IP
31
- * for EVERY client, collapsing the per-client lockout to one shared key. To
32
- * stop that from self-DoS-ing the sole legitimate user, {@link check} validates
33
- * the token FIRST: a CORRECT token is ALWAYS accepted, even while its key is
34
- * locked out — the lockout only ever throttles WRONG guesses. Supplying a real
35
- * per-client key (Fastify `trustProxy` scoped to the proxy) still makes the
36
- * throttle per-client, but is no longer required to keep the user from being
37
- * locked out by someone else's bad traffic.
38
- */
39
- export declare class AuthGate {
40
- private token?;
41
- private previousToken?;
42
- private previousValidUntil;
43
- private readonly maxFailures;
44
- private readonly lockoutMs;
45
- private readonly graceMs;
46
- private readonly now;
47
- private readonly clients;
48
- constructor(opts: AuthGateOptions);
49
- check(presentedToken: string | undefined, clientKey: string): AuthCheckResult;
50
- /**
51
- * Atomically swap in a fresh access token (POST /token/rotate). Every subsequent {@link check} compares
52
- * against the new token; the OLD token is accepted only for a brief grace window (graceMs) so in-flight
53
- * callbacks holding it (a running mcp-send subprocess) survive the rotation instead of 401-ing mid-turn.
54
- * A 2nd rotation within the window REPLACES previousToken with the most-recent old token (no list grows).
55
- * Also clears all per-client lockout state — rotation is an explicit administrative reset, so a fresh
56
- * slate is correct (and avoids leaving a client locked out against a token that no longer exists).
57
- */
58
- rotateToken(newToken: string): void;
59
- /** Drop entries whose lockout has expired and whose failure count is 0 — keeps the map bounded. */
60
- private sweepExpired;
61
- /** TEST ONLY: number of tracked clients currently locked (lockedUntil in the future). */
62
- lockedClientCount(): number;
63
- }
package/dist/auth.js DELETED
@@ -1,133 +0,0 @@
1
- import { timingSafeEqual } from "node:crypto";
2
- export function extractBearerToken(authorizationHeader) {
3
- if (!authorizationHeader)
4
- return undefined;
5
- const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
6
- return match ? match[1] : undefined;
7
- }
8
- /** Constant-time string compare that does not leak length via early return. */
9
- function constantTimeEqual(a, b) {
10
- const bufA = Buffer.from(a, "utf8");
11
- const bufB = Buffer.from(b, "utf8");
12
- // timingSafeEqual requires equal lengths; compare against a fixed-length digest-free padding.
13
- if (bufA.length !== bufB.length) {
14
- // Still do a compare to keep timing uniform, then return false.
15
- timingSafeEqual(bufA, bufA);
16
- return false;
17
- }
18
- return timingSafeEqual(bufA, bufB);
19
- }
20
- /**
21
- * Verifies a bearer access token in constant time and applies a per-client
22
- * lockout after repeated failures. Pure / I/O-free so it is unit-testable;
23
- * Task 8 wires it as a single global Fastify `preHandler` that also gates the
24
- * WebSocket upgrade.
25
- *
26
- * Proxy lockout caveat: the lockout is keyed by `clientKey` (`request.ip`).
27
- * Behind a reverse proxy (Caddy / Cloudflare) `request.ip` is the proxy's IP
28
- * for EVERY client, collapsing the per-client lockout to one shared key. To
29
- * stop that from self-DoS-ing the sole legitimate user, {@link check} validates
30
- * the token FIRST: a CORRECT token is ALWAYS accepted, even while its key is
31
- * locked out — the lockout only ever throttles WRONG guesses. Supplying a real
32
- * per-client key (Fastify `trustProxy` scoped to the proxy) still makes the
33
- * throttle per-client, but is no longer required to keep the user from being
34
- * locked out by someone else's bad traffic.
35
- */
36
- export class AuthGate {
37
- // NOT readonly: rotateToken() atomically swaps in a fresh secret (POST /token/rotate). Every subsequent
38
- // check() compares against the new token, so the OLD token is invalid the instant rotation completes —
39
- // except within the brief grace window below, where the PREVIOUS token is still accepted.
40
- token;
41
- // DUAL-TOKEN GRACE: the token rotation REPLACED, plus the wall time it stays acceptable until. Lets a
42
- // callback already in flight with the old token (a running mcp-send subprocess captured RC_TOKEN at
43
- // spawn; rewriting its 0600 config can't update the live process env) finish instead of 401-ing
44
- // mid-conversation. Only ONE previous token is ever retained — a 2nd rotation within the window
45
- // supersedes it with the most-recent old token (never a growing list).
46
- previousToken;
47
- previousValidUntil = 0;
48
- maxFailures;
49
- lockoutMs;
50
- graceMs;
51
- now;
52
- clients = new Map();
53
- constructor(opts) {
54
- this.token = opts.token;
55
- this.maxFailures = opts.maxFailures ?? 10;
56
- this.lockoutMs = opts.lockoutMs ?? 60000;
57
- this.graceMs = opts.graceMs ?? 60000;
58
- this.now = opts.now ?? Date.now;
59
- }
60
- check(presentedToken, clientKey) {
61
- if (!this.token)
62
- return { ok: false, reason: "missing-token-config" };
63
- this.sweepExpired();
64
- const state = this.clients.get(clientKey) ?? { failures: 0, lockedUntil: 0 };
65
- const t = this.now();
66
- // Validate the token FIRST — BEFORE consulting the lockout. Accept the CURRENT token, OR — within the
67
- // post-rotation grace window — the PREVIOUS one. Both go through the same length-mismatch-safe
68
- // constant-time compare (no early return leaks length). A correct token is accepted even while this key
69
- // is locked out, so bad guesses (from an attacker, or from ANY client when they all share the proxy's IP
70
- // as clientKey) can never lock out the real user. SECURITY: the grace is a marginal exposure — the old
71
- // token was already valid up to the instant of rotation; 60s more only lets in-flight MCP callbacks
72
- // (send_image/send_file) complete, the deliberate tradeoff vs breaking attachments mid-turn.
73
- const matchesCurrent = presentedToken !== undefined && constantTimeEqual(presentedToken, this.token);
74
- const matchesPrevious = presentedToken !== undefined &&
75
- this.previousToken !== undefined &&
76
- t <= this.previousValidUntil &&
77
- constantTimeEqual(presentedToken, this.previousToken);
78
- if (matchesCurrent || matchesPrevious) {
79
- this.clients.delete(clientKey); // reset on success
80
- return { ok: true };
81
- }
82
- // Wrong token. Only now does the lockout apply — a locked key keeps rejecting WRONG guesses without
83
- // counting further (the lock governs); the correct token above is unaffected.
84
- if (state.lockedUntil > t)
85
- return { ok: false, reason: "locked" };
86
- state.failures += 1;
87
- if (state.failures >= this.maxFailures) {
88
- state.lockedUntil = t + this.lockoutMs;
89
- state.failures = 0; // reset the counter; the lock now governs
90
- }
91
- this.clients.set(clientKey, state);
92
- return { ok: false, reason: "invalid" };
93
- }
94
- /**
95
- * Atomically swap in a fresh access token (POST /token/rotate). Every subsequent {@link check} compares
96
- * against the new token; the OLD token is accepted only for a brief grace window (graceMs) so in-flight
97
- * callbacks holding it (a running mcp-send subprocess) survive the rotation instead of 401-ing mid-turn.
98
- * A 2nd rotation within the window REPLACES previousToken with the most-recent old token (no list grows).
99
- * Also clears all per-client lockout state — rotation is an explicit administrative reset, so a fresh
100
- * slate is correct (and avoids leaving a client locked out against a token that no longer exists).
101
- */
102
- rotateToken(newToken) {
103
- // Retain the just-replaced token for the grace window — but ONLY when graceMs > 0, so graceMs:0 means
104
- // an UNAMBIGUOUS instant cutover (no previous token retained, no same-millisecond boundary acceptance).
105
- if (this.token !== undefined && this.graceMs > 0) {
106
- this.previousToken = this.token;
107
- this.previousValidUntil = this.now() + this.graceMs;
108
- }
109
- else {
110
- this.previousToken = undefined;
111
- this.previousValidUntil = 0;
112
- }
113
- this.token = newToken;
114
- this.clients.clear();
115
- }
116
- /** Drop entries whose lockout has expired and whose failure count is 0 — keeps the map bounded. */
117
- sweepExpired() {
118
- const t = this.now();
119
- for (const [key, state] of this.clients) {
120
- if (state.lockedUntil <= t && state.failures === 0)
121
- this.clients.delete(key);
122
- }
123
- }
124
- /** TEST ONLY: number of tracked clients currently locked (lockedUntil in the future). */
125
- lockedClientCount() {
126
- const t = this.now();
127
- let n = 0;
128
- for (const state of this.clients.values())
129
- if (state.lockedUntil > t)
130
- n += 1;
131
- return n;
132
- }
133
- }
@@ -1,76 +0,0 @@
1
- import { type ChildProcess } from "node:child_process";
2
- /**
3
- * In-app re-authentication of the `claude` CLI's Claude subscription, so a user whose server-side login
4
- * expired (Anthropic returns 401 "Invalid authentication credentials" → every turn fails) can sign in
5
- * again FROM THE APP instead of SSHing to the server to run `claude auth login`.
6
- *
7
- * It wraps `claude`'s OWN manual OAuth flow (no reverse-engineering of Anthropic's OAuth):
8
- * 1. `claude auth login --claudeai` prints an authorize URL whose redirect is a HOSTED page
9
- * (platform.claude.com/oauth/code/callback — not localhost), then waits on stdin for a pasted code.
10
- * 2. We spawn it, capture that URL, and hand it to the UI; the user opens it in ANY browser (their
11
- * phone), authorizes, and copies the code the callback page shows.
12
- * 3. The UI posts the code back; we write it to the subprocess stdin; `claude` exchanges it and saves
13
- * FRESH credentials where every future claude spawn reads them — auth is restored, no restart needed.
14
- *
15
- * Single in-flight login at a time (a new start supersedes a stale one); a started login self-expires so
16
- * an abandoned flow can't hold a subprocess forever. Spawn deps are injectable for unit tests.
17
- */
18
- export interface ClaudeAuthStatus {
19
- /** Whether `claude` has stored credentials. NOTE: this reflects the LOCAL creds existing, not that they
20
- * still work — expired creds report loggedIn:true yet 401 on use, so re-auth is offered regardless. */
21
- loggedIn: boolean;
22
- email?: string;
23
- /** "max" | "pro" | … (the Claude subscription tier), when reported. */
24
- subscriptionType?: string;
25
- authMethod?: string;
26
- orgName?: string;
27
- }
28
- /** Parse `claude auth status` JSON output into a status. Returns {loggedIn:false} on any unparhseable/empty. */
29
- export declare function parseAuthStatus(stdout: string): ClaudeAuthStatus;
30
- /** Pull the authorize URL out of `claude auth login` output ("…visit: https://…/oauth/authorize?…"). */
31
- export declare function extractLoginUrl(output: string): string | undefined;
32
- export interface ClaudeAuthDeps {
33
- /** Spawn a claude subcommand: `claude <args>` with the server env (login-session creds resolve). */
34
- spawn: (args: string[]) => ChildProcess;
35
- now: () => number;
36
- /** How long a started login waits for the URL before giving up (default 20s). */
37
- urlTimeoutMs?: number;
38
- /** How long a started login may sit awaiting its code before self-expiring (default 5min). */
39
- loginTtlMs?: number;
40
- }
41
- export declare const LOGIN_URL_TIMEOUT_MS = 20000;
42
- export declare const LOGIN_TTL_MS: number;
43
- export declare class ClaudeAuthService {
44
- private readonly deps;
45
- private pending?;
46
- constructor(deps: ClaudeAuthDeps);
47
- /** Current `claude auth status` (which account is signed in). Never rejects → {loggedIn:false} on error. */
48
- status(): Promise<ClaudeAuthStatus>;
49
- /**
50
- * Start a login: spawn `claude auth login --claudeai`, resolve with the authorize URL once it's printed.
51
- * Supersedes any prior in-flight login. Rejects if the URL never appears (timeout / immediate exit).
52
- */
53
- startLogin(): Promise<{
54
- loginId: string;
55
- url: string;
56
- }>;
57
- /**
58
- * Submit the pasted authorization code for an in-flight login: write it to the subprocess stdin and
59
- * resolve once `claude` finishes the exchange. {ok:true} on a clean exit (fresh creds saved); {ok:false}
60
- * + message otherwise. `loginId` must match the in-flight login (else it's stale/unknown).
61
- */
62
- submitCode(loginId: string, code: string): Promise<{
63
- ok: boolean;
64
- message?: string;
65
- }>;
66
- /** Abandon any in-flight login (e.g. the user cancels). */
67
- cancel(): void;
68
- private cancelIf;
69
- }
70
- /** Production wiring: spawn `claude <args>` with the server env (ANTHROPIC_API_KEY stripped → subscription
71
- * auth, mirroring the chat/usage spawns). stdin is piped so the code can be written for the login flow. */
72
- export declare function createClaudeAuthService(opts: {
73
- claudeBin: string;
74
- env?: NodeJS.ProcessEnv;
75
- now?: () => number;
76
- }): ClaudeAuthService;
@@ -1,217 +0,0 @@
1
- import { spawn as nodeSpawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- /** Parse `claude auth status` JSON output into a status. Returns {loggedIn:false} on any unparhseable/empty. */
4
- export function parseAuthStatus(stdout) {
5
- try {
6
- const o = JSON.parse(stdout);
7
- return {
8
- loggedIn: o.loggedIn === true,
9
- email: typeof o.email === "string" ? o.email : undefined,
10
- subscriptionType: typeof o.subscriptionType === "string" ? o.subscriptionType : undefined,
11
- authMethod: typeof o.authMethod === "string" ? o.authMethod : undefined,
12
- orgName: typeof o.orgName === "string" ? o.orgName : undefined,
13
- };
14
- }
15
- catch {
16
- return { loggedIn: false };
17
- }
18
- }
19
- /** Pull the authorize URL out of `claude auth login` output ("…visit: https://…/oauth/authorize?…"). */
20
- export function extractLoginUrl(output) {
21
- const m = /(https:\/\/[^\s]+oauth\/authorize[^\s]*)/i.exec(output);
22
- return m ? m[1] : undefined;
23
- }
24
- export const LOGIN_URL_TIMEOUT_MS = 20_000;
25
- export const LOGIN_TTL_MS = 5 * 60_000;
26
- const STATUS_TIMEOUT_MS = 15_000;
27
- export class ClaudeAuthService {
28
- deps;
29
- pending;
30
- constructor(deps) {
31
- this.deps = {
32
- spawn: deps.spawn,
33
- now: deps.now,
34
- urlTimeoutMs: deps.urlTimeoutMs ?? LOGIN_URL_TIMEOUT_MS,
35
- loginTtlMs: deps.loginTtlMs ?? LOGIN_TTL_MS,
36
- };
37
- }
38
- /** Current `claude auth status` (which account is signed in). Never rejects → {loggedIn:false} on error. */
39
- async status() {
40
- return new Promise((resolve) => {
41
- let child;
42
- try {
43
- child = this.deps.spawn(["auth", "status"]);
44
- }
45
- catch {
46
- resolve({ loggedIn: false });
47
- return;
48
- }
49
- let out = "";
50
- let settled = false;
51
- const done = (s) => {
52
- if (settled)
53
- return;
54
- settled = true;
55
- clearTimeout(timer);
56
- resolve(s);
57
- };
58
- const timer = setTimeout(() => {
59
- try {
60
- child.kill("SIGKILL");
61
- }
62
- catch {
63
- /* gone */
64
- }
65
- done({ loggedIn: false });
66
- }, STATUS_TIMEOUT_MS);
67
- child.stdout?.on("data", (d) => (out += d.toString()));
68
- child.on("error", () => done({ loggedIn: false }));
69
- child.on("close", () => done(parseAuthStatus(out)));
70
- });
71
- }
72
- /**
73
- * Start a login: spawn `claude auth login --claudeai`, resolve with the authorize URL once it's printed.
74
- * Supersedes any prior in-flight login. Rejects if the URL never appears (timeout / immediate exit).
75
- */
76
- async startLogin() {
77
- this.cancel(); // one in-flight login at a time
78
- return new Promise((resolve, reject) => {
79
- let child;
80
- try {
81
- child = this.deps.spawn(["auth", "login", "--claudeai"]);
82
- }
83
- catch (err) {
84
- reject(err instanceof Error ? err : new Error("failed to spawn claude"));
85
- return;
86
- }
87
- const id = randomUUID();
88
- let settled = false;
89
- let buf = "";
90
- const onData = (d) => {
91
- buf += d.toString();
92
- const url = extractLoginUrl(buf);
93
- if (url && !settled) {
94
- settled = true;
95
- clearTimeout(timer);
96
- // Hand off to a PendingLogin that lives until the code arrives (or it self-expires).
97
- const expiry = setTimeout(() => this.cancelIf(id), this.deps.loginTtlMs);
98
- this.pending = { id, child, output: buf, startedAt: this.deps.now(), expiry };
99
- // Keep accumulating output (the success/error after the code is submitted).
100
- child.stdout?.on("data", (x) => this.pending && (this.pending.output += x.toString()));
101
- child.stderr?.on("data", (x) => this.pending && (this.pending.output += x.toString()));
102
- resolve({ loginId: id, url });
103
- }
104
- };
105
- const fail = (msg) => {
106
- if (settled)
107
- return;
108
- settled = true;
109
- clearTimeout(timer);
110
- try {
111
- child.kill("SIGKILL");
112
- }
113
- catch {
114
- /* gone */
115
- }
116
- reject(new Error(msg));
117
- };
118
- const timer = setTimeout(() => fail("timed out waiting for the sign-in URL"), this.deps.urlTimeoutMs);
119
- child.stdout?.on("data", onData);
120
- child.stderr?.on("data", onData);
121
- child.on("error", (e) => fail(e.message));
122
- child.on("close", () => fail(buf.trim() || "claude exited before printing a sign-in URL"));
123
- });
124
- }
125
- /**
126
- * Submit the pasted authorization code for an in-flight login: write it to the subprocess stdin and
127
- * resolve once `claude` finishes the exchange. {ok:true} on a clean exit (fresh creds saved); {ok:false}
128
- * + message otherwise. `loginId` must match the in-flight login (else it's stale/unknown).
129
- */
130
- async submitCode(loginId, code) {
131
- const p = this.pending;
132
- if (!p || p.id !== loginId)
133
- return { ok: false, message: "no sign-in is in progress (it may have expired)" };
134
- const trimmed = code.trim();
135
- if (!trimmed)
136
- return { ok: false, message: "the code is empty" };
137
- return new Promise((resolve) => {
138
- let settled = false;
139
- const done = (r) => {
140
- if (settled)
141
- return;
142
- settled = true;
143
- clearTimeout(timer);
144
- if (this.pending === p) {
145
- clearTimeout(p.expiry);
146
- this.pending = undefined;
147
- }
148
- resolve(r);
149
- };
150
- const timer = setTimeout(() => {
151
- try {
152
- p.child.kill("SIGKILL");
153
- }
154
- catch {
155
- /* gone */
156
- }
157
- done({ ok: false, message: "timed out completing sign-in" });
158
- }, STATUS_TIMEOUT_MS);
159
- p.child.on("close", (codeNum) => {
160
- // A clean exit (0) means the token exchange succeeded. Otherwise surface the tail of the output.
161
- if (codeNum === 0)
162
- done({ ok: true });
163
- else
164
- done({ ok: false, message: tail(p.output) || `sign-in failed (exit ${codeNum ?? "?"})` });
165
- });
166
- p.child.on("error", (e) => done({ ok: false, message: e.message }));
167
- try {
168
- p.child.stdin?.write(trimmed + "\n");
169
- p.child.stdin?.end();
170
- }
171
- catch (e) {
172
- done({ ok: false, message: e instanceof Error ? e.message : "failed to submit the code" });
173
- }
174
- });
175
- }
176
- /** Abandon any in-flight login (e.g. the user cancels). */
177
- cancel() {
178
- const p = this.pending;
179
- if (!p)
180
- return;
181
- clearTimeout(p.expiry);
182
- try {
183
- p.child.kill("SIGKILL");
184
- }
185
- catch {
186
- /* gone */
187
- }
188
- this.pending = undefined;
189
- }
190
- cancelIf(id) {
191
- if (this.pending?.id === id)
192
- this.cancel();
193
- }
194
- }
195
- /** Last non-empty line of output (the error/success message claude prints), capped. */
196
- function tail(output) {
197
- const line = output
198
- .split("\n")
199
- .map((l) => l.trim())
200
- .filter(Boolean)
201
- .pop();
202
- if (!line)
203
- return "";
204
- return line.length > 200 ? line.slice(0, 197) + "…" : line;
205
- }
206
- /** Production wiring: spawn `claude <args>` with the server env (ANTHROPIC_API_KEY stripped → subscription
207
- * auth, mirroring the chat/usage spawns). stdin is piped so the code can be written for the login flow. */
208
- export function createClaudeAuthService(opts) {
209
- return new ClaudeAuthService({
210
- spawn: (args) => {
211
- const env = { ...(opts.env ?? process.env) };
212
- delete env.ANTHROPIC_API_KEY;
213
- return nodeSpawn(opts.claudeBin, args, { env, stdio: ["pipe", "pipe", "pipe"] });
214
- },
215
- now: opts.now ?? (() => Date.now()),
216
- });
217
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * The LATEST published `claude` CLI version, for update-awareness: the UI compares a session's
3
- * `claudeVersion` (what it spawned with) against this to show a subtle "update available" hint when a
4
- * chat is running an older Claude than what's out.
5
- *
6
- * Source: the npm registry's dist-tag for `@anthropic-ai/claude-code` (the same release train as the
7
- * native installer), fetched best-effort and TTL-cached so the rare /claude/version poll is cheap. Any
8
- * failure (offline, timeout, registry hiccup) degrades to the last good value, or undefined — the UI
9
- * simply hides the hint, never errors. The fetch is an INJECTABLE seam so the cache logic is unit-testable.
10
- */
11
- export declare const CLAUDE_LATEST_CACHE_MS: number;
12
- /** Resolve the latest version string (e.g. "2.1.195"). NEVER rejects — resolves undefined on any failure. */
13
- export type FetchLatest = () => Promise<string | undefined>;
14
- export interface ClaudeLatestDeps {
15
- fetchLatest: FetchLatest;
16
- now: () => number;
17
- ttlMs?: number;
18
- }
19
- /** Pull `version` out of an npm dist-tag document (`{ "version": "2.1.195", … }`). */
20
- export declare function parseNpmLatest(json: unknown): string | undefined;
21
- export declare class ClaudeLatestService {
22
- private readonly fetchLatest;
23
- private readonly now;
24
- private readonly ttlMs;
25
- private cache?;
26
- constructor(deps: ClaudeLatestDeps);
27
- /** The latest version, TTL-cached. A failed refresh keeps the last good value (or undefined). */
28
- getLatest(): Promise<string | undefined>;
29
- }
30
- /** The real fetch adapter: GET the npm dist-tag doc with a short timeout. Resolves undefined on any error. */
31
- export declare function createClaudeLatestService(opts?: {
32
- now?: () => number;
33
- ttlMs?: number;
34
- }): ClaudeLatestService;
@@ -1,61 +0,0 @@
1
- /**
2
- * The LATEST published `claude` CLI version, for update-awareness: the UI compares a session's
3
- * `claudeVersion` (what it spawned with) against this to show a subtle "update available" hint when a
4
- * chat is running an older Claude than what's out.
5
- *
6
- * Source: the npm registry's dist-tag for `@anthropic-ai/claude-code` (the same release train as the
7
- * native installer), fetched best-effort and TTL-cached so the rare /claude/version poll is cheap. Any
8
- * failure (offline, timeout, registry hiccup) degrades to the last good value, or undefined — the UI
9
- * simply hides the hint, never errors. The fetch is an INJECTABLE seam so the cache logic is unit-testable.
10
- */
11
- const NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
12
- export const CLAUDE_LATEST_CACHE_MS = 6 * 60 * 60 * 1000;
13
- const FETCH_TIMEOUT_MS = 8000;
14
- /** Pull `version` out of an npm dist-tag document (`{ "version": "2.1.195", … }`). */
15
- export function parseNpmLatest(json) {
16
- if (json && typeof json === "object" && typeof json.version === "string") {
17
- return json.version;
18
- }
19
- return undefined;
20
- }
21
- export class ClaudeLatestService {
22
- fetchLatest;
23
- now;
24
- ttlMs;
25
- cache;
26
- constructor(deps) {
27
- this.fetchLatest = deps.fetchLatest;
28
- this.now = deps.now;
29
- this.ttlMs = deps.ttlMs ?? CLAUDE_LATEST_CACHE_MS;
30
- }
31
- /** The latest version, TTL-cached. A failed refresh keeps the last good value (or undefined). */
32
- async getLatest() {
33
- const now = this.now();
34
- if (this.cache && now - this.cache.at < this.ttlMs)
35
- return this.cache.value;
36
- const fetched = await this.fetchLatest().catch(() => undefined);
37
- const value = fetched ?? this.cache?.value;
38
- this.cache = { at: now, value };
39
- return value;
40
- }
41
- }
42
- /** The real fetch adapter: GET the npm dist-tag doc with a short timeout. Resolves undefined on any error. */
43
- export function createClaudeLatestService(opts = {}) {
44
- const fetchLatest = async () => {
45
- const controller = new AbortController();
46
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
47
- try {
48
- const res = await fetch(NPM_LATEST_URL, { signal: controller.signal });
49
- if (!res.ok)
50
- return undefined;
51
- return parseNpmLatest(await res.json());
52
- }
53
- catch {
54
- return undefined;
55
- }
56
- finally {
57
- clearTimeout(timer);
58
- }
59
- };
60
- return new ClaudeLatestService({ fetchLatest, now: opts.now ?? (() => Date.now()), ttlMs: opts.ttlMs });
61
- }