@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 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/dist/auth/manager.js +1 -1
- package/dist/filesystem/__tests__/search-cache.test.js +5 -5
- package/dist/filesystem/search-cache.js +1 -1
- package/dist/index.js +25 -0
- package/dist/index.test.js +40 -10
- 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 +64 -56
- package/dist/server/http-server.js +7 -1
- package/dist/server/remote-proxy.js +68 -98
- 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/workspaces.test.js +38 -0
- package/dist/server/tls.js +2 -2
- package/dist/settings/binaries.test.js +2 -7
- package/dist/shutdown.js +67 -59
- package/dist/shutdown.test.js +115 -78
- package/dist/ui/remote-ui.js +2 -2
- package/dist/usage/providers/api-key.js +3 -3
- package/dist/usage/providers/google.js +1 -1
- package/dist/usage/providers/special.js +3 -3
- package/dist/usage/service.js +1 -1
- package/dist/usage/service.test.js +1 -1
- package/dist/usage/shared.js +4 -4
- package/dist/usage/shared.test.js +1 -1
- package/dist/workspaces/__tests__/spawn.test.js +2 -2
- package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
- package/dist/workspaces/binary-path.test.js +10 -0
- package/dist/workspaces/instance-client.js +4 -3
- package/dist/workspaces/launch-cleanup.test.js +21 -25
- package/dist/workspaces/manager.js +300 -351
- package/dist/workspaces/manager.test.js +209 -237
- package/dist/workspaces/process-identity.darwin.test.js +88 -0
- package/dist/workspaces/process-identity.js +172 -149
- package/dist/workspaces/process-identity.test.js +86 -140
- package/dist/workspaces/runtime.js +261 -373
- package/dist/workspaces/runtime.test.js +222 -547
- package/package.json +1 -1
- package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-B0-DlSCT.js} +2 -2
- package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-CxjbNBVr.js} +2 -2
- package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-CLQb-6uX.js} +1 -1
- package/public/assets/{StatusTab-D_IE79r-.js → StatusTab-CBGAuhmp.js} +1 -1
- package/public/assets/{align-justify-BKS9gyRk.js → align-justify-s5mnjsP0.js} +1 -1
- package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-lwIMY5om.js} +1 -1
- package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-CDtmFe6e.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-sLe9hEfQ.js → index-DupnEKCQ.js} +1 -1
- package/public/assets/index-HhZzWWha.js +1 -0
- package/public/assets/{index-DVc4ls8c.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-CDmt9OJU.js → loading-h8DvUqqc.js} +1 -1
- package/public/assets/main-BxUSjoWA.js +68 -0
- package/public/assets/{markdown-1ZLSxCFT.js → markdown-Djug_spc.js} +3 -3
- package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-lkUcsK65.js} +2 -2
- package/public/assets/{tool-call-CyXb63LH.js → tool-call-C4YH896w.js} +3 -3
- package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DwNZOVlY.js} +1 -1
- package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-BLVYxvcj.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/dist/workspaces/launch-cleanup.js +0 -132
- package/public/assets/index-B3YjNCh4.js +0 -1
- package/public/assets/index-B3ZD2_SU.js +0 -1
- package/public/assets/index-Bh2ofaQo.js +0 -1
- package/public/assets/index-CM0c9xRZ.js +0 -1
- package/public/assets/index-CdDxouJl.js +0 -2
- package/public/assets/index-D06Y2cFq.js +0 -1
- package/public/assets/index-PmE51vPh.js +0 -1
- package/public/assets/index-Qj63IiSj.css +0 -1
- package/public/assets/index-VY_zwmsG.js +0 -1
- package/public/assets/index-qiiH7egV.js +0 -1
- package/public/assets/main-CTN6Wwz1.js +0 -68
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;
|
|
@@ -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
|
@@ -41,10 +41,29 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
41
41
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
|
|
42
42
|
const DEFAULT_HTTPS_PORT = 9898;
|
|
43
43
|
const DEFAULT_HTTP_PORT = 9899;
|
|
44
|
+
export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown";
|
|
44
45
|
export function installShutdownSignalHandlers(source, shutdown) {
|
|
45
46
|
source.on("SIGINT", () => void shutdown("SIGINT"));
|
|
46
47
|
source.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
47
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
|
+
}
|
|
48
67
|
function parseCliOptions(argv) {
|
|
49
68
|
const program = new Command()
|
|
50
69
|
.name("codenomad")
|
|
@@ -431,6 +450,11 @@ async function main() {
|
|
|
431
450
|
}
|
|
432
451
|
const shutdown = createServerShutdownHandler({
|
|
433
452
|
logger,
|
|
453
|
+
holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000); }),
|
|
454
|
+
setExitCode: (code) => {
|
|
455
|
+
process.stdin.destroy();
|
|
456
|
+
process.exitCode = code;
|
|
457
|
+
},
|
|
434
458
|
shutdown: () => orchestrateServerShutdown({
|
|
435
459
|
stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
|
|
436
460
|
stopSidecars: () => sidecarManager.shutdown(),
|
|
@@ -451,6 +475,7 @@ async function main() {
|
|
|
451
475
|
}, logger),
|
|
452
476
|
});
|
|
453
477
|
installShutdownSignalHandlers(process, shutdown);
|
|
478
|
+
installShutdownStdinHandler(process.stdin, shutdown);
|
|
454
479
|
}
|
|
455
480
|
if (path.resolve(process.argv[1] ?? "") === __filename) {
|
|
456
481
|
main().catch((error) => {
|
package/dist/index.test.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
2
4
|
import { describe, it } from "node:test";
|
|
3
|
-
import { installShutdownSignalHandlers } from "./index";
|
|
5
|
+
import { installShutdownSignalHandlers, installShutdownStdinHandler, STDIN_SHUTDOWN_COMMAND } from "./index";
|
|
4
6
|
import { createServerShutdownHandler } from "./shutdown";
|
|
5
7
|
describe("CLI shutdown signal registration", () => {
|
|
6
8
|
it("routes a second process signal to forced nonzero escalation", async () => {
|
|
@@ -10,23 +12,51 @@ describe("CLI shutdown signal registration", () => {
|
|
|
10
12
|
finishCleanup = resolve;
|
|
11
13
|
});
|
|
12
14
|
const exits = [];
|
|
15
|
+
let handled;
|
|
13
16
|
const shutdown = createServerShutdownHandler({
|
|
14
17
|
shutdown: () => cleanup,
|
|
15
|
-
logger: {
|
|
16
|
-
info: () => undefined,
|
|
17
|
-
warn: () => undefined,
|
|
18
|
-
error: () => undefined,
|
|
19
|
-
},
|
|
18
|
+
logger: { info() { }, warn() { }, error() { } },
|
|
20
19
|
forceExit: (code) => exits.push(code),
|
|
21
20
|
setExitCode: () => undefined,
|
|
21
|
+
reportStatus: () => undefined,
|
|
22
22
|
});
|
|
23
|
-
installShutdownSignalHandlers({
|
|
24
|
-
on: (signal, listener) => listeners.set(signal, listener),
|
|
25
|
-
}, shutdown);
|
|
23
|
+
installShutdownSignalHandlers({ on: (signal, listener) => listeners.set(signal, listener) }, (signal) => (handled = shutdown(signal)));
|
|
26
24
|
listeners.get("SIGINT")?.();
|
|
27
25
|
listeners.get("SIGTERM")?.();
|
|
28
26
|
assert.deepEqual(exits, [1]);
|
|
29
27
|
finishCleanup();
|
|
30
|
-
await
|
|
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);
|
|
31
61
|
});
|
|
32
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
|
+
});
|
|
@@ -8,27 +8,20 @@ import { Agent, fetch } from "undici";
|
|
|
8
8
|
import { RemoteProxySessionManager } from "../remote-proxy";
|
|
9
9
|
import { resolveHttpsOptions } from "../tls";
|
|
10
10
|
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"));
|
|
11
|
-
const sharedTls = resolveHttpsOptions({
|
|
12
|
-
|
|
13
|
-
configDir: sharedTempDir,
|
|
14
|
-
host: "127.0.0.1",
|
|
15
|
-
logger: createStubLogger(),
|
|
16
|
-
});
|
|
17
|
-
if (!sharedTls) {
|
|
11
|
+
const sharedTls = resolveHttpsOptions({ enabled: true, configDir: sharedTempDir, host: "127.0.0.1", logger: createStubLogger() });
|
|
12
|
+
if (!sharedTls)
|
|
18
13
|
throw new Error("Failed to generate HTTPS options for remote proxy tests");
|
|
19
|
-
}
|
|
20
14
|
const sharedHttpsOptions = sharedTls.httpsOptions;
|
|
21
15
|
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
22
16
|
const managers = new Set();
|
|
23
17
|
afterEach(async () => {
|
|
24
|
-
for (const manager of managers)
|
|
25
|
-
await
|
|
26
|
-
}
|
|
18
|
+
for (const manager of managers)
|
|
19
|
+
await manager.shutdown().catch(() => undefined);
|
|
27
20
|
managers.clear();
|
|
28
21
|
});
|
|
29
|
-
after(() => {
|
|
22
|
+
after(async () => {
|
|
30
23
|
fs.rmSync(sharedTempDir, { recursive: true, force: true });
|
|
31
|
-
httpsDispatcher.
|
|
24
|
+
await httpsDispatcher.destroy().catch(() => { });
|
|
32
25
|
});
|
|
33
26
|
describe("RemoteProxySessionManager", () => {
|
|
34
27
|
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
|
|
@@ -66,8 +59,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
66
59
|
const requestUrl = req.url ?? "";
|
|
67
60
|
if (requestUrl === "/base/redirect") {
|
|
68
61
|
res.writeHead(302, { location: "/base/after?ok=1" });
|
|
69
|
-
res.end();
|
|
70
|
-
return;
|
|
62
|
+
return res.end();
|
|
71
63
|
}
|
|
72
64
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
73
65
|
res.end(requestUrl);
|
|
@@ -95,13 +87,11 @@ describe("RemoteProxySessionManager", () => {
|
|
|
95
87
|
"content-type": "text/plain",
|
|
96
88
|
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
|
97
89
|
});
|
|
98
|
-
res.end("ok");
|
|
99
|
-
return;
|
|
90
|
+
return res.end("ok");
|
|
100
91
|
}
|
|
101
92
|
if (requestUrl === "/base/whoami") {
|
|
102
93
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
103
|
-
res.end(req.headers.cookie ?? "");
|
|
104
|
-
return;
|
|
94
|
+
return res.end(req.headers.cookie ?? "");
|
|
105
95
|
}
|
|
106
96
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
107
97
|
res.end(requestUrl);
|
|
@@ -116,7 +106,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
116
106
|
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
117
107
|
const internalSessions = manager.sessions;
|
|
118
108
|
const internalCleanup = manager.cleanupExpiredSessions;
|
|
119
|
-
internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 *
|
|
109
|
+
internalSessions.get(session3.sessionId).lastAccessAt = Date.now() - 31 * 60_000;
|
|
120
110
|
await internalCleanup.call(manager);
|
|
121
111
|
assert.equal(internalSessions.has(session3.sessionId), false);
|
|
122
112
|
assert.equal(await manager.deleteSession(session3.sessionId), false);
|
|
@@ -139,7 +129,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
139
129
|
});
|
|
140
130
|
it("waits for in-flight idle cleanup during shutdown", async () => {
|
|
141
131
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
142
|
-
const manager = createSessionManager({ disposalTimeoutMs:
|
|
132
|
+
const manager = createSessionManager({ disposalTimeoutMs: 1_000 });
|
|
143
133
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
144
134
|
const internalSession = manager.sessions.get(session.sessionId);
|
|
145
135
|
const closeGate = deferred();
|
|
@@ -148,9 +138,8 @@ describe("RemoteProxySessionManager", () => {
|
|
|
148
138
|
await closeGate.promise;
|
|
149
139
|
return originalClose();
|
|
150
140
|
};
|
|
151
|
-
internalSession.lastAccessAt = Date.now() - 31 *
|
|
141
|
+
internalSession.lastAccessAt = Date.now() - 31 * 60_000;
|
|
152
142
|
const cleanup = manager.cleanupExpiredSessions();
|
|
153
|
-
assert.equal(manager.pendingDisposals.size, 1);
|
|
154
143
|
let shutdownSettled = false;
|
|
155
144
|
const shutdown = manager.shutdown().then(() => {
|
|
156
145
|
shutdownSettled = true;
|
|
@@ -160,7 +149,7 @@ describe("RemoteProxySessionManager", () => {
|
|
|
160
149
|
closeGate.resolve();
|
|
161
150
|
await cleanup;
|
|
162
151
|
await shutdown;
|
|
163
|
-
assert.equal(manager.
|
|
152
|
+
assert.equal(manager.disposals.size, 0);
|
|
164
153
|
}, (_req, res) => {
|
|
165
154
|
res.writeHead(200).end("ok");
|
|
166
155
|
});
|
|
@@ -177,12 +166,11 @@ describe("RemoteProxySessionManager", () => {
|
|
|
177
166
|
new Promise((_resolve, reject) => setTimeout(() => reject(new Error("shutdown stalled")), 500)),
|
|
178
167
|
]);
|
|
179
168
|
assert.equal(manager.sessions.size, 0);
|
|
180
|
-
assert.equal(manager.
|
|
169
|
+
assert.equal(manager.disposals.size, 0);
|
|
181
170
|
}, (req, res) => {
|
|
182
171
|
if (req.url === "/base/events") {
|
|
183
172
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
184
|
-
res.write("data: connected\n\n");
|
|
185
|
-
return;
|
|
173
|
+
return void res.write("data: connected\n\n");
|
|
186
174
|
}
|
|
187
175
|
res.writeHead(200).end("ok");
|
|
188
176
|
});
|
|
@@ -193,12 +181,18 @@ describe("RemoteProxySessionManager", () => {
|
|
|
193
181
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`);
|
|
194
182
|
const internalSession = manager.sessions.get(session.sessionId);
|
|
195
183
|
const originalClose = internalSession.app.close.bind(internalSession.app);
|
|
184
|
+
let failClose = true;
|
|
196
185
|
internalSession.app.close = async () => {
|
|
197
186
|
await originalClose();
|
|
198
|
-
|
|
187
|
+
if (failClose) {
|
|
188
|
+
failClose = false;
|
|
189
|
+
throw new Error("close failed");
|
|
190
|
+
}
|
|
199
191
|
};
|
|
200
192
|
await assert.rejects(manager.deleteSession(session.sessionId), /Remote proxy disposal failed/);
|
|
201
|
-
|
|
193
|
+
// A completed deletion failure predating shutdown must not poison it.
|
|
194
|
+
await manager.shutdown();
|
|
195
|
+
assert.equal(manager.sessions.size, 0);
|
|
202
196
|
}, (_req, res) => {
|
|
203
197
|
res.writeHead(200).end("ok");
|
|
204
198
|
});
|
|
@@ -210,31 +204,58 @@ describe("RemoteProxySessionManager", () => {
|
|
|
210
204
|
const shutdown = manager.shutdown();
|
|
211
205
|
await assert.rejects(creation, /shutting down/);
|
|
212
206
|
await shutdown;
|
|
213
|
-
assert.equal(manager.
|
|
207
|
+
assert.equal(manager.creations.size, 0);
|
|
214
208
|
assert.equal(manager.sessions.size, 0);
|
|
215
209
|
await assert.rejects(manager.createSession(`${upstreamBaseUrl}/base`, false), /shutting down/);
|
|
216
210
|
}, (_req, res) => {
|
|
217
211
|
res.writeHead(200).end("ok");
|
|
218
212
|
});
|
|
219
213
|
});
|
|
214
|
+
it("coalesces shutdown, retains current disposal failures, and gives every session its own agent", async () => {
|
|
215
|
+
await withUpstreamServer(async (upstreamBaseUrl) => {
|
|
216
|
+
const manager = createSessionManager();
|
|
217
|
+
const verified = await manager.createSession(`${upstreamBaseUrl}/verified`, false);
|
|
218
|
+
const insecure = await manager.createSession(`${upstreamBaseUrl}/insecure`, true);
|
|
219
|
+
const sessions = manager.sessions;
|
|
220
|
+
assert.ok(sessions.get(verified.sessionId).dispatcher instanceof Agent);
|
|
221
|
+
assert.ok(sessions.get(insecure.sessionId).dispatcher instanceof Agent);
|
|
222
|
+
assert.notStrictEqual(sessions.get(verified.sessionId).dispatcher, sessions.get(insecure.sessionId).dispatcher);
|
|
223
|
+
const closeGate = deferred();
|
|
224
|
+
const originalClose = sessions.get(verified.sessionId).app.close.bind(sessions.get(verified.sessionId).app);
|
|
225
|
+
let failClose = true;
|
|
226
|
+
sessions.get(verified.sessionId).app.close = async () => {
|
|
227
|
+
await closeGate.promise;
|
|
228
|
+
await originalClose();
|
|
229
|
+
if (failClose) {
|
|
230
|
+
failClose = false;
|
|
231
|
+
throw new Error("current close failed");
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const disposal = manager.deleteSession(verified.sessionId);
|
|
235
|
+
const first = manager.shutdown();
|
|
236
|
+
const concurrent = manager.shutdown();
|
|
237
|
+
assert.strictEqual(first, concurrent);
|
|
238
|
+
closeGate.resolve();
|
|
239
|
+
await assert.rejects(disposal, /Remote proxy disposal failed/);
|
|
240
|
+
await assert.rejects(first, (error) => error instanceof AggregateError && error.errors.some((cause) => cause instanceof AggregateError && cause.errors.some((nested) => /current close failed/.test(String(nested)))));
|
|
241
|
+
await manager.shutdown();
|
|
242
|
+
assert.equal(sessions.size, 0);
|
|
243
|
+
}, (_req, res) => {
|
|
244
|
+
res.writeHead(200).end("ok");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
220
247
|
});
|
|
221
248
|
function createSessionManager(options = {}) {
|
|
222
249
|
const manager = new RemoteProxySessionManager({
|
|
223
|
-
authManager: {
|
|
224
|
-
|
|
225
|
-
},
|
|
226
|
-
logger: createStubLogger(),
|
|
227
|
-
httpsOptions: sharedHttpsOptions,
|
|
228
|
-
...options,
|
|
250
|
+
authManager: { isLoopbackRequest: () => true },
|
|
251
|
+
logger: createStubLogger(), httpsOptions: sharedHttpsOptions, ...options,
|
|
229
252
|
});
|
|
230
253
|
managers.add(manager);
|
|
231
254
|
return manager;
|
|
232
255
|
}
|
|
233
256
|
function deferred() {
|
|
234
257
|
let resolve;
|
|
235
|
-
const promise = new Promise((resolvePromise) => {
|
|
236
|
-
resolve = resolvePromise;
|
|
237
|
-
});
|
|
258
|
+
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
|
238
259
|
return { promise, resolve };
|
|
239
260
|
}
|
|
240
261
|
async function createSession(manager, baseUrl) {
|
|
@@ -253,34 +274,28 @@ async function activateSession(session) {
|
|
|
253
274
|
headers: { "content-type": "application/json" },
|
|
254
275
|
body: JSON.stringify({ token: session.token }),
|
|
255
276
|
});
|
|
256
|
-
if (!response.ok)
|
|
277
|
+
if (!response.ok)
|
|
257
278
|
return false;
|
|
258
|
-
}
|
|
259
279
|
const body = (await response.json());
|
|
260
280
|
return body.ok === true;
|
|
261
281
|
}
|
|
262
282
|
function getSetCookie(response) {
|
|
263
283
|
const values = response.headers.getSetCookie?.();
|
|
264
|
-
if (Array.isArray(values) && values.length > 0)
|
|
284
|
+
if (Array.isArray(values) && values.length > 0)
|
|
265
285
|
return values;
|
|
266
|
-
}
|
|
267
286
|
const fallback = response.headers.get("set-cookie");
|
|
268
287
|
return fallback ? [fallback] : [];
|
|
269
288
|
}
|
|
270
289
|
async function proxyFetch(url, init) {
|
|
271
290
|
return fetch(url, { dispatcher: httpsDispatcher, ...init });
|
|
272
291
|
}
|
|
273
|
-
async function disposeManager(manager) {
|
|
274
|
-
await manager.shutdown().catch(() => undefined);
|
|
275
|
-
}
|
|
276
292
|
async function withUpstreamServer(callback, handler) {
|
|
277
293
|
const server = http.createServer(handler);
|
|
278
294
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
279
295
|
try {
|
|
280
296
|
const address = server.address();
|
|
281
|
-
if (!address || typeof address === "string")
|
|
297
|
+
if (!address || typeof address === "string")
|
|
282
298
|
throw new Error("Failed to resolve upstream server address");
|
|
283
|
-
}
|
|
284
299
|
await callback(`http://127.0.0.1:${address.port}`);
|
|
285
300
|
}
|
|
286
301
|
finally {
|
|
@@ -288,13 +303,6 @@ async function withUpstreamServer(callback, handler) {
|
|
|
288
303
|
}
|
|
289
304
|
}
|
|
290
305
|
function createStubLogger() {
|
|
291
|
-
const logger = {
|
|
292
|
-
info() { },
|
|
293
|
-
warn() { },
|
|
294
|
-
error() { },
|
|
295
|
-
child() {
|
|
296
|
-
return logger;
|
|
297
|
-
},
|
|
298
|
-
};
|
|
306
|
+
const logger = { info() { }, warn() { }, error() { }, child() { return logger; } };
|
|
299
307
|
return logger;
|
|
300
308
|
}
|