@textcortex/zenocode 0.1.13 → 0.1.15

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