@cmdctrl/cursor-cli 0.1.1
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/adapter/cursor-cli.d.ts +36 -0
- package/dist/adapter/cursor-cli.d.ts.map +1 -0
- package/dist/adapter/cursor-cli.js +332 -0
- package/dist/adapter/cursor-cli.js.map +1 -0
- package/dist/adapter/events.d.ts +33 -0
- package/dist/adapter/events.d.ts.map +1 -0
- package/dist/adapter/events.js +53 -0
- package/dist/adapter/events.js.map +1 -0
- package/dist/client/messages.d.ts +50 -0
- package/dist/client/messages.d.ts.map +1 -0
- package/dist/client/messages.js +6 -0
- package/dist/client/messages.js.map +1 -0
- package/dist/client/websocket.d.ts +69 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/client/websocket.js +272 -0
- package/dist/client/websocket.js.map +1 -0
- package/dist/commands/register.d.ts +10 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +173 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/start.d.ts +9 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +49 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +39 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +5 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +42 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/config/config.d.ts +60 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +176 -0
- package/dist/config/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/src/adapter/cursor-cli.ts +370 -0
- package/src/adapter/events.ts +77 -0
- package/src/client/messages.ts +75 -0
- package/src/client/websocket.ts +308 -0
- package/src/commands/register.ts +199 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +46 -0
- package/src/commands/stop.ts +47 -0
- package/src/commands/update.ts +73 -0
- package/src/config/config.ts +146 -0
- package/src/index.ts +56 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { CmdCtrlConfig, Credentials } from '../config/config';
|
|
4
|
+
import { CursorAdapter } from '../adapter/cursor-cli';
|
|
5
|
+
import {
|
|
6
|
+
ServerMessage,
|
|
7
|
+
DaemonMessage,
|
|
8
|
+
TaskStartMessage,
|
|
9
|
+
TaskResumeMessage,
|
|
10
|
+
TaskCancelMessage,
|
|
11
|
+
VersionStatusMessage,
|
|
12
|
+
} from './messages';
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
|
|
16
|
+
const MAX_RECONNECT_DELAY = 30000; // 30 seconds
|
|
17
|
+
const INITIAL_RECONNECT_DELAY = 1000; // 1 second
|
|
18
|
+
const PING_INTERVAL = 30000; // 30 seconds
|
|
19
|
+
|
|
20
|
+
export class DaemonClient {
|
|
21
|
+
private ws: WebSocket | null = null;
|
|
22
|
+
private config: CmdCtrlConfig;
|
|
23
|
+
private credentials: Credentials;
|
|
24
|
+
private reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
25
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
26
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
27
|
+
private shouldReconnect = true;
|
|
28
|
+
private adapter: CursorAdapter;
|
|
29
|
+
|
|
30
|
+
constructor(config: CmdCtrlConfig, credentials: Credentials) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.credentials = credentials;
|
|
33
|
+
this.adapter = new CursorAdapter(this.sendEvent.bind(this));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Connect to the CmdCtrl server via WebSocket
|
|
38
|
+
*/
|
|
39
|
+
async connect(): Promise<void> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const serverUrl = new URL(this.config.serverUrl);
|
|
42
|
+
const wsProtocol = serverUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
43
|
+
const wsUrl = `${wsProtocol}//${serverUrl.host}/ws/daemon`;
|
|
44
|
+
|
|
45
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
46
|
+
|
|
47
|
+
// Read version from package.json
|
|
48
|
+
let daemonVersion = 'unknown';
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
51
|
+
daemonVersion = pkg.version;
|
|
52
|
+
} catch {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
55
|
+
daemonVersion = pkg.version;
|
|
56
|
+
} catch { /* use default */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.ws = new WebSocket(wsUrl, {
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
62
|
+
'X-Device-ID': this.config.deviceId,
|
|
63
|
+
'X-Agent-Type': 'cursor_cli',
|
|
64
|
+
'X-Daemon-Version': daemonVersion,
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
this.ws.on('open', () => {
|
|
69
|
+
console.log('WebSocket connected');
|
|
70
|
+
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
|
|
71
|
+
this.startPingInterval();
|
|
72
|
+
this.sendStatus();
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.ws.on('message', (data) => {
|
|
77
|
+
this.handleMessage(data.toString());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
this.ws.on('close', (code, reason) => {
|
|
81
|
+
console.log(`WebSocket closed: ${code} ${reason}`);
|
|
82
|
+
this.stopPingInterval();
|
|
83
|
+
this.scheduleReconnect();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.ws.on('error', (err) => {
|
|
87
|
+
console.error('WebSocket error:', err.message);
|
|
88
|
+
if (this.ws?.readyState === WebSocket.CONNECTING) {
|
|
89
|
+
reject(err);
|
|
90
|
+
}
|
|
91
|
+
// For established connections, the 'close' event usually follows 'error',
|
|
92
|
+
// but if the connection was killed abruptly, we may not get a clean close.
|
|
93
|
+
// Force close the socket to ensure 'close' event fires and triggers reconnect.
|
|
94
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
95
|
+
this.ws.terminate();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Disconnect from server
|
|
103
|
+
*/
|
|
104
|
+
async disconnect(): Promise<void> {
|
|
105
|
+
this.shouldReconnect = false;
|
|
106
|
+
|
|
107
|
+
if (this.reconnectTimer) {
|
|
108
|
+
clearTimeout(this.reconnectTimer);
|
|
109
|
+
this.reconnectTimer = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.stopPingInterval();
|
|
113
|
+
|
|
114
|
+
// Stop all running tasks
|
|
115
|
+
await this.adapter.stopAll();
|
|
116
|
+
|
|
117
|
+
if (this.ws) {
|
|
118
|
+
this.ws.close(1000, 'Daemon shutting down');
|
|
119
|
+
this.ws = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send a message to the server
|
|
125
|
+
*/
|
|
126
|
+
private send(message: DaemonMessage): void {
|
|
127
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
128
|
+
this.ws.send(JSON.stringify(message));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Send an event for a task
|
|
134
|
+
*/
|
|
135
|
+
private sendEvent(
|
|
136
|
+
taskId: string,
|
|
137
|
+
eventType: string,
|
|
138
|
+
data: Record<string, unknown>
|
|
139
|
+
): void {
|
|
140
|
+
this.send({
|
|
141
|
+
type: 'event',
|
|
142
|
+
task_id: taskId,
|
|
143
|
+
event_type: eventType,
|
|
144
|
+
...data
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Send current status to server
|
|
150
|
+
*/
|
|
151
|
+
private sendStatus(): void {
|
|
152
|
+
this.send({
|
|
153
|
+
type: 'status',
|
|
154
|
+
running_tasks: this.adapter.getRunningTasks()
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle incoming message from server
|
|
160
|
+
*/
|
|
161
|
+
private handleMessage(raw: string): void {
|
|
162
|
+
let msg: ServerMessage;
|
|
163
|
+
try {
|
|
164
|
+
msg = JSON.parse(raw) as ServerMessage;
|
|
165
|
+
} catch {
|
|
166
|
+
console.error('Failed to parse message:', raw);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
switch (msg.type) {
|
|
171
|
+
case 'ping':
|
|
172
|
+
this.send({ type: 'pong' });
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case 'task_start':
|
|
176
|
+
this.handleTaskStart(msg as TaskStartMessage);
|
|
177
|
+
break;
|
|
178
|
+
|
|
179
|
+
case 'task_resume':
|
|
180
|
+
this.handleTaskResume(msg as TaskResumeMessage);
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case 'task_cancel':
|
|
184
|
+
this.handleTaskCancel(msg as TaskCancelMessage);
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case 'version_status':
|
|
188
|
+
this.handleVersionStatus(msg as VersionStatusMessage);
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
default:
|
|
192
|
+
console.warn('Unknown message type:', (msg as { type: string }).type);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Handle task_start message
|
|
198
|
+
*/
|
|
199
|
+
private async handleTaskStart(msg: TaskStartMessage): Promise<void> {
|
|
200
|
+
console.log(`Starting task ${msg.task_id}: ${msg.instruction.substring(0, 50)}...`);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
await this.adapter.startTask(msg.task_id, msg.instruction, msg.project_path);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`Failed to start task ${msg.task_id}:`, err);
|
|
206
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
207
|
+
error: (err as Error).message
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Handle task_resume message
|
|
214
|
+
*/
|
|
215
|
+
private async handleTaskResume(msg: TaskResumeMessage): Promise<void> {
|
|
216
|
+
console.log(`Resuming task ${msg.task_id} with session ${msg.session_id}`);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await this.adapter.resumeTask(msg.task_id, msg.session_id, msg.message, msg.project_path);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
console.error(`Failed to resume task ${msg.task_id}:`, err);
|
|
222
|
+
this.sendEvent(msg.task_id, 'ERROR', {
|
|
223
|
+
error: (err as Error).message
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Handle task_cancel message
|
|
230
|
+
*/
|
|
231
|
+
private async handleTaskCancel(msg: TaskCancelMessage): Promise<void> {
|
|
232
|
+
console.log(`Cancelling task ${msg.task_id}`);
|
|
233
|
+
await this.adapter.cancelTask(msg.task_id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Handle version_status message from server
|
|
238
|
+
*/
|
|
239
|
+
private handleVersionStatus(msg: VersionStatusMessage): void {
|
|
240
|
+
if (msg.status === 'update_required') {
|
|
241
|
+
console.error(`\n✖ Daemon version ${msg.your_version} is no longer supported (minimum: ${msg.min_version})`);
|
|
242
|
+
console.error(` Run: cmdctrl-cursor-cli update`);
|
|
243
|
+
if (msg.changelog_url) console.error(` Changelog: ${msg.changelog_url}`);
|
|
244
|
+
if (msg.message) console.error(` ${msg.message}`);
|
|
245
|
+
console.error('');
|
|
246
|
+
this.shouldReconnect = false;
|
|
247
|
+
process.exit(1);
|
|
248
|
+
} else if (msg.status === 'update_available') {
|
|
249
|
+
console.warn(`\n⚠ Update available: v${msg.latest_version} (you have v${msg.your_version})`);
|
|
250
|
+
console.warn(` Run: cmdctrl-cursor-cli update`);
|
|
251
|
+
if (msg.changelog_url) console.warn(` Changelog: ${msg.changelog_url}`);
|
|
252
|
+
console.warn('');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Schedule reconnection with exponential backoff
|
|
258
|
+
*/
|
|
259
|
+
private scheduleReconnect(): void {
|
|
260
|
+
if (!this.shouldReconnect) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(`Reconnecting in ${this.reconnectDelay / 1000}s...`);
|
|
265
|
+
|
|
266
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
267
|
+
try {
|
|
268
|
+
await this.connect();
|
|
269
|
+
} catch {
|
|
270
|
+
// Increase delay with exponential backoff
|
|
271
|
+
this.reconnectDelay = Math.min(
|
|
272
|
+
this.reconnectDelay * 2,
|
|
273
|
+
MAX_RECONNECT_DELAY
|
|
274
|
+
);
|
|
275
|
+
this.scheduleReconnect();
|
|
276
|
+
}
|
|
277
|
+
}, this.reconnectDelay);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Start ping interval to keep connection alive
|
|
282
|
+
*/
|
|
283
|
+
private startPingInterval(): void {
|
|
284
|
+
this.pingTimer = setInterval(() => {
|
|
285
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
286
|
+
this.ws.ping();
|
|
287
|
+
}
|
|
288
|
+
}, PING_INTERVAL);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Stop ping interval
|
|
293
|
+
*/
|
|
294
|
+
private stopPingInterval(): void {
|
|
295
|
+
if (this.pingTimer) {
|
|
296
|
+
clearInterval(this.pingTimer);
|
|
297
|
+
this.pingTimer = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Refresh access token using refresh token
|
|
303
|
+
*/
|
|
304
|
+
async refreshToken(): Promise<boolean> {
|
|
305
|
+
console.log('Token refresh not yet implemented');
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import * as https from 'https';
|
|
4
|
+
import { URL } from 'url';
|
|
5
|
+
import {
|
|
6
|
+
writeConfig,
|
|
7
|
+
writeCredentials,
|
|
8
|
+
readConfig,
|
|
9
|
+
isRegistered,
|
|
10
|
+
CmdCtrlConfig,
|
|
11
|
+
Credentials
|
|
12
|
+
} from '../config/config';
|
|
13
|
+
|
|
14
|
+
interface RegisterOptions {
|
|
15
|
+
server: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DeviceCodeResponse {
|
|
20
|
+
deviceCode: string;
|
|
21
|
+
userCode: string;
|
|
22
|
+
verificationUrl: string;
|
|
23
|
+
expiresIn: number;
|
|
24
|
+
interval: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TokenResponse {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
refreshToken: string;
|
|
30
|
+
expiresIn: number;
|
|
31
|
+
deviceId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Make an HTTP(S) request
|
|
36
|
+
*/
|
|
37
|
+
function request(
|
|
38
|
+
url: string,
|
|
39
|
+
method: string,
|
|
40
|
+
body?: object
|
|
41
|
+
): Promise<{ status: number; data: unknown }> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const parsed = new URL(url);
|
|
44
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
45
|
+
|
|
46
|
+
const options = {
|
|
47
|
+
hostname: parsed.hostname,
|
|
48
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
49
|
+
path: parsed.pathname + parsed.search,
|
|
50
|
+
method,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
Accept: 'application/json'
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const req = client.request(options, (res) => {
|
|
58
|
+
let data = '';
|
|
59
|
+
res.on('data', (chunk) => (data += chunk));
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
63
|
+
resolve({ status: res.statusCode || 0, data: parsed });
|
|
64
|
+
} catch {
|
|
65
|
+
resolve({ status: res.statusCode || 0, data: {} });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
|
|
72
|
+
if (body) {
|
|
73
|
+
req.write(JSON.stringify(body));
|
|
74
|
+
}
|
|
75
|
+
req.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Poll for token completion (after user verifies in browser)
|
|
81
|
+
*/
|
|
82
|
+
async function pollForToken(
|
|
83
|
+
serverUrl: string,
|
|
84
|
+
deviceCode: string,
|
|
85
|
+
interval: number,
|
|
86
|
+
expiresIn: number
|
|
87
|
+
): Promise<TokenResponse | null> {
|
|
88
|
+
const startTime = Date.now();
|
|
89
|
+
const expiresAt = startTime + expiresIn * 1000;
|
|
90
|
+
|
|
91
|
+
while (Date.now() < expiresAt) {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await request(`${serverUrl}/api/devices/token`, 'POST', {
|
|
96
|
+
deviceCode
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (response.status === 200) {
|
|
100
|
+
return response.data as TokenResponse;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 400 with "authorization_pending" means keep polling
|
|
104
|
+
const data = response.data as { error?: string };
|
|
105
|
+
if (response.status === 400 && data.error === 'authorization_pending') {
|
|
106
|
+
process.stdout.write('.');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Other errors should stop polling
|
|
111
|
+
if (response.status >= 400) {
|
|
112
|
+
console.error('\nError polling for token:', data);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('\nError polling for token:', err);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.error('\nDevice code expired. Please try again.');
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Register command - implements GitHub CLI style device auth flow
|
|
127
|
+
*/
|
|
128
|
+
export async function register(options: RegisterOptions): Promise<void> {
|
|
129
|
+
const serverUrl = options.server.replace(/\/$/, ''); // Remove trailing slash
|
|
130
|
+
const deviceName = options.name || `${os.hostname()}-cursor`;
|
|
131
|
+
|
|
132
|
+
// Check if already registered
|
|
133
|
+
if (isRegistered()) {
|
|
134
|
+
const config = readConfig();
|
|
135
|
+
console.log(`Already registered as "${config?.deviceName}" (${config?.deviceId})`);
|
|
136
|
+
console.log(`Server: ${config?.serverUrl}`);
|
|
137
|
+
console.log(`\nTo re-register, run: ${process.argv[1]} unregister`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`Registering Cursor CLI device "${deviceName}" with ${serverUrl}...\n`);
|
|
142
|
+
|
|
143
|
+
// Step 1: Request device code
|
|
144
|
+
let codeResponse: DeviceCodeResponse;
|
|
145
|
+
try {
|
|
146
|
+
const response = await request(`${serverUrl}/api/devices/code`, 'POST', {
|
|
147
|
+
deviceName,
|
|
148
|
+
hostname: os.hostname(),
|
|
149
|
+
agentType: 'cursor_cli'
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (response.status !== 200) {
|
|
153
|
+
console.error('Failed to get device code:', response.data);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
codeResponse = response.data as DeviceCodeResponse;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error('Failed to connect to server:', err);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 2: Display instructions to user
|
|
164
|
+
console.log('To complete registration, open this URL in your browser:\n');
|
|
165
|
+
console.log(` ${codeResponse.verificationUrl}\n`);
|
|
166
|
+
console.log('Waiting for verification...');
|
|
167
|
+
|
|
168
|
+
// Step 3: Poll for completion
|
|
169
|
+
const tokenResponse = await pollForToken(
|
|
170
|
+
serverUrl,
|
|
171
|
+
codeResponse.deviceCode,
|
|
172
|
+
codeResponse.interval,
|
|
173
|
+
codeResponse.expiresIn
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!tokenResponse) {
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Step 4: Save config and credentials
|
|
181
|
+
const config: CmdCtrlConfig = {
|
|
182
|
+
serverUrl,
|
|
183
|
+
deviceId: tokenResponse.deviceId,
|
|
184
|
+
deviceName
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const credentials: Credentials = {
|
|
188
|
+
accessToken: tokenResponse.accessToken,
|
|
189
|
+
refreshToken: tokenResponse.refreshToken,
|
|
190
|
+
expiresAt: Date.now() + tokenResponse.expiresIn * 1000
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
writeConfig(config);
|
|
194
|
+
writeCredentials(credentials);
|
|
195
|
+
|
|
196
|
+
console.log('\n\nRegistration complete!');
|
|
197
|
+
console.log(`Device ID: ${tokenResponse.deviceId}`);
|
|
198
|
+
console.log(`\nRun 'cmdctrl-cursor-cli start' to connect to the server.`);
|
|
199
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readConfig,
|
|
3
|
+
readCredentials,
|
|
4
|
+
isRegistered,
|
|
5
|
+
writePidFile,
|
|
6
|
+
isDaemonRunning
|
|
7
|
+
} from '../config/config';
|
|
8
|
+
import { DaemonClient } from '../client/websocket';
|
|
9
|
+
|
|
10
|
+
interface StartOptions {
|
|
11
|
+
foreground?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Start the daemon
|
|
16
|
+
*/
|
|
17
|
+
export async function start(options: StartOptions): Promise<void> {
|
|
18
|
+
// Check if registered
|
|
19
|
+
if (!isRegistered()) {
|
|
20
|
+
console.error('Device not registered. Run "cmdctrl-cursor-cli-daemon register" first.');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check if already running
|
|
25
|
+
if (isDaemonRunning()) {
|
|
26
|
+
console.error('Daemon is already running. Use "cmdctrl-cursor-cli-daemon stop" to stop it.');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config = readConfig()!;
|
|
31
|
+
const credentials = readCredentials()!;
|
|
32
|
+
|
|
33
|
+
console.log(`Starting Cursor CLI daemon for device "${config.deviceName}"...`);
|
|
34
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
35
|
+
|
|
36
|
+
// Write PID file
|
|
37
|
+
writePidFile(process.pid);
|
|
38
|
+
|
|
39
|
+
// Create and connect client
|
|
40
|
+
const client = new DaemonClient(config, credentials);
|
|
41
|
+
|
|
42
|
+
// Handle shutdown signals
|
|
43
|
+
const shutdown = async (signal: string) => {
|
|
44
|
+
console.log(`\nReceived ${signal}, shutting down...`);
|
|
45
|
+
await client.disconnect();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
50
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await client.connect();
|
|
54
|
+
console.log('Cursor CLI daemon connected and ready for tasks.');
|
|
55
|
+
|
|
56
|
+
// In foreground mode, just keep running
|
|
57
|
+
if (options.foreground) {
|
|
58
|
+
console.log('Running in foreground. Press Ctrl+C to stop.');
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('Failed to connect:', err);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readConfig,
|
|
3
|
+
readCredentials,
|
|
4
|
+
isRegistered,
|
|
5
|
+
isDaemonRunning,
|
|
6
|
+
readPidFile
|
|
7
|
+
} from '../config/config';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Show daemon status
|
|
11
|
+
*/
|
|
12
|
+
export async function status(): Promise<void> {
|
|
13
|
+
if (!isRegistered()) {
|
|
14
|
+
console.log('Status: Not registered');
|
|
15
|
+
console.log('\nRun "cmdctrl-cursor-cli-daemon register" to register this device.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = readConfig()!;
|
|
20
|
+
const credentials = readCredentials();
|
|
21
|
+
const running = isDaemonRunning();
|
|
22
|
+
const pid = readPidFile();
|
|
23
|
+
|
|
24
|
+
console.log('Cursor CLI Daemon Status');
|
|
25
|
+
console.log('========================');
|
|
26
|
+
console.log(`Device Name: ${config.deviceName}`);
|
|
27
|
+
console.log(`Device ID: ${config.deviceId}`);
|
|
28
|
+
console.log(`Server: ${config.serverUrl}`);
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(`Daemon: ${running ? `Running (PID ${pid})` : 'Stopped'}`);
|
|
31
|
+
|
|
32
|
+
if (credentials) {
|
|
33
|
+
const expiresIn = credentials.expiresAt - Date.now();
|
|
34
|
+
const expired = expiresIn <= 0;
|
|
35
|
+
console.log(`Token: ${expired ? 'Expired' : 'Valid'}`);
|
|
36
|
+
if (!expired) {
|
|
37
|
+
const hours = Math.floor(expiresIn / 1000 / 60 / 60);
|
|
38
|
+
const minutes = Math.floor((expiresIn / 1000 / 60) % 60);
|
|
39
|
+
console.log(`Expires in: ${hours}h ${minutes}m`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!running) {
|
|
44
|
+
console.log('\nRun "cmdctrl-cursor-cli-daemon start" to start the daemon.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDaemonRunning,
|
|
3
|
+
readPidFile,
|
|
4
|
+
deletePidFile
|
|
5
|
+
} from '../config/config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stop the daemon
|
|
9
|
+
*/
|
|
10
|
+
export async function stop(): Promise<void> {
|
|
11
|
+
if (!isDaemonRunning()) {
|
|
12
|
+
console.log('Daemon is not running.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pid = readPidFile();
|
|
17
|
+
if (!pid) {
|
|
18
|
+
console.log('Could not find daemon PID.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log(`Stopping Cursor CLI daemon (PID ${pid})...`);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Send SIGTERM to gracefully shutdown
|
|
26
|
+
process.kill(pid, 'SIGTERM');
|
|
27
|
+
|
|
28
|
+
// Wait a bit for graceful shutdown
|
|
29
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
30
|
+
|
|
31
|
+
// Check if still running
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
// Still running, send SIGKILL
|
|
35
|
+
console.log('Daemon not responding, force killing...');
|
|
36
|
+
process.kill(pid, 'SIGKILL');
|
|
37
|
+
} catch {
|
|
38
|
+
// Process is gone
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
deletePidFile();
|
|
42
|
+
console.log('Daemon stopped.');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Failed to stop daemon:', err);
|
|
45
|
+
deletePidFile();
|
|
46
|
+
}
|
|
47
|
+
}
|