@wonderwhy-er/desktop-commander 0.2.29-alpha.0 → 0.2.29-alpha.10
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/dist/index.js +10 -0
- package/dist/npm-scripts/remote.d.ts +1 -0
- package/dist/npm-scripts/remote.js +20 -0
- package/dist/remote-device/desktop-commander-integration.d.ts +143 -0
- package/dist/remote-device/desktop-commander-integration.js +147 -0
- package/dist/remote-device/device-authenticator.d.ts +16 -0
- package/dist/remote-device/device-authenticator.js +120 -0
- package/dist/remote-device/device.d.ts +25 -0
- package/dist/remote-device/device.js +308 -0
- package/dist/remote-device/remote-channel.d.ts +51 -0
- package/dist/remote-device/remote-channel.js +255 -0
- package/dist/remote-device/scripts/blocking-offline-update.js +64 -0
- package/dist/remote-device/templates/auth-success.d.ts +1 -0
- package/dist/remote-device/templates/auth-success.js +30 -0
- package/dist/server.js +39 -11
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -3
- package/dist/data/spec-kit-prompts.json +0 -123
- package/dist/handlers/node-handlers.d.ts +0 -6
- package/dist/handlers/node-handlers.js +0 -73
- package/dist/handlers/test-crash-handler.d.ts +0 -11
- package/dist/handlers/test-crash-handler.js +0 -26
- package/dist/http-index.d.ts +0 -45
- package/dist/http-index.js +0 -51
- package/dist/http-server-auto-tunnel.d.ts +0 -1
- package/dist/http-server-auto-tunnel.js +0 -667
- package/dist/http-server-named-tunnel.d.ts +0 -2
- package/dist/http-server-named-tunnel.js +0 -167
- package/dist/http-server-tunnel.d.ts +0 -2
- package/dist/http-server-tunnel.js +0 -111
- package/dist/http-server.d.ts +0 -2
- package/dist/http-server.js +0 -270
- package/dist/index-oauth.d.ts +0 -2
- package/dist/index-oauth.js +0 -201
- package/dist/oauth/auth-middleware.d.ts +0 -20
- package/dist/oauth/auth-middleware.js +0 -62
- package/dist/oauth/index.d.ts +0 -3
- package/dist/oauth/index.js +0 -3
- package/dist/oauth/oauth-manager.d.ts +0 -80
- package/dist/oauth/oauth-manager.js +0 -179
- package/dist/oauth/oauth-routes.d.ts +0 -3
- package/dist/oauth/oauth-routes.js +0 -377
- package/dist/oauth/provider.d.ts +0 -22
- package/dist/oauth/provider.js +0 -124
- package/dist/oauth/server.d.ts +0 -18
- package/dist/oauth/server.js +0 -160
- package/dist/oauth/types.d.ts +0 -54
- package/dist/oauth/types.js +0 -2
- package/dist/setup.log +0 -275
- package/dist/test-setup.js +0 -14
- package/dist/tools/pdf-processor.d.ts +0 -1
- package/dist/tools/pdf-processor.js +0 -3
- package/dist/tools/search.d.ts +0 -32
- package/dist/tools/search.js +0 -202
- package/dist/utils/crash-logger.d.ts +0 -18
- package/dist/utils/crash-logger.js +0 -44
- package/dist/utils/dedent.d.ts +0 -8
- package/dist/utils/dedent.js +0 -38
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { RemoteChannel } from './remote-channel.js';
|
|
3
|
+
import { DeviceAuthenticator } from './device-authenticator.js';
|
|
4
|
+
import { DesktopCommanderIntegration } from './desktop-commander-integration.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
export class MCPDevice {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.baseServerUrl = process.env.MCP_SERVER_URL || 'https://mcp.desktopcommander.app';
|
|
12
|
+
this.remoteChannel = new RemoteChannel();
|
|
13
|
+
this.deviceId = undefined;
|
|
14
|
+
this.user = null;
|
|
15
|
+
this.isShuttingDown = false;
|
|
16
|
+
this.configPath = path.join(os.homedir(), '.desktop-commander-device', 'device.json');
|
|
17
|
+
this.persistSession = options.persistSession || false;
|
|
18
|
+
// Initialize desktop integration
|
|
19
|
+
this.desktop = new DesktopCommanderIntegration();
|
|
20
|
+
// Graceful shutdown handlers (only set once)
|
|
21
|
+
this.setupShutdownHandlers();
|
|
22
|
+
}
|
|
23
|
+
setupShutdownHandlers() {
|
|
24
|
+
const handleShutdown = async (signal) => {
|
|
25
|
+
if (this.isShuttingDown) {
|
|
26
|
+
console.log(`\n${signal} received, but already shutting down...`);
|
|
27
|
+
// Force exit if we get multiple signals
|
|
28
|
+
process.exit(1);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.log(`\n${signal} received, initiating graceful shutdown...`);
|
|
32
|
+
// Force exit after 5 seconds if graceful shutdown hangs
|
|
33
|
+
const forceExit = setTimeout(() => {
|
|
34
|
+
console.error('\n⚠️ Graceful shutdown timed out, forcing exit...');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}, 5000);
|
|
37
|
+
try {
|
|
38
|
+
await this.shutdown();
|
|
39
|
+
clearTimeout(forceExit);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error('Error during shutdown:', error);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
// Remove any existing SIGINT/SIGTERM listeners to prevent default behavior
|
|
48
|
+
process.removeAllListeners('SIGINT');
|
|
49
|
+
process.removeAllListeners('SIGTERM');
|
|
50
|
+
// Add our custom handlers
|
|
51
|
+
process.on('SIGINT', () => {
|
|
52
|
+
handleShutdown('SIGINT').catch((error) => {
|
|
53
|
+
console.error('Fatal error during shutdown:', error);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
process.on('SIGTERM', () => {
|
|
58
|
+
handleShutdown('SIGTERM').catch((error) => {
|
|
59
|
+
console.error('Fatal error during shutdown:', error);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
try {
|
|
66
|
+
console.log('🚀 Starting MCP Device...');
|
|
67
|
+
if (process.env.DEBUG_MODE === 'true') {
|
|
68
|
+
console.log(` - 🐞 DEBUG_MODE`);
|
|
69
|
+
}
|
|
70
|
+
// Initialize desktop integration
|
|
71
|
+
await this.desktop.initialize();
|
|
72
|
+
console.log(`⏳ Connecting to Remote MCP ${this.baseServerUrl}`);
|
|
73
|
+
const { supabaseUrl, anonKey } = await this.fetchSupabaseConfig();
|
|
74
|
+
console.log(` - 🔌 Connected to Remote MCP`);
|
|
75
|
+
// Initialize Remote Channel
|
|
76
|
+
this.remoteChannel.initialize(supabaseUrl, anonKey);
|
|
77
|
+
// Load persisted configuration (deviceId, session)
|
|
78
|
+
let session = await this.loadPersistedConfig();
|
|
79
|
+
// 2. Set Session or Authenticate
|
|
80
|
+
if (session) {
|
|
81
|
+
const { error } = await this.remoteChannel.setSession(session);
|
|
82
|
+
if (error) {
|
|
83
|
+
console.log(' - ⚠️ Persisted session invalid:', error.message);
|
|
84
|
+
session = null;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(' - ✅ Session restored');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!session) {
|
|
91
|
+
console.log('\n🔐 Authenticating with Remote MCP server...');
|
|
92
|
+
const authenticator = new DeviceAuthenticator(this.baseServerUrl);
|
|
93
|
+
session = await authenticator.authenticate(this.deviceId);
|
|
94
|
+
if (session.device_id) {
|
|
95
|
+
if (!this.deviceId) {
|
|
96
|
+
console.log(` - ✅ Device ID assigned: ${session.device_id}`);
|
|
97
|
+
}
|
|
98
|
+
else if (this.deviceId !== session.device_id) {
|
|
99
|
+
console.log(` - ⚠️ Device ID changed: ${this.deviceId} → ${session.device_id}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
console.log(` - ✅ Device ID authenticated: ${session.device_id}`);
|
|
103
|
+
}
|
|
104
|
+
this.deviceId = session.device_id;
|
|
105
|
+
}
|
|
106
|
+
// Set session in Remote Channel
|
|
107
|
+
const { error } = await this.remoteChannel.setSession(session);
|
|
108
|
+
if (error)
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
// 3. Setup Token Refresh Listener
|
|
112
|
+
this.remoteChannel.onAuthStateChange(async (event, newSession) => {
|
|
113
|
+
const eventMap = {
|
|
114
|
+
'SIGNED_IN': '🔑 User signed in',
|
|
115
|
+
'TOKEN_REFRESHED': '🔄 Token refreshed',
|
|
116
|
+
'SIGNED_OUT': '⚠️ User signed out',
|
|
117
|
+
};
|
|
118
|
+
if (eventMap[event]) {
|
|
119
|
+
console.log(eventMap[event]);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
// Force save the current session immediately to ensure it's persisted
|
|
123
|
+
const currentSessionStore = await this.remoteChannel.getSession();
|
|
124
|
+
await this.savePersistedConfig(currentSessionStore.data.session);
|
|
125
|
+
// Get user info
|
|
126
|
+
const { data: { user }, error: userError } = await this.remoteChannel.getUser();
|
|
127
|
+
if (userError)
|
|
128
|
+
throw userError;
|
|
129
|
+
this.user = user;
|
|
130
|
+
const deviceName = os.hostname();
|
|
131
|
+
// Register as device
|
|
132
|
+
await this.remoteChannel.registerDevice(this.user.id, await this.desktop.listClientTools(), this.deviceId, deviceName);
|
|
133
|
+
// Also save session again just in case (optional, but harmless)
|
|
134
|
+
const { data: { session: currentSession } } = await this.remoteChannel.getSession();
|
|
135
|
+
await this.savePersistedConfig(currentSession);
|
|
136
|
+
// Subscribe to tool calls
|
|
137
|
+
await this.remoteChannel.subscribe(this.user.id, (payload) => this.handleNewToolCall(payload));
|
|
138
|
+
console.log('✅ Device ready:');
|
|
139
|
+
console.log(` - User: ${this.user.email}`);
|
|
140
|
+
console.log(` - Device ID: ${this.deviceId}`);
|
|
141
|
+
console.log(` - Device Name: ${deviceName}`);
|
|
142
|
+
// Keep process alive
|
|
143
|
+
this.remoteChannel.startHeartbeat(this.deviceId);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error(' - ❌ Device startup failed:', error.message);
|
|
147
|
+
if (error.stack && process.env.DEBUG_MODE === 'true') {
|
|
148
|
+
console.error('Stack trace:', error.stack);
|
|
149
|
+
}
|
|
150
|
+
await this.shutdown();
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async loadPersistedConfig() {
|
|
155
|
+
try {
|
|
156
|
+
const data = await fs.readFile(this.configPath, 'utf8');
|
|
157
|
+
const config = JSON.parse(data);
|
|
158
|
+
this.deviceId = config?.deviceId;
|
|
159
|
+
console.log('💾 Found persisted session for device ' + this.deviceId);
|
|
160
|
+
if (config.session) {
|
|
161
|
+
return config.session;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error.code !== 'ENOENT') {
|
|
167
|
+
console.warn('⚠️ Failed to load config:', error.message);
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
// No need to ensure device ID here
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async savePersistedConfig(session) {
|
|
176
|
+
try {
|
|
177
|
+
const config = {
|
|
178
|
+
deviceId: this.deviceId,
|
|
179
|
+
// Only save session if --persist-session flag is set
|
|
180
|
+
session: (session && this.persistSession) ? {
|
|
181
|
+
access_token: session.access_token,
|
|
182
|
+
refresh_token: session.refresh_token
|
|
183
|
+
} : null
|
|
184
|
+
};
|
|
185
|
+
// Ensure the config directory exists
|
|
186
|
+
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
|
187
|
+
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
188
|
+
// if (session) console.debug('💾 Session saved to ' + this.configPath);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
console.error(' - ❌ Failed to save config:', error.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async fetchSupabaseConfig() {
|
|
195
|
+
// No auth header needed for this public endpoint
|
|
196
|
+
const response = await fetch(`${this.baseServerUrl}/api/mcp-info`);
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
throw new Error(`Failed to fetch Supabase config: ${response.statusText}`);
|
|
199
|
+
}
|
|
200
|
+
const config = await response.json();
|
|
201
|
+
return {
|
|
202
|
+
supabaseUrl: config.supabaseUrl,
|
|
203
|
+
anonKey: config.supabasePublishableKey
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// Methods moved to RemoteChannel
|
|
207
|
+
async handleNewToolCall(payload) {
|
|
208
|
+
const toolCall = payload.new;
|
|
209
|
+
// Assuming database also renames agent_id to device_id, but user only said rename agent -> device everywhere but only inside src/remote-device
|
|
210
|
+
// If the database column is still agent_id, we need a mapping.
|
|
211
|
+
// However, the user said "literally all agent should be renamed to device everywhere", so we assume DB column is device_id.
|
|
212
|
+
const { id: call_id, tool_name, tool_args, device_id, metadata = {} } = toolCall;
|
|
213
|
+
// Only process jobs for this device
|
|
214
|
+
if (device_id && device_id !== this.deviceId) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.log(`🔧 Received tool call ${call_id}: ${tool_name} ${JSON.stringify(tool_args)} metadata: ${JSON.stringify(metadata)}`);
|
|
218
|
+
try {
|
|
219
|
+
// Update call status to executing
|
|
220
|
+
await this.remoteChannel.markCallExecuting(call_id);
|
|
221
|
+
let result;
|
|
222
|
+
// Handle 'ping' tool specially
|
|
223
|
+
if (tool_name === 'ping') {
|
|
224
|
+
result = {
|
|
225
|
+
content: [{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: `pong ${new Date().toISOString()}`
|
|
228
|
+
}]
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
else if (tool_name === 'shutdown') {
|
|
232
|
+
result = {
|
|
233
|
+
content: [{
|
|
234
|
+
type: 'text',
|
|
235
|
+
text: `Shutdown initialized at ${new Date().toISOString()}`
|
|
236
|
+
}]
|
|
237
|
+
};
|
|
238
|
+
// Trigger shutdown after sending response
|
|
239
|
+
setTimeout(async () => {
|
|
240
|
+
console.log('🛑 Remote shutdown requested. Exiting...');
|
|
241
|
+
await this.shutdown();
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}, 1000);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
// Execute other tools using desktop integration
|
|
247
|
+
result = await this.desktop.callClientTool(tool_name, tool_args, metadata);
|
|
248
|
+
}
|
|
249
|
+
console.log(`✅ Tool call ${tool_name} completed:\r\n ${JSON.stringify(result)}`);
|
|
250
|
+
// Update database with result
|
|
251
|
+
await this.remoteChannel.updateCallResult(call_id, 'completed', result);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
console.error(`❌ Tool call ${tool_name} failed:`, error.message);
|
|
255
|
+
await this.remoteChannel.updateCallResult(call_id, 'failed', null, error.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Moved to RemoteChannel
|
|
259
|
+
// Moved to RemoteChannel
|
|
260
|
+
async shutdown() {
|
|
261
|
+
if (this.isShuttingDown) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.isShuttingDown = true;
|
|
265
|
+
console.log('\n🛑 Shutting down device...');
|
|
266
|
+
try {
|
|
267
|
+
// Stop heartbeat first to prevent new operations
|
|
268
|
+
console.log(' → Stopping heartbeat...');
|
|
269
|
+
this.remoteChannel.stopHeartbeat();
|
|
270
|
+
console.log(' ✓ Heartbeat stopped');
|
|
271
|
+
// Unsubscribe from channel
|
|
272
|
+
console.log(' → Unsubscribing from channel...');
|
|
273
|
+
await this.remoteChannel.unsubscribe();
|
|
274
|
+
// Mark device offline
|
|
275
|
+
console.log(' → Marking device offline...');
|
|
276
|
+
await this.remoteChannel.setOffline(this.deviceId);
|
|
277
|
+
// Shutdown desktop integration
|
|
278
|
+
console.log(' → Shutting down desktop integration...');
|
|
279
|
+
await this.desktop.shutdown();
|
|
280
|
+
console.log(' ✓ Desktop integration shut down');
|
|
281
|
+
console.log('✓ Device shutdown complete');
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
console.error('Shutdown error:', error.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Start device if called directly or as a bin command
|
|
289
|
+
// When installed globally, npm creates a wrapper, so we need to check multiple conditions
|
|
290
|
+
const isMainModule = process.argv[1] && (
|
|
291
|
+
// Direct execution: node device.js
|
|
292
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
293
|
+
fileURLToPath(import.meta.url) === process.argv[1] ||
|
|
294
|
+
// Global bin execution: desktop-commander-device (npm creates a wrapper)
|
|
295
|
+
process.argv[1].endsWith('desktop-commander-device') ||
|
|
296
|
+
process.argv[1].endsWith('desktop-commander-device.js'));
|
|
297
|
+
if (isMainModule) {
|
|
298
|
+
// Parse command-line arguments
|
|
299
|
+
const args = process.argv.slice(2);
|
|
300
|
+
const options = {
|
|
301
|
+
persistSession: args.includes('--persist-session')
|
|
302
|
+
};
|
|
303
|
+
if (options.persistSession) {
|
|
304
|
+
console.log('🔒 Session persistence enabled');
|
|
305
|
+
}
|
|
306
|
+
const device = new MCPDevice(options);
|
|
307
|
+
device.start();
|
|
308
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Session, UserResponse } from '@supabase/supabase-js';
|
|
2
|
+
export interface AuthSession {
|
|
3
|
+
access_token: string;
|
|
4
|
+
refresh_token: string | null;
|
|
5
|
+
device_id?: string;
|
|
6
|
+
}
|
|
7
|
+
interface DeviceData {
|
|
8
|
+
user_id: string;
|
|
9
|
+
device_name: string;
|
|
10
|
+
capabilities: any;
|
|
11
|
+
status: string;
|
|
12
|
+
last_seen: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class RemoteChannel {
|
|
15
|
+
private client;
|
|
16
|
+
private channel;
|
|
17
|
+
private heartbeatInterval;
|
|
18
|
+
initialize(url: string, key: string): void;
|
|
19
|
+
setSession(session: AuthSession): Promise<{
|
|
20
|
+
error: any;
|
|
21
|
+
}>;
|
|
22
|
+
getSession(): Promise<{
|
|
23
|
+
data: {
|
|
24
|
+
session: Session | null;
|
|
25
|
+
};
|
|
26
|
+
error: any;
|
|
27
|
+
}>;
|
|
28
|
+
getUser(): Promise<UserResponse>;
|
|
29
|
+
onAuthStateChange(callback: (event: string, session: Session | null) => void): {
|
|
30
|
+
data: {
|
|
31
|
+
subscription: import("@supabase/supabase-js").Subscription;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
findDevice(deviceId: string, userId: string): Promise<{
|
|
35
|
+
id: any;
|
|
36
|
+
device_name: any;
|
|
37
|
+
} | null>;
|
|
38
|
+
updateDevice(deviceId: string, updates: any): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<null>>;
|
|
39
|
+
createDevice(deviceData: DeviceData): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<any>>;
|
|
40
|
+
registerDevice(userId: string, capabilities: any, currentDeviceId: string | undefined, deviceName: string): Promise<void>;
|
|
41
|
+
subscribe(userId: string, onToolCall: (payload: any) => void): Promise<void>;
|
|
42
|
+
markCallExecuting(callId: string): Promise<void>;
|
|
43
|
+
updateCallResult(callId: string, status: string, result?: any, errorMessage?: string | null): Promise<void>;
|
|
44
|
+
updateHeartbeat(deviceId: string): Promise<void>;
|
|
45
|
+
startHeartbeat(deviceId: string): void;
|
|
46
|
+
stopHeartbeat(): void;
|
|
47
|
+
setOnlineStatus(deviceId: string, status: 'online' | 'offline'): Promise<void>;
|
|
48
|
+
setOffline(deviceId: string | undefined): Promise<void>;
|
|
49
|
+
unsubscribe(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
const HEARTBEAT_INTERVAL = 15000;
|
|
3
|
+
export class RemoteChannel {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.client = null;
|
|
6
|
+
this.channel = null;
|
|
7
|
+
this.heartbeatInterval = null;
|
|
8
|
+
}
|
|
9
|
+
initialize(url, key) {
|
|
10
|
+
this.client = createClient(url, key);
|
|
11
|
+
}
|
|
12
|
+
async setSession(session) {
|
|
13
|
+
if (!this.client)
|
|
14
|
+
throw new Error('Client not initialized');
|
|
15
|
+
const { error } = await this.client.auth.setSession({
|
|
16
|
+
access_token: session.access_token,
|
|
17
|
+
refresh_token: session.refresh_token || ''
|
|
18
|
+
});
|
|
19
|
+
return { error };
|
|
20
|
+
}
|
|
21
|
+
async getSession() {
|
|
22
|
+
if (!this.client)
|
|
23
|
+
throw new Error('Client not initialized');
|
|
24
|
+
return await this.client.auth.getSession();
|
|
25
|
+
}
|
|
26
|
+
async getUser() {
|
|
27
|
+
if (!this.client)
|
|
28
|
+
throw new Error('Client not initialized');
|
|
29
|
+
return await this.client.auth.getUser();
|
|
30
|
+
}
|
|
31
|
+
onAuthStateChange(callback) {
|
|
32
|
+
if (!this.client)
|
|
33
|
+
throw new Error('Client not initialized');
|
|
34
|
+
return this.client.auth.onAuthStateChange(callback);
|
|
35
|
+
}
|
|
36
|
+
async findDevice(deviceId, userId) {
|
|
37
|
+
if (!this.client)
|
|
38
|
+
throw new Error('Client not initialized');
|
|
39
|
+
const { data, error } = await this.client
|
|
40
|
+
.from('mcp_devices')
|
|
41
|
+
.select('id, device_name')
|
|
42
|
+
.eq('id', deviceId)
|
|
43
|
+
.eq('user_id', userId)
|
|
44
|
+
.maybeSingle();
|
|
45
|
+
if (error)
|
|
46
|
+
throw error;
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
async updateDevice(deviceId, updates) {
|
|
50
|
+
if (!this.client)
|
|
51
|
+
throw new Error('Client not initialized');
|
|
52
|
+
return await this.client
|
|
53
|
+
.from('mcp_devices')
|
|
54
|
+
.update(updates)
|
|
55
|
+
.eq('id', deviceId);
|
|
56
|
+
}
|
|
57
|
+
async createDevice(deviceData) {
|
|
58
|
+
if (!this.client)
|
|
59
|
+
throw new Error('Client not initialized');
|
|
60
|
+
return await this.client
|
|
61
|
+
.from('mcp_devices')
|
|
62
|
+
.insert(deviceData)
|
|
63
|
+
.select()
|
|
64
|
+
.single();
|
|
65
|
+
}
|
|
66
|
+
async registerDevice(userId, capabilities, currentDeviceId, deviceName) {
|
|
67
|
+
let existingDevice = null;
|
|
68
|
+
if (currentDeviceId) {
|
|
69
|
+
existingDevice = await this.findDevice(currentDeviceId, userId);
|
|
70
|
+
}
|
|
71
|
+
if (existingDevice) {
|
|
72
|
+
console.log(`🔍 Found existing device: ${existingDevice.device_name} (${existingDevice.id})`);
|
|
73
|
+
await this.updateDevice(existingDevice.id, {
|
|
74
|
+
status: 'online',
|
|
75
|
+
last_seen: new Date().toISOString(),
|
|
76
|
+
capabilities: {}, // Not used atm
|
|
77
|
+
device_name: deviceName
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.error(` - ❌ Device not found: ${currentDeviceId}`);
|
|
82
|
+
throw new Error(`Device not found: ${currentDeviceId}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async subscribe(userId, onToolCall) {
|
|
86
|
+
if (!this.client)
|
|
87
|
+
throw new Error('Client not initialized');
|
|
88
|
+
console.debug(` - ⏳ Subscribing to tool call channel...`);
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
if (!this.client)
|
|
91
|
+
return reject(new Error('Client not initialized'));
|
|
92
|
+
this.channel = this.client.channel('device_tool_call_queue')
|
|
93
|
+
.on('postgres_changes', {
|
|
94
|
+
event: 'INSERT',
|
|
95
|
+
schema: 'public',
|
|
96
|
+
table: 'mcp_remote_calls',
|
|
97
|
+
filter: `user_id=eq.${userId}`
|
|
98
|
+
}, (payload) => onToolCall(payload))
|
|
99
|
+
.subscribe((status, err) => {
|
|
100
|
+
if (status === 'SUBSCRIBED') {
|
|
101
|
+
// console.debug(' - 🔌 Connected to tool call channel');
|
|
102
|
+
this.setOnlineStatus(userId, 'online');
|
|
103
|
+
resolve();
|
|
104
|
+
}
|
|
105
|
+
else if (status === 'CHANNEL_ERROR') {
|
|
106
|
+
// console.error(' - ❌ Failed to connect to tool call channel:', err);
|
|
107
|
+
this.setOnlineStatus(userId, 'offline');
|
|
108
|
+
reject(err || new Error('Failed to initialize tool call channel subscription'));
|
|
109
|
+
}
|
|
110
|
+
else if (status === 'TIMED_OUT') {
|
|
111
|
+
// console.error(' - ❌ Connection to tool call channel timed out');
|
|
112
|
+
this.setOnlineStatus(userId, 'offline');
|
|
113
|
+
reject(new Error('Tool call channel subscription timed out'));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
async markCallExecuting(callId) {
|
|
119
|
+
if (!this.client)
|
|
120
|
+
throw new Error('Client not initialized');
|
|
121
|
+
await this.client
|
|
122
|
+
.from('mcp_remote_calls')
|
|
123
|
+
.update({ status: 'executing' })
|
|
124
|
+
.eq('id', callId);
|
|
125
|
+
}
|
|
126
|
+
async updateCallResult(callId, status, result = null, errorMessage = null) {
|
|
127
|
+
if (!this.client)
|
|
128
|
+
throw new Error('Client not initialized');
|
|
129
|
+
const updateData = {
|
|
130
|
+
status: status,
|
|
131
|
+
completed_at: new Date().toISOString()
|
|
132
|
+
};
|
|
133
|
+
if (result !== null)
|
|
134
|
+
updateData.result = result;
|
|
135
|
+
if (errorMessage !== null)
|
|
136
|
+
updateData.error_message = errorMessage;
|
|
137
|
+
await this.client
|
|
138
|
+
.from('mcp_remote_calls')
|
|
139
|
+
.update(updateData)
|
|
140
|
+
.eq('id', callId);
|
|
141
|
+
}
|
|
142
|
+
async updateHeartbeat(deviceId) {
|
|
143
|
+
if (!this.client)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
await this.client
|
|
147
|
+
.from('mcp_devices')
|
|
148
|
+
.update({ last_seen: new Date().toISOString() })
|
|
149
|
+
.eq('id', deviceId);
|
|
150
|
+
// console.log(`🔌 Heartbeat sent for device: ${deviceId}`);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.error('Heartbeat failed:', error.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
startHeartbeat(deviceId) {
|
|
157
|
+
// Update last_seen every 30 seconds
|
|
158
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
159
|
+
await this.updateHeartbeat(deviceId);
|
|
160
|
+
}, HEARTBEAT_INTERVAL);
|
|
161
|
+
}
|
|
162
|
+
stopHeartbeat() {
|
|
163
|
+
if (this.heartbeatInterval) {
|
|
164
|
+
clearInterval(this.heartbeatInterval);
|
|
165
|
+
this.heartbeatInterval = null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async setOnlineStatus(deviceId, status) {
|
|
169
|
+
if (!this.client)
|
|
170
|
+
return;
|
|
171
|
+
const { error } = await this.client
|
|
172
|
+
.from('mcp_devices')
|
|
173
|
+
.update({ status: status, last_seen: new Date().toISOString() })
|
|
174
|
+
.eq('id', deviceId);
|
|
175
|
+
if (error) {
|
|
176
|
+
console.error('Failed to update device status:', error.message);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
console.log(status === 'online' ? `🔌 Device marked as ${status}` : `❌ Device marked as ${status}`);
|
|
180
|
+
}
|
|
181
|
+
async setOffline(deviceId) {
|
|
182
|
+
if (!deviceId || !this.client) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// console.log('🔍 [setOffline] Initiating blocking update...');
|
|
186
|
+
try {
|
|
187
|
+
// Get current session for the subprocess
|
|
188
|
+
const { data: sessionData } = await this.client.auth.getSession();
|
|
189
|
+
if (!sessionData?.session?.access_token) {
|
|
190
|
+
console.error('❌ No valid session for offline update');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Get Supabase config from client
|
|
194
|
+
const supabaseUrl = this.client.supabaseUrl;
|
|
195
|
+
const supabaseKey = this.client.supabaseKey;
|
|
196
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
197
|
+
console.error('❌ Missing Supabase configuration');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Use spawnSync to run the blocking update script
|
|
201
|
+
const { spawnSync } = await import('child_process');
|
|
202
|
+
const { fileURLToPath } = await import('url');
|
|
203
|
+
const path = await import('path');
|
|
204
|
+
// Get the script path relative to this file
|
|
205
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
206
|
+
const __dirname = path.dirname(__filename);
|
|
207
|
+
const scriptPath = path.join(__dirname, 'scripts', 'blocking-offline-update.js');
|
|
208
|
+
const result = spawnSync('node', [
|
|
209
|
+
scriptPath,
|
|
210
|
+
deviceId,
|
|
211
|
+
supabaseUrl,
|
|
212
|
+
supabaseKey,
|
|
213
|
+
sessionData.session.access_token,
|
|
214
|
+
sessionData.session.refresh_token || ''
|
|
215
|
+
], {
|
|
216
|
+
timeout: 3000,
|
|
217
|
+
stdio: 'pipe', // Capture output to prevent blocking
|
|
218
|
+
encoding: 'utf-8'
|
|
219
|
+
});
|
|
220
|
+
// Log subprocess output (with encoding:'utf-8', these are already strings)
|
|
221
|
+
if (result.stdout && result.stdout.trim()) {
|
|
222
|
+
console.log(result.stdout.trim());
|
|
223
|
+
}
|
|
224
|
+
if (result.stderr && result.stderr.trim()) {
|
|
225
|
+
console.error(result.stderr.trim());
|
|
226
|
+
}
|
|
227
|
+
// Handle exit codes
|
|
228
|
+
if (result.error) {
|
|
229
|
+
console.error('❌ Failed to spawn update process:', result.error.message);
|
|
230
|
+
}
|
|
231
|
+
else if (result.status === 0) {
|
|
232
|
+
console.log('✓ Device marked as offline (blocking)');
|
|
233
|
+
}
|
|
234
|
+
else if (result.status === 2) {
|
|
235
|
+
console.warn('⚠️ Device offline update timed out');
|
|
236
|
+
}
|
|
237
|
+
else if (result.signal) {
|
|
238
|
+
console.error(`❌ Update process killed by signal: ${result.signal}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
console.error(`❌ Update process failed with exit code: ${result.status}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
console.error('❌ Error in blocking offline update:', error.message);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async unsubscribe() {
|
|
249
|
+
if (this.channel) {
|
|
250
|
+
await this.channel.unsubscribe();
|
|
251
|
+
this.channel = null;
|
|
252
|
+
console.log('✓ Unsubscribed from tool call channel');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blocking script to update device status to offline
|
|
5
|
+
* Runs synchronously during shutdown to ensure DB update completes
|
|
6
|
+
*
|
|
7
|
+
* Usage: node blocking-offline-update.js <deviceId> <supabaseUrl> <supabaseKey> <accessToken> <refreshToken>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createClient } from '@supabase/supabase-js';
|
|
11
|
+
|
|
12
|
+
// Parse command line arguments
|
|
13
|
+
const [deviceId, supabaseUrl, supabaseKey, accessToken, refreshToken] = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
if (!deviceId || !supabaseUrl || !supabaseKey || !accessToken || !refreshToken) {
|
|
16
|
+
console.error('❌ Missing required arguments');
|
|
17
|
+
console.error('Usage: node blocking-offline-update.js <deviceId> <supabaseUrl> <supabaseKey> <accessToken> <refreshToken>');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Set timeout for entire operation
|
|
22
|
+
const TIMEOUT_MS = 3000;
|
|
23
|
+
const timeoutHandle = setTimeout(() => {
|
|
24
|
+
console.error('⏱️ Timeout: Update took too long');
|
|
25
|
+
process.exit(2); // Exit code 2 for timeout
|
|
26
|
+
}, TIMEOUT_MS);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// Create Supabase client
|
|
30
|
+
const client = createClient(supabaseUrl, supabaseKey);
|
|
31
|
+
|
|
32
|
+
// Set session using access token and refresh token
|
|
33
|
+
const { error: authError } = await client.auth.setSession({
|
|
34
|
+
access_token: accessToken,
|
|
35
|
+
refresh_token: refreshToken
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (authError) {
|
|
39
|
+
console.error('❌ Auth error:', authError.message);
|
|
40
|
+
clearTimeout(timeoutHandle);
|
|
41
|
+
process.exit(3); // Exit code 3 for auth error
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Update device status to offline
|
|
45
|
+
const { data, error } = await client
|
|
46
|
+
.from('mcp_devices')
|
|
47
|
+
.update({ status: 'offline' })
|
|
48
|
+
.eq('id', deviceId);
|
|
49
|
+
|
|
50
|
+
clearTimeout(timeoutHandle);
|
|
51
|
+
|
|
52
|
+
if (error) {
|
|
53
|
+
console.error('❌ DB update error:', error.message);
|
|
54
|
+
process.exit(4); // Exit code 4 for DB error
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log('✓ Device marked as offline');
|
|
58
|
+
process.exit(0); // Success
|
|
59
|
+
|
|
60
|
+
} catch (error) {
|
|
61
|
+
clearTimeout(timeoutHandle);
|
|
62
|
+
console.error('❌ Unexpected error:', error.message);
|
|
63
|
+
process.exit(5); // Exit code 5 for unexpected error
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const authSuccessHtml = "\n<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Authentication Successful</title>\n</head>\n\n<body\n style=\"margin: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; line-height: 1.5; color: #F8FAFC; background-color: #101219; min-height: 100vh; display: flex; align-items: center; justify-content: center;\">\n <div\n style=\"background: rgba(30, 41, 59, 0.4); backdrop-filter: blur(12px); border: 1px solid #2B303B; border-radius: 12px; padding: 40px; width: 100%; max-width: 480px; margin: 20px; text-align: center; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);\">\n <h2 style=\"font-size: 24px; font-weight: 700; color: #F8FAFC; margin-top: 0; margin-bottom: 16px;\">\n Authentication Successful!</h2>\n <p style=\"color: #94A3B8; font-size: 15px; line-height: 1.6; margin: 0 0 8px 0;\">Your device is now connected.\n </p>\n <p style=\"color: #94A3B8; font-size: 15px; line-height: 1.6; margin: 0;\">You can close this window.</p>\n </div>\n <script>\n // Clean up URL parameters and hash\n if (window.history && window.history.replaceState) {\n window.history.replaceState(null, '', window.location.pathname);\n }\n </script>\n</body>\n\n</html>\n";
|