@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
}
|