@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.
Files changed (59) hide show
  1. package/dist/index.js +10 -0
  2. package/dist/npm-scripts/remote.d.ts +1 -0
  3. package/dist/npm-scripts/remote.js +20 -0
  4. package/dist/remote-device/desktop-commander-integration.d.ts +143 -0
  5. package/dist/remote-device/desktop-commander-integration.js +147 -0
  6. package/dist/remote-device/device-authenticator.d.ts +16 -0
  7. package/dist/remote-device/device-authenticator.js +120 -0
  8. package/dist/remote-device/device.d.ts +25 -0
  9. package/dist/remote-device/device.js +308 -0
  10. package/dist/remote-device/remote-channel.d.ts +51 -0
  11. package/dist/remote-device/remote-channel.js +255 -0
  12. package/dist/remote-device/scripts/blocking-offline-update.js +64 -0
  13. package/dist/remote-device/templates/auth-success.d.ts +1 -0
  14. package/dist/remote-device/templates/auth-success.js +30 -0
  15. package/dist/server.js +39 -11
  16. package/dist/version.d.ts +1 -1
  17. package/dist/version.js +1 -1
  18. package/package.json +8 -3
  19. package/dist/data/spec-kit-prompts.json +0 -123
  20. package/dist/handlers/node-handlers.d.ts +0 -6
  21. package/dist/handlers/node-handlers.js +0 -73
  22. package/dist/handlers/test-crash-handler.d.ts +0 -11
  23. package/dist/handlers/test-crash-handler.js +0 -26
  24. package/dist/http-index.d.ts +0 -45
  25. package/dist/http-index.js +0 -51
  26. package/dist/http-server-auto-tunnel.d.ts +0 -1
  27. package/dist/http-server-auto-tunnel.js +0 -667
  28. package/dist/http-server-named-tunnel.d.ts +0 -2
  29. package/dist/http-server-named-tunnel.js +0 -167
  30. package/dist/http-server-tunnel.d.ts +0 -2
  31. package/dist/http-server-tunnel.js +0 -111
  32. package/dist/http-server.d.ts +0 -2
  33. package/dist/http-server.js +0 -270
  34. package/dist/index-oauth.d.ts +0 -2
  35. package/dist/index-oauth.js +0 -201
  36. package/dist/oauth/auth-middleware.d.ts +0 -20
  37. package/dist/oauth/auth-middleware.js +0 -62
  38. package/dist/oauth/index.d.ts +0 -3
  39. package/dist/oauth/index.js +0 -3
  40. package/dist/oauth/oauth-manager.d.ts +0 -80
  41. package/dist/oauth/oauth-manager.js +0 -179
  42. package/dist/oauth/oauth-routes.d.ts +0 -3
  43. package/dist/oauth/oauth-routes.js +0 -377
  44. package/dist/oauth/provider.d.ts +0 -22
  45. package/dist/oauth/provider.js +0 -124
  46. package/dist/oauth/server.d.ts +0 -18
  47. package/dist/oauth/server.js +0 -160
  48. package/dist/oauth/types.d.ts +0 -54
  49. package/dist/oauth/types.js +0 -2
  50. package/dist/setup.log +0 -275
  51. package/dist/test-setup.js +0 -14
  52. package/dist/tools/pdf-processor.d.ts +0 -1
  53. package/dist/tools/pdf-processor.js +0 -3
  54. package/dist/tools/search.d.ts +0 -32
  55. package/dist/tools/search.js +0 -202
  56. package/dist/utils/crash-logger.d.ts +0 -18
  57. package/dist/utils/crash-logger.js +0 -44
  58. package/dist/utils/dedent.d.ts +0 -8
  59. 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";