@textcortex/zenocode 0.1.15 → 0.1.17

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 CHANGED
@@ -46,9 +46,9 @@ Zenocode releases its launcher and managed runtime together. Update both with:
46
46
  npm install -g @textcortex/zenocode@latest
47
47
  ```
48
48
 
49
- The terminal runtime does not install upstream OpenCode updates. Running `zenocode upgrade` prints the managed update command above.
49
+ The terminal runtime never checks for, offers, or installs upstream OpenCode updates: the startup update check, the in-app "Update Available" prompt, and the runtime's `/global/upgrade` API are all removed at build time. Running `zenocode upgrade` prints the managed update command above.
50
50
 
51
- Runtime package versions include a Zenocode revision suffix (for example, `1.18.10-zenocode.1`). Increment that revision when the Zenocode source overlay changes without an upstream version bump, so an immutable npm runtime release can always be replaced deliberately.
51
+ Runtime package versions include a Zenocode revision suffix (for example, `1.18.10-zenocode.3`). Increment that revision when the Zenocode source overlay changes without an upstream version bump, so an immutable npm runtime release can always be replaced deliberately.
52
52
 
53
53
  ## Models
54
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/zenocode",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Secure, TextCortex-managed coding agent for customers that runs in your terminal, edits files, runs scripts, and more.",
5
5
  "keywords": [
6
6
  "ai",
@@ -7,10 +7,12 @@ import path from 'node:path';
7
7
  import process from 'node:process';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import {
10
+ openCodeBunVersion,
10
11
  openCodeRef,
11
12
  openCodeVersion,
12
13
  zenocodeRuntimeVersion,
13
14
  } from './opencode-version.mjs';
15
+ import { smokeTestRuntimeBinary } from './runtime-smoke.mjs';
14
16
  import { applyZenocodeSourceBranding } from './source-branding.mjs';
15
17
 
16
18
  const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
@@ -229,6 +231,29 @@ async function runAndCapture(command, args, options = {}) {
229
231
  });
230
232
  }
231
233
 
234
+ /**
235
+ * Fail unless the runtime is compiled with the Bun release that both Zenocode
236
+ * and the upstream checkout pin. Another Bun release can compile a runtime that
237
+ * passes `--version` but crashes on the first prompt.
238
+ */
239
+ export function assertPinnedBunToolchain({ installedVersion, packageManager }) {
240
+ const expected = `bun@${openCodeBunVersion}`;
241
+ if (packageManager !== expected) {
242
+ throw new Error(
243
+ `upstream OpenCode pins ${packageManager || 'no packageManager'}, but ` +
244
+ `opencode-version.mjs pins ${expected}. Update openCodeBunVersion ` +
245
+ 'together with openCodeVersion.'
246
+ );
247
+ }
248
+ const installed = installedVersion.trim();
249
+ if (installed !== openCodeBunVersion) {
250
+ throw new Error(
251
+ `Bun ${installed} is installed, but the Zenocode runtime must be built ` +
252
+ `with Bun ${openCodeBunVersion}.`
253
+ );
254
+ }
255
+ }
256
+
232
257
  async function requireCommand(command, args = ['--version']) {
233
258
  try {
234
259
  await run(command, args, { stdio: 'ignore' });
@@ -807,8 +832,21 @@ async function main() {
807
832
  cloneArgs.push(forkUrl, checkoutDir);
808
833
  await run(gitCommand, cloneArgs);
809
834
 
835
+ const upstreamPackage = JSON.parse(
836
+ await fs.readFile(path.join(checkoutDir, 'package.json'), 'utf-8')
837
+ );
838
+ const { stdout: installedBunVersion } = await runAndCapture(bunCommand, [
839
+ '--version',
840
+ ]);
841
+ assertPinnedBunToolchain({
842
+ installedVersion: installedBunVersion,
843
+ packageManager: upstreamPackage.packageManager,
844
+ });
845
+
810
846
  await applyZenocodeSourceBranding(checkoutDir);
811
- await run(bunCommand, ['install'], { cwd: checkoutDir });
847
+ await run(bunCommand, ['install', '--frozen-lockfile'], {
848
+ cwd: checkoutDir,
849
+ });
812
850
  await run(
813
851
  bunCommand,
814
852
  ['./packages/opencode/script/build.ts', ...buildArgs],
@@ -872,6 +910,8 @@ async function main() {
872
910
  if (process.platform !== 'win32') {
873
911
  await fs.chmod(localBinaryPath, 0o755);
874
912
  }
913
+ await smokeTestRuntimeBinary(localBinaryPath);
914
+ console.log(`Runtime smoke test passed: ${localBinaryPath}`);
875
915
 
876
916
  console.log('\nZenocode branded build complete.');
877
917
  console.log(`Branded binary: ${localBinaryPath}`);
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
  import {
4
+ assertPinnedBunToolchain,
4
5
  buildPublishCommandArgs,
5
6
  buildWrapperBinMap,
6
7
  binaryFilenameForPackage,
@@ -15,12 +16,13 @@ import {
15
16
  trustedPublishingRepository,
16
17
  zenocodeRuntimeVersion,
17
18
  } from './build-branded-opencode.mjs';
19
+ import { openCodeBunVersion } from './opencode-version.mjs';
18
20
 
19
21
  test('branded OpenCode build defaults to the latest stable upstream release', () => {
20
22
  assert.equal(defaultOpenCodeForkUrl, 'https://github.com/sst/opencode');
21
23
  assert.equal(defaultOpenCodeRef, 'v1.18.10');
22
24
  assert.equal(defaultOpenCodeVersion, '1.18.10');
23
- assert.equal(zenocodeRuntimeVersion, '1.18.10-zenocode.1');
25
+ assert.equal(zenocodeRuntimeVersion, '1.18.10-zenocode.3');
24
26
  assert.deepEqual(defaultOpenCodeBuildArgs, []);
25
27
  });
26
28
 
@@ -127,3 +129,36 @@ test('selectLatestTarball returns the sorted newest tarball', () => {
127
129
  );
128
130
  assert.equal(selectLatestTarball(['README.md']), null);
129
131
  });
132
+
133
+ test('runtime builds use the Bun release that upstream pins', () => {
134
+ assert.equal(openCodeBunVersion, '1.3.14');
135
+ assert.doesNotThrow(() =>
136
+ assertPinnedBunToolchain({
137
+ installedVersion: '1.3.14\n',
138
+ packageManager: 'bun@1.3.14',
139
+ })
140
+ );
141
+ });
142
+
143
+ test('runtime builds refuse a Bun release other than the pinned one', () => {
144
+ // Bun 1.4.2 compiled a runtime that crashed on every prompt (0.1.15, 0.1.16).
145
+ assert.throws(
146
+ () =>
147
+ assertPinnedBunToolchain({
148
+ installedVersion: '1.4.2',
149
+ packageManager: 'bun@1.3.14',
150
+ }),
151
+ /Bun 1\.4\.2 is installed, but the Zenocode runtime must be built with Bun 1\.3\.14/
152
+ );
153
+ });
154
+
155
+ test('runtime builds refuse an upstream checkout that pins a different Bun', () => {
156
+ assert.throws(
157
+ () =>
158
+ assertPinnedBunToolchain({
159
+ installedVersion: '1.3.14',
160
+ packageManager: 'bun@1.3.20',
161
+ }),
162
+ /upstream OpenCode pins bun@1\.3\.20[\s\S]*openCodeBunVersion/
163
+ );
164
+ });
@@ -1,6 +1,13 @@
1
1
  export const openCodeVersion = '1.18.10';
2
2
  export const openCodeRef = `v${openCodeVersion}`;
3
+ /**
4
+ * Bun release that compiles the runtime. It must equal the `packageManager`
5
+ * pin of the upstream OpenCode release above, and the build fails otherwise:
6
+ * a newer Bun compiled a runtime that crashed on every prompt while
7
+ * `--version` still worked. Change it together with `openCodeVersion`.
8
+ */
9
+ export const openCodeBunVersion = '1.3.14';
3
10
  export const zenocodeRuntimePackageName = '@textcortex/zenocode-ai';
4
- export const zenocodeRuntimeRevision = 1;
11
+ export const zenocodeRuntimeRevision = 3;
5
12
  export const zenocodeRuntimeVersion = `${openCodeVersion}-zenocode.${zenocodeRuntimeRevision}`;
6
13
  export const zenocodeRuntimePackageSpec = `${zenocodeRuntimePackageName}@${zenocodeRuntimeVersion}`;
@@ -30,7 +30,7 @@ import {
30
30
  test('Zenocode launches the pinned TextCortex-owned runtime by default', () => {
31
31
  assert.equal(
32
32
  resolveLaunchPackage(),
33
- '@textcortex/zenocode-ai@1.18.10-zenocode.1'
33
+ '@textcortex/zenocode-ai@1.18.10-zenocode.3'
34
34
  );
35
35
  });
36
36
 
@@ -316,13 +316,13 @@ test('buildPackageLauncherInvocation launches the owned runtime package unchange
316
316
  const invocation = buildPackageLauncherInvocation({
317
317
  runner: {
318
318
  command: 'pnpm',
319
- args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.1', '--version'],
319
+ args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.3', '--version'],
320
320
  },
321
321
  });
322
322
 
323
323
  assert.deepEqual(invocation, {
324
324
  command: 'pnpm',
325
- args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.1', '--version'],
325
+ args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.3', '--version'],
326
326
  });
327
327
  });
328
328
 
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { realpathSync } from 'node:fs';
4
+ import fs from 'node:fs/promises';
5
+ import http from 'node:http';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import {
12
+ buildOpenCodeConfig,
13
+ buildOpenCodeTuiConfig,
14
+ } from './run-zenocode.mjs';
15
+
16
+ const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
17
+ const providerID = 'textcortex';
18
+ const smokeModel = 'zenocode-smoke';
19
+ const providerPathPrefix = '/internal/v2/fastapi/zenocode/v1';
20
+
21
+ /** Text the mock provider streams back; a working runtime prints it. */
22
+ export const smokeReply = 'zenocode-runtime-smoke-ok';
23
+
24
+ function chatCompletionChunk(delta, finishReason = null) {
25
+ return {
26
+ id: 'chatcmpl-zenocode-smoke',
27
+ object: 'chat.completion.chunk',
28
+ created: 0,
29
+ model: smokeModel,
30
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Serve the Zenocode backend's OpenAI-compatible chat endpoint on loopback,
36
+ * streaming {@link smokeReply} for every completion request.
37
+ */
38
+ async function startMockProvider() {
39
+ const state = { providerRequests: 0, unexpectedRequests: [] };
40
+ const server = http.createServer((request, response) => {
41
+ request.resume();
42
+ request.on('end', () => {
43
+ if (
44
+ request.method !== 'POST' ||
45
+ request.url !== `${providerPathPrefix}/chat/completions`
46
+ ) {
47
+ state.unexpectedRequests.push(`${request.method} ${request.url}`);
48
+ response.writeHead(404).end();
49
+ return;
50
+ }
51
+ state.providerRequests += 1;
52
+ response.writeHead(200, {
53
+ 'content-type': 'text/event-stream',
54
+ 'cache-control': 'no-cache',
55
+ });
56
+ const events = [
57
+ chatCompletionChunk({ role: 'assistant', content: smokeReply }),
58
+ {
59
+ ...chatCompletionChunk({}, 'stop'),
60
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
61
+ },
62
+ ];
63
+ for (const event of events) {
64
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
65
+ }
66
+ response.end('data: [DONE]\n\n');
67
+ });
68
+ });
69
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
70
+ const { port } = server.address();
71
+ return {
72
+ baseUrl: `http://127.0.0.1:${port}`,
73
+ state,
74
+ close: () => new Promise((resolve) => server.close(resolve)),
75
+ };
76
+ }
77
+
78
+ /** Write the model catalog and configs the launcher would generate. */
79
+ async function writeRuntimeFiles(dir, baseUrl) {
80
+ const modelsPath = path.join(dir, 'models.json');
81
+ const configPath = path.join(dir, 'zenocode.jsonc');
82
+ const tuiConfigPath = path.join(dir, 'tui.json');
83
+ const catalog = {
84
+ [providerID]: {
85
+ id: providerID,
86
+ name: 'TextCortex',
87
+ api: new URL(providerPathPrefix, baseUrl).toString(),
88
+ env: ['TEXTCORTEX_API_KEY'],
89
+ models: {
90
+ [smokeModel]: {
91
+ id: smokeModel,
92
+ name: 'Zenocode smoke model',
93
+ attachment: false,
94
+ reasoning: false,
95
+ temperature: true,
96
+ tool_call: true,
97
+ limit: { context: 100000, output: 4096 },
98
+ modalities: { input: ['text'], output: ['text'] },
99
+ options: {},
100
+ },
101
+ },
102
+ },
103
+ };
104
+ const config = buildOpenCodeConfig({
105
+ baseUrl,
106
+ providerID,
107
+ model: smokeModel,
108
+ smallModel: smokeModel,
109
+ });
110
+ await fs.writeFile(modelsPath, JSON.stringify(catalog), 'utf-8');
111
+ await fs.writeFile(configPath, JSON.stringify(config), 'utf-8');
112
+ await fs.writeFile(
113
+ tuiConfigPath,
114
+ JSON.stringify(buildOpenCodeTuiConfig()),
115
+ 'utf-8'
116
+ );
117
+ return { modelsPath, configPath, tuiConfigPath };
118
+ }
119
+
120
+ /**
121
+ * Prove a compiled runtime can answer a prompt, not just print `--version`.
122
+ *
123
+ * Runs `<binary> run <prompt>` against a loopback mock of the Zenocode
124
+ * provider, in a throwaway HOME so no machine state leaks in. Resolves with the
125
+ * runtime output; rejects if the runtime crashes, hangs past `timeoutMs`, or
126
+ * exits without printing the streamed provider reply.
127
+ */
128
+ export async function smokeTestRuntimeBinary(
129
+ binaryPath,
130
+ { timeoutMs = 120_000 } = {}
131
+ ) {
132
+ const provider = await startMockProvider();
133
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-smoke-'));
134
+ try {
135
+ const home = path.join(root, 'home');
136
+ const workdir = path.join(root, 'work');
137
+ await fs.mkdir(home, { recursive: true });
138
+ await fs.mkdir(workdir, { recursive: true });
139
+ const files = await writeRuntimeFiles(root, provider.baseUrl);
140
+
141
+ const child = spawn(binaryPath, ['run', 'Reply with the smoke token.'], {
142
+ cwd: workdir,
143
+ stdio: ['ignore', 'pipe', 'pipe'],
144
+ env: {
145
+ PATH: process.env.PATH,
146
+ SYSTEMROOT: process.env.SYSTEMROOT,
147
+ HOME: home,
148
+ USERPROFILE: home,
149
+ XDG_CONFIG_HOME: path.join(home, '.config'),
150
+ XDG_DATA_HOME: path.join(home, '.local', 'share'),
151
+ XDG_CACHE_HOME: path.join(home, '.cache'),
152
+ XDG_STATE_HOME: path.join(home, '.local', 'state'),
153
+ OPENCODE_MODELS_PATH: files.modelsPath,
154
+ OPENCODE_CONFIG: files.configPath,
155
+ OPENCODE_TUI_CONFIG: files.tuiConfigPath,
156
+ OPENCODE_DISABLE_AUTOUPDATE: 'true',
157
+ TEXTCORTEX_API_KEY: 'zenocode-smoke',
158
+ },
159
+ });
160
+ let output = '';
161
+ child.stdout.on('data', (chunk) => (output += chunk.toString('utf-8')));
162
+ child.stderr.on('data', (chunk) => (output += chunk.toString('utf-8')));
163
+
164
+ const outcome = await new Promise((resolve, reject) => {
165
+ const timer = setTimeout(() => {
166
+ child.kill('SIGKILL');
167
+ resolve({ timedOut: true });
168
+ }, timeoutMs);
169
+ child.on('error', (error) => {
170
+ clearTimeout(timer);
171
+ reject(error);
172
+ });
173
+ child.on('exit', (code) => {
174
+ clearTimeout(timer);
175
+ resolve({ code });
176
+ });
177
+ });
178
+
179
+ const details = [
180
+ `provider requests: ${provider.state.providerRequests}`,
181
+ provider.state.unexpectedRequests.length
182
+ ? `unexpected requests: ${provider.state.unexpectedRequests.join(', ')}`
183
+ : null,
184
+ `runtime output:\n${output.slice(-4000)}`,
185
+ ]
186
+ .filter(Boolean)
187
+ .join('\n');
188
+ if (outcome.timedOut) {
189
+ throw new Error(
190
+ `Runtime smoke test did not finish within ${timeoutMs} ms.\n${details}`
191
+ );
192
+ }
193
+ if (outcome.code !== 0) {
194
+ throw new Error(
195
+ `Runtime smoke test exited with code ${outcome.code}.\n${details}`
196
+ );
197
+ }
198
+ if (!output.includes(smokeReply)) {
199
+ throw new Error(
200
+ `Runtime smoke test did not print the provider reply.\n${details}`
201
+ );
202
+ }
203
+ return { output, providerRequests: provider.state.providerRequests };
204
+ } finally {
205
+ await provider.close();
206
+ await fs.rm(root, { recursive: true, force: true });
207
+ }
208
+ }
209
+
210
+ if (process.argv[1] && realpathSync(process.argv[1]) === currentFilePath) {
211
+ const binaryPath = process.argv[2];
212
+ if (!binaryPath) {
213
+ console.error('Usage: node runtime-smoke.mjs <runtime-binary>');
214
+ process.exit(2);
215
+ }
216
+ smokeTestRuntimeBinary(path.resolve(binaryPath)).then(
217
+ () => console.log(`Runtime smoke test passed: ${binaryPath}`),
218
+ (error) => {
219
+ console.error(error instanceof Error ? error.message : String(error));
220
+ process.exit(1);
221
+ }
222
+ );
223
+ }
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { smokeReply, smokeTestRuntimeBinary } from './runtime-smoke.mjs';
8
+
9
+ /** Write an executable Node script that stands in for the runtime binary. */
10
+ async function writeFakeRuntime(body) {
11
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-smoke-test-'));
12
+ const filePath = path.join(dir, 'fake-runtime.mjs');
13
+ await fs.writeFile(filePath, `#!/usr/bin/env node\n${body}\n`, 'utf-8');
14
+ await fs.chmod(filePath, 0o755);
15
+ return filePath;
16
+ }
17
+
18
+ // Behaves like a working runtime: reads the generated config and model catalog,
19
+ // streams one chat completion from the configured provider, and prints it.
20
+ const workingRuntime = `
21
+ import fs from 'node:fs';
22
+ const config = JSON.parse(fs.readFileSync(process.env.OPENCODE_CONFIG, 'utf-8'));
23
+ const catalog = JSON.parse(fs.readFileSync(process.env.OPENCODE_MODELS_PATH, 'utf-8'));
24
+ const [providerID, model] = config.model.split('/');
25
+ if (!catalog[providerID]?.models?.[model]) process.exit(3);
26
+ const response = await fetch(config.provider[providerID].options.baseURL + '/chat/completions', {
27
+ method: 'POST',
28
+ headers: {
29
+ authorization: 'Bearer ' + process.env.TEXTCORTEX_API_KEY,
30
+ 'content-type': 'application/json',
31
+ },
32
+ body: JSON.stringify({ model, stream: true, messages: [{ role: 'user', content: process.argv.at(-1) }] }),
33
+ });
34
+ let text = '';
35
+ for (const line of (await response.text()).split('\\n')) {
36
+ if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
37
+ text += JSON.parse(line.slice(6)).choices[0]?.delta?.content ?? '';
38
+ }
39
+ console.log(text);
40
+ `;
41
+
42
+ test('runtime smoke passes when the runtime streams a provider reply', async () => {
43
+ const binary = await writeFakeRuntime(workingRuntime);
44
+
45
+ const result = await smokeTestRuntimeBinary(binary);
46
+
47
+ assert.match(result.output, new RegExp(smokeReply));
48
+ assert.ok(result.providerRequests >= 1);
49
+ });
50
+
51
+ test('runtime smoke fails when the runtime crashes before the provider call', async () => {
52
+ // The Bun 1.4.2 build failed exactly like this: `--version` worked, but the
53
+ // first prompt died while building the system prompt.
54
+ const binary = await writeFakeRuntime(`
55
+ console.error('Error: { "name": "UnknownError", "message": "Unexpected server error." }');
56
+ process.exit(1);
57
+ `);
58
+
59
+ await assert.rejects(
60
+ smokeTestRuntimeBinary(binary),
61
+ /exited with code 1[\s\S]*Unexpected server error/
62
+ );
63
+ });
64
+
65
+ test('runtime smoke fails when the runtime exits cleanly without a reply', async () => {
66
+ const binary = await writeFakeRuntime(`console.log('nothing to see');`);
67
+
68
+ await assert.rejects(
69
+ smokeTestRuntimeBinary(binary),
70
+ /did not print the provider reply/
71
+ );
72
+ });
73
+
74
+ test('runtime smoke fails when the runtime hangs', async () => {
75
+ const binary = await writeFakeRuntime(`setInterval(() => {}, 1000);`);
76
+
77
+ await assert.rejects(
78
+ smokeTestRuntimeBinary(binary, { timeoutMs: 1500 }),
79
+ /did not finish within 1500 ms/
80
+ );
81
+ });
@@ -112,6 +112,109 @@ const zenocodeUpgradeSource = `/**
112
112
  export async function upgrade() {}
113
113
  `;
114
114
 
115
+ /** Pinned upstream TUI handler that offers an in-app runtime upgrade. */
116
+ export const openCodeUpdatePromptSource = ` event.on("installation.update-available", async (evt) => {
117
+ console.log("installation.update-available", evt)
118
+ const version = evt.properties.version
119
+
120
+ const skipped = kv.get("skipped_version")
121
+ if (skipped && !isVersionGreater(version, skipped)) return
122
+
123
+ const choice = await DialogConfirm.show(
124
+ dialog,
125
+ \`Update Available\`,
126
+ \`A new release v\${version} is available. Would you like to update now?\`,
127
+ "skip",
128
+ )
129
+
130
+ if (choice === false) {
131
+ kv.set("skipped_version", version)
132
+ return
133
+ }
134
+
135
+ if (choice !== true) return
136
+
137
+ toast.show({
138
+ variant: "info",
139
+ message: \`Updating to v\${version}...\`,
140
+ duration: 30000,
141
+ })
142
+
143
+ const result = await sdk.client.global.upgrade({ target: version })
144
+
145
+ if (result.error || !result.data?.success) {
146
+ toast.show({
147
+ variant: "error",
148
+ title: "Update Failed",
149
+ message: "Update failed",
150
+ duration: 10000,
151
+ })
152
+ return
153
+ }
154
+
155
+ await DialogAlert.show(
156
+ dialog,
157
+ "Update Complete",
158
+ \`Successfully updated to OpenCode v\${result.data.version}. Please restart the application.\`,
159
+ )
160
+
161
+ void exit()
162
+ })
163
+ `;
164
+
165
+ // Zenocode pins its runtime package, so an in-app upgrade can never take effect
166
+ // and the "restart to finish updating" prompt would loop on every launch.
167
+ const zenocodeUpdatePromptSource = ` // Zenocode owns runtime updates; see apps/zenocode/scripts/source-branding.mjs.
168
+ event.on("installation.update-available", () => {})
169
+ `;
170
+
171
+ /** Pinned upstream server handler behind POST /global/upgrade. */
172
+ export const openCodeServerUpgradeSource = ` const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
173
+ const method = yield* installation.method()
174
+ if (method === "unknown") {
175
+ return {
176
+ status: 400,
177
+ body: { success: false as const, error: "Unknown installation method" },
178
+ }
179
+ }
180
+ const target = ctx.payload.target || (yield* installation.latest(method))
181
+ const result = yield* installation.upgrade(method, target).pipe(
182
+ Effect.as({ status: 200, body: { success: true as const, version: target } }),
183
+ Effect.catch((err) =>
184
+ Effect.succeed({
185
+ status: 500,
186
+ body: {
187
+ success: false as const,
188
+ error: err instanceof Error ? err.message : String(err),
189
+ },
190
+ }),
191
+ ),
192
+ )
193
+ if (!result.body.success) return result
194
+ GlobalBus.emit("event", {
195
+ directory: "global",
196
+ payload: {
197
+ type: Installation.Event.Updated.type,
198
+ properties: { version: target },
199
+ },
200
+ })
201
+ return result
202
+ })
203
+ `;
204
+
205
+ const zenocodeServerUpgradeSource = ` const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (_ctx: {
206
+ payload: typeof GlobalUpgradeInput.Type
207
+ }) {
208
+ return {
209
+ status: 400,
210
+ body: {
211
+ success: false as const,
212
+ error: "Zenocode manages runtime updates. Reinstall Zenocode to update.",
213
+ },
214
+ }
215
+ })
216
+ `;
217
+
115
218
  function decodeLogoLine(line) {
116
219
  return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, codePoint) =>
117
220
  String.fromCharCode(Number.parseInt(codePoint, 16))
@@ -205,8 +308,18 @@ const sourceBrandingRules = new Map([
205
308
  count: 1,
206
309
  },
207
310
  {
208
- from: 'Successfully updated to OpenCode',
209
- to: 'Successfully updated to Zenocode',
311
+ from: openCodeUpdatePromptSource,
312
+ to: zenocodeUpdatePromptSource,
313
+ count: 1,
314
+ },
315
+ ],
316
+ ],
317
+ [
318
+ 'packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts',
319
+ [
320
+ {
321
+ from: openCodeServerUpgradeSource,
322
+ to: zenocodeServerUpgradeSource,
210
323
  count: 1,
211
324
  },
212
325
  ],
@@ -3,6 +3,8 @@ import test from 'node:test';
3
3
 
4
4
  import {
5
5
  brandOpenCodeSourceFile,
6
+ openCodeServerUpgradeSource,
7
+ openCodeUpdatePromptSource,
6
8
  sourceBrandingFiles,
7
9
  } from './source-branding.mjs';
8
10
 
@@ -26,17 +28,45 @@ renderer.setTerminalTitle("OpenCode")
26
28
  renderer.setTerminalTitle("OpenCode")
27
29
  renderer.setTerminalTitle(\`OC | \${title}\`)
28
30
  renderer.setTerminalTitle(\`OC | \${route.data.id}\`)
29
- \`Successfully updated to OpenCode v\${result.data.version}. Please restart the application.\`
30
- `;
31
+ ${openCodeUpdatePromptSource}`;
31
32
 
32
33
  const branded = brandOpenCodeSourceFile('packages/tui/src/app.tsx', source);
33
34
 
34
35
  assert.match(branded, /setTerminalTitle\("Zenocode"\)/);
35
36
  assert.match(branded, /setTerminalTitle\(`ZC \| \$\{title\}`\)/);
36
- assert.match(branded, /Successfully updated to Zenocode/);
37
37
  assert.doesNotMatch(branded, /OpenCode/);
38
38
  });
39
39
 
40
+ test('source branding removes the in-app update prompt', () => {
41
+ const branded = brandOpenCodeSourceFile(
42
+ 'packages/tui/src/app.tsx',
43
+ `renderer.setTerminalTitle("OpenCode")
44
+ renderer.setTerminalTitle("OpenCode")
45
+ renderer.setTerminalTitle(\`OC | \${title}\`)
46
+ renderer.setTerminalTitle(\`OC | \${route.data.id}\`)
47
+ ${openCodeUpdatePromptSource}`
48
+ );
49
+
50
+ assert.match(
51
+ branded,
52
+ /event\.on\("installation\.update-available", \(\) => \{\}\)/
53
+ );
54
+ assert.doesNotMatch(branded, /Update Available/);
55
+ assert.doesNotMatch(branded, /global\.upgrade/);
56
+ });
57
+
58
+ test('source branding refuses runtime upgrades through the server API', () => {
59
+ const branded = brandOpenCodeSourceFile(
60
+ 'packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts',
61
+ openCodeServerUpgradeSource
62
+ );
63
+
64
+ assert.match(branded, /Zenocode manages runtime updates/);
65
+ assert.match(branded, /status: 400/);
66
+ assert.doesNotMatch(branded, /installation\.upgrade\(/);
67
+ assert.doesNotMatch(branded, /installation\.latest\(/);
68
+ });
69
+
40
70
  test('source branding replaces the terminal network identity', () => {
41
71
  const source = `
42
72
  describe: "custom domain name for mDNS service (default: opencode.local)",