@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/auth/manager.js +1 -1
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +1 -1
  4. package/dist/index.js +42 -0
  5. package/dist/index.test.js +40 -10
  6. package/dist/opencode-update/service.js +150 -0
  7. package/dist/opencode-update/service.test.js +107 -0
  8. package/dist/permissions/auto-accept-manager.js +165 -0
  9. package/dist/permissions/auto-accept-manager.test.js +208 -0
  10. package/dist/permissions/auto-accept-store.js +5 -2
  11. package/dist/permissions/opencode-yolo-metadata.js +81 -0
  12. package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
  13. package/dist/server/__tests__/remote-proxy.test.js +64 -56
  14. package/dist/server/http-server.js +12 -15
  15. package/dist/server/remote-proxy.js +68 -98
  16. package/dist/server/routes/opencode-update.js +37 -0
  17. package/dist/server/routes/opencode-update.test.js +34 -0
  18. package/dist/server/routes/remote-servers.js +1 -1
  19. package/dist/server/routes/workspaces.test.js +38 -0
  20. package/dist/server/routes/worktrees.js +19 -0
  21. package/dist/server/routes/yolo.js +2 -1
  22. package/dist/server/tls.js +2 -2
  23. package/dist/settings/binaries.test.js +2 -7
  24. package/dist/shutdown.js +67 -59
  25. package/dist/shutdown.test.js +115 -78
  26. package/dist/ui/remote-ui.js +2 -2
  27. package/dist/usage/providers/api-key.js +3 -3
  28. package/dist/usage/providers/google.js +1 -1
  29. package/dist/usage/providers/oauth.js +1 -36
  30. package/dist/usage/providers/special.js +3 -3
  31. package/dist/usage/service.js +1 -1
  32. package/dist/usage/service.test.js +5 -3
  33. package/dist/usage/shared.js +4 -4
  34. package/dist/usage/shared.test.js +4 -4
  35. package/dist/workspaces/__tests__/spawn.test.js +2 -2
  36. package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
  37. package/dist/workspaces/binary-path.test.js +10 -0
  38. package/dist/workspaces/instance-client.js +4 -3
  39. package/dist/workspaces/launch-cleanup.test.js +21 -25
  40. package/dist/workspaces/manager.js +300 -351
  41. package/dist/workspaces/manager.test.js +209 -237
  42. package/dist/workspaces/process-identity.darwin.test.js +88 -0
  43. package/dist/workspaces/process-identity.js +172 -149
  44. package/dist/workspaces/process-identity.test.js +86 -140
  45. package/dist/workspaces/runtime.js +261 -373
  46. package/dist/workspaces/runtime.test.js +222 -547
  47. package/package.json +1 -1
  48. package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
  49. package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
  50. package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
  51. package/public/assets/StatusTab-D3toOefp.js +1 -0
  52. package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
  53. package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
  54. package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
  55. package/public/assets/index-3dx-hhNQ.js +1 -0
  56. package/public/assets/index-BKwfZLzw.js +1 -0
  57. package/public/assets/index-C5IioEFT.js +1 -0
  58. package/public/assets/index-CZYc0VcU.js +2 -0
  59. package/public/assets/index-CdCoXYXP.js +1 -0
  60. package/public/assets/index-CwAVops2.js +1 -0
  61. package/public/assets/index-CzvCOwKc.js +1 -0
  62. package/public/assets/index-D-loOWPv.js +1 -0
  63. package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
  64. package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
  65. package/public/assets/index-L7fX3W8k.css +1 -0
  66. package/public/assets/index-w2KdHEME.js +1 -0
  67. package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
  68. package/public/assets/main-CaTCcck1.js +68 -0
  69. package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
  70. package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
  71. package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
  72. package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
  73. package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
  74. package/public/index.html +4 -4
  75. package/public/loading.html +4 -4
  76. package/public/sw.js +1 -1
  77. package/dist/workspaces/launch-cleanup.js +0 -132
  78. package/public/assets/StatusTab-D_IE79r-.js +0 -1
  79. package/public/assets/index-B3YjNCh4.js +0 -1
  80. package/public/assets/index-B3ZD2_SU.js +0 -1
  81. package/public/assets/index-Bh2ofaQo.js +0 -1
  82. package/public/assets/index-CM0c9xRZ.js +0 -1
  83. package/public/assets/index-CdDxouJl.js +0 -2
  84. package/public/assets/index-D06Y2cFq.js +0 -1
  85. package/public/assets/index-PmE51vPh.js +0 -1
  86. package/public/assets/index-Qj63IiSj.css +0 -1
  87. package/public/assets/index-VY_zwmsG.js +0 -1
  88. package/public/assets/index-qiiH7egV.js +0 -1
  89. package/public/assets/main-CTN6Wwz1.js +0 -68
@@ -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(60000) : null;
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 = 1000;
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")], 5000);
22
- const initial = getWorkspaceCandidates(workspacePath, 5001);
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")], 6000);
26
- const refreshed = getWorkspaceCandidates(workspacePath, 6001);
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 = 30000;
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
@@ -32,6 +32,9 @@ import { PluginChannelManager } from "./plugins/channel";
32
32
  import { VoiceModeManager } from "./plugins/voice-mode";
33
33
  import { runCliUpgrade } from "./cli-upgrade";
34
34
  import { createServerShutdownHandler, orchestrateServerShutdown } from "./shutdown";
35
+ import { AutoAcceptManager } from "./permissions/auto-accept-manager";
36
+ import { createOpencodePermissionReplier } from "./permissions/opencode-replier";
37
+ import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata";
35
38
  const require = createRequire(import.meta.url);
36
39
  const packageJson = require("../package.json");
37
40
  const __filename = fileURLToPath(import.meta.url);
@@ -41,10 +44,29 @@ const DEFAULT_HOST = "127.0.0.1";
41
44
  const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
42
45
  const DEFAULT_HTTPS_PORT = 9898;
43
46
  const DEFAULT_HTTP_PORT = 9899;
47
+ export const STDIN_SHUTDOWN_COMMAND = "codenomad:shutdown";
44
48
  export function installShutdownSignalHandlers(source, shutdown) {
45
49
  source.on("SIGINT", () => void shutdown("SIGINT"));
46
50
  source.on("SIGTERM", () => void shutdown("SIGTERM"));
47
51
  }
52
+ export function installShutdownStdinHandler(source, shutdown) {
53
+ let buffer = "";
54
+ let requested = false;
55
+ const onData = (chunk) => {
56
+ if (requested)
57
+ return;
58
+ buffer += chunk.toString();
59
+ const lines = buffer.split(/\r?\n/);
60
+ buffer = lines.pop() ?? "";
61
+ if (!lines.some((line) => line.trim() === STDIN_SHUTDOWN_COMMAND))
62
+ return;
63
+ requested = true;
64
+ source.off?.("data", onData);
65
+ source.destroy?.();
66
+ void shutdown("stdin");
67
+ };
68
+ source.on("data", onData);
69
+ }
48
70
  function parseCliOptions(argv) {
49
71
  const program = new Command()
50
72
  .name("codenomad")
@@ -238,6 +260,15 @@ async function main() {
238
260
  logger: logger.child({ component: "sidecars" }),
239
261
  });
240
262
  const previewManager = new PreviewManager();
263
+ const yoloLogger = logger.child({ component: "yolo" });
264
+ const sessionMetadataPersistence = createOpencodeYoloPersistence(workspaceManager);
265
+ const yoloManager = new AutoAcceptManager({
266
+ eventBus,
267
+ logger: yoloLogger,
268
+ replier: createOpencodePermissionReplier({ workspaceManager, logger: yoloLogger }),
269
+ persistence: sessionMetadataPersistence,
270
+ });
271
+ yoloManager.start();
241
272
  const instanceEventBridge = new InstanceEventBridge({
242
273
  workspaceManager,
243
274
  eventBus,
@@ -327,6 +358,8 @@ async function main() {
327
358
  pluginChannel,
328
359
  voiceModeManager,
329
360
  remoteProxySessionManager,
361
+ yoloManager,
362
+ sessionMetadataPersistence,
330
363
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
331
364
  uiDevServerUrl: uiResolution.uiDevServerUrl,
332
365
  logger,
@@ -353,6 +386,8 @@ async function main() {
353
386
  pluginChannel,
354
387
  voiceModeManager,
355
388
  remoteProxySessionManager,
389
+ yoloManager,
390
+ sessionMetadataPersistence,
356
391
  uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
357
392
  uiDevServerUrl: undefined,
358
393
  logger,
@@ -431,6 +466,11 @@ async function main() {
431
466
  }
432
467
  const shutdown = createServerShutdownHandler({
433
468
  logger,
469
+ holdAfterFailure: () => new Promise(() => { setInterval(() => undefined, 60_000); }),
470
+ setExitCode: (code) => {
471
+ process.stdin.destroy();
472
+ process.exitCode = code;
473
+ },
434
474
  shutdown: () => orchestrateServerShutdown({
435
475
  stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
436
476
  stopSidecars: () => sidecarManager.shutdown(),
@@ -438,6 +478,7 @@ async function main() {
438
478
  stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
439
479
  stopWorkspaces: () => workspaceManager.shutdown(),
440
480
  stopHttpServers: async () => {
481
+ yoloManager.stop();
441
482
  const results = await Promise.allSettled(servers.map((srv) => srv.stop()));
442
483
  const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
443
484
  if (failures.length > 0) {
@@ -451,6 +492,7 @@ async function main() {
451
492
  }, logger),
452
493
  });
453
494
  installShutdownSignalHandlers(process, shutdown);
495
+ installShutdownStdinHandler(process.stdin, shutdown);
454
496
  }
455
497
  if (path.resolve(process.argv[1] ?? "") === __filename) {
456
498
  main().catch((error) => {
@@ -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 cleanup;
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
+ });