@mearl/cloud-server 2.7.1 → 2.7.3

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/cli.js CHANGED
@@ -1,569 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ CONFIG_FILE,
4
+ CloudServer,
5
+ LOG_FILE,
6
+ MODE_FILE
7
+ } from "./chunks/chunk-DCTZIDJ7.js";
2
8
 
3
9
  // src/daemon.ts
4
10
  import { fileURLToPath } from "node:url";
5
11
  import { createDaemon, readRecord, removeRecord } from "@mearl/daemon-core";
6
-
7
- // src/index.ts
8
- import WebSocket, { WebSocketServer } from "ws";
9
- import { createServer as createHttpServer } from "node:http";
10
- import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
11
- import { randomBytes } from "node:crypto";
12
- import { networkInterfaces } from "node:os";
13
-
14
- // src/paths.ts
15
- import { join } from "node:path";
16
- import { DAEMON_HOME } from "@mearl/daemon-core";
17
- var CONFIG_DIR = DAEMON_HOME;
18
- var CONFIG_FILE = join(CONFIG_DIR, "cloud-server.json");
19
- var LOG_FILE = join(CONFIG_DIR, "cloud-server.log");
20
-
21
- // src/index.ts
22
- import {
23
- isAgentHelloMessage,
24
- isConnectorHelloMessage,
25
- isHeartbeatMessage,
26
- isCloudMessage,
27
- isCloudResponse,
28
- resolveCloudConnectorSelector,
29
- MAX_BUFFER_SIZE,
30
- DEFAULT_HEARTBEAT_TIMEOUT,
31
- DEFAULT_REQUEST_TIMEOUT
32
- } from "@mearl/cloud-types";
33
- import { resolveActionTimeoutSec } from "@mearl/client/action-timeouts";
34
- var fetchInstance = null;
35
- async function getFetch() {
36
- if (fetchInstance) {
37
- return fetchInstance;
38
- }
39
- if (typeof globalThis.fetch !== "undefined") {
40
- fetchInstance = globalThis.fetch;
41
- } else {
42
- const { default: fetch } = await import("node-fetch");
43
- fetchInstance = fetch;
44
- }
45
- return fetchInstance;
46
- }
47
- var DEFAULT_PORT = 8080;
48
- var DEFAULT_PATH = "/ws";
49
- var DEFAULT_MAX_CONNECTIONS = 100;
50
- var DEFAULT_MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
51
- var RESPONSE_GRACE_MS = 5e3;
52
- var LEGACY_CONNECTOR_HELLO_GRACE_MS = 1e3;
53
- var CloudServer = class {
54
- options;
55
- connectorWss = null;
56
- agentWss = null;
57
- connectorHttpServer = null;
58
- agentHttpServer = null;
59
- connections = /* @__PURE__ */ new Map();
60
- pendingRequests = /* @__PURE__ */ new Map();
61
- heartbeatTimer = null;
62
- constructor(options = {}) {
63
- const port = options.port ?? DEFAULT_PORT;
64
- this.options = {
65
- port,
66
- agentPort: options.agentPort ?? port + 1,
67
- path: options.path ?? DEFAULT_PATH,
68
- token: options.token,
69
- heartbeatTimeout: options.heartbeatTimeout ?? DEFAULT_HEARTBEAT_TIMEOUT,
70
- maxConnections: options.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
71
- maxMessageSize: options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE,
72
- requestTimeout: options.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
73
- allowRemoteAgent: options.allowRemoteAgent ?? false
74
- };
75
- }
76
- getToken() {
77
- if (!this.options.token) {
78
- this.options.token = this.generateRandomToken();
79
- }
80
- return this.options.token;
81
- }
82
- generateRandomToken() {
83
- return randomBytes(24).toString("base64url");
84
- }
85
- async start() {
86
- const token = this.getToken();
87
- await this.createListener(this.options.agentPort, "127.0.0.1", true);
88
- await this.createListener(this.options.port, "0.0.0.0", false);
89
- const localWsUrl = `ws://localhost:${this.options.agentPort}${this.options.path}?token=${token}`;
90
- let publicWsUrl;
91
- const sandboxUrl = await this.getSandboxPublicUrl(this.options.port);
92
- if (sandboxUrl) {
93
- try {
94
- const parsedUrl = new URL(sandboxUrl);
95
- parsedUrl.protocol = "wss:";
96
- parsedUrl.pathname = this.options.path;
97
- parsedUrl.search = `?token=${token}`;
98
- publicWsUrl = parsedUrl.toString();
99
- } catch {
100
- const httpsUrl = sandboxUrl.replace("https://", "wss://");
101
- publicWsUrl = `${httpsUrl}${this.options.path}?token=${token}`;
102
- }
103
- } else {
104
- const publicHost = process.env.CLOUD_SERVER_HOST || this.getPublicIP();
105
- publicWsUrl = `ws://${publicHost}:${this.options.port}${this.options.path}?token=${token}`;
106
- }
107
- if (publicWsUrl.startsWith("ws://") && !publicWsUrl.startsWith("ws://localhost")) {
108
- console.warn(
109
- "[CloudServer] Connector traffic is using unencrypted ws://. Use a TLS-terminating proxy and wss:// when the connection crosses a trusted network."
110
- );
111
- }
112
- const connectorCommand = `npx @mearl/cloud-connector "${publicWsUrl}"`;
113
- console.log(
114
- `[CloudServer] Connector listener on 0.0.0.0:${this.options.port} (network); agent listener on 127.0.0.1:${this.options.agentPort} (local only)`
115
- );
116
- console.log(`[CloudServer] Connect from local machine:`);
117
- console.log(` ${connectorCommand}`);
118
- this.startHeartbeatCheck();
119
- const info = {
120
- pid: process.pid,
121
- port: this.options.port,
122
- path: this.options.path,
123
- token,
124
- localUrl: localWsUrl,
125
- publicUrl: publicWsUrl,
126
- connectorCommand,
127
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
128
- };
129
- this.writeConfigFile(info);
130
- return info;
131
- }
132
- /**
133
- * Stand up one HTTP+WebSocket listener. `fromLocal` tags every connection
134
- * accepted here so handleRequest can pin command-issuing authority to the
135
- * loopback listener.
136
- */
137
- createListener(port, host, fromLocal) {
138
- return new Promise((resolve, reject) => {
139
- const httpServer = createHttpServer((req, res) => {
140
- console.log(`[CloudServer] HTTP request: ${req.method} ${req.url}`);
141
- res.writeHead(404, { "Content-Type": "text/plain" });
142
- res.end("Not Found - This is a WebSocket server");
143
- });
144
- const wss = new WebSocketServer({
145
- server: httpServer,
146
- path: this.options.path,
147
- maxPayload: this.options.maxMessageSize
148
- });
149
- wss.on("connection", (ws, req) => {
150
- console.log(
151
- `[CloudServer] New ${fromLocal ? "agent" : "connector"} connection from ${req.socket.remoteAddress}, path: ${req.url}`
152
- );
153
- this.handleConnection(ws, req, fromLocal);
154
- });
155
- wss.on("error", (error) => {
156
- console.error("[CloudServer] WebSocket server error:", error.message);
157
- });
158
- httpServer.on("error", (error) => {
159
- console.error("[CloudServer] HTTP server error:", error.message);
160
- reject(error);
161
- });
162
- if (fromLocal) {
163
- this.agentWss = wss;
164
- this.agentHttpServer = httpServer;
165
- } else {
166
- this.connectorWss = wss;
167
- this.connectorHttpServer = httpServer;
168
- }
169
- httpServer.listen(port, host, () => {
170
- resolve();
171
- });
172
- });
173
- }
174
- stop() {
175
- return new Promise((resolve) => {
176
- if (this.heartbeatTimer) {
177
- clearInterval(this.heartbeatTimer);
178
- this.heartbeatTimer = null;
179
- }
180
- for (const [ws] of this.connections) {
181
- ws.close();
182
- }
183
- this.connections.clear();
184
- for (const pending of this.pendingRequests.values()) {
185
- clearTimeout(pending.timer);
186
- }
187
- this.pendingRequests.clear();
188
- this.agentWss?.close();
189
- this.connectorWss?.close();
190
- this.agentWss = null;
191
- this.connectorWss = null;
192
- const servers = [this.agentHttpServer, this.connectorHttpServer].filter(
193
- (s) => s !== null
194
- );
195
- this.agentHttpServer = null;
196
- this.connectorHttpServer = null;
197
- if (servers.length === 0) {
198
- resolve();
199
- return;
200
- }
201
- let remaining = servers.length;
202
- for (const s of servers) {
203
- s.close(() => {
204
- if (--remaining <= 0) resolve();
205
- });
206
- }
207
- });
208
- }
209
- handleConnection(ws, req, fromLocal) {
210
- if (this.connections.size >= this.options.maxConnections) {
211
- ws.close(1008, "Server is full");
212
- return;
213
- }
214
- if (this.options.token) {
215
- const url = new URL(req.url || "", `http://${req.headers.host}`);
216
- const token = url.searchParams.get("token");
217
- if (token !== this.options.token) {
218
- ws.close(1008, "Unauthorized");
219
- return;
220
- }
221
- }
222
- const connId = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
223
- const connection = {
224
- ws,
225
- id: connId,
226
- lastHeartbeat: Date.now(),
227
- connectedAt: Date.now(),
228
- role: fromLocal ? "agent" : "pending",
229
- fromLocal,
230
- messageBuffer: ""
231
- };
232
- this.connections.set(ws, connection);
233
- console.log(`[CloudServer] Client connected: ${connId} (total: ${this.connections.size})`);
234
- ws.on("message", (data) => {
235
- const conn = this.connections.get(ws);
236
- if (!conn) return;
237
- conn.messageBuffer += data.toString("utf-8");
238
- if (conn.messageBuffer.length > MAX_BUFFER_SIZE) {
239
- console.error(
240
- `[CloudServer] Message buffer exceeds limit for ${connId}, closing connection`
241
- );
242
- ws.close(1008, "Message too large");
243
- return;
244
- }
245
- let newlineIndex;
246
- while ((newlineIndex = conn.messageBuffer.indexOf("\n")) !== -1) {
247
- const line = conn.messageBuffer.slice(0, newlineIndex);
248
- conn.messageBuffer = conn.messageBuffer.slice(newlineIndex + 1);
249
- if (!line.trim()) continue;
250
- try {
251
- const message = JSON.parse(line);
252
- this.handleMessage(ws, message);
253
- } catch (error) {
254
- console.error(`[CloudServer] Failed to parse message from ${connId}:`, line, error);
255
- }
256
- }
257
- });
258
- ws.on("close", () => {
259
- this.connections.delete(ws);
260
- this.rejectPendingForWs(ws);
261
- console.log(`[CloudServer] Client disconnected: ${connId} (total: ${this.connections.size})`);
262
- });
263
- ws.on("error", (error) => {
264
- console.error(`[CloudServer] Connection error for ${connId}:`, error.message);
265
- });
266
- }
267
- handleMessage(ws, message) {
268
- const conn = this.connections.get(ws);
269
- if (!conn) return;
270
- if (isHeartbeatMessage(message)) {
271
- if (message.type === "ping") {
272
- ws.send(
273
- JSON.stringify({ type: "pong", timestamp: Date.now() }) + "\n"
274
- );
275
- conn.lastHeartbeat = Date.now();
276
- } else if (message.type === "pong") {
277
- conn.lastHeartbeat = Date.now();
278
- }
279
- return;
280
- }
281
- if (isAgentHelloMessage(message)) {
282
- this.handleAgentHello(ws);
283
- return;
284
- }
285
- if (isConnectorHelloMessage(message)) {
286
- this.handleConnectorHello(ws, message);
287
- return;
288
- }
289
- if (isCloudResponse(message)) {
290
- this.handleResponse(ws, message);
291
- return;
292
- }
293
- if (isCloudMessage(message)) {
294
- this.handleRequest(ws, message);
295
- return;
296
- }
297
- }
298
- handleAgentHello(ws) {
299
- const conn = this.connections.get(ws);
300
- if (!conn) return;
301
- if (!conn.fromLocal && !this.options.allowRemoteAgent) {
302
- ws.close(1008, "Agent must be local");
303
- return;
304
- }
305
- conn.role = "agent";
306
- delete conn.connector;
307
- }
308
- handleConnectorHello(ws, message) {
309
- const conn = this.connections.get(ws);
310
- if (!conn || conn.fromLocal) {
311
- ws.close(1008, "Connector must use the connector listener");
312
- return;
313
- }
314
- for (const [otherWs, other] of this.connections) {
315
- if (otherWs !== ws && other.role === "connector" && other.connector?.connectorId === message.connectorId) {
316
- this.connections.delete(otherWs);
317
- this.rejectPendingForWs(otherWs);
318
- otherWs.close(1e3, "Connector replaced by a newer connection");
319
- }
320
- }
321
- conn.role = "connector";
322
- conn.connector = {
323
- connectorId: message.connectorId,
324
- name: message.name,
325
- ...message.version ? { version: message.version } : {}
326
- };
327
- console.log(
328
- `[CloudServer] Connector registered: ${message.name} (${message.connectorId}, v${message.version ?? "unknown"})`
329
- );
330
- }
331
- handleRequest(ws, message) {
332
- const { id, action, data } = message;
333
- const conn = this.connections.get(ws);
334
- if (!conn) return;
335
- if (conn.role !== "agent") {
336
- console.warn(
337
- `[CloudServer] Rejected agent request from non-local connection ${conn.id} (action: ${action})`
338
- );
339
- ws.send(
340
- JSON.stringify({
341
- id,
342
- success: false,
343
- error: this.options.allowRemoteAgent && !conn.fromLocal ? "Remote agents must identify themselves with an agent_hello message before issuing commands." : "Agent requests are only accepted on the local interface (127.0.0.1). Run @mearl/client on the same machine as cloud-server, or start the server with --allow-remote-agent to override."
344
- }) + "\n"
345
- );
346
- return;
347
- }
348
- if (action === "connector_list") {
349
- const response = {
350
- id,
351
- success: true,
352
- data: this.getConnectorList()
353
- };
354
- ws.send(JSON.stringify(response) + "\n");
355
- return;
356
- }
357
- const target = this.resolveConnector(message.connector);
358
- if ("error" in target) {
359
- ws.send(JSON.stringify({ id, success: false, error: target.error }) + "\n");
360
- return;
361
- }
362
- const routeId = randomBytes(12).toString("base64url");
363
- const requestedTimeoutSec = Number(message.timeoutSec);
364
- const timeoutSec = Number.isFinite(requestedTimeoutSec) && requestedTimeoutSec > 0 ? Math.min(requestedTimeoutSec, 3600) : resolveActionTimeoutSec(action, data, this.options.requestTimeout);
365
- const timer = setTimeout(
366
- () => {
367
- this.pendingRequests.delete(routeId);
368
- if (ws.readyState === WebSocket.OPEN) {
369
- ws.send(JSON.stringify({ id, success: false, error: "Request timeout" }) + "\n");
370
- }
371
- },
372
- timeoutSec * 1e3 + RESPONSE_GRACE_MS
373
- );
374
- this.pendingRequests.set(routeId, {
375
- agentRequestId: id,
376
- agentWs: ws,
377
- connectorWs: target.ws,
378
- timer
379
- });
380
- target.ws.send(
381
- JSON.stringify({
382
- id: routeId,
383
- action,
384
- data,
385
- browser: message.browser,
386
- timeoutSec
387
- }) + "\n"
388
- );
389
- }
390
- handleResponse(ws, response) {
391
- const conn = this.connections.get(ws);
392
- if (!conn) return;
393
- if (conn.role !== "connector") return;
394
- const pending = this.pendingRequests.get(response.id);
395
- if (!pending || pending.connectorWs !== ws) return;
396
- this.pendingRequests.delete(response.id);
397
- clearTimeout(pending.timer);
398
- if (pending.agentWs.readyState === WebSocket.OPEN) {
399
- pending.agentWs.send(
400
- JSON.stringify({ ...response, id: pending.agentRequestId }) + "\n"
401
- );
402
- }
403
- }
404
- connectorInfo(ws, conn) {
405
- const pendingRequests = Array.from(this.pendingRequests.values()).filter(
406
- (pending) => pending.connectorWs === ws
407
- ).length;
408
- return {
409
- connectorId: conn.connector?.connectorId ?? conn.id,
410
- name: conn.connector?.name ?? `legacy-${conn.id.slice(-8)}`,
411
- ...conn.connector?.version ? { version: conn.connector.version } : {},
412
- connectedAt: new Date(conn.connectedAt).toISOString(),
413
- lastHeartbeatAt: new Date(conn.lastHeartbeat).toISOString(),
414
- pendingRequests,
415
- ...!conn.connector ? { legacy: true } : {}
416
- };
417
- }
418
- getConnectorConnections() {
419
- const connectors = [];
420
- for (const [ws, conn] of this.connections) {
421
- if (conn.role === "pending" && Date.now() - conn.connectedAt >= LEGACY_CONNECTOR_HELLO_GRACE_MS) {
422
- conn.role = "connector";
423
- }
424
- if (conn.role !== "connector" || ws.readyState !== WebSocket.OPEN) continue;
425
- connectors.push({ ws, conn, info: this.connectorInfo(ws, conn) });
426
- }
427
- return connectors.sort((a, b) => a.info.name.localeCompare(b.info.name));
428
- }
429
- getConnectorList() {
430
- const connectors = this.getConnectorConnections().map(({ info }) => info);
431
- return { count: connectors.length, connectors };
432
- }
433
- resolveConnector(selector) {
434
- const connectors = this.getConnectorConnections();
435
- if (connectors.length === 0) return { error: "No local connector available" };
436
- if (!selector) {
437
- if (connectors.length === 1) {
438
- return { ws: connectors[0].ws, info: connectors[0].info };
439
- }
440
- return {
441
- error: `Multiple cloud connectors are connected (${connectors.length}). Run \`mearl connector_list\` and pass --connector <id|name>.`
442
- };
443
- }
444
- const selection = resolveCloudConnectorSelector(
445
- connectors.map(({ info }) => info),
446
- selector
447
- );
448
- if (selection.status === "matched") {
449
- const matched = connectors.find(({ info }) => info === selection.connector);
450
- return { ws: matched.ws, info: matched.info };
451
- }
452
- const available = connectors.map(({ info }) => `${info.name} [${info.connectorId}]`).join(", ");
453
- if (selection.status === "ambiguous") {
454
- return {
455
- error: `Cloud connector selector "${selector}" is ambiguous. Available: ${available}`
456
- };
457
- }
458
- return {
459
- error: `No cloud connector matches "${selector}". Available: ${available}`
460
- };
461
- }
462
- rejectPendingForWs(ws) {
463
- this.pendingRequests.forEach((pending, id) => {
464
- if (pending.agentWs !== ws && pending.connectorWs !== ws) return;
465
- clearTimeout(pending.timer);
466
- this.pendingRequests.delete(id);
467
- if (pending.connectorWs === ws && pending.agentWs !== ws && pending.agentWs.readyState === WebSocket.OPEN) {
468
- pending.agentWs.send(
469
- JSON.stringify({
470
- id: pending.agentRequestId,
471
- success: false,
472
- error: "Connector connection closed"
473
- }) + "\n"
474
- );
475
- }
476
- });
477
- }
478
- startHeartbeatCheck() {
479
- if (this.heartbeatTimer) return;
480
- this.heartbeatTimer = setInterval(() => {
481
- const now = Date.now();
482
- const timeoutMs = this.options.heartbeatTimeout * 1e3;
483
- for (const [ws, conn] of this.connections) {
484
- if (now - conn.lastHeartbeat > timeoutMs) {
485
- console.log(`[CloudServer] Client ${conn.id} heartbeat timeout, closing connection`);
486
- ws.close(1e3, "Heartbeat timeout");
487
- }
488
- }
489
- }, 3e4);
490
- }
491
- getPublicIP() {
492
- const interfaces = networkInterfaces();
493
- for (const name of Object.keys(interfaces)) {
494
- const iface = interfaces[name];
495
- if (!iface) continue;
496
- for (const addr of iface) {
497
- if (addr.family === "IPv4" && !addr.internal) {
498
- return addr.address;
499
- }
500
- }
501
- }
502
- return "localhost";
503
- }
504
- async getSandboxPublicUrl(port) {
505
- if (!process.env.AONE_SANDBOX_ID) {
506
- return null;
507
- }
508
- const mappingApiUrl = "http://localhost:58596/api/port-mapping";
509
- try {
510
- const fetch = await getFetch();
511
- const response = await fetch(`${mappingApiUrl}?port=${port}`);
512
- if (!response.ok) {
513
- return null;
514
- }
515
- const data = await response.json();
516
- if (data.success && data.url) {
517
- return data.url;
518
- }
519
- } catch (error) {
520
- console.error(
521
- `[CloudServer] Failed to query sandbox port mapping:`,
522
- error instanceof Error ? error.message : String(error)
523
- );
524
- }
525
- return null;
526
- }
527
- writeConfigFile(info) {
528
- try {
529
- if (!existsSync(CONFIG_DIR)) {
530
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
531
- }
532
- const config = { server: info.localUrl, ...info };
533
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
534
- encoding: "utf-8",
535
- mode: 384
536
- });
537
- if (process.platform !== "win32") {
538
- chmodSync(CONFIG_DIR, 448);
539
- chmodSync(CONFIG_FILE, 384);
540
- }
541
- console.log(`[CloudServer] Config written to: ${CONFIG_FILE}`);
542
- } catch (error) {
543
- console.error("[CloudServer] Failed to write config file:", error);
544
- }
545
- }
546
- getStats() {
547
- let agents = 0;
548
- for (const conn of this.connections.values()) {
549
- if (conn.role === "agent") agents++;
550
- }
551
- return {
552
- connections: this.connections.size,
553
- agents,
554
- connectors: this.getConnectorConnections().length,
555
- pendingRequests: this.pendingRequests.size
556
- };
557
- }
558
- };
559
-
560
- // src/daemon.ts
561
12
  var SCRIPT_PATH = fileURLToPath(import.meta.url);
562
13
  function formatConnectInfo(record) {
563
14
  const config = record;
564
15
  const lines = [];
565
16
  if (config.port) lines.push(` port: ${config.port}`);
566
- const command2 = config.connectorCommand || (config.publicUrl ? `npx @mearl/cloud-connector "${config.publicUrl}"` : null);
17
+ const command2 = config.publicUrl ? `npx @mearl/cloud-connector start "${config.publicUrl}"` : config.connectorCommand;
567
18
  if (command2) {
568
19
  lines.push("");
569
20
  lines.push(" Connect from your local machine:");
@@ -581,6 +32,9 @@ function daemon(foregroundArgs) {
581
32
  formatInfo: formatConnectInfo
582
33
  });
583
34
  }
35
+ function isDaemonRunning() {
36
+ return daemon([]).running() !== null;
37
+ }
584
38
  function startDaemon(passthroughArgs) {
585
39
  return daemon(passthroughArgs).start();
586
40
  }
@@ -619,8 +73,107 @@ async function runForeground(options) {
619
73
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
620
74
  }
621
75
 
76
+ // src/mode.ts
77
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
78
+ import { dirname } from "node:path";
79
+ var CLOUD_SERVER_MODE_ENV = "MEARL_CLOUD_MODE";
80
+ function isCloudServerMode(value) {
81
+ return value === "websocket" || value === "qoder";
82
+ }
83
+ function readStoredMode(storage) {
84
+ const modeFile = storage.modeFile ?? MODE_FILE;
85
+ const readText = storage.readText ?? ((filePath) => readFileSync(filePath, "utf8"));
86
+ try {
87
+ const value = JSON.parse(readText(modeFile));
88
+ return isCloudServerMode(value.mode) ? value.mode : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+ function writeStoredMode(mode, storage) {
94
+ const modeFile = storage.modeFile ?? MODE_FILE;
95
+ const writeText = storage.writeText ?? ((filePath, value) => {
96
+ mkdirSync(dirname(filePath), { recursive: true });
97
+ writeFileSync(filePath, value, { encoding: "utf8", mode: 384 });
98
+ });
99
+ writeText(modeFile, `${JSON.stringify({ mode })}
100
+ `);
101
+ }
102
+ function clearStoredMode(storage) {
103
+ const modeFile = storage.modeFile ?? MODE_FILE;
104
+ const removeFile = storage.removeFile ?? ((filePath) => rmSync(filePath, { force: true }));
105
+ removeFile(modeFile);
106
+ }
107
+ function resolveCloudServerMode(env = process.env, storage = {}) {
108
+ const requested = env[CLOUD_SERVER_MODE_ENV];
109
+ if (requested !== void 0) {
110
+ if (!isCloudServerMode(requested)) {
111
+ throw new Error(`${CLOUD_SERVER_MODE_ENV} must be "websocket" or "qoder"`);
112
+ }
113
+ if (requested === "qoder") writeStoredMode(requested, storage);
114
+ else clearStoredMode(storage);
115
+ return requested;
116
+ }
117
+ return readStoredMode(storage) ?? "websocket";
118
+ }
119
+
120
+ // src/qoder.ts
121
+ import { randomBytes } from "node:crypto";
122
+ function createQoderPairingInfo(options = {}) {
123
+ const pairingCode = (options.createCode ?? (() => randomBytes(24).toString("base64url")))();
124
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(pairingCode)) {
125
+ throw new Error("Qoder pairing code must be 16-128 URL-safe characters");
126
+ }
127
+ const createdAt = (options.now ?? Date.now)();
128
+ if (!Number.isSafeInteger(createdAt) || createdAt < 1e12 || createdAt > 9999999999999) {
129
+ throw new Error("Qoder pairing timestamp must be a 13-digit epoch millisecond value");
130
+ }
131
+ return {
132
+ pairingCode,
133
+ createdAt,
134
+ connectorUrl: `qoder://pair/${pairingCode}?created=${createdAt}`,
135
+ toolInput: { action: "pair", payload: { code: pairingCode } }
136
+ };
137
+ }
138
+ function formatQoderPairingOutput(info) {
139
+ const connectorCommand = `npx @mearl/cloud-connector start "${info.connectorUrl}"`;
140
+ return `[CloudServer] Qoder pairing mode is ready.
141
+
142
+ Connect from your local machine:
143
+ ${connectorCommand}
144
+
145
+ Then immediately invoke the Qoder client-side custom tool named "mearl" with this input:
146
+ ${JSON.stringify(info.toolInput)}
147
+
148
+ The connector resolves QODER_PERSONAL_ACCESS_TOKEN from the local environment, ~/.mearl/.env, or supported local credential storage. Never add the PAT to the pairing URL.`;
149
+ }
150
+ async function runQoderCloudServerCommand(command2, dependencies) {
151
+ const writeLine = dependencies.writeLine ?? console.log;
152
+ switch (command2) {
153
+ case "start":
154
+ case "restart":
155
+ if (dependencies.isServerRunning()) await dependencies.stopServer();
156
+ writeLine(
157
+ formatQoderPairingOutput(
158
+ (dependencies.createPairing ?? (() => createQoderPairingInfo()))()
159
+ )
160
+ );
161
+ break;
162
+ case "status":
163
+ dependencies.printStatus();
164
+ writeLine("[CloudServer] Mode: qoder pairing (no listening service)");
165
+ break;
166
+ case "stop":
167
+ await dependencies.stopServer();
168
+ break;
169
+ case "logs":
170
+ dependencies.printLogs();
171
+ break;
172
+ }
173
+ }
174
+
622
175
  // src/cli.ts
623
- var CLOUD_SERVER_VERSION = true ? "2.7.1" : "unknown";
176
+ var CLOUD_SERVER_VERSION = true ? "2.7.3" : "unknown";
624
177
  var argv = process.argv.slice(2);
625
178
  if (argv.includes("--version") || argv.includes("-v")) {
626
179
  console.log(CLOUD_SERVER_VERSION);
@@ -712,6 +265,16 @@ function parseOptions(args) {
712
265
  return options;
713
266
  }
714
267
  async function main() {
268
+ const mode = resolveCloudServerMode();
269
+ if (mode === "qoder") {
270
+ await runQoderCloudServerCommand(command, {
271
+ isServerRunning: isDaemonRunning,
272
+ stopServer: stopDaemon,
273
+ printStatus: statusDaemon,
274
+ printLogs
275
+ });
276
+ return;
277
+ }
715
278
  if (foreground) {
716
279
  await runForeground(parseOptions(rest));
717
280
  return;