fluxy-bot 0.1.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.
- package/bin/cli.js +469 -0
- package/client/index.html +13 -0
- package/client/public/fluxy.png +0 -0
- package/client/public/icons/claude.png +0 -0
- package/client/public/icons/codex.png +0 -0
- package/client/public/icons/openai.svg +15 -0
- package/client/src/App.tsx +81 -0
- package/client/src/components/Chat/ChatView.tsx +19 -0
- package/client/src/components/Chat/InputBar.tsx +242 -0
- package/client/src/components/Chat/MessageBubble.tsx +20 -0
- package/client/src/components/Chat/MessageList.tsx +39 -0
- package/client/src/components/Chat/TypingIndicator.tsx +10 -0
- package/client/src/components/Dashboard/ConversationAnalytics.tsx +84 -0
- package/client/src/components/Dashboard/DashboardPage.tsx +52 -0
- package/client/src/components/Dashboard/PromoCard.tsx +44 -0
- package/client/src/components/Dashboard/ReportCard.tsx +35 -0
- package/client/src/components/Dashboard/TodayStats.tsx +28 -0
- package/client/src/components/ErrorBoundary.tsx +23 -0
- package/client/src/components/FluxyFab.tsx +25 -0
- package/client/src/components/Layout/ConnectionStatus.tsx +8 -0
- package/client/src/components/Layout/DashboardHeader.tsx +90 -0
- package/client/src/components/Layout/DashboardLayout.tsx +24 -0
- package/client/src/components/Layout/Header.tsx +10 -0
- package/client/src/components/Layout/MobileNav.tsx +30 -0
- package/client/src/components/Layout/Sidebar.tsx +55 -0
- package/client/src/components/Onboard/OnboardWizard.tsx +763 -0
- package/client/src/components/ui/avatar.tsx +109 -0
- package/client/src/components/ui/badge.tsx +48 -0
- package/client/src/components/ui/button.tsx +64 -0
- package/client/src/components/ui/card.tsx +92 -0
- package/client/src/components/ui/dialog.tsx +156 -0
- package/client/src/components/ui/dropdown-menu.tsx +257 -0
- package/client/src/components/ui/input.tsx +21 -0
- package/client/src/components/ui/scroll-area.tsx +58 -0
- package/client/src/components/ui/select.tsx +190 -0
- package/client/src/components/ui/separator.tsx +28 -0
- package/client/src/components/ui/sheet.tsx +141 -0
- package/client/src/components/ui/skeleton.tsx +13 -0
- package/client/src/components/ui/switch.tsx +33 -0
- package/client/src/components/ui/tabs.tsx +89 -0
- package/client/src/components/ui/textarea.tsx +18 -0
- package/client/src/components/ui/tooltip.tsx +55 -0
- package/client/src/hooks/useChat.ts +69 -0
- package/client/src/hooks/useMobile.ts +16 -0
- package/client/src/hooks/useWebSocket.ts +24 -0
- package/client/src/lib/mock-data.ts +104 -0
- package/client/src/lib/utils.ts +6 -0
- package/client/src/lib/ws-client.ts +52 -0
- package/client/src/main.tsx +10 -0
- package/client/src/styles/globals.css +55 -0
- package/components.json +20 -0
- package/dist/assets/index-BkNWpS06.css +1 -0
- package/dist/assets/index-CX3QeqQ8.js +64 -0
- package/dist/fluxy.png +0 -0
- package/dist/icons/claude.png +0 -0
- package/dist/icons/codex.png +0 -0
- package/dist/icons/openai.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/registerSW.js +1 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-8c29f6e4.js +1 -0
- package/package.json +82 -0
- package/postcss.config.js +5 -0
- package/shared/ai.ts +141 -0
- package/shared/config.ts +37 -0
- package/shared/logger.ts +13 -0
- package/shared/paths.ts +14 -0
- package/shared/relay.ts +101 -0
- package/supervisor/fluxy.html +94 -0
- package/supervisor/index.ts +173 -0
- package/supervisor/tunnel.ts +62 -0
- package/supervisor/worker.ts +55 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +38 -0
- package/worker/claude-auth.ts +224 -0
- package/worker/codex-auth.ts +199 -0
- package/worker/db.ts +75 -0
- package/worker/index.ts +169 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import net from 'net';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
+
import { loadConfig } from '../shared/config.js';
|
|
6
|
+
import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
|
|
7
|
+
import { paths } from '../shared/paths.js';
|
|
8
|
+
import { log } from '../shared/logger.js';
|
|
9
|
+
import { startTunnel, stopTunnel } from './tunnel.js';
|
|
10
|
+
import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
|
|
11
|
+
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
12
|
+
|
|
13
|
+
const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Recovering</title>
|
|
14
|
+
<style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
15
|
+
div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-size:14px}a{color:#60a5fa}</style></head>
|
|
16
|
+
<body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically. <a href="/fluxy">Talk to Fluxy</a></p></div>
|
|
17
|
+
<script>setTimeout(()=>location.reload(),3000)</script></body></html>`;
|
|
18
|
+
|
|
19
|
+
export async function startSupervisor() {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const workerPort = getWorkerPort(config.port);
|
|
22
|
+
|
|
23
|
+
// Fluxy's AI brain
|
|
24
|
+
let ai: AiProvider | null = null;
|
|
25
|
+
if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
|
|
26
|
+
ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fluxy chat conversations (in-memory for now)
|
|
30
|
+
const conversations = new Map<WebSocket, ChatMessage[]>();
|
|
31
|
+
|
|
32
|
+
// HTTP server
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
// Fluxy routes — served directly, never proxied
|
|
35
|
+
if (req.url === '/fluxy' || req.url === '/fluxy/') {
|
|
36
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
37
|
+
res.end(fs.readFileSync(paths.fluxyHtml));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Everything else → proxy to worker
|
|
42
|
+
if (!isWorkerAlive()) {
|
|
43
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
44
|
+
res.end(RECOVERING_HTML);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const proxy = http.request(
|
|
49
|
+
{ host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
|
|
50
|
+
(proxyRes) => {
|
|
51
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
52
|
+
proxyRes.pipe(res);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
proxy.on('error', () => {
|
|
57
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
58
|
+
res.end(RECOVERING_HTML);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.pipe(proxy);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// WebSocket: Fluxy chat + proxy worker WS
|
|
65
|
+
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
66
|
+
|
|
67
|
+
fluxyWss.on('connection', (ws) => {
|
|
68
|
+
log.info('Fluxy chat connected');
|
|
69
|
+
conversations.set(ws, []);
|
|
70
|
+
|
|
71
|
+
ws.on('message', (raw) => {
|
|
72
|
+
const msg = JSON.parse(raw.toString());
|
|
73
|
+
if (msg.type !== 'message' || !msg.content) return;
|
|
74
|
+
|
|
75
|
+
const history = conversations.get(ws) || [];
|
|
76
|
+
history.push({ role: 'user', content: msg.content });
|
|
77
|
+
|
|
78
|
+
if (!ai) {
|
|
79
|
+
ws.send(JSON.stringify({ type: 'error', error: 'AI not configured. Set up your provider first.' }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ai.chat(
|
|
84
|
+
[{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
|
|
85
|
+
config.ai.model,
|
|
86
|
+
(token) => ws.send(JSON.stringify({ type: 'token', token })),
|
|
87
|
+
(full) => {
|
|
88
|
+
history.push({ role: 'assistant', content: full });
|
|
89
|
+
ws.send(JSON.stringify({ type: 'done' }));
|
|
90
|
+
},
|
|
91
|
+
(err) => ws.send(JSON.stringify({ type: 'error', error: err.message })),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ws.on('close', () => conversations.delete(ws));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
server.on('upgrade', (req, socket: net.Socket, head) => {
|
|
99
|
+
if (req.url === '/fluxy/ws') {
|
|
100
|
+
fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Proxy WS upgrade to worker
|
|
105
|
+
const proxy = net.connect(workerPort, () => {
|
|
106
|
+
const headers = Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n');
|
|
107
|
+
proxy.write(`GET ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
|
|
108
|
+
if (head.length > 0) proxy.write(head);
|
|
109
|
+
socket.pipe(proxy).pipe(socket);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
proxy.on('error', () => socket.destroy());
|
|
113
|
+
socket.on('error', () => proxy.destroy());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Start
|
|
117
|
+
server.listen(config.port, () => {
|
|
118
|
+
log.ok(`Supervisor on http://localhost:${config.port}`);
|
|
119
|
+
log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Spawn worker
|
|
123
|
+
spawnWorker(workerPort);
|
|
124
|
+
|
|
125
|
+
// Tunnel
|
|
126
|
+
let tunnelUrl: string | null = null;
|
|
127
|
+
if (config.tunnel.enabled) {
|
|
128
|
+
try {
|
|
129
|
+
tunnelUrl = await startTunnel(config.port);
|
|
130
|
+
log.ok(`Tunnel: ${tunnelUrl}`);
|
|
131
|
+
console.log(`__TUNNEL_URL__=${tunnelUrl}`);
|
|
132
|
+
|
|
133
|
+
// Register tunnel URL with relay and start heartbeats
|
|
134
|
+
if (config.relay?.token) {
|
|
135
|
+
try {
|
|
136
|
+
await updateTunnelUrl(config.relay.token, tunnelUrl);
|
|
137
|
+
startHeartbeat(config.relay.token, tunnelUrl);
|
|
138
|
+
if (config.relay.url) {
|
|
139
|
+
log.ok(`Relay: ${config.relay.url}`);
|
|
140
|
+
console.log(`__RELAY_URL__=${config.relay.url}`);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.warn(`Relay: ${err instanceof Error ? err.message : err}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log.warn(`Tunnel: ${err instanceof Error ? err.message : err}`);
|
|
148
|
+
console.log('__TUNNEL_FAILED__');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Shutdown
|
|
153
|
+
const shutdown = async () => {
|
|
154
|
+
log.info('Shutting down...');
|
|
155
|
+
stopHeartbeat();
|
|
156
|
+
if (config.relay?.token) {
|
|
157
|
+
await disconnect(config.relay.token);
|
|
158
|
+
}
|
|
159
|
+
stopWorker();
|
|
160
|
+
stopTunnel();
|
|
161
|
+
server.close();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
};
|
|
164
|
+
process.on('SIGINT', () => shutdown());
|
|
165
|
+
process.on('SIGTERM', () => shutdown());
|
|
166
|
+
|
|
167
|
+
return tunnelUrl;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
startSupervisor().catch((err) => {
|
|
171
|
+
log.error('Fatal', err);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn, execSync, type ChildProcess } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { paths } from '../shared/paths.js';
|
|
5
|
+
import { log } from '../shared/logger.js';
|
|
6
|
+
|
|
7
|
+
let proc: ChildProcess | null = null;
|
|
8
|
+
|
|
9
|
+
function findBinary(): string | null {
|
|
10
|
+
try { execSync('which cloudflared', { stdio: 'ignore' }); return 'cloudflared'; } catch {}
|
|
11
|
+
if (fs.existsSync(paths.cloudflared)) return paths.cloudflared;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function installCloudflared(): Promise<string> {
|
|
16
|
+
const existing = findBinary();
|
|
17
|
+
if (existing) return existing;
|
|
18
|
+
|
|
19
|
+
const dir = paths.cloudflared.replace(/\/[^/]+$/, '');
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const p = os.platform(), a = os.arch();
|
|
23
|
+
const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
24
|
+
|
|
25
|
+
let url: string;
|
|
26
|
+
if (p === 'darwin') url = `${base}/cloudflared-darwin-${a === 'arm64' ? 'arm64' : 'amd64'}.tgz`;
|
|
27
|
+
else if (a === 'arm64' || a === 'aarch64') url = `${base}/cloudflared-linux-arm64`;
|
|
28
|
+
else if (a.startsWith('arm')) url = `${base}/cloudflared-linux-arm`;
|
|
29
|
+
else url = `${base}/cloudflared-linux-amd64`;
|
|
30
|
+
|
|
31
|
+
log.info('Installing cloudflared...');
|
|
32
|
+
if (url.endsWith('.tgz')) execSync(`curl -fsSL "${url}" | tar xz -C "${dir}"`, { stdio: 'ignore' });
|
|
33
|
+
else execSync(`curl -fsSL -o "${paths.cloudflared}" "${url}"`, { stdio: 'ignore' });
|
|
34
|
+
fs.chmodSync(paths.cloudflared, 0o755);
|
|
35
|
+
log.ok('cloudflared installed');
|
|
36
|
+
return paths.cloudflared;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function startTunnel(port: number): Promise<string> {
|
|
40
|
+
return new Promise(async (resolve, reject) => {
|
|
41
|
+
const bin = await installCloudflared();
|
|
42
|
+
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 30_000);
|
|
43
|
+
|
|
44
|
+
proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const onData = (d: Buffer) => {
|
|
49
|
+
const m = d.toString().match(/https:\/\/[^\s]+\.trycloudflare\.com/);
|
|
50
|
+
if (m) { clearTimeout(timeout); resolve(m[0]); }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
proc.stdout?.on('data', onData);
|
|
54
|
+
proc.stderr?.on('data', onData);
|
|
55
|
+
proc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function stopTunnel(): void {
|
|
60
|
+
proc?.kill();
|
|
61
|
+
proc = null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PKG_DIR } from '../shared/paths.js';
|
|
4
|
+
import { log } from '../shared/logger.js';
|
|
5
|
+
|
|
6
|
+
let child: ChildProcess | null = null;
|
|
7
|
+
let restarts = 0;
|
|
8
|
+
const MAX_RESTARTS = 3;
|
|
9
|
+
|
|
10
|
+
export function getWorkerPort(basePort: number): number {
|
|
11
|
+
return basePort + 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function spawnWorker(port: number): ChildProcess {
|
|
15
|
+
const workerPath = path.join(PKG_DIR, 'worker', 'index.ts');
|
|
16
|
+
|
|
17
|
+
child = spawn('node', ['--import', 'tsx/esm', workerPath], {
|
|
18
|
+
cwd: PKG_DIR,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
env: { ...process.env, WORKER_PORT: String(port) },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
child.stdout?.on('data', (d) => {
|
|
24
|
+
process.stdout.write(d);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.stderr?.on('data', (d) => {
|
|
28
|
+
process.stderr.write(d);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
child.on('exit', (code) => {
|
|
32
|
+
if (code !== 0 && code !== null) {
|
|
33
|
+
log.warn(`Worker crashed (code ${code})`);
|
|
34
|
+
if (restarts < MAX_RESTARTS) {
|
|
35
|
+
restarts++;
|
|
36
|
+
log.info(`Restarting worker (${restarts}/${MAX_RESTARTS})...`);
|
|
37
|
+
setTimeout(() => spawnWorker(port), 1000);
|
|
38
|
+
} else {
|
|
39
|
+
log.error('Worker failed too many times. Use Fluxy chat to debug.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
log.ok(`Worker spawned on port ${port}`);
|
|
45
|
+
return child;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function stopWorker(): void {
|
|
49
|
+
child?.kill();
|
|
50
|
+
child = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isWorkerAlive(): boolean {
|
|
54
|
+
return child !== null && child.exitCode === null;
|
|
55
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"jsx": "react-jsx",
|
|
12
|
+
"types": [],
|
|
13
|
+
"paths": {
|
|
14
|
+
"@server/*": ["./server/*"],
|
|
15
|
+
"@client/*": ["./client/src/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["server/**/*", "client/src/**/*", "vite.config.ts"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "data"]
|
|
20
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import { VitePWA } from 'vite-plugin-pwa';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
root: 'client',
|
|
8
|
+
resolve: {
|
|
9
|
+
alias: { '@': path.resolve(__dirname, 'client/src') },
|
|
10
|
+
},
|
|
11
|
+
build: {
|
|
12
|
+
outDir: '../dist',
|
|
13
|
+
emptyOutDir: true,
|
|
14
|
+
},
|
|
15
|
+
server: {
|
|
16
|
+
port: 5173,
|
|
17
|
+
proxy: {
|
|
18
|
+
'/api': 'http://localhost:3000',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
plugins: [
|
|
22
|
+
react(),
|
|
23
|
+
VitePWA({
|
|
24
|
+
registerType: 'autoUpdate',
|
|
25
|
+
manifest: {
|
|
26
|
+
name: 'Fluxy',
|
|
27
|
+
short_name: 'Fluxy',
|
|
28
|
+
theme_color: '#212121',
|
|
29
|
+
background_color: '#212121',
|
|
30
|
+
display: 'standalone',
|
|
31
|
+
icons: [
|
|
32
|
+
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
|
33
|
+
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude OAuth PKCE flow for Anthropic subscription authentication.
|
|
3
|
+
* Adapted from CodeDeck's ClaudeOAuthService for server-side Node.js.
|
|
4
|
+
*
|
|
5
|
+
* User signs in at claude.ai, receives a code, pastes it back.
|
|
6
|
+
* Credentials stored in ~/.claude/.credentials.json + macOS Keychain.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { execFileSync } from 'child_process';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { log } from '../shared/logger.js';
|
|
15
|
+
|
|
16
|
+
const OAUTH_CONFIG = {
|
|
17
|
+
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
|
18
|
+
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
|
19
|
+
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
|
20
|
+
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
|
21
|
+
SCOPES: 'org:create_api_key user:profile user:inference',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
25
|
+
const CREDENTIALS_FILE = path.join(CLAUDE_DIR, '.credentials.json');
|
|
26
|
+
|
|
27
|
+
let codeVerifier: string | null = null;
|
|
28
|
+
|
|
29
|
+
/* ── Public API ── */
|
|
30
|
+
|
|
31
|
+
export function startClaudeOAuth(): { success: boolean; authUrl?: string; error?: string } {
|
|
32
|
+
// Generate PKCE
|
|
33
|
+
codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
34
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
35
|
+
|
|
36
|
+
const params = new URLSearchParams({
|
|
37
|
+
code: 'true',
|
|
38
|
+
client_id: OAUTH_CONFIG.CLIENT_ID,
|
|
39
|
+
response_type: 'code',
|
|
40
|
+
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
|
41
|
+
scope: OAUTH_CONFIG.SCOPES,
|
|
42
|
+
code_challenge: codeChallenge,
|
|
43
|
+
code_challenge_method: 'S256',
|
|
44
|
+
state: codeVerifier, // state = verifier (OpenClaw legacy flow)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const authUrl = `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}`;
|
|
48
|
+
log.ok('Claude OAuth flow started');
|
|
49
|
+
return { success: true, authUrl };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function exchangeClaudeCode(codeInput: string): Promise<{ success: boolean; error?: string }> {
|
|
53
|
+
if (!codeVerifier) {
|
|
54
|
+
return { success: false, error: 'OAuth flow not started. Click "Authenticate" first.' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse code — might be "code#state" or just "code"
|
|
58
|
+
const parts = codeInput.trim().split('#');
|
|
59
|
+
const code = parts[0].trim();
|
|
60
|
+
const state = parts[1]?.trim() || codeVerifier;
|
|
61
|
+
|
|
62
|
+
if (!code) {
|
|
63
|
+
return { success: false, error: 'Invalid code. Please copy the full code from the page.' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Token exchange uses JSON body (not form-urlencoded)
|
|
68
|
+
const payload = {
|
|
69
|
+
grant_type: 'authorization_code',
|
|
70
|
+
client_id: OAUTH_CONFIG.CLIENT_ID,
|
|
71
|
+
code,
|
|
72
|
+
state,
|
|
73
|
+
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
|
74
|
+
code_verifier: codeVerifier,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify(payload),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
return { success: false, error: `Authentication failed (${response.status}). Please try again.` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tokens = await response.json();
|
|
88
|
+
storeCredentials(tokens);
|
|
89
|
+
codeVerifier = null;
|
|
90
|
+
return { success: true };
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
return { success: false, error: err.message };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getClaudeAuthStatus(): { authenticated: boolean; error?: string } {
|
|
97
|
+
// Check credentials file
|
|
98
|
+
try {
|
|
99
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
100
|
+
const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
101
|
+
if (creds.accessToken) {
|
|
102
|
+
if (creds.expiresAt && Date.now() >= creds.expiresAt) {
|
|
103
|
+
return { authenticated: false, error: 'Token expired' };
|
|
104
|
+
}
|
|
105
|
+
return { authenticated: true };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
// macOS: check Keychain as fallback
|
|
111
|
+
if (process.platform === 'darwin') {
|
|
112
|
+
try {
|
|
113
|
+
const result = execFileSync('security', [
|
|
114
|
+
'find-generic-password', '-s', 'Claude Code-credentials', '-w',
|
|
115
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
|
|
116
|
+
const parsed = JSON.parse(result);
|
|
117
|
+
if (parsed.claudeAiOauth?.accessToken) {
|
|
118
|
+
if (parsed.claudeAiOauth.expiresAt && Date.now() >= parsed.claudeAiOauth.expiresAt) {
|
|
119
|
+
return { authenticated: false, error: 'Token expired' };
|
|
120
|
+
}
|
|
121
|
+
return { authenticated: true };
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Legacy check
|
|
127
|
+
try {
|
|
128
|
+
const legacyPath = path.join(os.homedir(), '.claude.json');
|
|
129
|
+
if (fs.existsSync(legacyPath)) {
|
|
130
|
+
const config = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
|
|
131
|
+
if (config.oauthAccessToken) return { authenticated: true };
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
|
|
135
|
+
return { authenticated: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function readClaudeAccessToken(): string | null {
|
|
139
|
+
// Primary: credentials file
|
|
140
|
+
try {
|
|
141
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
142
|
+
const creds = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
143
|
+
if (creds.accessToken) {
|
|
144
|
+
if (creds.expiresAt && Date.now() >= creds.expiresAt) return null;
|
|
145
|
+
return creds.accessToken;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
|
|
150
|
+
// macOS Keychain fallback
|
|
151
|
+
if (process.platform === 'darwin') {
|
|
152
|
+
try {
|
|
153
|
+
const result = execFileSync('security', [
|
|
154
|
+
'find-generic-password', '-s', 'Claude Code-credentials', '-w',
|
|
155
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
|
|
156
|
+
const parsed = JSON.parse(result);
|
|
157
|
+
if (parsed.claudeAiOauth?.accessToken) {
|
|
158
|
+
if (parsed.claudeAiOauth.expiresAt && Date.now() >= parsed.claudeAiOauth.expiresAt) return null;
|
|
159
|
+
return parsed.claudeAiOauth.accessToken;
|
|
160
|
+
}
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ── Helpers ── */
|
|
168
|
+
|
|
169
|
+
function storeCredentials(tokens: any): void {
|
|
170
|
+
if (!fs.existsSync(CLAUDE_DIR)) {
|
|
171
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Read existing credentials
|
|
175
|
+
let credentials: Record<string, any> = {};
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
178
|
+
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
|
|
182
|
+
credentials.accessToken = tokens.access_token;
|
|
183
|
+
if (tokens.refresh_token) credentials.refreshToken = tokens.refresh_token;
|
|
184
|
+
if (tokens.expires_in) {
|
|
185
|
+
credentials.expiresAt = Date.now() + (tokens.expires_in - 300) * 1000;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), 'utf-8');
|
|
189
|
+
try { fs.chmodSync(CREDENTIALS_FILE, 0o600); } catch {}
|
|
190
|
+
log.ok('Claude credentials stored');
|
|
191
|
+
|
|
192
|
+
// macOS: also write to Keychain
|
|
193
|
+
if (process.platform === 'darwin') {
|
|
194
|
+
try {
|
|
195
|
+
const keychainValue = JSON.stringify({ claudeAiOauth: credentials });
|
|
196
|
+
try {
|
|
197
|
+
execFileSync('security', ['delete-generic-password', '-s', 'Claude Code-credentials'], {
|
|
198
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
199
|
+
});
|
|
200
|
+
} catch {} // OK if entry doesn't exist
|
|
201
|
+
execFileSync('security', [
|
|
202
|
+
'add-generic-password',
|
|
203
|
+
'-s', 'Claude Code-credentials',
|
|
204
|
+
'-a', os.userInfo().username,
|
|
205
|
+
'-w', keychainValue,
|
|
206
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Legacy fallback (~/.claude.json)
|
|
211
|
+
try {
|
|
212
|
+
const legacyPath = path.join(os.homedir(), '.claude.json');
|
|
213
|
+
let legacyConfig: Record<string, any> = {};
|
|
214
|
+
try {
|
|
215
|
+
if (fs.existsSync(legacyPath)) {
|
|
216
|
+
legacyConfig = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
|
|
217
|
+
}
|
|
218
|
+
} catch {}
|
|
219
|
+
legacyConfig.oauthAccessToken = tokens.access_token;
|
|
220
|
+
legacyConfig.hasCompletedOnboarding = true;
|
|
221
|
+
fs.writeFileSync(legacyPath, JSON.stringify(legacyConfig, null, 2), 'utf-8');
|
|
222
|
+
try { fs.chmodSync(legacyPath, 0o600); } catch {}
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|