@neuralnomads/codenomad-dev 0.18.0-dev-20260715-07e8ce68 → 0.18.0-dev-20260718-8f6aa44b
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/README.md +12 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/dist/auth/manager.js +1 -1
- package/dist/events/bus.js +13 -0
- package/dist/events/bus.test.js +37 -0
- package/dist/filesystem/__tests__/search-cache.test.js +5 -5
- package/dist/filesystem/search-cache.js +1 -1
- package/dist/index.js +58 -56
- package/dist/index.test.js +62 -0
- package/dist/opencode-update/service.js +150 -0
- package/dist/opencode-update/service.test.js +107 -0
- package/dist/server/__tests__/remote-proxy.test.js +151 -47
- package/dist/server/http-server.js +9 -1
- package/dist/server/remote-proxy.js +91 -34
- package/dist/server/routes/opencode-update.js +37 -0
- package/dist/server/routes/opencode-update.test.js +34 -0
- package/dist/server/routes/remote-servers.js +1 -1
- package/dist/server/routes/usage.js +18 -0
- package/dist/server/routes/usage.test.js +26 -0
- package/dist/server/routes/workspaces.js +32 -1
- package/dist/server/routes/workspaces.test.js +113 -0
- package/dist/server/tls.js +2 -2
- package/dist/settings/binaries.js +4 -1
- package/dist/settings/binaries.test.js +19 -0
- package/dist/shutdown.js +83 -0
- package/dist/shutdown.test.js +148 -0
- package/dist/ui/remote-ui.js +2 -2
- package/dist/usage/providers/api-key.js +171 -0
- package/dist/usage/providers/google.js +171 -0
- package/dist/usage/providers/minimax.js +109 -0
- package/dist/usage/providers/oauth.js +111 -0
- package/dist/usage/providers/special.js +148 -0
- package/dist/usage/service.js +83 -0
- package/dist/usage/service.test.js +59 -0
- package/dist/usage/shared.js +130 -0
- package/dist/usage/shared.test.js +33 -0
- package/dist/usage/types.js +1 -0
- package/dist/workspaces/__tests__/spawn.test.js +64 -7
- package/dist/workspaces/__tests__/workspace-identity.test.js +99 -139
- package/dist/workspaces/binary-path.test.js +10 -0
- package/dist/workspaces/instance-client.js +4 -3
- package/dist/workspaces/launch-cleanup.test.js +29 -0
- package/dist/workspaces/manager.js +399 -192
- package/dist/workspaces/manager.test.js +290 -0
- package/dist/workspaces/process-identity.darwin.test.js +88 -0
- package/dist/workspaces/process-identity.js +431 -0
- package/dist/workspaces/process-identity.test.js +111 -0
- package/dist/workspaces/runtime.js +471 -138
- package/dist/workspaces/runtime.test.js +278 -0
- package/dist/workspaces/spawn.js +86 -15
- package/package.json +1 -1
- package/public/assets/{FilesTab-EPvldvn1.js → FilesTab-B0-DlSCT.js} +2 -2
- package/public/assets/{GitChangesTab-ofjovmv2.js → GitChangesTab-CxjbNBVr.js} +2 -2
- package/public/assets/{SplitFilePanel-CyoPkcLd.js → SplitFilePanel-CLQb-6uX.js} +1 -1
- package/public/assets/StatusTab-CBGAuhmp.js +1 -0
- package/public/assets/{align-justify-Bcf1-ZOl.js → align-justify-s5mnjsP0.js} +1 -1
- package/public/assets/{bundle-full-D5dzvToA.js → bundle-full-lwIMY5om.js} +1 -1
- package/public/assets/{diff-viewer-DViXCI-4.js → diff-viewer-CDtmFe6e.js} +1 -1
- package/public/assets/{git-diff-vendor-CSgooKT_.js → git-diff-vendor-CYS3ApzA.js} +1 -1
- package/public/assets/index-6ABz_Izb.js +1 -0
- package/public/assets/index-BPdjM9ZI.css +1 -0
- package/public/assets/index-BQK8um1r.js +1 -0
- package/public/assets/index-BkUAI4ZY.js +1 -0
- package/public/assets/index-D8bkUvEd.js +2 -0
- package/public/assets/index-DY_sbCt9.js +1 -0
- package/public/assets/index-DhmUOdAI.js +1 -0
- package/public/assets/{index-UbL8tz_V.js → index-DupnEKCQ.js} +1 -1
- package/public/assets/index-HhZzWWha.js +1 -0
- package/public/assets/{index-lf4nUOU1.js → index-lBAEWdAF.js} +1 -1
- package/public/assets/index-nVII4VJz.js +1 -0
- package/public/assets/index-p1Sble5V.js +1 -0
- package/public/assets/{loading-CZ_tt3h0.js → loading-h8DvUqqc.js} +1 -1
- package/public/assets/main-BxUSjoWA.js +68 -0
- package/public/assets/{markdown-G11aN6hz.js → markdown-Djug_spc.js} +55 -44
- package/public/assets/monaco-viewer-lkUcsK65.js +26 -0
- package/public/assets/{tool-call-BsSXWErm.js → tool-call-C4YH896w.js} +32 -32
- package/public/assets/{unified-picker-BRNI3UvE.js → unified-picker-DwNZOVlY.js} +1 -1
- package/public/assets/{wrap-text-B7up-uv7.js → wrap-text-BLVYxvcj.js} +1 -1
- package/public/index.html +5 -5
- package/public/loading.html +5 -5
- package/public/sw.js +1 -1
- package/public/assets/StatusTab-Bgu1Fh24.js +0 -1
- package/public/assets/index-3KsjbWGP.js +0 -1
- package/public/assets/index-B0WK8-cF.js +0 -1
- package/public/assets/index-BDv_iVtj.js +0 -1
- package/public/assets/index-BMvFMsuy.css +0 -1
- package/public/assets/index-BjxZvyb9.js +0 -2
- package/public/assets/index-BleKf9Nj.js +0 -1
- package/public/assets/index-CiIQ43NW.js +0 -1
- package/public/assets/index-DBquqL6Q.js +0 -1
- package/public/assets/index-yf9zolv4.js +0 -1
- package/public/assets/index-ypaL7i_s.js +0 -1
- package/public/assets/main-CAkg_fp_.js +0 -68
- package/public/assets/monaco-viewer-CZefBfct.js +0 -26
package/README.md
CHANGED
|
@@ -217,3 +217,15 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|
|
217
217
|
|
|
218
218
|
- **Config**: `~/.config/codenomad/config.json`
|
|
219
219
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
|
220
|
+
|
|
221
|
+
### Provider Plan Usage
|
|
222
|
+
|
|
223
|
+
The Status panel automatically displays quota information for the provider used by the active session. CodeNomad reads existing OpenCode credentials and never returns provider secrets through its API.
|
|
224
|
+
|
|
225
|
+
Some optional usage integrations require credentials that OpenCode does not expose. They can be enabled without UI configuration through these environment variables:
|
|
226
|
+
|
|
227
|
+
- Google token refresh: `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`
|
|
228
|
+
- Antigravity token refresh: `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`
|
|
229
|
+
- Cursor: `CURSOR_ACCESS_TOKEN` or `CURSOR_TOKEN`, with optional `CURSOR_REFRESH_TOKEN`
|
|
230
|
+
- Ollama Cloud: `OLLAMA_CLOUD_COOKIE`
|
|
231
|
+
- OpenCode Go: `OPENCODE_GO_WORKSPACE_ID` and `OPENCODE_GO_AUTH_COOKIE`
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
The provider usage adapters are derived from OpenChamber:
|
|
4
|
+
https://github.com/openchamber/openchamber
|
|
5
|
+
|
|
6
|
+
MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Bohdan Triapitsyn
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/dist/auth/manager.js
CHANGED
|
@@ -26,7 +26,7 @@ export class AuthManager {
|
|
|
26
26
|
password: init.password,
|
|
27
27
|
allowBootstrapWithoutPassword: init.generateToken,
|
|
28
28
|
});
|
|
29
|
-
this.tokenManager = init.generateToken ? new TokenManager(
|
|
29
|
+
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null;
|
|
30
30
|
}
|
|
31
31
|
isAuthEnabled() {
|
|
32
32
|
return this.authEnabled;
|
package/dist/events/bus.js
CHANGED
|
@@ -3,8 +3,19 @@ export class EventBus extends EventEmitter {
|
|
|
3
3
|
constructor(logger) {
|
|
4
4
|
super();
|
|
5
5
|
this.logger = logger;
|
|
6
|
+
this.instanceStatuses = new Map();
|
|
6
7
|
}
|
|
7
8
|
publish(event) {
|
|
9
|
+
if (event.type === "instance.eventStatus") {
|
|
10
|
+
const terminal = event.status === "disconnected"
|
|
11
|
+
&& (event.reason === "workspace stopped" || event.reason === "workspace error");
|
|
12
|
+
if (terminal) {
|
|
13
|
+
this.instanceStatuses.delete(event.instanceId);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
this.instanceStatuses.set(event.instanceId, event);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
8
19
|
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
|
9
20
|
this.logger?.debug({ type: event.type }, "Publishing workspace event");
|
|
10
21
|
if (this.logger?.isLevelEnabled("trace")) {
|
|
@@ -29,6 +40,8 @@ export class EventBus extends EventEmitter {
|
|
|
29
40
|
this.on("instance.eventStatus", handler);
|
|
30
41
|
this.on("yolo.stateChanged", handler);
|
|
31
42
|
this.on("yolo.autoAccepted", handler);
|
|
43
|
+
for (const status of this.instanceStatuses.values())
|
|
44
|
+
listener(status);
|
|
32
45
|
return () => {
|
|
33
46
|
this.off("workspace.created", handler);
|
|
34
47
|
this.off("workspace.started", handler);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { EventBus } from "./bus";
|
|
4
|
+
describe("event bus instance status replay", () => {
|
|
5
|
+
it("replays the latest instance status to a late subscriber", () => {
|
|
6
|
+
const bus = new EventBus();
|
|
7
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connecting" });
|
|
8
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
|
|
9
|
+
const received = [];
|
|
10
|
+
bus.onEvent((event) => received.push(event));
|
|
11
|
+
assert.deepEqual(received, [
|
|
12
|
+
{ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
it("delivers terminal disconnects live without replaying stopped workspaces", () => {
|
|
16
|
+
const bus = new EventBus();
|
|
17
|
+
bus.publish({ type: "instance.eventStatus", instanceId: "workspace-1", status: "connected" });
|
|
18
|
+
const live = [];
|
|
19
|
+
bus.onEvent((event) => live.push(event));
|
|
20
|
+
live.length = 0;
|
|
21
|
+
bus.publish({
|
|
22
|
+
type: "instance.eventStatus",
|
|
23
|
+
instanceId: "workspace-1",
|
|
24
|
+
status: "disconnected",
|
|
25
|
+
reason: "workspace stopped",
|
|
26
|
+
});
|
|
27
|
+
assert.deepEqual(live, [{
|
|
28
|
+
type: "instance.eventStatus",
|
|
29
|
+
instanceId: "workspace-1",
|
|
30
|
+
status: "disconnected",
|
|
31
|
+
reason: "workspace stopped",
|
|
32
|
+
}]);
|
|
33
|
+
const replayed = [];
|
|
34
|
+
bus.onEvent((event) => replayed.push(event));
|
|
35
|
+
assert.deepEqual(replayed, []);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -7,7 +7,7 @@ describe("workspace search cache", () => {
|
|
|
7
7
|
});
|
|
8
8
|
it("expires cached candidates after the TTL", () => {
|
|
9
9
|
const workspacePath = "/tmp/workspace";
|
|
10
|
-
const startTime =
|
|
10
|
+
const startTime = 1_000;
|
|
11
11
|
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
|
|
12
12
|
const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
|
|
13
13
|
assert.ok(beforeExpiry);
|
|
@@ -18,12 +18,12 @@ describe("workspace search cache", () => {
|
|
|
18
18
|
});
|
|
19
19
|
it("replaces cached entries when manually refreshed", () => {
|
|
20
20
|
const workspacePath = "/tmp/workspace";
|
|
21
|
-
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")],
|
|
22
|
-
const initial = getWorkspaceCandidates(workspacePath,
|
|
21
|
+
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000);
|
|
22
|
+
const initial = getWorkspaceCandidates(workspacePath, 5_001);
|
|
23
23
|
assert.ok(initial);
|
|
24
24
|
assert.equal(initial[0].name, "file-a");
|
|
25
|
-
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")],
|
|
26
|
-
const refreshed = getWorkspaceCandidates(workspacePath,
|
|
25
|
+
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000);
|
|
26
|
+
const refreshed = getWorkspaceCandidates(workspacePath, 6_001);
|
|
27
27
|
assert.ok(refreshed);
|
|
28
28
|
assert.equal(refreshed[0].name, "file-b");
|
|
29
29
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS =
|
|
2
|
+
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000;
|
|
3
3
|
const workspaceCandidateCache = new Map();
|
|
4
4
|
export function getWorkspaceCandidates(rootDir, now = Date.now()) {
|
|
5
5
|
const key = normalizeKey(rootDir);
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { ClientConnectionManager } from "./clients/connection-manager";
|
|
|
31
31
|
import { PluginChannelManager } from "./plugins/channel";
|
|
32
32
|
import { VoiceModeManager } from "./plugins/voice-mode";
|
|
33
33
|
import { runCliUpgrade } from "./cli-upgrade";
|
|
34
|
+
import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown";
|
|
34
35
|
const require = createRequire(import.meta.url);
|
|
35
36
|
const packageJson = require("../package.json");
|
|
36
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -40,6 +41,29 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
40
41
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
|
|
41
42
|
const DEFAULT_HTTPS_PORT = 9898;
|
|
42
43
|
const DEFAULT_HTTP_PORT = 9899;
|
|
44
|
+
export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown";
|
|
45
|
+
export function installShutdownSignalHandlers(source, shutdown) {
|
|
46
|
+
source.on("SIGINT", () => void shutdown("SIGINT"));
|
|
47
|
+
source.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
48
|
+
}
|
|
49
|
+
export function installShutdownStdinHandler(source, shutdown) {
|
|
50
|
+
let buffer = "";
|
|
51
|
+
let requested = false;
|
|
52
|
+
const onData = (chunk) => {
|
|
53
|
+
if (requested)
|
|
54
|
+
return;
|
|
55
|
+
buffer += chunk.toString();
|
|
56
|
+
const lines = buffer.split(/\r?\n/);
|
|
57
|
+
buffer = lines.pop() ?? "";
|
|
58
|
+
if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND))
|
|
59
|
+
return;
|
|
60
|
+
requested = true;
|
|
61
|
+
source.off?.("data", onData);
|
|
62
|
+
source.destroy?.();
|
|
63
|
+
void shutdown("stdin");
|
|
64
|
+
};
|
|
65
|
+
source.on("data", onData);
|
|
66
|
+
}
|
|
43
67
|
function parseCliOptions(argv) {
|
|
44
68
|
const program = new Command()
|
|
45
69
|
.name("codenomad")
|
|
@@ -424,61 +448,39 @@ async function main() {
|
|
|
424
448
|
if (options.launch) {
|
|
425
449
|
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }));
|
|
426
450
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
try {
|
|
449
|
-
clientConnectionManager.shutdown();
|
|
450
|
-
}
|
|
451
|
-
catch (error) {
|
|
452
|
-
logger.warn({ err: error }, "Client connection manager shutdown failed");
|
|
453
|
-
}
|
|
454
|
-
try {
|
|
455
|
-
await workspaceManager.shutdown();
|
|
456
|
-
logger.info("Workspace manager shutdown complete");
|
|
457
|
-
}
|
|
458
|
-
catch (error) {
|
|
459
|
-
logger.error({ err: error }, "Workspace manager shutdown failed");
|
|
460
|
-
}
|
|
461
|
-
})();
|
|
462
|
-
const shutdownHttp = (async () => {
|
|
463
|
-
try {
|
|
464
|
-
await Promise.allSettled(servers.map((srv) => srv.stop()));
|
|
451
|
+
const shutdown = createServerShutdownHandler({
|
|
452
|
+
logger,
|
|
453
|
+
holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000); }),
|
|
454
|
+
setExitCode: (code) => {
|
|
455
|
+
process.stdin.destroy();
|
|
456
|
+
process.exitCode = code;
|
|
457
|
+
},
|
|
458
|
+
shutdown: () => orchestrateServerShutdown({
|
|
459
|
+
stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
|
|
460
|
+
stopSidecars: () => sidecarManager.shutdown(),
|
|
461
|
+
stopClientConnections: () => clientConnectionManager.shutdown(),
|
|
462
|
+
stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
|
|
463
|
+
stopWorkspaces: () => workspaceManager.shutdown(),
|
|
464
|
+
stopHttpServers: async () => {
|
|
465
|
+
const results = await Promise.allSettled(servers.map((srv) => srv.stop()));
|
|
466
|
+
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
|
467
|
+
if (failures.length > 0) {
|
|
468
|
+
const error = new Error("One or more HTTP servers failed to stop");
|
|
469
|
+
error.failures = failures;
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
465
472
|
logger.info("HTTP server(s) stopped");
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
473
|
+
},
|
|
474
|
+
stopReleaseMonitor: () => devReleaseMonitor?.stop(),
|
|
475
|
+
}, logger),
|
|
476
|
+
});
|
|
477
|
+
installShutdownSignalHandlers(process, shutdown);
|
|
478
|
+
installShutdownStdinHandler(process.stdin, shutdown);
|
|
479
|
+
}
|
|
480
|
+
if (path.resolve(process.argv[1] ?? "") === __filename) {
|
|
481
|
+
main().catch((error) => {
|
|
482
|
+
const logger = createLogger({ component: "app" });
|
|
483
|
+
logger.error({ err: error }, "CLI server crashed");
|
|
484
|
+
process.exit(1);
|
|
485
|
+
});
|
|
479
486
|
}
|
|
480
|
-
main().catch((error) => {
|
|
481
|
-
const logger = createLogger({ component: "app" });
|
|
482
|
-
logger.error({ err: error }, "CLI server crashed");
|
|
483
|
-
process.exit(1);
|
|
484
|
-
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index";
|
|
6
|
+
import { createServerShutdownHandler } from "./shutdown";
|
|
7
|
+
describe("CLI shutdown signal registration", () => {
|
|
8
|
+
it("routes a second process signal to forced nonzero escalation", async () => {
|
|
9
|
+
const listeners = new Map();
|
|
10
|
+
let finishCleanup;
|
|
11
|
+
const cleanup = new Promise((resolve) => {
|
|
12
|
+
finishCleanup = resolve;
|
|
13
|
+
});
|
|
14
|
+
const exits = [];
|
|
15
|
+
let handled;
|
|
16
|
+
const shutdown = createServerShutdownHandler({
|
|
17
|
+
shutdown: () => cleanup,
|
|
18
|
+
logger: { info() { }, warn() { }, error() { } },
|
|
19
|
+
forceExit: (code) => exits.push(code),
|
|
20
|
+
setExitCode: () => undefined,
|
|
21
|
+
reportStatus: () => undefined,
|
|
22
|
+
});
|
|
23
|
+
installShutdownSignalHandlers({ on: (signal, listener) => listeners.set(signal, listener) }, (signal) => (handled = shutdown(signal)));
|
|
24
|
+
listeners.get("SIGINT")?.();
|
|
25
|
+
listeners.get("SIGTERM")?.();
|
|
26
|
+
assert.deepEqual(exits, [1]);
|
|
27
|
+
finishCleanup();
|
|
28
|
+
await handled;
|
|
29
|
+
});
|
|
30
|
+
it("coalesces chunked and repeated stdin shutdown commands through the same handler", async () => {
|
|
31
|
+
const listeners = new Map();
|
|
32
|
+
const triggers = [];
|
|
33
|
+
let destroys = 0;
|
|
34
|
+
const source = {
|
|
35
|
+
on: (event, listener) => listeners.set(event, listener),
|
|
36
|
+
off: (event, listener) => {
|
|
37
|
+
if (listeners.get(event) === listener)
|
|
38
|
+
listeners.delete(event);
|
|
39
|
+
},
|
|
40
|
+
destroy: () => { destroys++; },
|
|
41
|
+
};
|
|
42
|
+
installShutdownStdinHandler(source, async (trigger) => { triggers.push(trigger); });
|
|
43
|
+
const listener = listeners.get("data");
|
|
44
|
+
listener(STDIN_SHUTDOWN_COMMAND.slice(0, 8));
|
|
45
|
+
listener(`${STDIN_SHUTDOWN_COMMAND.slice(8)}\n${STDIN_SHUTDOWN_COMMAND}\n`);
|
|
46
|
+
listener(`${STDIN_SHUTDOWN_COMMAND}\n`);
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
assert.deepEqual(triggers, ["stdin"]);
|
|
49
|
+
assert.equal(destroys, 1);
|
|
50
|
+
assert.equal(listeners.has("data"), false);
|
|
51
|
+
});
|
|
52
|
+
it("allows a real piped process to exit naturally after the shutdown command", { timeout: 5_000 }, async () => {
|
|
53
|
+
const moduleUrl = new URL("./index.ts", import.meta.url).href;
|
|
54
|
+
const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", `
|
|
55
|
+
import { installShutdownStdinHandler } from ${JSON.stringify(moduleUrl)}
|
|
56
|
+
installShutdownStdinHandler(process.stdin, async () => { process.exitCode = 0 })
|
|
57
|
+
`], { stdio: ["pipe", "ignore", "inherit"] });
|
|
58
|
+
child.stdin.end(`${STDIN_SHUTDOWN_COMMAND}\n`);
|
|
59
|
+
const [code] = await once(child, "exit");
|
|
60
|
+
assert.equal(code, 0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
2
|
+
import { BinaryResolver } from "../settings/binaries";
|
|
3
|
+
import { createInstanceClient } from "../workspaces/instance-client";
|
|
4
|
+
import { probeBinaryVersion } from "../workspaces/spawn";
|
|
5
|
+
import { compareVersionStrings, stripTagPrefix } from "../releases/release-monitor";
|
|
6
|
+
const OPENCODE_LATEST_URL = "https://registry.npmjs.org/opencode-ai/latest";
|
|
7
|
+
const LATEST_VERSION_CACHE_MS = 5 * 60_000;
|
|
8
|
+
const UPGRADE_TIMEOUT_MS = 10 * 60_000;
|
|
9
|
+
const inFlightUpgrades = new Map();
|
|
10
|
+
export class OpenCodeUpdateError extends Error {
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "OpenCodeUpdateError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class OpenCodeUpdateService {
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
this.latestVersionCache = null;
|
|
21
|
+
}
|
|
22
|
+
async getStatus() {
|
|
23
|
+
const binary = this.deps.resolveBinary();
|
|
24
|
+
const currentVersion = this.readCurrentVersion(binary.path);
|
|
25
|
+
let latestVersion;
|
|
26
|
+
try {
|
|
27
|
+
latestVersion = await this.readLatestVersion();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (!(error instanceof OpenCodeUpdateError) || error.code !== "update_check_failed")
|
|
31
|
+
throw error;
|
|
32
|
+
return {
|
|
33
|
+
currentVersion,
|
|
34
|
+
latestVersion: null,
|
|
35
|
+
updateAvailable: null,
|
|
36
|
+
canUpgrade: false,
|
|
37
|
+
checkError: "update_check_failed",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const updateAvailable = compareVersionStrings(latestVersion, currentVersion) > 0;
|
|
41
|
+
const readyInstanceId = this.deps.findReadyInstanceId(binary.path);
|
|
42
|
+
return {
|
|
43
|
+
currentVersion,
|
|
44
|
+
latestVersion,
|
|
45
|
+
updateAvailable,
|
|
46
|
+
canUpgrade: updateAvailable && Boolean(readyInstanceId),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
upgrade() {
|
|
50
|
+
const binary = this.deps.resolveBinary();
|
|
51
|
+
const existing = inFlightUpgrades.get(binary.path);
|
|
52
|
+
if (existing)
|
|
53
|
+
return existing;
|
|
54
|
+
const pending = this.performUpgrade(binary).finally(() => {
|
|
55
|
+
if (inFlightUpgrades.get(binary.path) === pending)
|
|
56
|
+
inFlightUpgrades.delete(binary.path);
|
|
57
|
+
});
|
|
58
|
+
inFlightUpgrades.set(binary.path, pending);
|
|
59
|
+
return pending;
|
|
60
|
+
}
|
|
61
|
+
async performUpgrade(binary) {
|
|
62
|
+
const currentVersion = this.readCurrentVersion(binary.path);
|
|
63
|
+
const latestVersion = await this.readLatestVersion();
|
|
64
|
+
if (compareVersionStrings(latestVersion, currentVersion) <= 0) {
|
|
65
|
+
return { success: true, version: currentVersion };
|
|
66
|
+
}
|
|
67
|
+
const instanceId = this.deps.findReadyInstanceId(binary.path);
|
|
68
|
+
if (!instanceId) {
|
|
69
|
+
throw new OpenCodeUpdateError("no_ready_instance", "No running OpenCode instance uses the configured binary");
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const result = await this.deps.upgradeInstance(instanceId, latestVersion);
|
|
73
|
+
if (!result.success) {
|
|
74
|
+
throw new OpenCodeUpdateError("upgrade_failed", result.error);
|
|
75
|
+
}
|
|
76
|
+
const installedVersion = this.readCurrentVersion(binary.path);
|
|
77
|
+
if (compareVersionStrings(installedVersion, latestVersion) !== 0) {
|
|
78
|
+
throw new OpenCodeUpdateError("upgrade_verification_failed", `OpenCode reported ${result.version}, but the configured binary is still ${installedVersion}`);
|
|
79
|
+
}
|
|
80
|
+
return { success: true, version: installedVersion };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof OpenCodeUpdateError)
|
|
84
|
+
throw error;
|
|
85
|
+
throw new OpenCodeUpdateError("upgrade_failed", error instanceof Error ? error.message : "OpenCode upgrade failed");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
readCurrentVersion(binaryPath) {
|
|
89
|
+
if (process.platform === "win32" && /["\r\n]/.test(binaryPath)) {
|
|
90
|
+
throw new OpenCodeUpdateError("binary_unavailable", "The configured OpenCode binary path is invalid");
|
|
91
|
+
}
|
|
92
|
+
const result = this.deps.probeBinary(binaryPath);
|
|
93
|
+
const version = stripTagPrefix(result.version);
|
|
94
|
+
if (!result.valid || !version) {
|
|
95
|
+
throw new OpenCodeUpdateError("binary_unavailable", result.error ?? "Unable to read OpenCode version");
|
|
96
|
+
}
|
|
97
|
+
return version;
|
|
98
|
+
}
|
|
99
|
+
async readLatestVersion() {
|
|
100
|
+
const now = (this.deps.now ?? Date.now)();
|
|
101
|
+
if (this.latestVersionCache && this.latestVersionCache.expiresAt > now) {
|
|
102
|
+
return this.latestVersionCache.version;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const version = stripTagPrefix(await this.deps.fetchLatestVersion());
|
|
106
|
+
if (!version || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
107
|
+
throw new Error("OpenCode registry returned an invalid version");
|
|
108
|
+
}
|
|
109
|
+
this.latestVersionCache = { version, expiresAt: now + LATEST_VERSION_CACHE_MS };
|
|
110
|
+
return version;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw new OpenCodeUpdateError("update_check_failed", error instanceof Error ? error.message : "Unable to check the latest OpenCode version");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export async function fetchLatestOpenCodeVersion() {
|
|
118
|
+
const response = await fetch(OPENCODE_LATEST_URL, {
|
|
119
|
+
headers: { Accept: "application/json", "User-Agent": "CodeNomad-CLI" },
|
|
120
|
+
signal: AbortSignal.timeout(10_000),
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw new Error(`OpenCode registry responded with ${response.status}`);
|
|
124
|
+
}
|
|
125
|
+
const payload = (await response.json());
|
|
126
|
+
if (typeof payload.version !== "string") {
|
|
127
|
+
throw new Error("OpenCode registry response did not include a version");
|
|
128
|
+
}
|
|
129
|
+
return payload.version;
|
|
130
|
+
}
|
|
131
|
+
export function createOpenCodeUpdateService(settings, workspaceManager) {
|
|
132
|
+
const binaryResolver = new BinaryResolver(settings);
|
|
133
|
+
return new OpenCodeUpdateService({
|
|
134
|
+
resolveBinary: () => {
|
|
135
|
+
const binary = binaryResolver.resolveDefault();
|
|
136
|
+
return { ...binary, path: workspaceManager.resolveBinaryPath(binary.path) };
|
|
137
|
+
},
|
|
138
|
+
probeBinary: probeBinaryVersion,
|
|
139
|
+
findReadyInstanceId: (binaryPath) => workspaceManager.findReadyInstanceIdByBinary(binaryPath),
|
|
140
|
+
fetchLatestVersion: fetchLatestOpenCodeVersion,
|
|
141
|
+
upgradeInstance: async (instanceId, target) => {
|
|
142
|
+
const client = createInstanceClient(workspaceManager, instanceId, { timeoutMs: UPGRADE_TIMEOUT_MS });
|
|
143
|
+
if (!client) {
|
|
144
|
+
throw new OpenCodeUpdateError("no_ready_instance", "OpenCode instance is not ready");
|
|
145
|
+
}
|
|
146
|
+
const { data } = await client.global.upgrade({ target }, { throwOnError: true });
|
|
147
|
+
return data;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { OpenCodeUpdateError, OpenCodeUpdateService } from "./service";
|
|
4
|
+
function createDeps(overrides = {}) {
|
|
5
|
+
let currentVersion = "1.0.0";
|
|
6
|
+
return {
|
|
7
|
+
resolveBinary: () => ({ path: "opencode", label: "OpenCode" }),
|
|
8
|
+
probeBinary: () => ({ valid: true, version: currentVersion }),
|
|
9
|
+
findReadyInstanceId: () => "workspace-1",
|
|
10
|
+
fetchLatestVersion: async () => "1.1.0",
|
|
11
|
+
upgradeInstance: async (_instanceId, target) => {
|
|
12
|
+
currentVersion = target;
|
|
13
|
+
return { success: true, version: target };
|
|
14
|
+
},
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
test("reports an available update and caches the latest version", async () => {
|
|
19
|
+
let checks = 0;
|
|
20
|
+
const service = new OpenCodeUpdateService(createDeps({
|
|
21
|
+
fetchLatestVersion: async () => {
|
|
22
|
+
checks += 1;
|
|
23
|
+
return "1.1.0";
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
assert.deepEqual(await service.getStatus(), {
|
|
27
|
+
currentVersion: "1.0.0",
|
|
28
|
+
latestVersion: "1.1.0",
|
|
29
|
+
updateAvailable: true,
|
|
30
|
+
canUpgrade: true,
|
|
31
|
+
});
|
|
32
|
+
await service.getStatus();
|
|
33
|
+
assert.equal(checks, 1);
|
|
34
|
+
});
|
|
35
|
+
test("keeps the update visible when no matching instance is ready", async () => {
|
|
36
|
+
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }));
|
|
37
|
+
assert.deepEqual(await service.getStatus(), {
|
|
38
|
+
currentVersion: "1.0.0",
|
|
39
|
+
latestVersion: "1.1.0",
|
|
40
|
+
updateAvailable: true,
|
|
41
|
+
canUpgrade: false,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("preserves the installed version when the registry check fails", async () => {
|
|
45
|
+
const service = new OpenCodeUpdateService(createDeps({
|
|
46
|
+
fetchLatestVersion: async () => {
|
|
47
|
+
throw new Error("registry unavailable");
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
assert.deepEqual(await service.getStatus(), {
|
|
51
|
+
currentVersion: "1.0.0",
|
|
52
|
+
latestVersion: null,
|
|
53
|
+
updateAvailable: null,
|
|
54
|
+
canUpgrade: false,
|
|
55
|
+
checkError: "update_check_failed",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
test("upgrades through the matching OpenCode instance to the advertised version", async () => {
|
|
59
|
+
const calls = [];
|
|
60
|
+
let currentVersion = "1.0.0";
|
|
61
|
+
const service = new OpenCodeUpdateService(createDeps({
|
|
62
|
+
probeBinary: () => ({ valid: true, version: currentVersion }),
|
|
63
|
+
upgradeInstance: async (instanceId, target) => {
|
|
64
|
+
calls.push({ instanceId, target });
|
|
65
|
+
currentVersion = target;
|
|
66
|
+
return { success: true, version: target };
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
assert.deepEqual(await service.upgrade(), { success: true, version: "1.1.0" });
|
|
70
|
+
assert.deepEqual(calls, [{ instanceId: "workspace-1", target: "1.1.0" }]);
|
|
71
|
+
});
|
|
72
|
+
test("rejects success when the configured binary was not updated", async () => {
|
|
73
|
+
const service = new OpenCodeUpdateService(createDeps({
|
|
74
|
+
probeBinary: () => ({ valid: true, version: "1.0.0" }),
|
|
75
|
+
upgradeInstance: async (_instanceId, target) => ({ success: true, version: target }),
|
|
76
|
+
}));
|
|
77
|
+
await assert.rejects(() => service.upgrade(), (error) => error instanceof OpenCodeUpdateError && error.code === "upgrade_verification_failed");
|
|
78
|
+
});
|
|
79
|
+
test("joins concurrent upgrades for the same binary", async () => {
|
|
80
|
+
let currentVersion = "1.0.0";
|
|
81
|
+
let upgrades = 0;
|
|
82
|
+
let finishUpgrade;
|
|
83
|
+
const gate = new Promise((resolve) => {
|
|
84
|
+
finishUpgrade = resolve;
|
|
85
|
+
});
|
|
86
|
+
const service = new OpenCodeUpdateService(createDeps({
|
|
87
|
+
probeBinary: () => ({ valid: true, version: currentVersion }),
|
|
88
|
+
upgradeInstance: async (_instanceId, target) => {
|
|
89
|
+
upgrades += 1;
|
|
90
|
+
await gate;
|
|
91
|
+
currentVersion = target;
|
|
92
|
+
return { success: true, version: target };
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
const first = service.upgrade();
|
|
96
|
+
const second = service.upgrade();
|
|
97
|
+
finishUpgrade?.();
|
|
98
|
+
assert.deepEqual(await Promise.all([first, second]), [
|
|
99
|
+
{ success: true, version: "1.1.0" },
|
|
100
|
+
{ success: true, version: "1.1.0" },
|
|
101
|
+
]);
|
|
102
|
+
assert.equal(upgrades, 1);
|
|
103
|
+
});
|
|
104
|
+
test("rejects an upgrade when no matching OpenCode instance is running", async () => {
|
|
105
|
+
const service = new OpenCodeUpdateService(createDeps({ findReadyInstanceId: () => undefined }));
|
|
106
|
+
await assert.rejects(() => service.upgrade(), (error) => error instanceof OpenCodeUpdateError && error.code === "no_ready_instance");
|
|
107
|
+
});
|