@textcortex/zenocode 0.1.14 → 0.1.16

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.
@@ -1,14 +1,10 @@
1
- import assert from "node:assert/strict";
2
- import http from "node:http";
3
- import fs from "node:fs/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { spawn } from "node:child_process";
7
- import test from "node:test";
8
- import {
9
- buildPaddedLogoSnippet,
10
- patchLogoSnippetText,
11
- } from "./branding-patch.mjs";
1
+ import assert from 'node:assert/strict';
2
+ import http from 'node:http';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import test from 'node:test';
12
8
  import {
13
9
  buildOpenCodeConfig,
14
10
  buildPackageLauncherInvocation,
@@ -17,248 +13,402 @@ import {
17
13
  buildZenocodeBanner,
18
14
  chooseDefaults,
19
15
  hasLocalBaseUrlFlag,
20
- packageNameFromSpecifier,
16
+ isRecoverableSessionError,
17
+ resolveLaunchPackage,
18
+ resolvePackageRunners,
19
+ resolveConfiguredRuntimePackage,
21
20
  resolveLoginBaseUrl,
22
21
  resolveLoginSuccessIdentifier,
23
22
  resolveTextCortexBaseUrl,
24
23
  runRuntimeWithSessionRecovery,
25
24
  shouldBypassZenocodePreparation,
26
25
  shouldFallbackLoginToCloud,
26
+ zenocodeOwnedCommandMessage,
27
27
  writePrivateJsonFile,
28
- } from "./run-zenocode.mjs";
28
+ } from './run-zenocode.mjs';
29
+
30
+ test('Zenocode launches the pinned TextCortex-owned runtime by default', () => {
31
+ assert.equal(
32
+ resolveLaunchPackage(),
33
+ '@textcortex/zenocode-ai@1.18.10-zenocode.2'
34
+ );
35
+ });
36
+
37
+ test('Zenocode ignores generic OpenCode package overrides', () => {
38
+ assert.equal(
39
+ resolveConfiguredRuntimePackage({ OPENCODE_PACKAGE: 'opencode-ai' }),
40
+ null
41
+ );
42
+ assert.equal(
43
+ resolveConfiguredRuntimePackage({
44
+ ZENOCODE_OPENCODE_PACKAGE: '@textcortex/zenocode-ai@dev',
45
+ }),
46
+ '@textcortex/zenocode-ai@dev'
47
+ );
48
+ });
49
+
50
+ test('Zenocode owns runtime upgrades instead of forwarding them to OpenCode', () => {
51
+ assert.match(
52
+ zenocodeOwnedCommandMessage(['upgrade']),
53
+ /npm install -g @textcortex\/zenocode@latest/
54
+ );
55
+ assert.match(
56
+ zenocodeOwnedCommandMessage(['uninstall']),
57
+ /npm uninstall -g @textcortex\/zenocode/
58
+ );
59
+ assert.equal(
60
+ zenocodeOwnedCommandMessage(['run', 'upgrade dependencies']),
61
+ null
62
+ );
63
+ });
64
+
65
+ test('chooseDefaults prefers GPT-6 Luna as the Zenocode default', () => {
66
+ const defaults = chooseDefaults({
67
+ 'gpt-6-sol': {},
68
+ 'claude-sonnet-5-5': {},
69
+ 'glm-5-3': {},
70
+ 'kimi-k3-non-thinking': {},
71
+ 'gpt-6-luna': {},
72
+ 'gpt-5-6-luna': {},
73
+ });
74
+
75
+ assert.deepEqual(defaults, {
76
+ model: 'gpt-6-luna',
77
+ smallModel: 'gpt-6-luna',
78
+ });
79
+ });
29
80
 
30
- test("chooseDefaults prefers GPT-5.6 Luna as the Zenocode default", () => {
81
+ test('chooseDefaults ranks GLM 5.3 and Claude 5.5 above their predecessors', () => {
82
+ assert.equal(
83
+ chooseDefaults({ 'glm-5-2-non-thinking': {}, 'glm-5-3': {} }).model,
84
+ 'glm-5-3'
85
+ );
86
+ assert.equal(
87
+ chooseDefaults({ 'claude-5-sonnet': {}, 'claude-sonnet-5-5': {} }).model,
88
+ 'claude-sonnet-5-5'
89
+ );
90
+ });
91
+
92
+ test('chooseDefaults falls back to GPT-5.6 Luna when GPT-6 Luna is unavailable', () => {
31
93
  const defaults = chooseDefaults({
32
- "kimi-k2p7-code-non-thinking": {},
33
- "gpt-5-6-luna": {},
34
- "glm-5-2-non-thinking": {},
35
- "minimax-m3-thinking": {},
36
- "gpt-5-2": {},
94
+ 'kimi-k2p7-code-non-thinking': {},
95
+ 'gpt-5-6-luna': {},
96
+ 'glm-5-2-non-thinking': {},
97
+ 'minimax-m3-thinking': {},
98
+ 'gpt-5-2': {},
37
99
  });
38
100
 
39
101
  assert.deepEqual(defaults, {
40
- model: "gpt-5-6-luna",
41
- smallModel: "gpt-5-6-luna",
102
+ model: 'gpt-5-6-luna',
103
+ smallModel: 'gpt-5-6-luna',
42
104
  });
43
105
  });
44
106
 
45
- test("chooseDefaults falls back to Kimi K2.7 Code when GPT-5.6 Luna is unavailable", () => {
107
+ test('chooseDefaults falls back to Kimi K2.7 Code when GPT-5.6 Luna is unavailable', () => {
46
108
  const defaults = chooseDefaults({
47
- "kimi-k2p7-code-non-thinking": {},
48
- "glm-5-2-non-thinking": {},
49
- "glm-5-1-non-thinking": {},
50
- "kimi-k2-6-non-thinking": {},
51
- "minimax-m3-thinking": {},
52
- "gpt-5-2": {},
109
+ 'kimi-k2p7-code-non-thinking': {},
110
+ 'glm-5-2-non-thinking': {},
111
+ 'glm-5-1-non-thinking': {},
112
+ 'kimi-k2-6-non-thinking': {},
113
+ 'minimax-m3-thinking': {},
114
+ 'gpt-5-2': {},
53
115
  });
54
116
 
55
117
  assert.deepEqual(defaults, {
56
- model: "kimi-k2p7-code-non-thinking",
57
- smallModel: "kimi-k2p7-code-non-thinking",
118
+ model: 'kimi-k2p7-code-non-thinking',
119
+ smallModel: 'kimi-k2p7-code-non-thinking',
58
120
  });
59
121
  });
60
122
 
61
- test("chooseDefaults prefers GLM 5.2 over GLM 5.1 for Zenocode fallback", () => {
123
+ test('chooseDefaults never selects deprecated GLM 5.1 for Zenocode fallback', () => {
62
124
  const defaults = chooseDefaults({
63
- "glm-5-1-non-thinking": {},
64
- "glm-5-2-non-thinking": {},
65
- "gpt-5-mini": {},
125
+ 'glm-5-1-non-thinking': {},
126
+ 'future-model': {},
66
127
  });
67
128
 
68
129
  assert.deepEqual(defaults, {
69
- model: "glm-5-2-non-thinking",
70
- smallModel: "gpt-5-mini",
130
+ model: 'future-model',
131
+ smallModel: 'future-model',
71
132
  });
72
133
  });
73
134
 
74
- test("chooseDefaults falls back to explicit thinking variants when base models are absent", () => {
135
+ test('chooseDefaults falls back to explicit thinking variants when base models are absent', () => {
75
136
  const defaults = chooseDefaults({
76
- "kimi-k2p7-code-thinking": {},
77
- "glm-5-2-non-thinking": {},
78
- "kimi-k2-6-thinking": {},
137
+ 'kimi-k2p7-code-thinking': {},
138
+ 'glm-5-2-non-thinking': {},
139
+ 'kimi-k2-6-thinking': {},
79
140
  });
80
141
 
81
142
  assert.deepEqual(defaults, {
82
- model: "kimi-k2p7-code-thinking",
83
- smallModel: "kimi-k2p7-code-thinking",
143
+ model: 'kimi-k2p7-code-thinking',
144
+ smallModel: 'kimi-k2p7-code-thinking',
84
145
  });
85
146
  });
86
147
 
87
- test("packageNameFromSpecifier strips npm versions without breaking scopes", () => {
88
- assert.equal(packageNameFromSpecifier("opencode-ai@1.17.6"), "opencode-ai");
89
- assert.equal(
90
- packageNameFromSpecifier("@textcortex/zenocode-ai@1.17.6"),
91
- "@textcortex/zenocode-ai",
92
- );
93
- assert.equal(
94
- packageNameFromSpecifier("@textcortex/zenocode-ai"),
95
- "@textcortex/zenocode-ai",
96
- );
148
+ test('chooseDefaults prefers Kimi K3 over older Kimi generations', () => {
149
+ const defaults = chooseDefaults({
150
+ 'kimi-k2p7-code-non-thinking': {},
151
+ 'kimi-k3-non-thinking': {},
152
+ 'kimi-k2-6-non-thinking': {},
153
+ 'minimax-m3-thinking': {},
154
+ });
155
+
156
+ assert.deepEqual(defaults, {
157
+ model: 'kimi-k3-non-thinking',
158
+ smallModel: 'kimi-k3-non-thinking',
159
+ });
97
160
  });
98
161
 
99
- test("shouldBypassZenocodePreparation skips auth for runtime metadata commands", () => {
100
- assert.equal(shouldBypassZenocodePreparation(["--version"]), true);
101
- assert.equal(shouldBypassZenocodePreparation(["completion", "zsh"]), true);
102
- assert.equal(shouldBypassZenocodePreparation(["run"]), false);
103
- assert.equal(shouldBypassZenocodePreparation(["run", "implement", "completion"]), false);
104
- assert.equal(shouldBypassZenocodePreparation(["run", "--help"]), false);
162
+ test('chooseDefaults ranks Claude 5 below the coding-first models', () => {
163
+ const defaults = chooseDefaults({
164
+ 'claude-opus-5': {},
165
+ 'claude-5-sonnet': {},
166
+ 'kimi-k3-thinking': {},
167
+ });
168
+
169
+ assert.deepEqual(defaults, {
170
+ model: 'kimi-k3-thinking',
171
+ smallModel: 'kimi-k3-thinking',
172
+ });
105
173
  });
106
174
 
107
- test("resolveTextCortexBaseUrl defaults to the cloud API for packaged usage", () => {
175
+ test('chooseDefaults selects Claude 5 when it is the only catalog entry', () => {
176
+ const defaults = chooseDefaults({
177
+ 'claude-5-sonnet': {},
178
+ 'claude-opus-5': {},
179
+ });
180
+
181
+ assert.deepEqual(defaults, {
182
+ model: 'claude-5-sonnet',
183
+ smallModel: 'claude-5-sonnet',
184
+ });
185
+ });
186
+
187
+ test('shouldBypassZenocodePreparation skips auth for runtime metadata commands', () => {
188
+ assert.equal(shouldBypassZenocodePreparation(['--version']), true);
189
+ assert.equal(shouldBypassZenocodePreparation(['completion', 'zsh']), true);
190
+ assert.equal(shouldBypassZenocodePreparation(['run']), false);
191
+ assert.equal(
192
+ shouldBypassZenocodePreparation(['run', 'implement', 'completion']),
193
+ false
194
+ );
195
+ assert.equal(shouldBypassZenocodePreparation(['run', '--help']), false);
196
+ });
197
+
198
+ test('resolveTextCortexBaseUrl defaults to the cloud API for packaged usage', () => {
108
199
  assert.equal(
109
200
  resolveTextCortexBaseUrl({
110
- envBaseUrl: "",
201
+ envBaseUrl: '',
111
202
  storedBaseUrl: null,
112
203
  }),
113
- "https://api.textcortex.com",
204
+ 'https://api.textcortex.com'
114
205
  );
115
206
  });
116
207
 
117
- test("resolveTextCortexBaseUrl prefers the explicit env var over stored credentials", () => {
208
+ test('resolveTextCortexBaseUrl prefers the explicit env var over stored credentials', () => {
118
209
  assert.equal(
119
210
  resolveTextCortexBaseUrl({
120
- envBaseUrl: "https://staging.textcortex.com",
121
- storedBaseUrl: "http://127.0.0.1:8080",
211
+ envBaseUrl: 'https://staging.textcortex.com',
212
+ storedBaseUrl: 'http://127.0.0.1:8080',
122
213
  }),
123
- "https://staging.textcortex.com",
214
+ 'https://staging.textcortex.com'
124
215
  );
125
216
  });
126
217
 
127
- test("resolveTextCortexBaseUrl prefers localhost when the local flag is enabled", () => {
218
+ test('resolveTextCortexBaseUrl prefers localhost when the local flag is enabled', () => {
128
219
  assert.equal(
129
220
  resolveTextCortexBaseUrl({
130
- envBaseUrl: "https://staging.textcortex.com",
131
- storedBaseUrl: "https://api.textcortex.com",
221
+ envBaseUrl: 'https://staging.textcortex.com',
222
+ storedBaseUrl: 'https://api.textcortex.com',
132
223
  preferLocalhost: true,
133
224
  }),
134
- "http://127.0.0.1:8080",
225
+ 'http://127.0.0.1:8080'
135
226
  );
136
227
  });
137
228
 
138
- test("resolveLoginBaseUrl ignores stored credentials and defaults explicit login to cloud", () => {
229
+ test('resolveLoginBaseUrl ignores stored credentials and defaults explicit login to cloud', () => {
139
230
  assert.equal(
140
231
  resolveLoginBaseUrl({
141
- envBaseUrl: "",
232
+ envBaseUrl: '',
142
233
  }),
143
- "https://api.textcortex.com",
234
+ 'https://api.textcortex.com'
144
235
  );
145
236
  });
146
237
 
147
- test("resolveLoginBaseUrl still respects explicit env overrides", () => {
238
+ test('resolveLoginBaseUrl still respects explicit env overrides', () => {
148
239
  assert.equal(
149
240
  resolveLoginBaseUrl({
150
- envBaseUrl: "https://staging.textcortex.com",
241
+ envBaseUrl: 'https://staging.textcortex.com',
151
242
  }),
152
- "https://staging.textcortex.com",
243
+ 'https://staging.textcortex.com'
153
244
  );
154
245
  });
155
246
 
156
- test("resolveLoginBaseUrl prefers localhost when the local flag is enabled", () => {
247
+ test('resolveLoginBaseUrl prefers localhost when the local flag is enabled', () => {
157
248
  assert.equal(
158
249
  resolveLoginBaseUrl({
159
- envBaseUrl: "https://staging.textcortex.com",
250
+ envBaseUrl: 'https://staging.textcortex.com',
160
251
  preferLocalhost: true,
161
252
  }),
162
- "http://127.0.0.1:8080",
253
+ 'http://127.0.0.1:8080'
163
254
  );
164
255
  });
165
256
 
166
- test("hasLocalBaseUrlFlag detects localhost flags", () => {
167
- assert.equal(hasLocalBaseUrlFlag(["login", "--local"]), true);
168
- assert.equal(hasLocalBaseUrlFlag(["--localhost", "run"]), true);
169
- assert.equal(hasLocalBaseUrlFlag(["run", "--help"]), false);
257
+ test('hasLocalBaseUrlFlag detects localhost flags', () => {
258
+ assert.equal(hasLocalBaseUrlFlag(['login', '--local']), true);
259
+ assert.equal(hasLocalBaseUrlFlag(['--localhost', 'run']), true);
260
+ assert.equal(hasLocalBaseUrlFlag(['run', '--help']), false);
170
261
  });
171
262
 
172
- test("shouldFallbackLoginToCloud retries the cloud API when the local auth route returns 404", () => {
263
+ test('shouldFallbackLoginToCloud retries the cloud API when the local auth route returns 404', () => {
173
264
  assert.equal(
174
265
  shouldFallbackLoginToCloud({
175
- baseUrl: "http://127.0.0.1:8080",
266
+ baseUrl: 'http://127.0.0.1:8080',
176
267
  hasExplicitBaseUrl: false,
177
- error: new Error("Login initiate failed (404): not found"),
268
+ error: new Error('Login initiate failed (404): not found'),
178
269
  }),
179
- true,
270
+ true
180
271
  );
181
272
  });
182
273
 
183
- test("shouldFallbackLoginToCloud does not override an explicit base URL", () => {
274
+ test('shouldFallbackLoginToCloud does not override an explicit base URL', () => {
184
275
  assert.equal(
185
276
  shouldFallbackLoginToCloud({
186
- baseUrl: "http://127.0.0.1:8080",
277
+ baseUrl: 'http://127.0.0.1:8080',
187
278
  hasExplicitBaseUrl: true,
188
- error: new Error("fetch failed"),
279
+ error: new Error('fetch failed'),
189
280
  }),
190
- false,
281
+ false
191
282
  );
192
283
  });
193
284
 
194
- test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plugins", () => {
285
+ test('buildOpenCodeConfig includes an openai stub for fallback runtime auth plugins', () => {
195
286
  const config = buildOpenCodeConfig({
196
- baseUrl: "http://127.0.0.1:8080",
197
- providerID: "textcortex",
198
- model: "minimax-m3-thinking",
199
- smallModel: "gpt-5-2",
287
+ baseUrl: 'http://127.0.0.1:8080',
288
+ providerID: 'textcortex',
289
+ model: 'minimax-m3-thinking',
290
+ smallModel: 'gpt-5-2',
200
291
  });
201
292
 
202
- assert.deepEqual(config.enabled_providers, ["textcortex"]);
203
- assert.equal(config.model, "textcortex/minimax-m3-thinking");
204
- assert.equal(config.small_model, "textcortex/gpt-5-2");
205
- assert.equal(config.theme, "system");
293
+ assert.deepEqual(config.enabled_providers, ['textcortex']);
294
+ assert.equal(config.model, 'textcortex/minimax-m3-thinking');
295
+ assert.equal(config.small_model, 'textcortex/gpt-5-2');
296
+ assert.equal(config.theme, 'system');
297
+ assert.equal(config.autoupdate, false);
206
298
  assert.ok(config.provider.openai);
207
299
  assert.deepEqual(config.provider.openai.models, {});
208
300
  });
209
301
 
210
- test("buildPackageLauncherChildOptions keeps fallback package launchers attached to the terminal", () => {
211
- const childOptions = buildPackageLauncherChildOptions(
212
- {
213
- cwd: "/tmp/zenocode",
214
- env: { TEXTCORTEX_API_KEY: "token-1" },
215
- },
216
- "/tmp/zenocode-runtime",
217
- );
302
+ test('buildPackageLauncherChildOptions keeps fallback package launchers attached to the terminal', () => {
303
+ const childOptions = buildPackageLauncherChildOptions({
304
+ cwd: '/tmp/zenocode',
305
+ env: { TEXTCORTEX_API_KEY: 'token-1' },
306
+ });
218
307
 
219
- assert.equal(childOptions.stdio, "inherit");
220
- assert.equal(childOptions.cwd, "/tmp/zenocode");
308
+ assert.equal(childOptions.stdio, 'inherit');
309
+ assert.equal(childOptions.cwd, '/tmp/zenocode');
221
310
  assert.deepEqual(childOptions.env, {
222
- TEXTCORTEX_API_KEY: "token-1",
223
- OPENCODE_BIN_PATH: "/tmp/zenocode-runtime",
311
+ TEXTCORTEX_API_KEY: 'token-1',
224
312
  });
225
313
  });
226
314
 
227
- test("buildPackageLauncherInvocation runs pinned runtimes directly", () => {
315
+ test('buildPackageLauncherInvocation launches the owned runtime package unchanged', () => {
228
316
  const invocation = buildPackageLauncherInvocation({
229
317
  runner: {
230
- command: "npx",
231
- args: ["--yes", "opencode-ai@1.17.6", "--version"],
318
+ command: 'pnpm',
319
+ args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.2', '--version'],
232
320
  },
233
- args: ["--version"],
234
- pinnedRuntimePath: "/tmp/zenocode-runtime",
235
321
  });
236
322
 
237
323
  assert.deepEqual(invocation, {
238
- command: "/tmp/zenocode-runtime",
239
- args: ["--version"],
240
- usePinnedRuntime: true,
324
+ command: 'pnpm',
325
+ args: ['dlx', '@textcortex/zenocode-ai@1.18.10-zenocode.2', '--version'],
241
326
  });
242
327
  });
243
328
 
244
- test("buildPackageLauncherInvocation uses package runners without a pinned runtime", () => {
245
- const invocation = buildPackageLauncherInvocation({
246
- runner: {
247
- command: "pnpm",
248
- args: ["dlx", "opencode-ai@1.17.6", "--version"],
329
+ test('resolvePackageRunners keeps pnpm then npx on POSIX platforms', () => {
330
+ const runners = resolvePackageRunners(
331
+ '@textcortex/zenocode-ai@1.0.0',
332
+ ['run'],
333
+ {
334
+ platform: 'linux',
335
+ execPath: '/usr/bin/node',
336
+ env: {},
337
+ fileExists: () => true,
338
+ }
339
+ );
340
+
341
+ assert.deepEqual(runners, [
342
+ { command: 'pnpm', args: ['dlx', '@textcortex/zenocode-ai@1.0.0', 'run'] },
343
+ { command: 'npx', args: ['--yes', '@textcortex/zenocode-ai@1.0.0', 'run'] },
344
+ ]);
345
+ });
346
+
347
+ test('resolvePackageRunners never spawns .cmd/.bat shims on Windows', () => {
348
+ // Node >= 18.20.2 / 20.12.2 rejects spawning batch files without a shell
349
+ // (CVE-2024-27980) with `spawn EINVAL`, so Windows runners must be executables.
350
+ const execPath = 'C:\\Program Files\\nodejs\\node.exe';
351
+ const npxCli = path.win32.join(
352
+ 'C:\\Program Files\\nodejs',
353
+ 'node_modules',
354
+ 'npm',
355
+ 'bin',
356
+ 'npx-cli.js'
357
+ );
358
+ const runners = resolvePackageRunners(
359
+ '@textcortex/zenocode-ai@1.0.0',
360
+ ['run'],
361
+ {
362
+ platform: 'win32',
363
+ execPath,
364
+ env: {},
365
+ fileExists: (candidate) => candidate === npxCli,
366
+ }
367
+ );
368
+
369
+ for (const runner of runners) {
370
+ assert.doesNotMatch(runner.command, /\.(cmd|bat)$/i);
371
+ }
372
+ assert.deepEqual(runners, [
373
+ {
374
+ command: 'pnpm.exe',
375
+ args: ['dlx', '@textcortex/zenocode-ai@1.0.0', 'run'],
249
376
  },
250
- args: ["--version"],
251
- pinnedRuntimePath: null,
377
+ {
378
+ command: execPath,
379
+ args: [npxCli, '--yes', '@textcortex/zenocode-ai@1.0.0', 'run'],
380
+ },
381
+ ]);
382
+ });
383
+
384
+ test('resolvePackageRunners finds npx beside npm_execpath on Windows', () => {
385
+ const npmCli = 'D:\\tools\\npm\\bin\\npm-cli.js';
386
+ const npxCli = 'D:\\tools\\npm\\bin\\npx-cli.js';
387
+ const runners = resolvePackageRunners('pkg@1', [], {
388
+ platform: 'win32',
389
+ execPath: 'D:\\volta\\node.exe',
390
+ env: { npm_execpath: npmCli },
391
+ fileExists: (candidate) => candidate === npxCli,
252
392
  });
253
393
 
254
- assert.deepEqual(invocation, {
255
- command: "pnpm",
256
- args: ["dlx", "opencode-ai@1.17.6", "--version"],
257
- usePinnedRuntime: false,
394
+ assert.deepEqual(runners.at(-1), {
395
+ command: 'D:\\volta\\node.exe',
396
+ args: [npxCli, '--yes', 'pkg@1'],
258
397
  });
259
398
  });
260
399
 
261
- test("buildZenocodeBanner renders block logo art instead of plain text", () => {
400
+ test('resolvePackageRunners omits npx on Windows when npm is not beside node', () => {
401
+ const runners = resolvePackageRunners('pkg@1', [], {
402
+ platform: 'win32',
403
+ execPath: 'C:\\node\\node.exe',
404
+ env: {},
405
+ fileExists: () => false,
406
+ });
407
+
408
+ assert.deepEqual(runners, [{ command: 'pnpm.exe', args: ['dlx', 'pkg@1'] }]);
409
+ });
410
+
411
+ test('buildZenocodeBanner renders block logo art instead of plain text', () => {
262
412
  const banner = buildZenocodeBanner();
263
413
 
264
414
  assert.match(banner, /▀▀█/);
@@ -267,51 +417,20 @@ test("buildZenocodeBanner renders block logo art instead of plain text", () => {
267
417
  assert.doesNotMatch(banner, /\bZenocode\b/);
268
418
  });
269
419
 
270
- test("patchLogoSnippetText upgrades previously branded Zenocode logos", () => {
271
- const legacyTextLogoSnippet = `var logo = {
272
- left: [" ", " Zenocode ", " Zenocode ", " "],
273
- right: [" ", " ", " ", " "]
274
- };
275
- var marks = "_^~";`;
276
-
277
- const result = patchLogoSnippetText(buildPaddedLogoSnippet(legacyTextLogoSnippet));
278
-
279
- assert.equal(result.patched, true);
280
- assert.match(result.text, /\\u2580\\u2580\\u2588/);
281
- assert.match(result.text, /\\u2588\\u2580\\u2580/);
282
- assert.doesNotMatch(result.text, /\\\\u2580/);
283
- assert.doesNotMatch(result.text, /Zenocode/);
284
- });
285
-
286
- test("patchLogoSnippetText upgrades double-escaped Zenocode glyph logos", () => {
287
- const brokenGlyphLogoSnippet = String.raw`var logo = {
288
- left: [" "," \\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580 \\u2588\\u2584 \\u2588 \\u2588\\u2580\\u2588"," \\u2588\\u2584\\u2584 \\u2588\\u2588\\u2584 \\u2588 \\u2580\\u2588 \\u2588\\u2584\\u2588"," "],
289
- right: [" "," \\u2588\\u2580\\u2580 \\u2588\\u2580\\u2588 \\u2588\\u2580\\u2584 \\u2588\\u2580\\u2580"," \\u2588\\u2584\\u2584 \\u2588\\u2584\\u2588 \\u2588\\u2584\\u2580 \\u2588\\u2588\\u2584"," "]
290
- };
291
- var marks = "_^~";`;
292
-
293
- const result = patchLogoSnippetText(buildPaddedLogoSnippet(brokenGlyphLogoSnippet));
294
-
295
- assert.equal(result.patched, true);
296
- assert.match(result.text, /\\u2580\\u2580\\u2588/);
297
- assert.match(result.text, /\\u2588\\u2580\\u2580/);
298
- assert.doesNotMatch(result.text, /\\\\u2580/);
299
- });
300
-
301
- test("writePrivateJsonFile stores credentials with private permissions", async (t) => {
302
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-runtime-"));
303
- const credentialsPath = path.join(tempDir, "runtime", "credentials.json");
420
+ test('writePrivateJsonFile stores credentials with private permissions', async (t) => {
421
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-runtime-'));
422
+ const credentialsPath = path.join(tempDir, 'runtime', 'credentials.json');
304
423
 
305
424
  t.after(async () => {
306
425
  await fs.rm(tempDir, { recursive: true, force: true });
307
426
  });
308
427
 
309
- await writePrivateJsonFile(credentialsPath, { access_token: "secret-token" });
428
+ await writePrivateJsonFile(credentialsPath, { access_token: 'secret-token' });
310
429
 
311
- const saved = JSON.parse(await fs.readFile(credentialsPath, "utf-8"));
312
- assert.equal(saved.access_token, "secret-token");
430
+ const saved = JSON.parse(await fs.readFile(credentialsPath, 'utf-8'));
431
+ assert.equal(saved.access_token, 'secret-token');
313
432
 
314
- if (process.platform !== "win32") {
433
+ if (process.platform !== 'win32') {
315
434
  const dirMode = (await fs.stat(path.dirname(credentialsPath))).mode & 0o777;
316
435
  const fileMode = (await fs.stat(credentialsPath)).mode & 0o777;
317
436
 
@@ -320,68 +439,89 @@ test("writePrivateJsonFile stores credentials with private permissions", async (
320
439
  }
321
440
  });
322
441
 
323
- test("canRecoverRuntimeSessionFromTranscript detects expired session output", () => {
442
+ test('canRecoverRuntimeSessionFromTranscript detects expired session output', () => {
324
443
  const transcript = [
325
- "\u001b[2J\u001b[H",
326
- "cool, is there anything about zenocode?",
327
- "",
444
+ '\u001b[2J\u001b[H',
445
+ 'cool, is there anything about zenocode?',
446
+ '',
328
447
  'Unauthorized: {"detail":"Token has expired"}',
329
- ].join("\n");
448
+ ].join('\n');
330
449
 
331
450
  assert.equal(canRecoverRuntimeSessionFromTranscript(transcript), true);
332
451
  });
333
452
 
334
- test("resolveLoginSuccessIdentifier avoids email fallback", () => {
453
+ test('isRecoverableSessionError accepts expired and malformed model tokens', () => {
454
+ assert.equal(
455
+ isRecoverableSessionError(
456
+ new Error('Models fetch failed (401): Token has expired')
457
+ ),
458
+ true
459
+ );
460
+ assert.equal(
461
+ isRecoverableSessionError(
462
+ new Error('Models fetch failed (401): Invalid token format')
463
+ ),
464
+ true
465
+ );
466
+ assert.equal(
467
+ isRecoverableSessionError(
468
+ new Error('Models fetch failed (401): forbidden')
469
+ ),
470
+ false
471
+ );
472
+ });
473
+
474
+ test('resolveLoginSuccessIdentifier avoids email fallback', () => {
335
475
  assert.equal(
336
476
  resolveLoginSuccessIdentifier({
337
- auth_id: "auth_123",
338
- email: "user@example.com",
477
+ auth_id: 'auth_123',
478
+ email: 'user@example.com',
339
479
  }),
340
- "auth_123",
480
+ 'auth_123'
341
481
  );
342
482
  assert.equal(
343
483
  resolveLoginSuccessIdentifier({
344
- email: "user@example.com",
484
+ email: 'user@example.com',
345
485
  }),
346
- "unknown",
486
+ 'unknown'
347
487
  );
348
488
  });
349
489
 
350
- test("runRuntimeWithSessionRecovery reruns login after runtime session expiry", async () => {
490
+ test('runRuntimeWithSessionRecovery reruns login after runtime session expiry', async () => {
351
491
  const events = [];
352
492
  let launchCount = 0;
353
493
 
354
494
  const result = await runRuntimeWithSessionRecovery({
355
- args: ["run"],
356
- baseUrl: "http://127.0.0.1:8080",
357
- token: "token-1",
495
+ args: ['run'],
496
+ baseUrl: 'http://127.0.0.1:8080',
497
+ token: 'token-1',
358
498
  childOptions: {
359
499
  cwd: process.cwd(),
360
500
  env: {},
361
501
  },
362
502
  canAutoLoginRuntime: true,
363
503
  refreshTokenFn: async (baseUrl) => {
364
- events.push(["refresh", baseUrl]);
365
- throw new Error("refresh failed");
504
+ events.push(['refresh', baseUrl]);
505
+ throw new Error('refresh failed');
366
506
  },
367
507
  runLogin: async (baseUrl, loginArgs) => {
368
- events.push(["login", baseUrl, loginArgs]);
508
+ events.push(['login', baseUrl, loginArgs]);
369
509
  },
370
510
  resolveTokenFn: async () => {
371
- events.push(["resolve-token"]);
372
- return "token-2";
511
+ events.push(['resolve-token']);
512
+ return 'token-2';
373
513
  },
374
514
  resolveStoredBaseUrlFn: async () => {
375
- events.push(["resolve-base-url"]);
376
- return "https://api.textcortex.com";
515
+ events.push(['resolve-base-url']);
516
+ return 'https://api.textcortex.com';
377
517
  },
378
518
  prepareRuntimeFn: async (baseUrl, token) => {
379
- events.push(["prepare", baseUrl, token]);
380
- return "kimi-k2-5-thinking";
519
+ events.push(['prepare', baseUrl, token]);
520
+ return 'kimi-k2-5-thinking';
381
521
  },
382
522
  launchRuntimeFn: async ({ childOptions }) => {
383
523
  launchCount += 1;
384
- events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
524
+ events.push(['launch', launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
385
525
  if (launchCount === 1) {
386
526
  return { expiredSession: true };
387
527
  }
@@ -390,47 +530,47 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
390
530
  });
391
531
 
392
532
  assert.deepEqual(events, [
393
- ["launch", 1, "token-1"],
394
- ["refresh", "http://127.0.0.1:8080"],
395
- ["login", "http://127.0.0.1:8080", []],
396
- ["resolve-token"],
397
- ["resolve-base-url"],
398
- ["prepare", "https://api.textcortex.com", "token-2"],
399
- ["launch", 2, "token-2"],
533
+ ['launch', 1, 'token-1'],
534
+ ['refresh', 'http://127.0.0.1:8080'],
535
+ ['login', 'http://127.0.0.1:8080', []],
536
+ ['resolve-token'],
537
+ ['resolve-base-url'],
538
+ ['prepare', 'https://api.textcortex.com', 'token-2'],
539
+ ['launch', 2, 'token-2'],
400
540
  ]);
401
541
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
402
542
  });
403
543
 
404
- test("runRuntimeWithSessionRecovery refreshes stored credentials before forcing login", async () => {
544
+ test('runRuntimeWithSessionRecovery refreshes stored credentials before forcing login', async () => {
405
545
  const events = [];
406
546
  let launchCount = 0;
407
547
 
408
548
  const result = await runRuntimeWithSessionRecovery({
409
- args: ["run"],
410
- baseUrl: "http://127.0.0.1:8080",
411
- token: "token-1",
549
+ args: ['run'],
550
+ baseUrl: 'http://127.0.0.1:8080',
551
+ token: 'token-1',
412
552
  childOptions: {
413
553
  cwd: process.cwd(),
414
554
  env: {},
415
555
  },
416
556
  canAutoLoginRuntime: true,
417
557
  refreshTokenFn: async (baseUrl) => {
418
- events.push(["refresh", baseUrl]);
558
+ events.push(['refresh', baseUrl]);
419
559
  return {
420
- token: "token-2",
421
- baseUrl: "https://api.textcortex.com",
560
+ token: 'token-2',
561
+ baseUrl: 'https://api.textcortex.com',
422
562
  };
423
563
  },
424
564
  runLogin: async () => {
425
- throw new Error("login should not be called");
565
+ throw new Error('login should not be called');
426
566
  },
427
567
  prepareRuntimeFn: async (baseUrl, token) => {
428
- events.push(["prepare", baseUrl, token]);
429
- return "kimi-k2-5-thinking";
568
+ events.push(['prepare', baseUrl, token]);
569
+ return 'kimi-k2-5-thinking';
430
570
  },
431
571
  launchRuntimeFn: async ({ childOptions }) => {
432
572
  launchCount += 1;
433
- events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
573
+ events.push(['launch', launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
434
574
  if (launchCount === 1) {
435
575
  return { expiredSession: true };
436
576
  }
@@ -439,22 +579,22 @@ test("runRuntimeWithSessionRecovery refreshes stored credentials before forcing
439
579
  });
440
580
 
441
581
  assert.deepEqual(events, [
442
- ["launch", 1, "token-1"],
443
- ["refresh", "http://127.0.0.1:8080"],
444
- ["prepare", "https://api.textcortex.com", "token-2"],
445
- ["launch", 2, "token-2"],
582
+ ['launch', 1, 'token-1'],
583
+ ['refresh', 'http://127.0.0.1:8080'],
584
+ ['prepare', 'https://api.textcortex.com', 'token-2'],
585
+ ['launch', 2, 'token-2'],
446
586
  ]);
447
587
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
448
588
  });
449
589
 
450
- test("runRuntimeWithSessionRecovery preserves explicit localhost preference during login recovery", async () => {
590
+ test('runRuntimeWithSessionRecovery preserves explicit localhost preference during login recovery', async () => {
451
591
  const events = [];
452
592
  let launchCount = 0;
453
593
 
454
594
  const result = await runRuntimeWithSessionRecovery({
455
- args: ["run"],
456
- baseUrl: "http://127.0.0.1:8080",
457
- token: "token-1",
595
+ args: ['run'],
596
+ baseUrl: 'http://127.0.0.1:8080',
597
+ token: 'token-1',
458
598
  childOptions: {
459
599
  cwd: process.cwd(),
460
600
  env: {},
@@ -462,27 +602,27 @@ test("runRuntimeWithSessionRecovery preserves explicit localhost preference duri
462
602
  canAutoLoginRuntime: true,
463
603
  preferLocalhost: true,
464
604
  refreshTokenFn: async (baseUrl) => {
465
- events.push(["refresh", baseUrl]);
466
- throw new Error("refresh failed");
605
+ events.push(['refresh', baseUrl]);
606
+ throw new Error('refresh failed');
467
607
  },
468
608
  runLogin: async (baseUrl, loginArgs, options) => {
469
- events.push(["login", baseUrl, loginArgs, options]);
609
+ events.push(['login', baseUrl, loginArgs, options]);
470
610
  },
471
611
  resolveTokenFn: async () => {
472
- events.push(["resolve-token"]);
473
- return "token-2";
612
+ events.push(['resolve-token']);
613
+ return 'token-2';
474
614
  },
475
615
  resolveStoredBaseUrlFn: async () => {
476
- events.push(["resolve-base-url"]);
477
- return "https://api.textcortex.com";
616
+ events.push(['resolve-base-url']);
617
+ return 'https://api.textcortex.com';
478
618
  },
479
619
  prepareRuntimeFn: async (baseUrl, token) => {
480
- events.push(["prepare", baseUrl, token]);
481
- return "kimi-k2-5-thinking";
620
+ events.push(['prepare', baseUrl, token]);
621
+ return 'kimi-k2-5-thinking';
482
622
  },
483
623
  launchRuntimeFn: async ({ childOptions }) => {
484
624
  launchCount += 1;
485
- events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
625
+ events.push(['launch', launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
486
626
  if (launchCount === 1) {
487
627
  return { expiredSession: true };
488
628
  }
@@ -491,27 +631,27 @@ test("runRuntimeWithSessionRecovery preserves explicit localhost preference duri
491
631
  });
492
632
 
493
633
  assert.deepEqual(events, [
494
- ["launch", 1, "token-1"],
495
- ["refresh", "http://127.0.0.1:8080"],
496
- ["login", "http://127.0.0.1:8080", ["--local"], { preferLocalhost: true }],
497
- ["resolve-token"],
498
- ["resolve-base-url"],
499
- ["prepare", "http://127.0.0.1:8080", "token-2"],
500
- ["launch", 2, "token-2"],
634
+ ['launch', 1, 'token-1'],
635
+ ['refresh', 'http://127.0.0.1:8080'],
636
+ ['login', 'http://127.0.0.1:8080', ['--local'], { preferLocalhost: true }],
637
+ ['resolve-token'],
638
+ ['resolve-base-url'],
639
+ ['prepare', 'http://127.0.0.1:8080', 'token-2'],
640
+ ['launch', 2, 'token-2'],
501
641
  ]);
502
642
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
503
643
  });
504
644
 
505
- test("runRuntimeWithSessionRecovery stops after repeated recovery failures", async () => {
645
+ test('runRuntimeWithSessionRecovery stops after repeated recovery failures', async () => {
506
646
  const events = [];
507
647
  let launchCount = 0;
508
648
 
509
649
  await assert.rejects(
510
650
  () =>
511
651
  runRuntimeWithSessionRecovery({
512
- args: ["run"],
513
- baseUrl: "http://127.0.0.1:8080",
514
- token: "token-1",
652
+ args: ['run'],
653
+ baseUrl: 'http://127.0.0.1:8080',
654
+ token: 'token-1',
515
655
  childOptions: {
516
656
  cwd: process.cwd(),
517
657
  env: {},
@@ -519,60 +659,64 @@ test("runRuntimeWithSessionRecovery stops after repeated recovery failures", asy
519
659
  canAutoLoginRuntime: true,
520
660
  maxRecoveryAttempts: 2,
521
661
  sleepFn: async () => {
522
- events.push(["sleep"]);
662
+ events.push(['sleep']);
523
663
  },
524
664
  refreshTokenFn: async (baseUrl) => {
525
- events.push(["refresh", baseUrl]);
526
- throw new Error("refresh failed");
665
+ events.push(['refresh', baseUrl]);
666
+ throw new Error('refresh failed');
527
667
  },
528
668
  runLogin: async (baseUrl, loginArgs) => {
529
- events.push(["login", baseUrl, loginArgs]);
669
+ events.push(['login', baseUrl, loginArgs]);
530
670
  },
531
671
  resolveTokenFn: async () => {
532
- events.push(["resolve-token"]);
533
- return "token-2";
672
+ events.push(['resolve-token']);
673
+ return 'token-2';
534
674
  },
535
675
  resolveStoredBaseUrlFn: async () => {
536
- events.push(["resolve-base-url"]);
537
- return "https://api.textcortex.com";
676
+ events.push(['resolve-base-url']);
677
+ return 'https://api.textcortex.com';
538
678
  },
539
679
  prepareRuntimeFn: async (baseUrl, token) => {
540
- events.push(["prepare", baseUrl, token]);
541
- return "kimi-k2-5-thinking";
680
+ events.push(['prepare', baseUrl, token]);
681
+ return 'kimi-k2-5-thinking';
542
682
  },
543
683
  launchRuntimeFn: async ({ childOptions }) => {
544
684
  launchCount += 1;
545
- events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
685
+ events.push([
686
+ 'launch',
687
+ launchCount,
688
+ childOptions.env.TEXTCORTEX_API_KEY,
689
+ ]);
546
690
  return { expiredSession: true };
547
691
  },
548
692
  }),
549
- /Zenocode session recovery failed after 2 attempts/,
693
+ /Zenocode session recovery failed after 2 attempts/
550
694
  );
551
695
 
552
696
  assert.deepEqual(events, [
553
- ["launch", 1, "token-1"],
554
- ["refresh", "http://127.0.0.1:8080"],
555
- ["login", "http://127.0.0.1:8080", []],
556
- ["resolve-token"],
557
- ["resolve-base-url"],
558
- ["prepare", "https://api.textcortex.com", "token-2"],
559
- ["sleep"],
560
- ["launch", 2, "token-2"],
561
- ["refresh", "https://api.textcortex.com"],
562
- ["login", "https://api.textcortex.com", []],
563
- ["resolve-token"],
564
- ["resolve-base-url"],
565
- ["prepare", "https://api.textcortex.com", "token-2"],
566
- ["sleep"],
567
- ["launch", 3, "token-2"],
697
+ ['launch', 1, 'token-1'],
698
+ ['refresh', 'http://127.0.0.1:8080'],
699
+ ['login', 'http://127.0.0.1:8080', []],
700
+ ['resolve-token'],
701
+ ['resolve-base-url'],
702
+ ['prepare', 'https://api.textcortex.com', 'token-2'],
703
+ ['sleep'],
704
+ ['launch', 2, 'token-2'],
705
+ ['refresh', 'https://api.textcortex.com'],
706
+ ['login', 'https://api.textcortex.com', []],
707
+ ['resolve-token'],
708
+ ['resolve-base-url'],
709
+ ['prepare', 'https://api.textcortex.com', 'token-2'],
710
+ ['sleep'],
711
+ ['launch', 3, 'token-2'],
568
712
  ]);
569
713
  });
570
714
 
571
- test("prepare-only refreshes stored Zenocode credentials when the access token has expired", async (t) => {
572
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-refresh-"));
573
- const zenocodeHome = path.join(tempDir, ".zenocode");
574
- const credentialsPath = path.join(zenocodeHome, "credentials.json");
575
- const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
715
+ test('prepare-only refreshes stored Zenocode credentials when the selected token is invalid', async (t) => {
716
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-refresh-'));
717
+ const zenocodeHome = path.join(tempDir, '.zenocode');
718
+ const credentialsPath = path.join(zenocodeHome, 'credentials.json');
719
+ const scriptPath = new URL('./run-zenocode.mjs', import.meta.url);
576
720
  let refreshCalls = 0;
577
721
  let refreshedAuthHeader = null;
578
722
  let modelAuthHeader = null;
@@ -586,127 +730,142 @@ test("prepare-only refreshes stored Zenocode credentials when the access token h
586
730
  credentialsPath,
587
731
  JSON.stringify(
588
732
  {
589
- base_url: "http://127.0.0.1:1",
590
- access_token: "expired-access",
591
- refresh_token: "refresh-token",
733
+ base_url: 'http://127.0.0.1:1',
734
+ access_token: 'expired-access',
735
+ refresh_token: 'refresh-token',
592
736
  expires_at: new Date(Date.now() - 60_000).toISOString(),
593
737
  },
594
738
  null,
595
- 2,
596
- ),
739
+ 2
740
+ )
597
741
  );
598
742
 
599
743
  const server = http.createServer((req, res) => {
600
- if (req.method === "POST" && req.url === "/auth/token/refresh/") {
744
+ if (req.method === 'POST' && req.url === '/auth/token/refresh/') {
601
745
  refreshCalls += 1;
602
746
  refreshedAuthHeader = req.headers.authorization || null;
603
- res.writeHead(200, { "Content-Type": "application/json" });
747
+ res.writeHead(200, { 'Content-Type': 'application/json' });
604
748
  res.end(
605
749
  JSON.stringify({
606
- access_token: "fresh-access",
607
- refresh_token: "fresh-refresh",
608
- }),
750
+ access_token: 'fresh-access',
751
+ refresh_token: 'fresh-refresh',
752
+ })
609
753
  );
610
754
  return;
611
755
  }
612
756
 
613
757
  if (
614
- req.method === "GET" &&
615
- req.url === "/internal/v2/fastapi/zenocode/models/api.json"
758
+ req.method === 'GET' &&
759
+ req.url === '/internal/v2/fastapi/zenocode/models/api.json'
616
760
  ) {
617
761
  modelAuthHeader = req.headers.authorization || null;
618
- if (modelAuthHeader === "Bearer expired-access") {
619
- res.writeHead(401, { "Content-Type": "application/json" });
620
- res.end(JSON.stringify({ detail: "Token has expired" }));
762
+ if (modelAuthHeader === 'Bearer stale-invalid-token') {
763
+ res.writeHead(401, { 'Content-Type': 'application/json' });
764
+ res.end(JSON.stringify({ detail: 'Invalid token format' }));
621
765
  return;
622
766
  }
623
767
 
624
- res.writeHead(200, { "Content-Type": "application/json" });
768
+ res.writeHead(200, { 'Content-Type': 'application/json' });
625
769
  res.end(
626
770
  JSON.stringify({
627
771
  textcortex: {
628
772
  models: {
629
- "minimax-m3-thinking": {},
630
- "kimi-k2-6-non-thinking": {},
631
- "kimi-k2-6-thinking": {},
632
- "kimi-k2-5": {},
633
- "kimi-k2-5-thinking": {},
634
- "glm-5-2-non-thinking": {},
635
- "glm-5-2-thinking": {},
636
- "glm-5-1-non-thinking": {},
637
- "glm-5-1-thinking": {},
773
+ 'minimax-m3-thinking': {},
774
+ 'kimi-k2-6-non-thinking': {},
775
+ 'kimi-k2-6-thinking': {},
776
+ 'kimi-k2-5': {},
777
+ 'kimi-k2-5-thinking': {},
778
+ 'glm-5-2-non-thinking': {},
779
+ 'glm-5-2-thinking': {},
780
+ 'glm-5-3': {},
781
+ 'glm-5-1-non-thinking': {},
782
+ 'glm-5-1-thinking': {},
638
783
  },
639
784
  },
640
- }),
785
+ })
641
786
  );
642
787
  return;
643
788
  }
644
789
 
645
- res.writeHead(404, { "Content-Type": "application/json" });
646
- res.end(JSON.stringify({ detail: "not found" }));
790
+ res.writeHead(404, { 'Content-Type': 'application/json' });
791
+ res.end(JSON.stringify({ detail: 'not found' }));
647
792
  });
648
793
 
649
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
794
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
650
795
  const address = server.address();
651
796
  const baseUrl = `http://127.0.0.1:${address.port}`;
652
797
 
653
798
  t.after(async () => {
654
- await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
799
+ await new Promise((resolve, reject) =>
800
+ server.close((error) => (error ? reject(error) : resolve()))
801
+ );
655
802
  });
656
803
 
657
804
  const result = await new Promise((resolve, reject) => {
658
805
  const child = spawn(
659
806
  process.execPath,
660
- [scriptPath.pathname, "--prepare-only"],
807
+ [scriptPath.pathname, '--prepare-only'],
661
808
  {
662
809
  cwd: tempDir,
663
810
  env: {
664
811
  ...process.env,
665
812
  ZENOCODE_HOME: zenocodeHome,
666
813
  TEXTCORTEX_BASE_URL: baseUrl,
667
- ZENOCODE_NO_BANNER: "1",
814
+ TEXTCORTEX_API_KEY: 'stale-invalid-token',
815
+ ZENOCODE_NO_BANNER: '1',
668
816
  },
669
- stdio: ["ignore", "pipe", "pipe"],
670
- },
817
+ stdio: ['ignore', 'pipe', 'pipe'],
818
+ }
671
819
  );
672
- let stdout = "";
673
- let stderr = "";
674
- child.stdout.on("data", (chunk) => {
820
+ let stdout = '';
821
+ let stderr = '';
822
+ child.stdout.on('data', (chunk) => {
675
823
  stdout += String(chunk);
676
824
  });
677
- child.stderr.on("data", (chunk) => {
825
+ child.stderr.on('data', (chunk) => {
678
826
  stderr += String(chunk);
679
827
  });
680
- child.on("error", reject);
681
- child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
828
+ child.on('error', reject);
829
+ child.on('exit', (code, signal) =>
830
+ resolve({ code, signal, stdout, stderr })
831
+ );
682
832
  });
683
833
 
684
- assert.equal(result.code, 0);
834
+ assert.equal(result.code, 0, result.stderr);
685
835
  assert.equal(refreshCalls, 1);
686
- assert.equal(refreshedAuthHeader, "Bearer refresh-token");
687
- assert.equal(modelAuthHeader, "Bearer fresh-access");
836
+ assert.equal(refreshedAuthHeader, 'Bearer refresh-token');
837
+ assert.equal(modelAuthHeader, 'Bearer fresh-access');
688
838
 
689
- const savedCredentials = JSON.parse(await fs.readFile(credentialsPath, "utf-8"));
690
- assert.equal(savedCredentials.access_token, "fresh-access");
691
- assert.equal(savedCredentials.refresh_token, "fresh-refresh");
839
+ const savedCredentials = JSON.parse(
840
+ await fs.readFile(credentialsPath, 'utf-8')
841
+ );
842
+ assert.equal(savedCredentials.access_token, 'fresh-access');
843
+ assert.equal(savedCredentials.refresh_token, 'fresh-refresh');
692
844
 
693
845
  const savedConfig = JSON.parse(
694
- await fs.readFile(path.join(zenocodeHome, "opencode.jsonc"), "utf-8"),
846
+ await fs.readFile(path.join(zenocodeHome, 'zenocode.jsonc'), 'utf-8')
695
847
  );
696
- assert.equal(savedConfig.model, "textcortex/minimax-m3-thinking");
848
+ assert.equal(savedConfig.model, 'textcortex/minimax-m3-thinking');
697
849
 
698
850
  const savedModels = JSON.parse(
699
- await fs.readFile(path.join(zenocodeHome, "models.json"), "utf-8"),
851
+ await fs.readFile(path.join(zenocodeHome, 'models.json'), 'utf-8')
852
+ );
853
+ assert.ok(savedModels.textcortex.models['glm-5-2-non-thinking']);
854
+ assert.ok(savedModels.textcortex.models['glm-5-3']);
855
+ assert.equal(
856
+ savedModels.textcortex.models['glm-5-1-non-thinking'],
857
+ undefined
700
858
  );
701
- assert.ok(savedModels.textcortex.models["glm-5-2-non-thinking"]);
859
+ assert.equal(savedModels.textcortex.models['glm-5-1-thinking'], undefined);
702
860
  });
703
861
 
704
- test("login launches the runtime immediately with system TUI theming", async (t) => {
705
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-login-"));
706
- const zenocodeHome = path.join(tempDir, ".zenocode");
707
- const runtimeLogPath = path.join(tempDir, "runtime-log.json");
708
- const fakeRuntimePath = path.join(tempDir, "fake-opencode");
709
- const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
862
+ test('login launches with the newly issued token even when the environment token is stale', async (t) => {
863
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-login-'));
864
+ const zenocodeHome = path.join(tempDir, '.zenocode');
865
+ const runtimeLogPath = path.join(tempDir, 'runtime-log.json');
866
+ const fakeRuntimePath = path.join(tempDir, 'fake-opencode');
867
+ const scriptPath = new URL('./run-zenocode.mjs', import.meta.url);
868
+ let modelAuthHeader = null;
710
869
 
711
870
  t.after(async () => {
712
871
  await fs.rm(tempDir, { recursive: true, force: true });
@@ -724,6 +883,7 @@ async function main() {
724
883
  env: {
725
884
  OPENCODE_CONFIG: process.env.OPENCODE_CONFIG,
726
885
  OPENCODE_TUI_CONFIG: process.env.OPENCODE_TUI_CONFIG,
886
+ OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE,
727
887
  TEXTCORTEX_API_KEY: process.env.TEXTCORTEX_API_KEY,
728
888
  },
729
889
  };
@@ -735,83 +895,91 @@ main().catch((error) => {
735
895
  process.exit(1);
736
896
  });
737
897
  `,
738
- "utf-8",
898
+ 'utf-8'
739
899
  );
740
900
  await fs.chmod(fakeRuntimePath, 0o755);
741
901
 
742
902
  const server = http.createServer((req, res) => {
743
903
  if (
744
- req.method === "POST" &&
745
- req.url === "/internal/v2/fastapi/zenocode/oauth2/initiate"
904
+ req.method === 'POST' &&
905
+ req.url === '/internal/v2/fastapi/zenocode/oauth2/initiate'
746
906
  ) {
747
- res.writeHead(200, { "Content-Type": "application/json" });
907
+ res.writeHead(200, { 'Content-Type': 'application/json' });
748
908
  res.end(
749
909
  JSON.stringify({
750
910
  data: {
751
- device_code: "device-code",
752
- user_code: "ABCD-1234",
753
- verification_url_complete: "https://textcortex.example/verify",
911
+ device_code: 'device-code',
912
+ user_code: 'ABCD-1234',
913
+ verification_url_complete: 'https://textcortex.example/verify',
754
914
  interval: 0,
755
915
  expires_in: 30,
756
916
  },
757
- }),
917
+ })
758
918
  );
759
919
  return;
760
920
  }
761
921
 
762
922
  if (
763
- req.method === "POST" &&
764
- req.url === "/internal/v2/fastapi/zenocode/oauth2/token"
923
+ req.method === 'POST' &&
924
+ req.url === '/internal/v2/fastapi/zenocode/oauth2/token'
765
925
  ) {
766
- res.writeHead(200, { "Content-Type": "application/json" });
926
+ res.writeHead(200, { 'Content-Type': 'application/json' });
767
927
  res.end(
768
928
  JSON.stringify({
769
929
  data: {
770
- access_token: "fresh-access",
771
- refresh_token: "fresh-refresh",
772
- auth_id: "auth_123",
930
+ access_token: 'fresh-access',
931
+ refresh_token: 'fresh-refresh',
932
+ auth_id: 'auth_123',
773
933
  },
774
- }),
934
+ })
775
935
  );
776
936
  return;
777
937
  }
778
938
 
779
939
  if (
780
- req.method === "GET" &&
781
- req.url === "/internal/v2/fastapi/zenocode/models/api.json"
940
+ req.method === 'GET' &&
941
+ req.url === '/internal/v2/fastapi/zenocode/models/api.json'
782
942
  ) {
783
- res.writeHead(200, { "Content-Type": "application/json" });
943
+ modelAuthHeader = req.headers.authorization || null;
944
+ if (modelAuthHeader !== 'Bearer fresh-access') {
945
+ res.writeHead(401, { 'Content-Type': 'application/json' });
946
+ res.end(JSON.stringify({ detail: 'Invalid token format' }));
947
+ return;
948
+ }
949
+
950
+ res.writeHead(200, { 'Content-Type': 'application/json' });
784
951
  res.end(
785
952
  JSON.stringify({
786
953
  textcortex: {
787
954
  models: {
788
- "minimax-m3-thinking": {},
789
- "kimi-k2-6-non-thinking": {},
790
- "kimi-k2-6-thinking": {},
791
- "kimi-k2-5": {},
792
- "kimi-k2-5-thinking": {},
793
- "glm-5-2-non-thinking": {},
794
- "glm-5-2-thinking": {},
795
- "glm-5-1-non-thinking": {},
796
- "glm-5-1-thinking": {},
955
+ 'minimax-m3-thinking': {},
956
+ 'kimi-k2-6-non-thinking': {},
957
+ 'kimi-k2-6-thinking': {},
958
+ 'kimi-k2-5': {},
959
+ 'kimi-k2-5-thinking': {},
960
+ 'glm-5-2-non-thinking': {},
961
+ 'glm-5-2-thinking': {},
962
+ 'glm-5-3': {},
963
+ 'glm-5-1-non-thinking': {},
964
+ 'glm-5-1-thinking': {},
797
965
  },
798
966
  },
799
- }),
967
+ })
800
968
  );
801
969
  return;
802
970
  }
803
971
 
804
- res.writeHead(404, { "Content-Type": "application/json" });
805
- res.end(JSON.stringify({ detail: "not found" }));
972
+ res.writeHead(404, { 'Content-Type': 'application/json' });
973
+ res.end(JSON.stringify({ detail: 'not found' }));
806
974
  });
807
975
 
808
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
976
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
809
977
  const address = server.address();
810
978
  const baseUrl = `http://127.0.0.1:${address.port}`;
811
979
 
812
980
  t.after(async () => {
813
981
  await new Promise((resolve, reject) =>
814
- server.close((error) => (error ? reject(error) : resolve())),
982
+ server.close((error) => (error ? reject(error) : resolve()))
815
983
  );
816
984
  });
817
985
 
@@ -820,122 +988,147 @@ main().catch((error) => {
820
988
  process.execPath,
821
989
  [
822
990
  scriptPath.pathname,
823
- "login",
824
- "--email",
825
- "person@example.com",
826
- "--no-launch-browser",
991
+ 'login',
992
+ '--email',
993
+ 'person@example.com',
994
+ '--no-launch-browser',
827
995
  ],
828
996
  {
829
997
  cwd: tempDir,
830
998
  env: {
831
999
  ...process.env,
832
1000
  ZENOCODE_HOME: zenocodeHome,
833
- ZENOCODE_NO_BANNER: "1",
1001
+ ZENOCODE_NO_BANNER: '1',
834
1002
  ZENOCODE_OPENCODE_BIN_PATH: fakeRuntimePath,
835
1003
  TEXTCORTEX_BASE_URL: baseUrl,
1004
+ TEXTCORTEX_API_KEY: 'stale-invalid-token',
836
1005
  RUNTIME_LOG_PATH: runtimeLogPath,
837
1006
  },
838
- stdio: ["ignore", "pipe", "pipe"],
839
- },
1007
+ stdio: ['ignore', 'pipe', 'pipe'],
1008
+ }
840
1009
  );
841
- let stdout = "";
842
- let stderr = "";
843
- child.stdout.on("data", (chunk) => {
1010
+ let stdout = '';
1011
+ let stderr = '';
1012
+ child.stdout.on('data', (chunk) => {
844
1013
  stdout += String(chunk);
845
1014
  });
846
- child.stderr.on("data", (chunk) => {
1015
+ child.stderr.on('data', (chunk) => {
847
1016
  stderr += String(chunk);
848
1017
  });
849
- child.on("error", reject);
850
- child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
1018
+ child.on('error', reject);
1019
+ child.on('exit', (code, signal) =>
1020
+ resolve({ code, signal, stdout, stderr })
1021
+ );
851
1022
  });
852
1023
 
853
1024
  assert.equal(result.code, 0);
854
1025
  assert.match(result.stdout, /Login successful for auth_123/);
855
1026
  assert.match(result.stdout, /Zenocode config ready at/);
1027
+ assert.equal(modelAuthHeader, 'Bearer fresh-access');
856
1028
 
857
- const runtimeInvocation = JSON.parse(await fs.readFile(runtimeLogPath, "utf-8"));
1029
+ const runtimeInvocation = JSON.parse(
1030
+ await fs.readFile(runtimeLogPath, 'utf-8')
1031
+ );
858
1032
  assert.deepEqual(runtimeInvocation.args, []);
859
- assert.equal(runtimeInvocation.env.TEXTCORTEX_API_KEY, "fresh-access");
1033
+ assert.equal(runtimeInvocation.env.TEXTCORTEX_API_KEY, 'fresh-access');
860
1034
 
861
1035
  const opencodeConfig = JSON.parse(
862
- await fs.readFile(runtimeInvocation.env.OPENCODE_CONFIG, "utf-8"),
1036
+ await fs.readFile(runtimeInvocation.env.OPENCODE_CONFIG, 'utf-8')
863
1037
  );
864
- assert.equal(opencodeConfig.theme, "system");
865
- assert.equal(opencodeConfig.model, "textcortex/minimax-m3-thinking");
1038
+ assert.equal(opencodeConfig.theme, 'system');
1039
+ assert.equal(opencodeConfig.autoupdate, false);
1040
+ assert.equal(opencodeConfig.model, 'textcortex/minimax-m3-thinking');
1041
+ assert.equal(runtimeInvocation.env.OPENCODE_DISABLE_AUTOUPDATE, 'true');
866
1042
 
867
1043
  const modelsConfig = JSON.parse(
868
- await fs.readFile(path.join(zenocodeHome, "models.json"), "utf-8"),
1044
+ await fs.readFile(path.join(zenocodeHome, 'models.json'), 'utf-8')
869
1045
  );
870
- assert.ok(modelsConfig.textcortex.models["glm-5-2-non-thinking"]);
1046
+ assert.ok(modelsConfig.textcortex.models['glm-5-2-non-thinking']);
1047
+ assert.ok(modelsConfig.textcortex.models['glm-5-3']);
1048
+ assert.equal(
1049
+ modelsConfig.textcortex.models['glm-5-1-non-thinking'],
1050
+ undefined
1051
+ );
1052
+ assert.equal(modelsConfig.textcortex.models['glm-5-1-thinking'], undefined);
871
1053
 
872
1054
  const tuiConfig = JSON.parse(
873
- await fs.readFile(runtimeInvocation.env.OPENCODE_TUI_CONFIG, "utf-8"),
1055
+ await fs.readFile(runtimeInvocation.env.OPENCODE_TUI_CONFIG, 'utf-8')
874
1056
  );
875
- assert.equal(tuiConfig.theme, "system");
1057
+ assert.equal(tuiConfig.theme, 'system');
876
1058
  });
877
1059
 
878
- test("logout removes runtime credentials and blocks shared fallback credentials", async (t) => {
879
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-logout-"));
880
- const zenocodeHome = path.join(tempDir, ".zenocode");
881
- const codecortexHome = path.join(tempDir, ".codecortex");
882
- const homeCredentialsPath = path.join(tempDir, ".credentials.json");
883
- const runtimeCredentialsPath = path.join(zenocodeHome, "credentials.json");
884
- const legacyRuntimeCredentialsPath = path.join(codecortexHome, "credentials.json");
885
- const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
1060
+ test('logout removes runtime credentials and blocks shared fallback credentials', async (t) => {
1061
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zenocode-logout-'));
1062
+ const zenocodeHome = path.join(tempDir, '.zenocode');
1063
+ const codecortexHome = path.join(tempDir, '.codecortex');
1064
+ const homeCredentialsPath = path.join(tempDir, '.credentials.json');
1065
+ const runtimeCredentialsPath = path.join(zenocodeHome, 'credentials.json');
1066
+ const legacyRuntimeCredentialsPath = path.join(
1067
+ codecortexHome,
1068
+ 'credentials.json'
1069
+ );
1070
+ const scriptPath = new URL('./run-zenocode.mjs', import.meta.url);
886
1071
 
887
1072
  t.after(async () => {
888
1073
  await fs.rm(tempDir, { recursive: true, force: true });
889
1074
  });
890
1075
 
891
1076
  await fs.mkdir(path.dirname(runtimeCredentialsPath), { recursive: true });
892
- await fs.mkdir(path.dirname(legacyRuntimeCredentialsPath), { recursive: true });
893
- await fs.writeFile(runtimeCredentialsPath, JSON.stringify({ access_token: "current" }));
1077
+ await fs.mkdir(path.dirname(legacyRuntimeCredentialsPath), {
1078
+ recursive: true,
1079
+ });
1080
+ await fs.writeFile(
1081
+ runtimeCredentialsPath,
1082
+ JSON.stringify({ access_token: 'current' })
1083
+ );
894
1084
  await fs.writeFile(
895
1085
  legacyRuntimeCredentialsPath,
896
- JSON.stringify({ access_token: "legacy" }),
1086
+ JSON.stringify({ access_token: 'legacy' })
1087
+ );
1088
+ await fs.writeFile(
1089
+ homeCredentialsPath,
1090
+ JSON.stringify({ access_token: 'home' })
897
1091
  );
898
- await fs.writeFile(homeCredentialsPath, JSON.stringify({ access_token: "home" }));
899
1092
 
900
1093
  const result = await new Promise((resolve, reject) => {
901
- const child = spawn(
902
- process.execPath,
903
- [scriptPath.pathname, "logout"],
904
- {
905
- cwd: tempDir,
906
- env: {
907
- ...process.env,
908
- HOME: tempDir,
909
- ZENOCODE_HOME: zenocodeHome,
910
- CODECORTEX_HOME: codecortexHome,
911
- },
912
- stdio: ["ignore", "pipe", "pipe"],
1094
+ const child = spawn(process.execPath, [scriptPath.pathname, 'logout'], {
1095
+ cwd: tempDir,
1096
+ env: {
1097
+ ...process.env,
1098
+ HOME: tempDir,
1099
+ ZENOCODE_HOME: zenocodeHome,
1100
+ CODECORTEX_HOME: codecortexHome,
913
1101
  },
914
- );
915
- let stdout = "";
916
- let stderr = "";
917
- child.stdout.on("data", (chunk) => {
1102
+ stdio: ['ignore', 'pipe', 'pipe'],
1103
+ });
1104
+ let stdout = '';
1105
+ let stderr = '';
1106
+ child.stdout.on('data', (chunk) => {
918
1107
  stdout += String(chunk);
919
1108
  });
920
- child.stderr.on("data", (chunk) => {
1109
+ child.stderr.on('data', (chunk) => {
921
1110
  stderr += String(chunk);
922
1111
  });
923
- child.on("error", reject);
924
- child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
1112
+ child.on('error', reject);
1113
+ child.on('exit', (code, signal) =>
1114
+ resolve({ code, signal, stdout, stderr })
1115
+ );
925
1116
  });
926
1117
 
927
- assert.equal(result.code, 0);
928
- await assert.rejects(fs.stat(runtimeCredentialsPath), { code: "ENOENT" });
929
- await assert.rejects(fs.stat(legacyRuntimeCredentialsPath), { code: "ENOENT" });
1118
+ assert.equal(result.code, 0, result.stderr);
1119
+ await assert.rejects(fs.stat(runtimeCredentialsPath), { code: 'ENOENT' });
1120
+ await assert.rejects(fs.stat(legacyRuntimeCredentialsPath), {
1121
+ code: 'ENOENT',
1122
+ });
930
1123
  assert.deepEqual(
931
- JSON.parse(await fs.readFile(homeCredentialsPath, "utf-8")),
932
- { access_token: "home" },
1124
+ JSON.parse(await fs.readFile(homeCredentialsPath, 'utf-8')),
1125
+ { access_token: 'home' }
933
1126
  );
934
1127
 
935
1128
  const prepareOnlyResult = await new Promise((resolve, reject) => {
936
1129
  const child = spawn(
937
1130
  process.execPath,
938
- [scriptPath.pathname, "--prepare-only"],
1131
+ [scriptPath.pathname, '--prepare-only'],
939
1132
  {
940
1133
  cwd: tempDir,
941
1134
  env: {
@@ -943,23 +1136,25 @@ test("logout removes runtime credentials and blocks shared fallback credentials"
943
1136
  HOME: tempDir,
944
1137
  ZENOCODE_HOME: zenocodeHome,
945
1138
  CODECORTEX_HOME: codecortexHome,
946
- ZENOCODE_NO_BANNER: "1",
1139
+ ZENOCODE_NO_BANNER: '1',
947
1140
  },
948
- stdio: ["ignore", "pipe", "pipe"],
949
- },
1141
+ stdio: ['ignore', 'pipe', 'pipe'],
1142
+ }
950
1143
  );
951
- let stdout = "";
952
- let stderr = "";
953
- child.stdout.on("data", (chunk) => {
1144
+ let stdout = '';
1145
+ let stderr = '';
1146
+ child.stdout.on('data', (chunk) => {
954
1147
  stdout += String(chunk);
955
1148
  });
956
- child.stderr.on("data", (chunk) => {
1149
+ child.stderr.on('data', (chunk) => {
957
1150
  stderr += String(chunk);
958
1151
  });
959
- child.on("error", reject);
960
- child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
1152
+ child.on('error', reject);
1153
+ child.on('exit', (code, signal) =>
1154
+ resolve({ code, signal, stdout, stderr })
1155
+ );
961
1156
  });
962
1157
 
963
- assert.equal(prepareOnlyResult.code, 1);
1158
+ assert.equal(prepareOnlyResult.code, 1, prepareOnlyResult.stderr);
964
1159
  assert.match(prepareOnlyResult.stderr, /Missing API token/);
965
1160
  });