@cloudcli-ai/cloudcli 1.34.0 → 1.35.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.
Files changed (247) hide show
  1. package/README.de.md +9 -1
  2. package/README.ja.md +8 -0
  3. package/README.ko.md +9 -1
  4. package/README.md +23 -5
  5. package/README.ru.md +9 -1
  6. package/README.tr.md +8 -1
  7. package/README.zh-CN.md +9 -1
  8. package/README.zh-TW.md +9 -1
  9. package/dist/api-docs.html +36 -18
  10. package/dist/assets/index-BvX30NW5.css +32 -0
  11. package/dist/assets/index-D1IdCTT6.js +1418 -0
  12. package/dist/index.html +12 -4
  13. package/dist-server/server/browser-use-mcp.js +350 -0
  14. package/dist-server/server/browser-use-mcp.js.map +1 -0
  15. package/dist-server/server/claude-sdk.js +25 -5
  16. package/dist-server/server/claude-sdk.js.map +1 -1
  17. package/dist-server/server/cli.js +14 -6
  18. package/dist-server/server/cli.js.map +1 -1
  19. package/dist-server/server/cursor-cli.js +27 -11
  20. package/dist-server/server/cursor-cli.js.map +1 -1
  21. package/dist-server/server/gemini-cli.js +17 -2
  22. package/dist-server/server/gemini-cli.js.map +1 -1
  23. package/dist-server/server/index.js +136 -40
  24. package/dist-server/server/index.js.map +1 -1
  25. package/dist-server/server/load-env.js +1 -1
  26. package/dist-server/server/load-env.js.map +1 -1
  27. package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js +107 -0
  28. package/dist-server/server/modules/browser-use/browser-use-mcp.routes.js.map +1 -0
  29. package/dist-server/server/modules/browser-use/browser-use.routes.js +93 -0
  30. package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -0
  31. package/dist-server/server/modules/browser-use/browser-use.service.js +688 -0
  32. package/dist-server/server/modules/browser-use/browser-use.service.js.map +1 -0
  33. package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js +8 -0
  34. package/dist-server/server/modules/browser-use/tests/browser-use.service.test.js.map +1 -0
  35. package/dist-server/server/modules/database/index.js +1 -0
  36. package/dist-server/server/modules/database/index.js.map +1 -1
  37. package/dist-server/server/modules/database/migrations.js +23 -1
  38. package/dist-server/server/modules/database/migrations.js.map +1 -1
  39. package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js +100 -0
  40. package/dist-server/server/modules/database/repositories/notification-channel-endpoints.js.map +1 -0
  41. package/dist-server/server/modules/database/repositories/notification-preferences.js +8 -0
  42. package/dist-server/server/modules/database/repositories/notification-preferences.js.map +1 -1
  43. package/dist-server/server/modules/database/repositories/sessions.db.js +173 -17
  44. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  45. package/dist-server/server/modules/database/schema.js +26 -0
  46. package/dist-server/server/modules/database/schema.js.map +1 -1
  47. package/dist-server/server/modules/database/tests/projects.db.integration.test.js.map +1 -0
  48. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js +78 -0
  49. package/dist-server/server/modules/database/tests/sessions-provider-mapping.test.js.map +1 -0
  50. package/dist-server/server/modules/database/{repositories → tests}/sessions.db.integration.test.js +10 -0
  51. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -0
  52. package/dist-server/server/modules/notifications/index.js +4 -0
  53. package/dist-server/server/modules/notifications/index.js.map +1 -0
  54. package/dist-server/server/modules/notifications/notifications.routes.js +115 -0
  55. package/dist-server/server/modules/notifications/notifications.routes.js.map +1 -0
  56. package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js +97 -0
  57. package/dist-server/server/modules/notifications/services/desktop-notification-clients.service.js.map +1 -0
  58. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js +229 -0
  59. package/dist-server/server/modules/notifications/services/notification-orchestrator.service.js.map +1 -0
  60. package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js +79 -0
  61. package/dist-server/server/modules/notifications/websocket/desktop-notifications-websocket.service.js.map +1 -0
  62. package/dist-server/server/modules/projects/services/project-management.service.js +0 -4
  63. package/dist-server/server/modules/projects/services/project-management.service.js.map +1 -1
  64. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js +10 -38
  65. package/dist-server/server/modules/projects/services/projects-with-sessions-fetch.service.js.map +1 -1
  66. package/dist-server/server/modules/providers/index.js +1 -0
  67. package/dist-server/server/modules/providers/index.js.map +1 -1
  68. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +24 -1
  69. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
  70. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +9 -12
  71. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  72. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +7 -0
  73. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
  74. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +7 -3
  75. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  76. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +3 -9
  77. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  78. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js +7 -0
  79. package/dist-server/server/modules/providers/list/codex/codex-skills.provider.js.map +1 -1
  80. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js +13 -22
  81. package/dist-server/server/modules/providers/list/cursor/cursor-sessions.provider.js.map +1 -1
  82. package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js +7 -0
  83. package/dist-server/server/modules/providers/list/cursor/cursor-skills.provider.js.map +1 -1
  84. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js +5 -8
  85. package/dist-server/server/modules/providers/list/gemini/gemini-models.provider.js.map +1 -1
  86. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js +6 -6
  87. package/dist-server/server/modules/providers/list/gemini/gemini-sessions.provider.js.map +1 -1
  88. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js +7 -0
  89. package/dist-server/server/modules/providers/list/gemini/gemini-skills.provider.js.map +1 -1
  90. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +17 -3
  91. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  92. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js +9 -10
  93. package/dist-server/server/modules/providers/list/opencode/opencode-sessions.provider.js.map +1 -1
  94. package/dist-server/server/modules/providers/provider.routes.js +123 -1
  95. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  96. package/dist-server/server/modules/providers/services/mcp.service.js +23 -0
  97. package/dist-server/server/modules/providers/services/mcp.service.js.map +1 -1
  98. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +65 -0
  99. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -0
  100. package/dist-server/server/modules/providers/services/provider-models.service.js +1 -1
  101. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  102. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +62 -23
  103. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  104. package/dist-server/server/modules/providers/services/sessions.service.js +62 -4
  105. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  106. package/dist-server/server/modules/providers/services/skills.service.js +11 -0
  107. package/dist-server/server/modules/providers/services/skills.service.js.map +1 -1
  108. package/dist-server/server/modules/providers/shared/skills/skills.provider.js +163 -1
  109. package/dist-server/server/modules/providers/shared/skills/skills.provider.js.map +1 -1
  110. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +45 -0
  111. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  112. package/dist-server/server/modules/providers/tests/skills.test.js +160 -0
  113. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  114. package/dist-server/server/modules/websocket/index.js +1 -0
  115. package/dist-server/server/modules/websocket/index.js.map +1 -1
  116. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +252 -0
  117. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -0
  118. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +105 -0
  119. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -0
  120. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +218 -156
  121. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  122. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +83 -31
  123. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  124. package/dist-server/server/modules/websocket/services/websocket-server.service.js +5 -0
  125. package/dist-server/server/modules/websocket/services/websocket-server.service.js.map +1 -1
  126. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +216 -0
  127. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -0
  128. package/dist-server/server/openai-codex.js +24 -14
  129. package/dist-server/server/openai-codex.js.map +1 -1
  130. package/dist-server/server/opencode-cli.js +21 -8
  131. package/dist-server/server/opencode-cli.js.map +1 -1
  132. package/dist-server/server/opencode-cli.test.js +19 -1
  133. package/dist-server/server/opencode-cli.test.js.map +1 -1
  134. package/dist-server/server/routes/settings.js +1 -1
  135. package/dist-server/server/routes/settings.js.map +1 -1
  136. package/dist-server/server/services/notification-orchestrator.js +1 -176
  137. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  138. package/dist-server/server/services/tests/notification-orchestrator.test.js +64 -0
  139. package/dist-server/server/services/tests/notification-orchestrator.test.js.map +1 -0
  140. package/dist-server/server/shared/{claude-cli-path.test.js → tests/claude-cli-path.test.js} +1 -1
  141. package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -0
  142. package/dist-server/server/shared/tests/slice-tail-page.test.js +35 -0
  143. package/dist-server/server/shared/tests/slice-tail-page.test.js.map +1 -0
  144. package/dist-server/server/shared/utils.js +73 -1
  145. package/dist-server/server/shared/utils.js.map +1 -1
  146. package/dist-server/server/voice-proxy.js +226 -0
  147. package/dist-server/server/voice-proxy.js.map +1 -0
  148. package/electron/assets/logo-macos.icns +0 -0
  149. package/electron/assets/logo-macos.png +0 -0
  150. package/electron/assets/logo-windows.ico +0 -0
  151. package/electron/cloud.js +260 -0
  152. package/electron/desktopNotifications.js +378 -0
  153. package/electron/desktopWindow.js +766 -0
  154. package/electron/launcher/index.html +14 -0
  155. package/electron/launcher/launcher.css +801 -0
  156. package/electron/launcher/launcher.js +687 -0
  157. package/electron/localServer.js +550 -0
  158. package/electron/main.js +944 -0
  159. package/electron/preload.cjs +60 -0
  160. package/electron/scripts/generate-macos-icon.js +62 -0
  161. package/electron/serverInstaller.js +277 -0
  162. package/electron/tabs.js +87 -0
  163. package/electron/viewHost.js +331 -0
  164. package/package.json +79 -3
  165. package/public/api-docs.html +36 -18
  166. package/scripts/release/build-server-bundle.js +176 -0
  167. package/scripts/release/prepare-desktop-app.js +152 -0
  168. package/server/browser-use-mcp.ts +384 -0
  169. package/server/claude-sdk.js +27 -5
  170. package/server/cli.js +15 -6
  171. package/server/cursor-cli.js +27 -11
  172. package/server/gemini-cli.js +17 -2
  173. package/server/index.js +137 -47
  174. package/server/load-env.js +1 -1
  175. package/server/modules/browser-use/browser-use-mcp.routes.ts +120 -0
  176. package/server/modules/browser-use/browser-use.routes.ts +96 -0
  177. package/server/modules/browser-use/browser-use.service.ts +836 -0
  178. package/server/modules/browser-use/tests/browser-use.service.test.ts +10 -0
  179. package/server/modules/database/index.ts +1 -0
  180. package/server/modules/database/migrations.ts +25 -0
  181. package/server/modules/database/repositories/notification-channel-endpoints.ts +153 -0
  182. package/server/modules/database/repositories/notification-preferences.ts +12 -1
  183. package/server/modules/database/repositories/sessions.db.ts +227 -23
  184. package/server/modules/database/schema.ts +27 -0
  185. package/server/modules/database/tests/sessions-provider-mapping.test.ts +108 -0
  186. package/server/modules/database/{repositories → tests}/sessions.db.integration.test.ts +12 -0
  187. package/server/modules/notifications/index.ts +13 -0
  188. package/server/modules/notifications/notifications.routes.ts +127 -0
  189. package/server/modules/notifications/services/desktop-notification-clients.service.ts +124 -0
  190. package/server/modules/notifications/services/notification-orchestrator.service.js +288 -0
  191. package/server/modules/notifications/websocket/desktop-notifications-websocket.service.ts +109 -0
  192. package/server/modules/projects/services/project-management.service.ts +0 -8
  193. package/server/modules/projects/services/projects-with-sessions-fetch.service.ts +12 -53
  194. package/server/modules/providers/README.md +1 -1
  195. package/server/modules/providers/index.ts +1 -0
  196. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +26 -1
  197. package/server/modules/providers/list/claude/claude-sessions.provider.ts +9 -14
  198. package/server/modules/providers/list/claude/claude-skills.provider.ts +8 -0
  199. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +7 -3
  200. package/server/modules/providers/list/codex/codex-sessions.provider.ts +3 -12
  201. package/server/modules/providers/list/codex/codex-skills.provider.ts +8 -0
  202. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +13 -22
  203. package/server/modules/providers/list/cursor/cursor-skills.provider.ts +8 -0
  204. package/server/modules/providers/list/gemini/gemini-models.provider.ts +5 -8
  205. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +6 -6
  206. package/server/modules/providers/list/gemini/gemini-skills.provider.ts +8 -0
  207. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +18 -4
  208. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +9 -12
  209. package/server/modules/providers/provider.routes.ts +167 -1
  210. package/server/modules/providers/services/mcp.service.ts +26 -0
  211. package/server/modules/providers/services/provider-capabilities.service.ts +91 -0
  212. package/server/modules/providers/services/provider-models.service.ts +1 -1
  213. package/server/modules/providers/services/sessions-watcher.service.ts +71 -25
  214. package/server/modules/providers/services/sessions.service.ts +79 -4
  215. package/server/modules/providers/services/skills.service.ts +25 -1
  216. package/server/modules/providers/shared/skills/skills.provider.ts +223 -0
  217. package/server/modules/providers/tests/opencode-sessions.test.ts +49 -0
  218. package/server/modules/providers/tests/skills.test.ts +212 -0
  219. package/server/modules/websocket/README.md +33 -27
  220. package/server/modules/websocket/index.ts +1 -0
  221. package/server/modules/websocket/services/chat-run-registry.service.ts +327 -0
  222. package/server/modules/websocket/services/chat-session-writer.service.ts +145 -0
  223. package/server/modules/websocket/services/chat-websocket.service.ts +292 -207
  224. package/server/modules/websocket/services/shell-websocket.service.ts +108 -32
  225. package/server/modules/websocket/services/websocket-server.service.ts +6 -0
  226. package/server/modules/websocket/tests/chat-run-registry.test.ts +244 -0
  227. package/server/openai-codex.js +24 -14
  228. package/server/opencode-cli.js +21 -8
  229. package/server/opencode-cli.test.js +20 -1
  230. package/server/routes/settings.js +6 -1
  231. package/server/services/notification-orchestrator.js +3 -224
  232. package/server/services/tests/notification-orchestrator.test.js +80 -0
  233. package/server/shared/interfaces.ts +15 -0
  234. package/server/shared/tests/slice-tail-page.test.ts +42 -0
  235. package/server/shared/types.ts +84 -1
  236. package/server/shared/utils.ts +95 -1
  237. package/server/voice-proxy.js +224 -0
  238. package/dist/assets/index-73Xw3i9v.css +0 -32
  239. package/dist/assets/index-BIHaviaA.js +0 -1361
  240. package/dist/modelConstants.js +0 -854
  241. package/dist-server/server/modules/database/repositories/projects.db.integration.test.js.map +0 -1
  242. package/dist-server/server/modules/database/repositories/sessions.db.integration.test.js.map +0 -1
  243. package/dist-server/server/shared/claude-cli-path.test.js.map +0 -1
  244. package/public/modelConstants.js +0 -854
  245. /package/dist-server/server/modules/database/{repositories → tests}/projects.db.integration.test.js +0 -0
  246. /package/server/modules/database/{repositories → tests}/projects.db.integration.test.ts +0 -0
  247. /package/server/shared/{claude-cli-path.test.ts → tests/claude-cli-path.test.ts} +0 -0
@@ -0,0 +1,260 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { safeStorage } from 'electron';
5
+
6
+ const CLOUD_API_TIMEOUT_MS = 15000;
7
+
8
+ function encryptSecret(secret) {
9
+ if (!safeStorage.isEncryptionAvailable()) {
10
+ return { encrypted: false, value: secret };
11
+ }
12
+
13
+ return {
14
+ encrypted: true,
15
+ value: safeStorage.encryptString(secret).toString('base64'),
16
+ };
17
+ }
18
+
19
+ function decryptSecret(record) {
20
+ if (!record?.value) return null;
21
+ if (!record.encrypted) return record.value;
22
+ try {
23
+ return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export class CloudController {
30
+ constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
31
+ this.storePath = storePath;
32
+ this.controlPlaneUrl = controlPlaneUrl;
33
+ this.callbackUrl = callbackUrl;
34
+ this.onChange = onChange;
35
+ this.cloudAccount = null;
36
+ this.cloudEnvironments = [];
37
+ this.authState = 'logged_out';
38
+ }
39
+
40
+ getAccount() {
41
+ return this.cloudAccount;
42
+ }
43
+
44
+ getAuthState() {
45
+ return this.authState;
46
+ }
47
+
48
+ getEnvironments() {
49
+ return this.cloudEnvironments;
50
+ }
51
+
52
+ getEnvironmentUrl(environment) {
53
+ return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
54
+ }
55
+
56
+ async getEnvironmentLaunchUrl(environment) {
57
+ if (!environment?.id) {
58
+ return this.getEnvironmentUrl(environment);
59
+ }
60
+
61
+ const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
62
+ method: 'POST',
63
+ });
64
+
65
+ return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
66
+ }
67
+
68
+ findEnvironment(environmentId) {
69
+ return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
70
+ }
71
+
72
+ async loadCloudAccount() {
73
+ try {
74
+ const raw = await fs.readFile(this.storePath, 'utf8');
75
+ const stored = JSON.parse(raw);
76
+ const apiKey = decryptSecret(stored.apiKey);
77
+ this.cloudAccount = {
78
+ deviceId: stored.deviceId || crypto.randomUUID(),
79
+ email: stored.email || null,
80
+ apiKey: apiKey || null,
81
+ };
82
+ this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
83
+ return this.cloudAccount;
84
+ } catch {
85
+ this.cloudAccount = {
86
+ deviceId: crypto.randomUUID(),
87
+ email: null,
88
+ apiKey: null,
89
+ };
90
+ this.authState = 'logged_out';
91
+ return this.cloudAccount;
92
+ }
93
+ }
94
+
95
+ async saveCloudAccount(account) {
96
+ const payload = {
97
+ deviceId: account.deviceId || crypto.randomUUID(),
98
+ email: account.email || null,
99
+ apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
100
+ };
101
+
102
+ await fs.mkdir(path.dirname(this.storePath), { recursive: true });
103
+ await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
104
+ this.cloudAccount = {
105
+ deviceId: payload.deviceId,
106
+ email: payload.email,
107
+ apiKey: account.apiKey || null,
108
+ };
109
+ this.authState = account.apiKey ? 'connected' : 'logged_out';
110
+ this.onChange?.();
111
+ return this.cloudAccount;
112
+ }
113
+
114
+ async clearCloudAccount() {
115
+ this.cloudAccount = {
116
+ deviceId: crypto.randomUUID(),
117
+ email: null,
118
+ apiKey: null,
119
+ };
120
+ this.cloudEnvironments = [];
121
+ this.authState = 'logged_out';
122
+ await fs.rm(this.storePath, { force: true });
123
+ this.onChange?.();
124
+ }
125
+
126
+ async invalidateCloudAccount() {
127
+ this.cloudEnvironments = [];
128
+ if (!this.cloudAccount) {
129
+ this.cloudAccount = {
130
+ deviceId: crypto.randomUUID(),
131
+ email: null,
132
+ apiKey: null,
133
+ };
134
+ } else {
135
+ this.cloudAccount = {
136
+ ...this.cloudAccount,
137
+ apiKey: null,
138
+ };
139
+ }
140
+ this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
141
+ const payload = {
142
+ deviceId: this.cloudAccount.deviceId,
143
+ email: this.cloudAccount.email || null,
144
+ apiKey: null,
145
+ };
146
+ await fs.mkdir(path.dirname(this.storePath), { recursive: true });
147
+ await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
148
+ this.onChange?.();
149
+ }
150
+
151
+ async cloudApi(pathname, options = {}) {
152
+ if (!this.cloudAccount?.apiKey) {
153
+ throw new Error('Connect your CloudCLI account first.');
154
+ }
155
+
156
+ const controller = new AbortController();
157
+ const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
158
+ let response;
159
+
160
+ try {
161
+ response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
162
+ ...options,
163
+ signal: options.signal || controller.signal,
164
+ headers: {
165
+ 'Content-Type': 'application/json',
166
+ 'X-API-Key': this.cloudAccount.apiKey,
167
+ ...(options.headers || {}),
168
+ },
169
+ });
170
+ } catch (error) {
171
+ if (error?.name === 'AbortError') {
172
+ throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
173
+ }
174
+ throw error;
175
+ } finally {
176
+ clearTimeout(timeout);
177
+ }
178
+
179
+ const body = await response.json().catch(() => ({}));
180
+ if (!response.ok) {
181
+ if (response.status === 401 || response.status === 403) {
182
+ await this.invalidateCloudAccount();
183
+ }
184
+ throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
185
+ }
186
+
187
+ return body;
188
+ }
189
+
190
+ async refreshCloudEnvironments() {
191
+ if (!this.cloudAccount?.apiKey) {
192
+ this.cloudEnvironments = [];
193
+ this.onChange?.();
194
+ return [];
195
+ }
196
+
197
+ const data = await this.cloudApi('/api/v1/environments');
198
+ this.cloudEnvironments = data.environments || [];
199
+ this.onChange?.();
200
+ return this.cloudEnvironments;
201
+ }
202
+
203
+ async startEnvironment(environment) {
204
+ await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
205
+ method: 'POST',
206
+ });
207
+ }
208
+
209
+ async stopEnvironment(environment) {
210
+ await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
211
+ method: 'POST',
212
+ });
213
+ }
214
+
215
+ async getEnvironmentCredentials(environment) {
216
+ return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
217
+ }
218
+
219
+ async startEnvironmentAndWait(environment, timeoutMs) {
220
+ await this.startEnvironment(environment);
221
+
222
+ const startedAt = Date.now();
223
+ while (Date.now() - startedAt < timeoutMs) {
224
+ const environments = await this.refreshCloudEnvironments();
225
+ const current = environments.find((env) => env.id === environment.id);
226
+ if (current?.status === 'running') {
227
+ return current;
228
+ }
229
+ await new Promise((resolve) => setTimeout(resolve, 2000));
230
+ }
231
+
232
+ throw new Error(`${environment.name} did not become ready in time.`);
233
+ }
234
+
235
+ buildConnectUrl() {
236
+ if (!this.cloudAccount?.deviceId) {
237
+ this.cloudAccount = {
238
+ deviceId: crypto.randomUUID(),
239
+ email: null,
240
+ apiKey: null,
241
+ };
242
+ }
243
+
244
+ const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
245
+ connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
246
+ connectUrl.searchParams.set('callback_url', this.callbackUrl);
247
+ connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
248
+ connectUrl.searchParams.set('client_platform', 'desktop');
249
+ return connectUrl.toString();
250
+ }
251
+
252
+ async saveFromCallback({ apiKey, email }) {
253
+ await this.saveCloudAccount({
254
+ deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
255
+ email,
256
+ apiKey,
257
+ });
258
+ return this.cloudAccount;
259
+ }
260
+ }
@@ -0,0 +1,378 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { Notification } from 'electron';
4
+ import WebSocket from 'ws';
5
+
6
+ const RECONNECT_MIN_MS = 1000;
7
+ const RECONNECT_MAX_MS = 30000;
8
+ const TARGET_REGISTER_TIMEOUT_MS = 8000;
9
+
10
+ function toNotificationsWsUrl(httpUrl) {
11
+ try {
12
+ const parsed = new URL(httpUrl);
13
+ parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
14
+ parsed.pathname = '/desktop-notifications';
15
+ parsed.search = '';
16
+ parsed.hash = '';
17
+ return parsed.toString();
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function readJsonMessage(raw) {
24
+ try {
25
+ return JSON.parse(String(raw));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
32
+ const controller = new AbortController();
33
+ const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
34
+ try {
35
+ const response = await fetch(url, {
36
+ method,
37
+ signal: controller.signal,
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ ...headers,
41
+ },
42
+ ...(body == null ? {} : { body: JSON.stringify(body) }),
43
+ });
44
+ const payload = await response.json().catch(() => ({}));
45
+ if (!response.ok) {
46
+ throw new Error(payload.error || `Request failed with status ${response.status}`);
47
+ }
48
+ return payload;
49
+ } finally {
50
+ clearTimeout(timeout);
51
+ }
52
+ }
53
+
54
+ export class DesktopNotificationsController {
55
+ constructor({
56
+ settingsPath,
57
+ appVersion,
58
+ appName,
59
+ getDeviceId,
60
+ getAccountEmail,
61
+ getRunningEnvironmentUrls,
62
+ getApiKey,
63
+ getAuthToken,
64
+ getIconPath,
65
+ openNotificationTarget,
66
+ onChange,
67
+ }) {
68
+ this.settingsPath = settingsPath;
69
+ this.appVersion = appVersion;
70
+ this.appName = appName;
71
+ this.getDeviceId = getDeviceId;
72
+ this.getAccountEmail = getAccountEmail;
73
+ this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
74
+ this.getApiKey = getApiKey;
75
+ this.getAuthToken = getAuthToken;
76
+ this.getIconPath = getIconPath;
77
+ this.openNotificationTarget = openNotificationTarget;
78
+ this.onChange = onChange;
79
+ this.settings = { enabled: false };
80
+ this.connections = new Map();
81
+ this.lastEvent = null;
82
+ this.lastError = null;
83
+ }
84
+
85
+ getState() {
86
+ const connectedTargets = [];
87
+ for (const [url, connection] of this.connections.entries()) {
88
+ if (connection.ws?.readyState === WebSocket.OPEN) {
89
+ connectedTargets.push(url);
90
+ }
91
+ }
92
+
93
+ return {
94
+ enabled: this.settings.enabled,
95
+ supported: Notification.isSupported(),
96
+ targetCount: this.connections.size,
97
+ connectedCount: connectedTargets.length,
98
+ connectedTargets,
99
+ lastEvent: this.lastEvent,
100
+ lastError: this.lastError,
101
+ };
102
+ }
103
+
104
+ async loadSettings() {
105
+ try {
106
+ const raw = await fs.readFile(this.settingsPath, 'utf8');
107
+ const stored = JSON.parse(raw);
108
+ this.settings = { enabled: Boolean(stored.enabled) };
109
+ } catch {
110
+ this.settings = { enabled: false };
111
+ }
112
+ return this.settings;
113
+ }
114
+
115
+ async saveSettings(next) {
116
+ const enabled = Boolean(next?.enabled);
117
+ if (!enabled && this.settings.enabled) {
118
+ await this.disableCurrentTargets();
119
+ }
120
+ this.settings = { enabled };
121
+ await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
122
+ await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
123
+ await this.sync();
124
+ this.onChange?.();
125
+ return this.settings;
126
+ }
127
+
128
+ async sync() {
129
+ if (!this.settings.enabled) {
130
+ this.stop();
131
+ this.lastEvent = 'disabled';
132
+ this.onChange?.();
133
+ return;
134
+ }
135
+
136
+ if (!Notification.isSupported()) {
137
+ this.stop();
138
+ this.lastEvent = 'unsupported';
139
+ this.lastError = 'Native notifications are not supported on this system.';
140
+ this.onChange?.();
141
+ return;
142
+ }
143
+
144
+ const deviceId = this.getDeviceId?.();
145
+ if (!deviceId) {
146
+ this.stop();
147
+ this.lastEvent = 'missing-device';
148
+ this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
149
+ this.onChange?.();
150
+ return;
151
+ }
152
+
153
+ const targets = (this.getRunningEnvironmentUrls?.() || [])
154
+ .map((httpUrl) => ({
155
+ httpUrl,
156
+ wsUrl: toNotificationsWsUrl(httpUrl),
157
+ }))
158
+ .filter((target) => target.wsUrl);
159
+
160
+ const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
161
+ for (const [wsUrl, connection] of this.connections.entries()) {
162
+ if (!nextWsUrls.has(wsUrl)) {
163
+ this.closeConnection(connection);
164
+ this.connections.delete(wsUrl);
165
+ }
166
+ }
167
+
168
+ for (const target of targets) {
169
+ if (!this.connections.has(target.wsUrl)) {
170
+ void this.connect(target).catch((error) => {
171
+ this.lastEvent = 'connect-error';
172
+ this.lastError = error instanceof Error ? error.message : String(error);
173
+ this.onChange?.();
174
+ });
175
+ }
176
+ }
177
+
178
+ this.lastEvent = targets.length ? 'sync' : 'no-targets';
179
+ this.onChange?.();
180
+ }
181
+
182
+ async connect(target, attempt = 0) {
183
+ const existing = this.connections.get(target.wsUrl);
184
+ if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
185
+ return;
186
+ }
187
+
188
+ const connection = {
189
+ ...target,
190
+ ws: null,
191
+ reconnectTimer: null,
192
+ closed: false,
193
+ attempt,
194
+ };
195
+ this.connections.set(target.wsUrl, connection);
196
+
197
+ const headers = await this.getTargetAuthHeaders(target.httpUrl);
198
+ if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
199
+ return;
200
+ }
201
+
202
+ const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
203
+ connection.ws = ws;
204
+
205
+ ws.on('open', async () => {
206
+ try {
207
+ await this.registerTarget(target.httpUrl);
208
+ ws.send(JSON.stringify({
209
+ type: 'register',
210
+ deviceId: this.getDeviceId?.(),
211
+ label: this.getAccountEmail?.() || this.appName,
212
+ platform: process.platform,
213
+ appVersion: this.appVersion,
214
+ }));
215
+ connection.attempt = 0;
216
+ this.lastEvent = 'connected';
217
+ this.lastError = null;
218
+ this.onChange?.();
219
+ } catch (error) {
220
+ this.lastEvent = 'register-error';
221
+ this.lastError = error instanceof Error ? error.message : String(error);
222
+ this.onChange?.();
223
+ try { ws.close(); } catch {}
224
+ }
225
+ });
226
+
227
+ ws.on('message', (raw) => this.handleMessage(target, ws, raw));
228
+ ws.on('close', () => this.scheduleReconnect(target.wsUrl));
229
+ ws.on('error', (error) => {
230
+ this.lastEvent = 'socket-error';
231
+ this.lastError = error instanceof Error ? error.message : String(error);
232
+ this.onChange?.();
233
+ });
234
+ }
235
+
236
+ async registerTarget(httpUrl) {
237
+ const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
238
+ await requestJson(url, {
239
+ method: 'POST',
240
+ headers: await this.getTargetAuthHeaders(httpUrl),
241
+ body: {
242
+ channel: 'desktop',
243
+ endpointId: this.getDeviceId?.(),
244
+ label: this.getAccountEmail?.() || this.appName,
245
+ metadata: {
246
+ platform: process.platform,
247
+ appVersion: this.appVersion,
248
+ },
249
+ enabled: true,
250
+ },
251
+ });
252
+ }
253
+
254
+ async disableCurrentTargets() {
255
+ const deviceId = this.getDeviceId?.();
256
+ if (!deviceId) return;
257
+
258
+ const targets = new Set([
259
+ ...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
260
+ ...(this.getRunningEnvironmentUrls?.() || []),
261
+ ]);
262
+
263
+ const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
264
+ const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
265
+ await requestJson(url, {
266
+ method: 'PATCH',
267
+ headers: await this.getTargetAuthHeaders(httpUrl),
268
+ body: { enabled: false },
269
+ });
270
+ }));
271
+
272
+ const rejected = results.find((result) => result.status === 'rejected');
273
+ if (rejected) {
274
+ this.lastEvent = 'disable-endpoint-error';
275
+ this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
276
+ }
277
+ }
278
+
279
+ async getTargetAuthHeaders(httpUrl) {
280
+ const headers = {};
281
+ const apiKey = this.getApiKey?.();
282
+ if (apiKey) {
283
+ headers['X-API-Key'] = apiKey;
284
+ }
285
+
286
+ const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
287
+ if (authToken) {
288
+ headers.Authorization = `Bearer ${authToken}`;
289
+ }
290
+ return headers;
291
+ }
292
+
293
+ handleMessage(target, ws, raw) {
294
+ const message = readJsonMessage(raw);
295
+ if (!message || message.type !== 'notification' || !message.payload) {
296
+ return;
297
+ }
298
+
299
+ const shown = this.showNativeNotification(target, message.payload);
300
+ if (shown && ws.readyState === WebSocket.OPEN) {
301
+ ws.send(JSON.stringify({
302
+ type: 'notification_ack',
303
+ id: message.id || message.payload?.data?.tag || null,
304
+ action: 'shown',
305
+ }));
306
+ }
307
+ }
308
+
309
+ showNativeNotification(target, payload) {
310
+ if (!Notification.isSupported()) return false;
311
+
312
+ const notification = new Notification({
313
+ title: payload.title || this.appName,
314
+ body: payload.body || '',
315
+ icon: this.getIconPath?.(),
316
+ silent: false,
317
+ });
318
+
319
+ notification.on('click', () => {
320
+ void this.openNotificationTarget?.({
321
+ environmentUrl: target.httpUrl,
322
+ sessionId: payload.data?.sessionId || null,
323
+ provider: payload.data?.provider || null,
324
+ }).catch((error) => {
325
+ this.lastEvent = 'click-error';
326
+ this.lastError = error instanceof Error ? error.message : String(error);
327
+ this.onChange?.();
328
+ });
329
+ });
330
+
331
+ notification.show();
332
+ this.lastEvent = 'notification-shown';
333
+ this.lastError = null;
334
+ this.onChange?.();
335
+ return true;
336
+ }
337
+
338
+ scheduleReconnect(wsUrl) {
339
+ const connection = this.connections.get(wsUrl);
340
+ if (!connection || connection.closed || !this.settings.enabled) {
341
+ return;
342
+ }
343
+
344
+ const attempt = connection.attempt + 1;
345
+ connection.attempt = attempt;
346
+ const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
347
+ connection.reconnectTimer = setTimeout(() => {
348
+ if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
349
+ void this.connect({
350
+ httpUrl: connection.httpUrl,
351
+ wsUrl: connection.wsUrl,
352
+ }, attempt).catch((error) => {
353
+ this.lastEvent = 'connect-error';
354
+ this.lastError = error instanceof Error ? error.message : String(error);
355
+ this.onChange?.();
356
+ });
357
+ }, delay);
358
+ this.lastEvent = 'reconnecting';
359
+ this.onChange?.();
360
+ }
361
+
362
+ closeConnection(connection) {
363
+ connection.closed = true;
364
+ if (connection.reconnectTimer) {
365
+ clearTimeout(connection.reconnectTimer);
366
+ connection.reconnectTimer = null;
367
+ }
368
+ try { connection.ws?.close(); } catch {}
369
+ }
370
+
371
+ stop() {
372
+ for (const connection of this.connections.values()) {
373
+ this.closeConnection(connection);
374
+ }
375
+ this.connections.clear();
376
+ this.onChange?.();
377
+ }
378
+ }