@anusornneal/chat-relay 0.7.2 → 0.8.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.
@@ -0,0 +1,109 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ const POWERSHELL_TIMEOUT_MS = 15_000;
8
+ const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop-worker.ps1");
9
+
10
+ export class PersistentDesktopRunner {
11
+ constructor(options = {}) {
12
+ this.platform = options.platform ?? process.platform;
13
+ this.timeoutMs = Number(options.timeoutMs ?? POWERSHELL_TIMEOUT_MS);
14
+ this.process = null;
15
+ this.stdout = null;
16
+ this.pending = new Map();
17
+ this.stderr = "";
18
+ }
19
+
20
+ ensureStarted() {
21
+ if (this.platform !== "win32") return false;
22
+ if (this.process && !this.process.killed) return true;
23
+
24
+ const child = spawn("powershell.exe", [
25
+ "-NoLogo",
26
+ "-NoProfile",
27
+ "-NonInteractive",
28
+ "-Sta",
29
+ "-ExecutionPolicy",
30
+ "Bypass",
31
+ "-File",
32
+ helperPath,
33
+ ], {
34
+ windowsHide: true,
35
+ stdio: ["pipe", "pipe", "pipe"],
36
+ });
37
+
38
+ this.process = child;
39
+ this.stderr = "";
40
+ this.stdout = readline.createInterface({ input: child.stdout });
41
+
42
+ this.stdout.on("line", (line) => {
43
+ let message;
44
+ try {
45
+ message = JSON.parse(line);
46
+ } catch {
47
+ return;
48
+ }
49
+ const pending = this.pending.get(message?.id);
50
+ if (!pending) return;
51
+ clearTimeout(pending.timer);
52
+ this.pending.delete(message.id);
53
+ pending.resolve(message.result ?? { ok: false, error: "desktop_worker_error" });
54
+ });
55
+
56
+ child.stderr.on("data", (chunk) => {
57
+ this.stderr = (this.stderr + String(chunk)).slice(-8192);
58
+ });
59
+
60
+ child.on("exit", () => {
61
+ const error = { ok: false, error: "desktop_worker_exited" };
62
+ for (const pending of this.pending.values()) {
63
+ clearTimeout(pending.timer);
64
+ pending.resolve(error);
65
+ }
66
+ this.pending.clear();
67
+ this.stdout?.close();
68
+ this.stdout = null;
69
+ this.process = null;
70
+ });
71
+
72
+ child.on("error", () => {
73
+ // exit handler settles pending work.
74
+ });
75
+
76
+ return true;
77
+ }
78
+
79
+ async run(operation, args) {
80
+ if (!this.ensureStarted() || !this.process?.stdin?.writable) {
81
+ return { ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" };
82
+ }
83
+
84
+ const id = randomUUID();
85
+ const request = JSON.stringify({ id, operation, args: args || {} }) + "\n";
86
+
87
+ return new Promise((resolve) => {
88
+ const timer = setTimeout(() => {
89
+ this.pending.delete(id);
90
+ resolve({ ok: false, error: operation === "screenshot" ? "capture_timeout" : "input_timeout" });
91
+ }, this.timeoutMs);
92
+
93
+ this.pending.set(id, { resolve, timer });
94
+ this.process.stdin.write(request, (error) => {
95
+ if (!error) return;
96
+ clearTimeout(timer);
97
+ this.pending.delete(id);
98
+ resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
99
+ });
100
+ });
101
+ }
102
+
103
+ close() {
104
+ try { this.stdout?.close(); } catch {}
105
+ try { this.process?.kill(); } catch {}
106
+ this.stdout = null;
107
+ this.process = null;
108
+ }
109
+ }
@@ -8,7 +8,9 @@ Add-Type -AssemblyName System.Drawing
8
8
 
9
9
  Add-Type @"
10
10
  using System;
11
+ using System.Collections.Generic;
11
12
  using System.Runtime.InteropServices;
13
+ using System.Text;
12
14
 
13
15
  public static class ChatRelayNative {
14
16
  public const uint INPUT_MOUSE = 0;
@@ -47,10 +49,78 @@ public static class ChatRelayNative {
47
49
  public INPUTUNION U;
48
50
  }
49
51
 
52
+ [StructLayout(LayoutKind.Sequential)]
53
+ public struct RECT {
54
+ public int Left;
55
+ public int Top;
56
+ public int Right;
57
+ public int Bottom;
58
+ }
59
+
60
+ public sealed class WindowInfo {
61
+ public string WindowId;
62
+ public uint ProcessId;
63
+ public string Title;
64
+ public int X;
65
+ public int Y;
66
+ public int Width;
67
+ public int Height;
68
+ public bool IsForeground;
69
+ }
70
+
71
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
72
+
50
73
  [DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
51
74
  [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
52
75
  [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
53
76
  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
77
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
78
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
79
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
80
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextLength(IntPtr hWnd);
81
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxCount);
82
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
83
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
84
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
85
+ [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
86
+
87
+ public static WindowInfo[] ListWindows(int limit) {
88
+ var items = new List<WindowInfo>();
89
+ var foreground = GetForegroundWindow();
90
+ EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
91
+ if (items.Count >= limit) return false;
92
+ if (!IsWindowVisible(hWnd)) return true;
93
+ int titleLength = GetWindowTextLength(hWnd);
94
+ if (titleLength <= 0) return true;
95
+ var title = new StringBuilder(titleLength + 1);
96
+ if (GetWindowText(hWnd, title, title.Capacity) <= 0) return true;
97
+ uint pid;
98
+ GetWindowThreadProcessId(hWnd, out pid);
99
+ RECT rect;
100
+ if (!GetWindowRect(hWnd, out rect)) return true;
101
+ items.Add(new WindowInfo {
102
+ WindowId = unchecked((ulong)hWnd.ToInt64()).ToString(),
103
+ ProcessId = pid,
104
+ Title = title.ToString(),
105
+ X = rect.Left,
106
+ Y = rect.Top,
107
+ Width = Math.Max(0, rect.Right - rect.Left),
108
+ Height = Math.Max(0, rect.Bottom - rect.Top),
109
+ IsForeground = hWnd == foreground,
110
+ });
111
+ return true;
112
+ }, IntPtr.Zero);
113
+ return items.ToArray();
114
+ }
115
+
116
+ public static bool FocusWindow(string windowId) {
117
+ ulong raw;
118
+ if (!UInt64.TryParse(windowId, out raw) || raw == 0) return false;
119
+ var hWnd = new IntPtr(unchecked((long)raw));
120
+ if (!IsWindow(hWnd)) return false;
121
+ ShowWindowAsync(hWnd, 9);
122
+ return SetForegroundWindow(hWnd);
123
+ }
54
124
 
55
125
  public static bool MouseButton(uint downFlag, uint upFlag) {
56
126
  var inputs = new INPUT[2];
@@ -125,6 +195,7 @@ function Resolve-Screen($Monitor) {
125
195
  }
126
196
 
127
197
  function Invoke-Screenshot($Payload) {
198
+ $totalTimer = [System.Diagnostics.Stopwatch]::StartNew()
128
199
  if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) {
129
200
  return Result-Error "session_unavailable"
130
201
  }
@@ -142,11 +213,15 @@ function Invoke-Screenshot($Payload) {
142
213
  try {
143
214
  $source = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
144
215
  $graphics = [System.Drawing.Graphics]::FromImage($source)
216
+ $captureTimer = [System.Diagnostics.Stopwatch]::StartNew()
145
217
  try {
146
218
  $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
147
219
  } finally {
220
+ $captureTimer.Stop()
148
221
  $graphics.Dispose()
149
222
  }
223
+ $captureMs = [int][Math]::Round($captureTimer.Elapsed.TotalMilliseconds)
224
+ $encodeTimer = [System.Diagnostics.Stopwatch]::StartNew()
150
225
 
151
226
  $codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
152
227
  Where-Object { $_.MimeType -eq "image/jpeg" } |
@@ -154,12 +229,17 @@ function Invoke-Screenshot($Payload) {
154
229
  if ($null -eq $codec) { return Result-Error "capture_failed" }
155
230
 
156
231
  $maxBytes = 32768
157
- $targetWidth = [Math]::Min($bounds.Width, 960)
232
+ $requestedMaxWidth = if ($null -eq $Payload.maxWidth) { 960 } else { [int]$Payload.maxWidth }
233
+ if ($requestedMaxWidth -lt 320 -or $requestedMaxWidth -gt 1920) { return Result-Error "invalid_max_width" }
234
+ $requestedQuality = if ($null -eq $Payload.quality) { 58 } else { [int]$Payload.quality }
235
+ if ($requestedQuality -lt 20 -or $requestedQuality -gt 85) { return Result-Error "invalid_quality" }
236
+ $targetWidth = [Math]::Min($bounds.Width, $requestedMaxWidth)
158
237
  $targetHeight = [Math]::Max(1, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
159
- $qualities = @(58L, 42L, 30L, 22L)
238
+ $qualities = @([long]$requestedQuality, [long][Math]::Min($requestedQuality, 42), [long][Math]::Min($requestedQuality, 30), 20L) | Select-Object -Unique
160
239
  $bytes = $null
161
240
  $finalWidth = $targetWidth
162
241
  $finalHeight = $targetHeight
242
+ $finalQuality = $requestedQuality
163
243
 
164
244
  for ($sizeAttempt = 0; $sizeAttempt -lt 3 -and $null -eq $bytes; $sizeAttempt++) {
165
245
  $resized = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
@@ -188,6 +268,7 @@ function Invoke-Screenshot($Payload) {
188
268
  $bytes = $stream.ToArray()
189
269
  $finalWidth = $targetWidth
190
270
  $finalHeight = $targetHeight
271
+ $finalQuality = [int]$quality
191
272
  break
192
273
  }
193
274
  } finally {
@@ -206,13 +287,24 @@ function Invoke-Screenshot($Payload) {
206
287
  }
207
288
 
208
289
  if ($null -eq $bytes) { return Result-Error "screenshot_too_large" }
290
+ $base64 = [Convert]::ToBase64String($bytes)
291
+ $encodeTimer.Stop()
292
+ $totalTimer.Stop()
293
+ $virtualBounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
294
+ $monitorCount = @([System.Windows.Forms.Screen]::AllScreens).Count
209
295
 
210
296
  return [pscustomobject]@{
211
297
  ok = $true
212
298
  mimeType = "image/jpeg"
213
- data = [Convert]::ToBase64String($bytes)
299
+ data = $base64
214
300
  width = $finalWidth
215
301
  height = $finalHeight
302
+ virtualDesktopOriginX = $virtualBounds.Left
303
+ virtualDesktopOriginY = $virtualBounds.Top
304
+ virtualDesktopWidth = $virtualBounds.Width
305
+ virtualDesktopHeight = $virtualBounds.Height
306
+ monitorCount = $monitorCount
307
+ jpegQuality = $finalQuality
216
308
  desktopOriginX = $bounds.Left
217
309
  desktopOriginY = $bounds.Top
218
310
  desktopWidth = $bounds.Width
@@ -223,6 +315,11 @@ function Invoke-Screenshot($Payload) {
223
315
  isPrimary = [bool]$screen.Primary
224
316
  deviceName = [string]$screen.DeviceName
225
317
  byteLength = $bytes.Length
318
+ timing = [pscustomobject]@{
319
+ captureMs = $captureMs
320
+ encodeMs = [int][Math]::Round($encodeTimer.Elapsed.TotalMilliseconds)
321
+ screenshotTotalMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
322
+ }
226
323
  }
227
324
  } catch {
228
325
  return Result-Error "capture_failed"
@@ -295,61 +392,166 @@ function Invoke-KeyboardInput($Payload) {
295
392
  return [pscustomobject]@{ ok = $true; action = "keyboard_input" }
296
393
  }
297
394
 
395
+ function Invoke-ClipboardRead($Payload) {
396
+ if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) { return Result-Error "session_unavailable" }
397
+ try {
398
+ $text = if ([System.Windows.Forms.Clipboard]::ContainsText()) { [System.Windows.Forms.Clipboard]::GetText() } else { "" }
399
+ $truncated = $text.Length -gt 8192
400
+ if ($truncated) { $text = $text.Substring(0, 8192) }
401
+ return [pscustomobject]@{ ok = $true; text = $text; truncated = $truncated }
402
+ } catch { return Result-Error "clipboard_failed" }
403
+ }
404
+
405
+ function Invoke-ClipboardWrite($Payload) {
406
+ if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
407
+ try {
408
+ $text = [string]$Payload.text
409
+ if ($text.Length -gt 8192) { return Result-Error "text_too_large" }
410
+ if ($text.Length -eq 0) { [System.Windows.Forms.Clipboard]::Clear() } else { [System.Windows.Forms.Clipboard]::SetText($text) }
411
+ return [pscustomobject]@{ ok = $true; action = "clipboard_write"; length = $text.Length }
412
+ } catch { return Result-Error "clipboard_failed" }
413
+ }
414
+
415
+ function Invoke-WindowList($Payload) {
416
+ if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) { return Result-Error "session_unavailable" }
417
+ $limit = if ($null -eq $Payload.limit) { 50 } else { [int]$Payload.limit }
418
+ if ($limit -lt 1 -or $limit -gt 100) { return Result-Error "invalid_window_limit" }
419
+ try {
420
+ $windows = @([ChatRelayNative]::ListWindows($limit))
421
+ return [pscustomobject]@{ ok = $true; windows = $windows; count = $windows.Count }
422
+ } catch { return Result-Error "window_list_failed" }
423
+ }
424
+
425
+ function Invoke-WindowFocus($Payload) {
426
+ if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
427
+ $windowId = [string]$Payload.windowId
428
+ if ($windowId -notmatch '^[1-9][0-9]{0,19}$') { return Result-Error "invalid_window_id" }
429
+ try {
430
+ if (-not [ChatRelayNative]::FocusWindow($windowId)) { return Result-Error "focus_failed" }
431
+ return [pscustomobject]@{ ok = $true; action = "window_focus"; windowId = $windowId }
432
+ } catch { return Result-Error "focus_failed" }
433
+ }
434
+
298
435
  function Invoke-Step($Payload) {
436
+ $totalTimer = [System.Diagnostics.Stopwatch]::StartNew()
299
437
  $completed = 0
438
+ $inputMs = 0
439
+ $explicitWaitMs = 0
440
+ $settleMs = 0
441
+
300
442
  foreach ($action in @($Payload.actions)) {
301
443
  $result = $null
302
- switch ([string]$action.type) {
303
- "click" { $result = Invoke-MouseClick $action }
304
- "text" { $result = Invoke-KeyboardInput $action }
305
- "key" { $result = Invoke-KeyboardInput $action }
306
- "wait" {
307
- Start-Sleep -Milliseconds ([int]$action.ms)
308
- $result = [pscustomobject]@{ ok = $true; action = "wait" }
444
+ if ([string]$action.type -eq "wait") {
445
+ $waitTimer = [System.Diagnostics.Stopwatch]::StartNew()
446
+ Start-Sleep -Milliseconds ([int]$action.ms)
447
+ $waitTimer.Stop()
448
+ $explicitWaitMs += [int][Math]::Round($waitTimer.Elapsed.TotalMilliseconds)
449
+ $result = [pscustomobject]@{ ok = $true; action = "wait" }
450
+ } else {
451
+ $inputTimer = [System.Diagnostics.Stopwatch]::StartNew()
452
+ switch ([string]$action.type) {
453
+ "click" { $result = Invoke-MouseClick $action }
454
+ "text" { $result = Invoke-KeyboardInput $action }
455
+ "key" { $result = Invoke-KeyboardInput $action }
456
+ default { $result = Result-Error "invalid_step_action" }
309
457
  }
310
- default { $result = Result-Error "invalid_step_action" }
458
+ $inputTimer.Stop()
459
+ $inputMs += [int][Math]::Round($inputTimer.Elapsed.TotalMilliseconds)
311
460
  }
312
461
 
313
462
  if (-not $result.ok) {
463
+ $totalTimer.Stop()
314
464
  return [pscustomobject]@{
315
465
  ok = $false
316
466
  error = $result.error
317
467
  actionsCompleted = $completed
468
+ timing = [pscustomobject]@{
469
+ inputMs = $inputMs
470
+ explicitWaitMs = $explicitWaitMs
471
+ settleMs = $settleMs
472
+ captureMs = 0
473
+ encodeMs = 0
474
+ workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
475
+ }
318
476
  }
319
477
  }
478
+
320
479
  $completed++
480
+ $actionSettleMs = [int]$action.settleAfterMs
481
+ if ($actionSettleMs -gt 0) {
482
+ $settleTimer = [System.Diagnostics.Stopwatch]::StartNew()
483
+ Start-Sleep -Milliseconds $actionSettleMs
484
+ $settleTimer.Stop()
485
+ $settleMs += [int][Math]::Round($settleTimer.Elapsed.TotalMilliseconds)
486
+ }
321
487
  }
322
488
 
323
489
  if ([int]$Payload.settleMs -gt 0) {
490
+ $finalSettleTimer = [System.Diagnostics.Stopwatch]::StartNew()
324
491
  Start-Sleep -Milliseconds ([int]$Payload.settleMs)
492
+ $finalSettleTimer.Stop()
493
+ $settleMs += [int][Math]::Round($finalSettleTimer.Elapsed.TotalMilliseconds)
325
494
  }
326
495
 
327
- if ($Payload.captureAfter -eq $false) {
496
+ if ($Payload.captureAfter -ne $true) {
497
+ $totalTimer.Stop()
328
498
  return [pscustomobject]@{
329
499
  ok = $true
330
500
  action = "step"
331
501
  actionsCompleted = $completed
502
+ timing = [pscustomobject]@{
503
+ inputMs = $inputMs
504
+ explicitWaitMs = $explicitWaitMs
505
+ settleMs = $settleMs
506
+ captureMs = 0
507
+ encodeMs = 0
508
+ workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
509
+ }
332
510
  }
333
511
  }
334
512
 
335
- $shotPayload = [pscustomobject]@{ monitor = $Payload.monitor }
513
+ $shotPayload = [pscustomobject]@{ monitor = $Payload.monitor; maxWidth = $Payload.maxWidth; quality = $Payload.quality }
336
514
  $shot = Invoke-Screenshot $shotPayload
337
515
  if (-not $shot.ok) {
516
+ $totalTimer.Stop()
338
517
  return [pscustomobject]@{
339
518
  ok = $false
340
519
  error = $shot.error
341
520
  actionsCompleted = $completed
521
+ timing = [pscustomobject]@{
522
+ inputMs = $inputMs
523
+ explicitWaitMs = $explicitWaitMs
524
+ settleMs = $settleMs
525
+ captureMs = 0
526
+ encodeMs = 0
527
+ workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
528
+ }
342
529
  }
343
530
  }
344
531
 
532
+ $totalTimer.Stop()
533
+ $captureMs = if ($null -ne $shot.timing) { [int]$shot.timing.captureMs } else { 0 }
534
+ $encodeMs = if ($null -ne $shot.timing) { [int]$shot.timing.encodeMs } else { 0 }
345
535
  $shot | Add-Member -NotePropertyName action -NotePropertyValue "step" -Force
346
536
  $shot | Add-Member -NotePropertyName actionsCompleted -NotePropertyValue $completed -Force
537
+ $shot | Add-Member -NotePropertyName timing -NotePropertyValue ([pscustomobject]@{
538
+ inputMs = $inputMs
539
+ explicitWaitMs = $explicitWaitMs
540
+ settleMs = $settleMs
541
+ captureMs = $captureMs
542
+ encodeMs = $encodeMs
543
+ workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
544
+ }) -Force
347
545
  return $shot
348
546
  }
349
547
 
350
548
  function Invoke-Operation([string]$Operation, $Payload) {
351
549
  switch ($Operation) {
352
550
  "screenshot" { return Invoke-Screenshot $Payload }
551
+ "clipboard_read" { return Invoke-ClipboardRead $Payload }
552
+ "clipboard_write" { return Invoke-ClipboardWrite $Payload }
553
+ "window_list" { return Invoke-WindowList $Payload }
554
+ "window_focus" { return Invoke-WindowFocus $Payload }
353
555
  "mouse_click" { return Invoke-MouseClick $Payload }
354
556
  "keyboard_input" { return Invoke-KeyboardInput $Payload }
355
557
  "step" { return Invoke-Step $Payload }
package/cli/config.mjs CHANGED
@@ -1,114 +1,114 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- export function configDir() {
6
- if (process.env.CHAT_RELAY_HOME) return path.resolve(process.env.CHAT_RELAY_HOME);
7
- if (process.platform === "win32") {
8
- const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
9
- return path.join(base, "chat-relay");
10
- }
11
- const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
12
- return path.join(base, "chat-relay");
13
- }
14
-
15
- export function configPath() {
16
- return path.join(configDir(), "config.json");
17
- }
18
-
19
- export function identityPath() {
20
- return path.join(configDir(), "identity.json");
21
- }
22
-
23
- export function loadIdentity() {
24
- const file = identityPath();
25
- if (!fs.existsSync(file)) return null;
26
- try {
27
- return JSON.parse(fs.readFileSync(file, "utf8"));
28
- } catch {
29
- return null;
30
- }
31
- }
32
-
33
- export function saveIdentity(identity) {
34
- const dir = configDir();
35
- fs.mkdirSync(dir, { recursive: true });
36
- const file = identityPath();
37
- fs.writeFileSync(file, JSON.stringify(identity, null, 2) + os.EOL, {
38
- encoding: "utf8",
39
- mode: 0o600,
40
- });
41
- try { fs.chmodSync(file, 0o600); } catch {}
42
- return file;
43
- }
44
-
45
- function parseEnvFile(file) {
46
- const result = {};
47
- for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
48
- const line = raw.trim();
49
- if (!line || line.startsWith("#") || !line.includes("=")) continue;
50
- const index = line.indexOf("=");
51
- result[line.slice(0, index)] = line.slice(index + 1);
52
- }
53
- return result;
54
- }
55
-
56
- function migrateLegacyConfig() {
57
- const legacyPath = path.resolve(".dev.vars");
58
- if (!fs.existsSync(legacyPath)) return null;
59
- const legacy = parseEnvFile(legacyPath);
60
- if (!legacy.RELAY_URL || !legacy.CALLER_TOKEN || !legacy.AGENT_TOKEN) return null;
61
-
62
- const config = {
63
- relayUrl: legacy.RELAY_URL.replace(/\/$/, ""),
64
- user: { id: "owner", name: "Owner", login: null },
65
- userToken: legacy.CALLER_TOKEN,
66
- agentId: legacy.AGENT_ID || "default",
67
- agentName: legacy.AGENT_NAME || "Primary PC",
68
- agentToken: legacy.AGENT_TOKEN,
69
- allowedRoots: legacy.ALLOWED_ROOTS || defaultAllowedRoot(),
70
- terminalEnabled: legacy.TERMINAL_ENABLED !== "0",
71
- desktopEnabled: legacy.DESKTOP_ENABLED === "1",
72
- migratedAt: new Date().toISOString(),
73
- };
74
- saveConfig(config);
75
- saveIdentity({
76
- agentId: config.agentId,
77
- createdAt: new Date().toISOString(),
78
- updatedAt: new Date().toISOString(),
79
- });
80
- return config;
81
- }
82
-
83
- export function loadConfig() {
84
- const file = configPath();
85
- if (!fs.existsSync(file)) return migrateLegacyConfig();
86
- try {
87
- return JSON.parse(fs.readFileSync(file, "utf8"));
88
- } catch {
89
- throw new Error(`Invalid Chat Relay config: ${file}`);
90
- }
91
- }
92
-
93
- export function saveConfig(config) {
94
- const dir = configDir();
95
- fs.mkdirSync(dir, { recursive: true });
96
- const file = configPath();
97
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + os.EOL, {
98
- encoding: "utf8",
99
- mode: 0o600,
100
- });
101
- try { fs.chmodSync(file, 0o600); } catch {}
102
- return file;
103
- }
104
-
105
- export function clearConfig() {
106
- const file = configPath();
107
- if (fs.existsSync(file)) fs.rmSync(file, { force: true });
108
- }
109
-
110
- export function defaultAllowedRoot() {
111
- const projects = path.join(os.homedir(), "Projects");
112
- if (fs.existsSync(projects)) return projects;
113
- return os.homedir();
114
- }
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export function configDir() {
6
+ if (process.env.CHAT_RELAY_HOME) return path.resolve(process.env.CHAT_RELAY_HOME);
7
+ if (process.platform === "win32") {
8
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
9
+ return path.join(base, "chat-relay");
10
+ }
11
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
12
+ return path.join(base, "chat-relay");
13
+ }
14
+
15
+ export function configPath() {
16
+ return path.join(configDir(), "config.json");
17
+ }
18
+
19
+ export function identityPath() {
20
+ return path.join(configDir(), "identity.json");
21
+ }
22
+
23
+ export function loadIdentity() {
24
+ const file = identityPath();
25
+ if (!fs.existsSync(file)) return null;
26
+ try {
27
+ return JSON.parse(fs.readFileSync(file, "utf8"));
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export function saveIdentity(identity) {
34
+ const dir = configDir();
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ const file = identityPath();
37
+ fs.writeFileSync(file, JSON.stringify(identity, null, 2) + os.EOL, {
38
+ encoding: "utf8",
39
+ mode: 0o600,
40
+ });
41
+ try { fs.chmodSync(file, 0o600); } catch {}
42
+ return file;
43
+ }
44
+
45
+ function parseEnvFile(file) {
46
+ const result = {};
47
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
48
+ const line = raw.trim();
49
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
50
+ const index = line.indexOf("=");
51
+ result[line.slice(0, index)] = line.slice(index + 1);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function migrateLegacyConfig() {
57
+ const legacyPath = path.resolve(".dev.vars");
58
+ if (!fs.existsSync(legacyPath)) return null;
59
+ const legacy = parseEnvFile(legacyPath);
60
+ if (!legacy.RELAY_URL || !legacy.CALLER_TOKEN || !legacy.AGENT_TOKEN) return null;
61
+
62
+ const config = {
63
+ relayUrl: legacy.RELAY_URL.replace(/\/$/, ""),
64
+ user: { id: "owner", name: "Owner", login: null },
65
+ userToken: legacy.CALLER_TOKEN,
66
+ agentId: legacy.AGENT_ID || "default",
67
+ agentName: legacy.AGENT_NAME || "Primary PC",
68
+ agentToken: legacy.AGENT_TOKEN,
69
+ allowedRoots: legacy.ALLOWED_ROOTS || defaultAllowedRoot(),
70
+ terminalEnabled: legacy.TERMINAL_ENABLED !== "0",
71
+ desktopEnabled: legacy.DESKTOP_ENABLED === "1",
72
+ migratedAt: new Date().toISOString(),
73
+ };
74
+ saveConfig(config);
75
+ saveIdentity({
76
+ agentId: config.agentId,
77
+ createdAt: new Date().toISOString(),
78
+ updatedAt: new Date().toISOString(),
79
+ });
80
+ return config;
81
+ }
82
+
83
+ export function loadConfig() {
84
+ const file = configPath();
85
+ if (!fs.existsSync(file)) return migrateLegacyConfig();
86
+ try {
87
+ return JSON.parse(fs.readFileSync(file, "utf8"));
88
+ } catch {
89
+ throw new Error(`Invalid Chat Relay config: ${file}`);
90
+ }
91
+ }
92
+
93
+ export function saveConfig(config) {
94
+ const dir = configDir();
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ const file = configPath();
97
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + os.EOL, {
98
+ encoding: "utf8",
99
+ mode: 0o600,
100
+ });
101
+ try { fs.chmodSync(file, 0o600); } catch {}
102
+ return file;
103
+ }
104
+
105
+ export function clearConfig() {
106
+ const file = configPath();
107
+ if (fs.existsSync(file)) fs.rmSync(file, { force: true });
108
+ }
109
+
110
+ export function defaultAllowedRoot() {
111
+ const projects = path.join(os.homedir(), "Projects");
112
+ if (fs.existsSync(projects)) return projects;
113
+ return os.homedir();
114
+ }