@hellcoder/companion 0.96.0

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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Mock fetch globally
4
+ const mockFetch = vi.fn();
5
+ vi.stubGlobal("fetch", mockFetch);
6
+
7
+ // Mock settings-manager to control updateChannel
8
+ const mockGetSettings = vi.fn(() => ({
9
+ updateChannel: "stable" as "stable" | "prerelease",
10
+ }));
11
+ vi.mock("./settings-manager.js", () => ({
12
+ getSettings: () => mockGetSettings(),
13
+ }));
14
+
15
+ let checker: typeof import("./update-checker.js");
16
+
17
+ beforeEach(async () => {
18
+ vi.resetModules();
19
+ mockFetch.mockReset();
20
+ mockGetSettings.mockReturnValue({ updateChannel: "stable" });
21
+ checker = await import("./update-checker.js");
22
+ });
23
+
24
+ afterEach(() => {
25
+ checker.stopPeriodicCheck();
26
+ });
27
+
28
+ // ===========================================================================
29
+ // isNewerVersion — stable versions
30
+ // ===========================================================================
31
+ describe("isNewerVersion", () => {
32
+ it("returns true when major version is higher", () => {
33
+ expect(checker.isNewerVersion("2.0.0", "1.0.0")).toBe(true);
34
+ });
35
+
36
+ it("returns true when minor version is higher", () => {
37
+ expect(checker.isNewerVersion("1.1.0", "1.0.0")).toBe(true);
38
+ });
39
+
40
+ it("returns true when patch version is higher", () => {
41
+ expect(checker.isNewerVersion("1.0.1", "1.0.0")).toBe(true);
42
+ });
43
+
44
+ it("returns false when versions are equal", () => {
45
+ expect(checker.isNewerVersion("1.0.0", "1.0.0")).toBe(false);
46
+ });
47
+
48
+ it("returns false when version is lower", () => {
49
+ expect(checker.isNewerVersion("1.0.0", "1.0.1")).toBe(false);
50
+ expect(checker.isNewerVersion("0.9.0", "1.0.0")).toBe(false);
51
+ });
52
+ });
53
+
54
+ // ===========================================================================
55
+ // isNewerVersion — prerelease versions
56
+ // ===========================================================================
57
+ describe("isNewerVersion (prerelease)", () => {
58
+ // Stable release is newer than prerelease of the same core version
59
+ it("stable is newer than prerelease of same core version", () => {
60
+ expect(checker.isNewerVersion("1.0.0", "1.0.0-preview.1")).toBe(true);
61
+ });
62
+
63
+ // Prerelease is older than stable of the same core version
64
+ it("prerelease is older than stable of same core version", () => {
65
+ expect(checker.isNewerVersion("1.0.0-preview.1", "1.0.0")).toBe(false);
66
+ });
67
+
68
+ // Higher core version prerelease is newer than lower core stable
69
+ it("higher core prerelease is newer than lower core stable", () => {
70
+ expect(checker.isNewerVersion("1.1.0-preview.1", "1.0.0")).toBe(true);
71
+ });
72
+
73
+ // Later prerelease of same core is newer
74
+ it("later prerelease of same core is newer", () => {
75
+ expect(checker.isNewerVersion("1.0.0-preview.2", "1.0.0-preview.1")).toBe(true);
76
+ });
77
+
78
+ // Earlier prerelease of same core is older
79
+ it("earlier prerelease of same core is older", () => {
80
+ expect(checker.isNewerVersion("1.0.0-preview.1", "1.0.0-preview.2")).toBe(false);
81
+ });
82
+
83
+ // Handles timestamp-based prerelease identifiers
84
+ it("compares timestamp-based prerelease identifiers correctly", () => {
85
+ expect(checker.isNewerVersion(
86
+ "0.66.0-preview.20260228140000.abc1234",
87
+ "0.66.0-preview.20260228120000.def5678",
88
+ )).toBe(true);
89
+ });
90
+
91
+ // Equal prerelease versions
92
+ it("returns false for equal prerelease versions", () => {
93
+ expect(checker.isNewerVersion("1.0.0-preview.1", "1.0.0-preview.1")).toBe(false);
94
+ });
95
+
96
+ // Alphanumeric prerelease identifiers compared lexically
97
+ it("compares alphanumeric prerelease identifiers lexically", () => {
98
+ expect(checker.isNewerVersion("1.0.0-beta.1", "1.0.0-alpha.1")).toBe(true);
99
+ expect(checker.isNewerVersion("1.0.0-alpha.1", "1.0.0-beta.1")).toBe(false);
100
+ });
101
+ });
102
+
103
+ // ===========================================================================
104
+ // Prerelease update-channel regression tests (THE-216)
105
+ //
106
+ // The preview workflow publishes versions with a patch-core bump so that
107
+ // prerelease builds are always semver-ahead of the current stable line.
108
+ // These tests lock in the intended behavior to prevent regressions.
109
+ // ===========================================================================
110
+ describe("isNewerVersion — prerelease channel regressions (THE-216)", () => {
111
+ // A same-core prerelease (the old, broken format) must NOT be considered
112
+ // newer than the stable release it was derived from.
113
+ it("same-core prerelease is NOT newer than stable (old broken format)", () => {
114
+ // e.g. stable 0.68.0, preview publishes 0.68.0-preview.20260301120000.abc1234
115
+ expect(checker.isNewerVersion("0.68.0-preview.20260301120000.abc1234", "0.68.0")).toBe(false);
116
+ });
117
+
118
+ // A patch-bumped prerelease (the fixed format) IS newer than the stable
119
+ // release it was derived from.
120
+ it("patch-bumped prerelease IS newer than stable (fixed format)", () => {
121
+ // e.g. stable 0.68.0, preview publishes 0.68.1-preview.20260301120000.abc1234
122
+ expect(checker.isNewerVersion("0.68.1-preview.20260301120000.abc1234", "0.68.0")).toBe(true);
123
+ });
124
+
125
+ // Successive preview builds (same core, increasing timestamps) stay
126
+ // monotonically ordered.
127
+ it("later timestamp preview is newer than earlier timestamp preview", () => {
128
+ expect(checker.isNewerVersion(
129
+ "0.68.1-preview.20260301140000.abc1234",
130
+ "0.68.1-preview.20260301120000.def5678",
131
+ )).toBe(true);
132
+ });
133
+
134
+ // After a new stable release that matches or exceeds the preview core,
135
+ // the old preview is no longer considered newer.
136
+ it("stable release at preview core supersedes the preview", () => {
137
+ // When 0.68.1 stable is released, the preview 0.68.1-preview.* is older
138
+ expect(checker.isNewerVersion("0.68.1-preview.20260301120000.abc1234", "0.68.1")).toBe(false);
139
+ });
140
+
141
+ // A new stable that leapfrogs past the preview core is newer.
142
+ it("higher stable is newer than older-core preview", () => {
143
+ expect(checker.isNewerVersion("0.69.0", "0.68.1-preview.20260301120000.abc1234")).toBe(true);
144
+ });
145
+ });
146
+
147
+ // ===========================================================================
148
+ // getCurrentVersion
149
+ // ===========================================================================
150
+ describe("getCurrentVersion", () => {
151
+ it("returns a semver string", () => {
152
+ const version = checker.getCurrentVersion();
153
+ expect(version).toMatch(/^\d+\.\d+\.\d+/);
154
+ });
155
+ });
156
+
157
+ // ===========================================================================
158
+ // getUpdateState
159
+ // ===========================================================================
160
+ describe("getUpdateState", () => {
161
+ it("returns initial state with current version and no latest version", () => {
162
+ const state = checker.getUpdateState();
163
+ expect(state.currentVersion).toBe(checker.getCurrentVersion());
164
+ expect(state.latestVersion).toBeNull();
165
+ expect(state.isServiceMode).toBe(false);
166
+ expect(state.checking).toBe(false);
167
+ expect(state.updateInProgress).toBe(false);
168
+ expect(state.channel).toBe("stable");
169
+ });
170
+ });
171
+
172
+ // ===========================================================================
173
+ // checkForUpdate
174
+ // ===========================================================================
175
+ describe("checkForUpdate", () => {
176
+ it("fetches from stable dist-tag by default", async () => {
177
+ mockFetch.mockResolvedValueOnce({
178
+ ok: true,
179
+ json: () => Promise.resolve({ version: "99.0.0" }),
180
+ });
181
+
182
+ await checker.checkForUpdate();
183
+
184
+ // Should use /latest for stable channel. The "/" in "@hellcoder/companion"
185
+ // is URL-encoded to "%2F" because that is what npm's registry requires.
186
+ expect(mockFetch).toHaveBeenCalledWith(
187
+ "https://registry.npmjs.org/%40hellcoder%2Fcompanion/latest",
188
+ expect.objectContaining({
189
+ headers: { Accept: "application/json" },
190
+ }),
191
+ );
192
+ const state = checker.getUpdateState();
193
+ expect(state.latestVersion).toBe("99.0.0");
194
+ expect(state.lastChecked).toBeGreaterThan(0);
195
+ expect(state.channel).toBe("stable");
196
+ });
197
+
198
+ it("fetches from next dist-tag when channel is prerelease", async () => {
199
+ mockGetSettings.mockReturnValue({ updateChannel: "prerelease" });
200
+ mockFetch.mockResolvedValueOnce({
201
+ ok: true,
202
+ json: () => Promise.resolve({ version: "99.0.0-preview.1" }),
203
+ });
204
+
205
+ await checker.checkForUpdate();
206
+
207
+ // Should use /next for prerelease channel. Scoped name is URL-encoded.
208
+ expect(mockFetch).toHaveBeenCalledWith(
209
+ "https://registry.npmjs.org/%40hellcoder%2Fcompanion/next",
210
+ expect.objectContaining({
211
+ headers: { Accept: "application/json" },
212
+ }),
213
+ );
214
+ const state = checker.getUpdateState();
215
+ expect(state.latestVersion).toBe("99.0.0-preview.1");
216
+ expect(state.channel).toBe("prerelease");
217
+ });
218
+
219
+ // When switching channels, the previous channel's latestVersion must be
220
+ // cleared to avoid cross-channel stale comparisons.
221
+ it("clears latestVersion when channel changes to avoid stale comparison", async () => {
222
+ // First check on stable channel sets a latestVersion
223
+ mockFetch.mockResolvedValueOnce({
224
+ ok: true,
225
+ json: () => Promise.resolve({ version: "99.0.0" }),
226
+ });
227
+ await checker.checkForUpdate();
228
+ expect(checker.getUpdateState().latestVersion).toBe("99.0.0");
229
+
230
+ // Switch to prerelease but fetch fails
231
+ mockGetSettings.mockReturnValue({ updateChannel: "prerelease" });
232
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
233
+ await checker.checkForUpdate();
234
+
235
+ // latestVersion should be null (not the stale stable version)
236
+ const state = checker.getUpdateState();
237
+ expect(state.latestVersion).toBeNull();
238
+ expect(state.channel).toBe("prerelease");
239
+ });
240
+
241
+ it("handles fetch errors gracefully", async () => {
242
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
243
+
244
+ await checker.checkForUpdate();
245
+
246
+ const state = checker.getUpdateState();
247
+ expect(state.latestVersion).toBeNull();
248
+ });
249
+
250
+ it("handles non-ok response gracefully", async () => {
251
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
252
+
253
+ await checker.checkForUpdate();
254
+
255
+ const state = checker.getUpdateState();
256
+ expect(state.latestVersion).toBeNull();
257
+ });
258
+ });
259
+
260
+ // ===========================================================================
261
+ // isUpdateAvailable
262
+ // ===========================================================================
263
+ describe("isUpdateAvailable", () => {
264
+ it("returns false when no latest version is set", () => {
265
+ expect(checker.isUpdateAvailable()).toBe(false);
266
+ });
267
+
268
+ it("returns true when latest is newer than current", async () => {
269
+ mockFetch.mockResolvedValueOnce({
270
+ ok: true,
271
+ json: () => Promise.resolve({ version: "99.0.0" }),
272
+ });
273
+
274
+ await checker.checkForUpdate();
275
+ expect(checker.isUpdateAvailable()).toBe(true);
276
+ });
277
+
278
+ it("returns false when latest equals current", async () => {
279
+ mockFetch.mockResolvedValueOnce({
280
+ ok: true,
281
+ json: () => Promise.resolve({ version: checker.getCurrentVersion() }),
282
+ });
283
+
284
+ await checker.checkForUpdate();
285
+ expect(checker.isUpdateAvailable()).toBe(false);
286
+ });
287
+ });
288
+
289
+ // ===========================================================================
290
+ // setServiceMode / setUpdateInProgress
291
+ // ===========================================================================
292
+ describe("state setters", () => {
293
+ it("setServiceMode updates isServiceMode", () => {
294
+ checker.setServiceMode(true);
295
+ expect(checker.getUpdateState().isServiceMode).toBe(true);
296
+ checker.setServiceMode(false);
297
+ expect(checker.getUpdateState().isServiceMode).toBe(false);
298
+ });
299
+
300
+ it("setUpdateInProgress updates updateInProgress", () => {
301
+ checker.setUpdateInProgress(true);
302
+ expect(checker.getUpdateState().updateInProgress).toBe(true);
303
+ checker.setUpdateInProgress(false);
304
+ expect(checker.getUpdateState().updateInProgress).toBe(false);
305
+ });
306
+ });
@@ -0,0 +1,197 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getSettings, type UpdateChannel } from "./settings-manager.js";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ // Read current version from package.json
9
+ const packageJsonPath = resolve(__dirname, "..", "package.json");
10
+ const currentVersion: string = JSON.parse(
11
+ readFileSync(packageJsonPath, "utf-8"),
12
+ ).version;
13
+
14
+ // Package name is URL-encoded because scoped npm packages (e.g. @hellcoder/companion)
15
+ // require the slash to be escaped in registry URLs.
16
+ const NPM_PACKAGE_NAME = "@hellcoder/companion";
17
+ const NPM_REGISTRY_BASE = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE_NAME)}`;
18
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
19
+ const INITIAL_DELAY_MS = 10_000; // 10 seconds after boot
20
+
21
+ interface UpdateState {
22
+ currentVersion: string;
23
+ latestVersion: string | null;
24
+ lastChecked: number;
25
+ isServiceMode: boolean;
26
+ checking: boolean;
27
+ updateInProgress: boolean;
28
+ channel: UpdateChannel;
29
+ }
30
+
31
+ const state: UpdateState = {
32
+ currentVersion,
33
+ latestVersion: null,
34
+ lastChecked: 0,
35
+ isServiceMode: false,
36
+ checking: false,
37
+ updateInProgress: false,
38
+ channel: "stable",
39
+ };
40
+
41
+ export function getUpdateState(): Readonly<UpdateState> {
42
+ return { ...state };
43
+ }
44
+
45
+ export function getCurrentVersion(): string {
46
+ return currentVersion;
47
+ }
48
+
49
+ /** Returns the npm registry URL for the given dist-tag. */
50
+ function getRegistryUrl(channel: UpdateChannel): string {
51
+ const distTag = channel === "prerelease" ? "next" : "latest";
52
+ return `${NPM_REGISTRY_BASE}/${distTag}`;
53
+ }
54
+
55
+ export async function checkForUpdate(): Promise<void> {
56
+ if (state.checking) return;
57
+ state.checking = true;
58
+ try {
59
+ // Read channel from settings on each check so switching is immediate
60
+ const channel = getSettings().updateChannel;
61
+ if (channel !== state.channel) {
62
+ state.latestVersion = null; // avoid cross-channel stale comparison
63
+ }
64
+ state.channel = channel;
65
+ const url = getRegistryUrl(channel);
66
+
67
+ const res = await fetch(url, {
68
+ headers: { Accept: "application/json" },
69
+ signal: AbortSignal.timeout(10_000),
70
+ });
71
+ if (res.ok) {
72
+ const data = (await res.json()) as { version: string };
73
+ state.latestVersion = data.version;
74
+ state.lastChecked = Date.now();
75
+ if (isUpdateAvailable()) {
76
+ console.log(
77
+ `[update-checker] Update available (${channel}): ${currentVersion} -> ${state.latestVersion}`,
78
+ );
79
+ }
80
+ }
81
+ } catch (err) {
82
+ console.warn(
83
+ "[update-checker] Failed to check for updates:",
84
+ err instanceof Error ? err.message : String(err),
85
+ );
86
+ } finally {
87
+ state.checking = false;
88
+ }
89
+ }
90
+
91
+ export function setServiceMode(isService: boolean): void {
92
+ state.isServiceMode = isService;
93
+ }
94
+
95
+ export function setUpdateInProgress(inProgress: boolean): void {
96
+ state.updateInProgress = inProgress;
97
+ }
98
+
99
+ export function isUpdateAvailable(): boolean {
100
+ if (!state.latestVersion) return false;
101
+ return isNewerVersion(state.latestVersion, currentVersion);
102
+ }
103
+
104
+ /**
105
+ * Parse a semver string into its components.
106
+ * Handles versions like "1.2.3", "1.2.3-preview.20260228120000.abc1234"
107
+ */
108
+ function parseSemver(v: string): { major: number; minor: number; patch: number; prerelease: string[] } {
109
+ const [corePart, ...prereleaseParts] = v.split("-");
110
+ const prerelease = prereleaseParts.length > 0 ? prereleaseParts.join("-").split(".") : [];
111
+ const parts = corePart.split(".").map(Number);
112
+ return {
113
+ major: parts[0] || 0,
114
+ minor: parts[1] || 0,
115
+ patch: parts[2] || 0,
116
+ prerelease,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Compare two semver prerelease identifier arrays.
122
+ * Returns -1 if a < b, 0 if a == b, 1 if a > b.
123
+ * A version with no prerelease identifiers has higher precedence than one with.
124
+ */
125
+ function comparePrereleaseArrays(a: string[], b: string[]): number {
126
+ // No prerelease on both = equal
127
+ if (a.length === 0 && b.length === 0) return 0;
128
+ // No prerelease > has prerelease (stable is newer than prerelease of same core version)
129
+ if (a.length === 0) return 1;
130
+ if (b.length === 0) return -1;
131
+
132
+ const maxLen = Math.max(a.length, b.length);
133
+ for (let i = 0; i < maxLen; i++) {
134
+ // Fewer fields = lower precedence
135
+ if (i >= a.length) return -1;
136
+ if (i >= b.length) return 1;
137
+
138
+ const aNum = Number(a[i]);
139
+ const bNum = Number(b[i]);
140
+ const aIsNum = !isNaN(aNum);
141
+ const bIsNum = !isNaN(bNum);
142
+
143
+ if (aIsNum && bIsNum) {
144
+ if (aNum > bNum) return 1;
145
+ if (aNum < bNum) return -1;
146
+ } else if (aIsNum) {
147
+ // Numeric identifiers have lower precedence than alphanumeric
148
+ return -1;
149
+ } else if (bIsNum) {
150
+ return 1;
151
+ } else {
152
+ // Both alphanumeric: compare lexically
153
+ if (a[i] > b[i]) return 1;
154
+ if (a[i] < b[i]) return -1;
155
+ }
156
+ }
157
+ return 0;
158
+ }
159
+
160
+ /**
161
+ * Prerelease-aware semver comparison: returns true if a > b.
162
+ * Handles both stable versions (1.2.3) and prerelease versions
163
+ * (1.2.3-preview.20260228120000.abc1234).
164
+ */
165
+ export function isNewerVersion(a: string, b: string): boolean {
166
+ const pa = parseSemver(a);
167
+ const pb = parseSemver(b);
168
+
169
+ // Compare major.minor.patch
170
+ if (pa.major !== pb.major) return pa.major > pb.major;
171
+ if (pa.minor !== pb.minor) return pa.minor > pb.minor;
172
+ if (pa.patch !== pb.patch) return pa.patch > pb.patch;
173
+
174
+ // Core versions are equal — compare prerelease
175
+ return comparePrereleaseArrays(pa.prerelease, pb.prerelease) > 0;
176
+ }
177
+
178
+ let intervalId: ReturnType<typeof setInterval> | null = null;
179
+
180
+ export function startPeriodicCheck(): void {
181
+ // Initial check after a short delay
182
+ setTimeout(() => {
183
+ checkForUpdate();
184
+ }, INITIAL_DELAY_MS);
185
+
186
+ // Periodic checks
187
+ intervalId = setInterval(() => {
188
+ checkForUpdate();
189
+ }, CHECK_INTERVAL_MS);
190
+ }
191
+
192
+ export function stopPeriodicCheck(): void {
193
+ if (intervalId) {
194
+ clearInterval(intervalId);
195
+ intervalId = null;
196
+ }
197
+ }