@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,83 +1,86 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { realpathSync } from "node:fs";
4
- import fs from "node:fs/promises";
5
- import os from "node:os";
6
- import path from "node:path";
7
- import process from "node:process";
8
- import readline from "node:readline/promises";
9
- import { fileURLToPath } from "node:url";
10
- import {
11
- patchZenocodeBinaryText,
12
- zenocodeLogo,
13
- } from "./branding-patch.mjs";
14
- import {
15
- openCodePackageName,
16
- openCodePackageSpec,
17
- } from "./opencode-version.mjs";
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import process from 'node:process';
8
+ import readline from 'node:readline/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { zenocodeRuntimePackageSpec } from './opencode-version.mjs';
11
+ import { zenocodeLogo } from './source-branding.mjs';
18
12
 
19
13
  const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
20
- const __dirname = path.dirname(currentFilePath);
21
- const appRoot = path.resolve(__dirname, "..");
22
14
  const runtimeDir =
23
15
  process.env.ZENOCODE_HOME ||
24
16
  process.env.CODECORTEX_HOME ||
25
- path.join(os.homedir(), ".zenocode");
17
+ path.join(os.homedir(), '.zenocode');
26
18
  const legacyRuntimeDir =
27
- process.env.CODECORTEX_HOME || path.join(os.homedir(), ".codecortex");
28
- const runtimeCredentialsPath = path.join(runtimeDir, "credentials.json");
19
+ process.env.CODECORTEX_HOME || path.join(os.homedir(), '.codecortex');
20
+ const runtimeCredentialsPath = path.join(runtimeDir, 'credentials.json');
29
21
  const legacyRuntimeCredentialsPath = path.join(
30
22
  legacyRuntimeDir,
31
- "credentials.json",
23
+ 'credentials.json'
32
24
  );
33
- const logoutMarkerPath = path.join(runtimeDir, "logout-marker.json");
34
- const modelsPath = path.join(runtimeDir, "models.json");
35
- const configPath = path.join(runtimeDir, "opencode.jsonc");
36
- const tuiConfigPath = path.join(runtimeDir, "opencode.tui.json");
37
- const localBaseUrlDefault = "http://127.0.0.1:8080";
38
- const cloudBaseUrlDefault = "https://api.textcortex.com";
39
- const localBaseUrlFlags = new Set(["--local", "--localhost"]);
40
-
41
- const providerID = "textcortex";
42
- const configuredOpencodePackage =
43
- process.env.ZENOCODE_OPENCODE_PACKAGE ||
44
- process.env.CODECORTEX_OPENCODE_PACKAGE ||
45
- process.env.OPENCODE_PACKAGE ||
46
- null;
47
- const defaultBrandedOpencodePackage = openCodePackageSpec;
48
- const legacyBrandedOpencodePackage = "@textcortex/opencode-ai";
49
- const fallbackOpencodePackage = openCodePackageName;
50
- const opencodePackage = configuredOpencodePackage || defaultBrandedOpencodePackage;
25
+ const logoutMarkerPath = path.join(runtimeDir, 'logout-marker.json');
26
+ const modelsPath = path.join(runtimeDir, 'models.json');
27
+ const configPath = path.join(runtimeDir, 'zenocode.jsonc');
28
+ const tuiConfigPath = path.join(runtimeDir, 'zenocode.tui.json');
29
+ const localBaseUrlDefault = 'http://127.0.0.1:8080';
30
+ const cloudBaseUrlDefault = 'https://api.textcortex.com';
31
+ const localBaseUrlFlags = new Set(['--local', '--localhost']);
32
+
33
+ const providerID = 'textcortex';
34
+ export function resolveConfiguredRuntimePackage(env = process.env) {
35
+ return (
36
+ env.ZENOCODE_OPENCODE_PACKAGE || env.CODECORTEX_OPENCODE_PACKAGE || null
37
+ );
38
+ }
39
+
40
+ const configuredOpencodePackage = resolveConfiguredRuntimePackage();
41
+ const opencodePackage = configuredOpencodePackage || zenocodeRuntimePackageSpec;
51
42
  const opencodeBinaryPath =
52
43
  process.env.ZENOCODE_OPENCODE_BIN_PATH ||
53
44
  process.env.CODECORTEX_OPENCODE_BIN_PATH ||
54
- "";
55
- const oauthInitiatePath = "/internal/v2/fastapi/zenocode/oauth2/initiate";
56
- const oauthTokenPath = "/internal/v2/fastapi/zenocode/oauth2/token";
45
+ '';
46
+ const oauthInitiatePath = '/internal/v2/fastapi/zenocode/oauth2/initiate';
47
+ const oauthTokenPath = '/internal/v2/fastapi/zenocode/oauth2/token';
57
48
  const defaultOrder = [
58
- "gpt-5-6-luna",
59
- "kimi-k2p7-code-non-thinking",
60
- "kimi-k2p7-code-thinking",
61
- "minimax-m3-thinking",
62
- "kimi-k2-6-non-thinking",
63
- "kimi-k2-6-thinking",
64
- "kimi-k2-5",
65
- "kimi-k2-5-thinking",
66
- "glm-5-2-non-thinking",
67
- "glm-5-2-thinking",
68
- "glm-5-1-non-thinking",
69
- "glm-5-1-thinking",
70
- "gpt-5-2",
71
- "gpt-5-1",
72
- "gpt-5",
73
- "claude-4-6-sonnet",
74
- "gpt-5-mini",
49
+ 'gpt-6-luna',
50
+ 'gpt-5-6-luna',
51
+ 'kimi-k3-non-thinking',
52
+ 'kimi-k3-thinking',
53
+ 'kimi-k2p7-code-non-thinking',
54
+ 'kimi-k2p7-code-thinking',
55
+ 'minimax-m3-thinking',
56
+ 'kimi-k2-6-non-thinking',
57
+ 'kimi-k2-6-thinking',
58
+ 'kimi-k2-5',
59
+ 'kimi-k2-5-thinking',
60
+ 'glm-5-3',
61
+ 'glm-5-2-non-thinking',
62
+ 'glm-5-2-thinking',
63
+ 'gpt-5-2',
64
+ 'gpt-5-1',
65
+ 'gpt-5',
66
+ 'claude-sonnet-5-5',
67
+ 'claude-5-sonnet',
68
+ 'claude-opus-5-5',
69
+ 'claude-opus-5',
70
+ 'gpt-5-mini',
75
71
  ];
72
+ const retiredModelIDs = new Set([
73
+ 'glm-4-7',
74
+ 'glm-5',
75
+ 'glm-5-1',
76
+ 'glm-5-1-non-thinking',
77
+ 'glm-5-1-thinking',
78
+ ]);
76
79
  const devCredentialFiles =
77
- process.env.ZENOCODE_DEV_MODE === "1"
80
+ process.env.ZENOCODE_DEV_MODE === '1'
78
81
  ? [
79
- path.join(process.cwd(), "backend", ".credentials.json"),
80
- path.join(process.cwd(), ".credentials.json"),
82
+ path.join(process.cwd(), 'backend', '.credentials.json'),
83
+ path.join(process.cwd(), '.credentials.json'),
81
84
  ]
82
85
  : [];
83
86
  const runtimeCredentialFiles = [
@@ -85,53 +88,61 @@ const runtimeCredentialFiles = [
85
88
  legacyRuntimeCredentialsPath,
86
89
  ].filter((value, index, values) => values.indexOf(value) === index);
87
90
  const fallbackCredentialFiles = [
88
- path.join(os.homedir(), ".credentials.json"),
91
+ path.join(os.homedir(), '.credentials.json'),
89
92
  ...devCredentialFiles,
90
93
  ].filter((value, index, values) => values.indexOf(value) === index);
91
94
  const credentialFiles = [...runtimeCredentialFiles, ...fallbackCredentialFiles];
92
95
 
93
96
  function _decodeEscapedLogoLine(line) {
94
97
  return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, codePoint) =>
95
- String.fromCharCode(Number.parseInt(codePoint, 16)),
98
+ String.fromCharCode(Number.parseInt(codePoint, 16))
96
99
  );
97
100
  }
98
101
 
99
102
  export function buildZenocodeBanner() {
100
103
  const bannerLines = zenocodeLogo.left
101
104
  .map((leftLine, index) => {
102
- const rightLine = zenocodeLogo.right[index] || "";
105
+ const rightLine = zenocodeLogo.right[index] || '';
103
106
  return `${_decodeEscapedLogoLine(leftLine)} ${_decodeEscapedLogoLine(rightLine)}`.trimEnd();
104
107
  })
105
108
  .filter((line) => line.trim());
106
- return `\n${bannerLines.join("\n")}\n`;
109
+ return `\n${bannerLines.join('\n')}\n`;
107
110
  }
108
111
  const privateDirectoryMode = 0o700;
109
112
  const privateFileMode = 0o600;
110
113
 
114
+ async function _pathExists(pathToCheck) {
115
+ try {
116
+ await fs.access(pathToCheck);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
111
123
  async function readJson(filePath) {
112
124
  try {
113
- return JSON.parse(await fs.readFile(filePath, "utf-8"));
125
+ return JSON.parse(await fs.readFile(filePath, 'utf-8'));
114
126
  } catch (error) {
115
- if (error?.code === "ENOENT") return null;
127
+ if (error?.code === 'ENOENT') return null;
116
128
  throw error;
117
129
  }
118
130
  }
119
131
 
120
132
  async function ensurePrivateDirectory(dirPath) {
121
133
  await fs.mkdir(dirPath, { recursive: true, mode: privateDirectoryMode });
122
- if (process.platform !== "win32") {
134
+ if (process.platform !== 'win32') {
123
135
  await fs.chmod(dirPath, privateDirectoryMode);
124
136
  }
125
137
  }
126
138
 
127
139
  export async function writePrivateJsonFile(filePath, payload) {
128
140
  await ensurePrivateDirectory(path.dirname(filePath));
129
- await fs.writeFile(
130
- filePath,
131
- `${JSON.stringify(payload, null, 2)}\n`,
132
- { encoding: "utf-8", mode: privateFileMode },
133
- );
134
- if (process.platform !== "win32") {
141
+ await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, {
142
+ encoding: 'utf-8',
143
+ mode: privateFileMode,
144
+ });
145
+ if (process.platform !== 'win32') {
135
146
  await fs.chmod(filePath, privateFileMode);
136
147
  }
137
148
  }
@@ -140,7 +151,7 @@ async function clearLogoutMarker() {
140
151
  try {
141
152
  await fs.unlink(logoutMarkerPath);
142
153
  } catch (error) {
143
- if (error?.code !== "ENOENT") {
154
+ if (error?.code !== 'ENOENT') {
144
155
  throw error;
145
156
  }
146
157
  }
@@ -151,24 +162,31 @@ async function writeLogoutMarker() {
151
162
  await fs.writeFile(
152
163
  logoutMarkerPath,
153
164
  `${JSON.stringify({ logged_out_at: new Date().toISOString() }, null, 2)}\n`,
154
- { encoding: "utf-8", mode: privateFileMode },
165
+ {
166
+ encoding: 'utf-8',
167
+ mode: privateFileMode,
168
+ }
155
169
  );
156
- if (process.platform !== "win32") {
170
+ if (process.platform !== 'win32') {
157
171
  await fs.chmod(logoutMarkerPath, privateFileMode);
158
172
  }
159
173
  }
160
174
 
161
175
  function extractTokenFromCredentialPayload(parsed) {
162
176
  if (!parsed) return null;
163
- if (typeof parsed?.access_token === "string" && parsed.access_token) {
177
+ if (typeof parsed?.access_token === 'string' && parsed.access_token) {
164
178
  return parsed.access_token;
165
179
  }
166
180
  if (Array.isArray(parsed?.accounts)) {
167
- const active = parsed.accounts.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
181
+ const active = parsed.accounts.find(
182
+ (entry) => typeof entry?.access_token === 'string' && entry.access_token
183
+ );
168
184
  if (active) return active.access_token;
169
185
  }
170
186
  if (Array.isArray(parsed)) {
171
- const match = parsed.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
187
+ const match = parsed.find(
188
+ (entry) => typeof entry?.access_token === 'string' && entry.access_token
189
+ );
172
190
  if (match) return match.access_token;
173
191
  }
174
192
  return null;
@@ -176,17 +194,19 @@ function extractTokenFromCredentialPayload(parsed) {
176
194
 
177
195
  function extractBaseUrlFromCredentialPayload(parsed) {
178
196
  if (!parsed) return null;
179
- if (typeof parsed?.base_url === "string" && parsed.base_url) {
197
+ if (typeof parsed?.base_url === 'string' && parsed.base_url) {
180
198
  return parsed.base_url;
181
199
  }
182
200
  if (Array.isArray(parsed?.accounts)) {
183
201
  const active = parsed.accounts.find(
184
- (entry) => typeof entry?.base_url === "string" && entry.base_url,
202
+ (entry) => typeof entry?.base_url === 'string' && entry.base_url
185
203
  );
186
204
  if (active) return active.base_url;
187
205
  }
188
206
  if (Array.isArray(parsed)) {
189
- const match = parsed.find((entry) => typeof entry?.base_url === "string" && entry.base_url);
207
+ const match = parsed.find(
208
+ (entry) => typeof entry?.base_url === 'string' && entry.base_url
209
+ );
190
210
  if (match) return match.base_url;
191
211
  }
192
212
  return null;
@@ -194,17 +214,17 @@ function extractBaseUrlFromCredentialPayload(parsed) {
194
214
 
195
215
  export function resolveLoginSuccessIdentifier(tokenData) {
196
216
  const authId =
197
- typeof tokenData?.auth_id === "string" ? tokenData.auth_id.trim() : "";
198
- return authId || "unknown";
217
+ typeof tokenData?.auth_id === 'string' ? tokenData.auth_id.trim() : '';
218
+ return authId || 'unknown';
199
219
  }
200
220
 
201
221
  function _isCredentialEntry(entry) {
202
222
  return Boolean(
203
223
  entry &&
204
- typeof entry === "object" &&
224
+ typeof entry === 'object' &&
205
225
  !Array.isArray(entry) &&
206
- (typeof entry.access_token === "string" ||
207
- typeof entry.refresh_token === "string"),
226
+ (typeof entry.access_token === 'string' ||
227
+ typeof entry.refresh_token === 'string')
208
228
  );
209
229
  }
210
230
 
@@ -219,7 +239,9 @@ function _selectCredentialEntry(parsed) {
219
239
  }
220
240
 
221
241
  if (Array.isArray(parsed?.accounts)) {
222
- const index = parsed.accounts.findIndex((entry) => _isCredentialEntry(entry));
242
+ const index = parsed.accounts.findIndex((entry) =>
243
+ _isCredentialEntry(entry)
244
+ );
223
245
  if (index !== -1) {
224
246
  return {
225
247
  entry: parsed.accounts[index],
@@ -281,14 +303,14 @@ async function findStoredCredentialState() {
281
303
  }
282
304
 
283
305
  function _decodeBase64Url(value) {
284
- const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
285
- const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
286
- return Buffer.from(`${normalized}${padding}`, "base64").toString("utf-8");
306
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/');
307
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
308
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf-8');
287
309
  }
288
310
 
289
311
  function extractAccessTokenExpiry(accessToken) {
290
312
  try {
291
- const [, payloadSegment] = String(accessToken || "").split(".");
313
+ const [, payloadSegment] = String(accessToken || '').split('.');
292
314
  if (!payloadSegment) return null;
293
315
  const payload = JSON.parse(_decodeBase64Url(payloadSegment));
294
316
  const exp = Number(payload?.exp);
@@ -302,24 +324,24 @@ function extractAccessTokenExpiry(accessToken) {
302
324
  }
303
325
 
304
326
  async function exchangeRefreshToken(baseUrl, refreshToken) {
305
- const refreshUrl = new URL("/auth/token/refresh/", baseUrl).toString();
327
+ const refreshUrl = new URL('/auth/token/refresh/', baseUrl).toString();
306
328
  const { response, payload, text } = await requestJson(refreshUrl, {
307
- method: "POST",
329
+ method: 'POST',
308
330
  headers: {
309
331
  Authorization: `Bearer ${refreshToken}`,
310
- Accept: "application/json",
311
- "Content-Type": "application/json",
332
+ Accept: 'application/json',
333
+ 'Content-Type': 'application/json',
312
334
  },
313
335
  });
314
336
 
315
337
  if (!response.ok) {
316
- const message = extractErrorMessage(payload, text || "request failed");
338
+ const message = extractErrorMessage(payload, text || 'request failed');
317
339
  throw new Error(`Token refresh failed (${response.status}): ${message}`);
318
340
  }
319
341
 
320
342
  const data = unwrapData(payload);
321
343
  if (!data?.access_token) {
322
- throw new Error("Token refresh failed: invalid response payload.");
344
+ throw new Error('Token refresh failed: invalid response payload.');
323
345
  }
324
346
 
325
347
  return data;
@@ -328,20 +350,20 @@ async function exchangeRefreshToken(baseUrl, refreshToken) {
328
350
  async function refreshStoredCredentials(baseUrl) {
329
351
  const state = await findStoredCredentialState();
330
352
  const refreshToken =
331
- typeof state?.entry?.refresh_token === "string" && state.entry.refresh_token
353
+ typeof state?.entry?.refresh_token === 'string' && state.entry.refresh_token
332
354
  ? state.entry.refresh_token
333
355
  : null;
334
356
 
335
357
  if (!state || !refreshToken) {
336
- throw new Error("No refresh token available.");
358
+ throw new Error('No refresh token available.');
337
359
  }
338
360
 
339
361
  const resolvedBaseUrl =
340
362
  process.env.TEXTCORTEX_BASE_URL ||
341
- (typeof state.entry.base_url === "string" && state.entry.base_url) ||
363
+ (typeof state.entry.base_url === 'string' && state.entry.base_url) ||
342
364
  baseUrl;
343
365
  if (!resolvedBaseUrl) {
344
- throw new Error("Missing base URL for token refresh.");
366
+ throw new Error('Missing base URL for token refresh.');
345
367
  }
346
368
 
347
369
  const refreshed = await exchangeRefreshToken(resolvedBaseUrl, refreshToken);
@@ -350,7 +372,7 @@ async function refreshStoredCredentials(baseUrl) {
350
372
  base_url: resolvedBaseUrl,
351
373
  access_token: refreshed.access_token,
352
374
  refresh_token:
353
- typeof refreshed.refresh_token === "string" && refreshed.refresh_token
375
+ typeof refreshed.refresh_token === 'string' && refreshed.refresh_token
354
376
  ? refreshed.refresh_token
355
377
  : refreshToken,
356
378
  auth_id: refreshed.auth_id || state.entry.auth_id || null,
@@ -371,7 +393,8 @@ async function refreshStoredCredentials(baseUrl) {
371
393
  }
372
394
 
373
395
  async function resolveToken() {
374
- const envToken = process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
396
+ const envToken =
397
+ process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
375
398
  if (envToken) return envToken;
376
399
 
377
400
  for (const filePath of await resolveCredentialFiles()) {
@@ -382,10 +405,10 @@ async function resolveToken() {
382
405
 
383
406
  throw new Error(
384
407
  [
385
- "Missing API token.",
386
- "Run `zenocode login` first,",
387
- "or set TEXTCORTEX_API_KEY / TEXTCORTEX_API_TOKEN.",
388
- ].join(" "),
408
+ 'Missing API token.',
409
+ 'Run `zenocode login` first,',
410
+ 'or set TEXTCORTEX_API_KEY / TEXTCORTEX_API_TOKEN.',
411
+ ].join(' ')
389
412
  );
390
413
  }
391
414
 
@@ -397,35 +420,45 @@ async function resolveStoredBaseUrl() {
397
420
  return baseUrl || null;
398
421
  }
399
422
 
423
+ /** Choose runtime defaults without reviving retired rows from stale catalogs. */
400
424
  export function chooseDefaults(models) {
401
- const ids = Object.keys(models);
402
- if (!ids.length) throw new Error("No models were returned by the backend.");
425
+ const ids = Object.keys(models).filter((id) => !retiredModelIDs.has(id));
426
+ if (!ids.length) throw new Error('No models were returned by the backend.');
403
427
  const model = defaultOrder.find((id) => ids.includes(id)) || ids[0];
404
428
  const smallModel =
405
- ids.find((id) => id.includes("-mini") || id.includes("haiku")) || model;
429
+ ids.find((id) => id.includes('-mini') || id.includes('haiku')) || model;
406
430
  return { model, smallModel };
407
431
  }
408
432
 
409
- export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel }) {
433
+ export function buildOpenCodeConfig({
434
+ baseUrl,
435
+ providerID,
436
+ model,
437
+ smallModel,
438
+ }) {
410
439
  return {
411
- $schema: "https://opencode.ai/config.json",
440
+ $schema: 'https://opencode.ai/config.json',
412
441
  enabled_providers: [providerID],
442
+ // Zenocode owns runtime updates. Its source-branded runtime is released with
443
+ // the launcher, so an independently updating runtime would split the release.
444
+ autoupdate: false,
413
445
  // Keep the legacy theme key populated for older OpenCode builds.
414
- theme: "system",
446
+ theme: 'system',
415
447
  model: `${providerID}/${model}`,
416
448
  small_model: `${providerID}/${smallModel}`,
417
449
  provider: {
418
450
  [providerID]: {
419
- name: "Zenocode",
451
+ name: 'Zenocode',
420
452
  options: {
421
- baseURL: new URL("/internal/v2/fastapi/zenocode/v1", baseUrl).toString(),
453
+ baseURL: new URL(
454
+ '/internal/v2/fastapi/zenocode/v1',
455
+ baseUrl
456
+ ).toString(),
422
457
  },
423
458
  },
424
- // Older fallback opencode-ai builds can load the Codex auth plugin when
425
- // a local OpenAI OAuth session exists. They assume `database.openai`
426
- // exists and crash on `provider.models` otherwise, so keep a harmless
427
- // empty provider entry around even though Zenocode only enables
428
- // `textcortex`.
459
+ // The inherited Codex auth plugin assumes `database.openai` exists when a
460
+ // local OpenAI OAuth session is present. Keep a harmless empty entry even
461
+ // though Zenocode enables only `textcortex`.
429
462
  openai: {
430
463
  models: {},
431
464
  },
@@ -435,27 +468,35 @@ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel })
435
468
 
436
469
  export function buildOpenCodeTuiConfig() {
437
470
  return {
438
- $schema: "https://opencode.ai/tui.json",
439
- theme: "system",
471
+ $schema: 'https://opencode.ai/tui.json',
472
+ theme: 'system',
440
473
  };
441
474
  }
442
475
 
443
476
  function unwrapData(payload) {
444
- if (payload && typeof payload === "object" && payload.data && typeof payload.data === "object") {
477
+ if (
478
+ payload &&
479
+ typeof payload === 'object' &&
480
+ payload.data &&
481
+ typeof payload.data === 'object'
482
+ ) {
445
483
  return payload.data;
446
484
  }
447
485
  return payload;
448
486
  }
449
487
 
450
488
  function extractErrorMessage(payload, fallbackMessage) {
451
- if (payload && typeof payload === "object") {
489
+ if (payload && typeof payload === 'object') {
452
490
  const errorMessage = payload?.error?.message;
453
- if (typeof errorMessage === "string" && errorMessage) return errorMessage;
454
- if (typeof payload.message === "string" && payload.message) return payload.message;
455
- if (typeof payload.detail === "string" && payload.detail) return payload.detail;
456
- if (payload.detail && typeof payload.detail === "object") {
491
+ if (typeof errorMessage === 'string' && errorMessage) return errorMessage;
492
+ if (typeof payload.message === 'string' && payload.message)
493
+ return payload.message;
494
+ if (typeof payload.detail === 'string' && payload.detail)
495
+ return payload.detail;
496
+ if (payload.detail && typeof payload.detail === 'object') {
457
497
  const detailMessage = payload.detail.message;
458
- if (typeof detailMessage === "string" && detailMessage) return detailMessage;
498
+ if (typeof detailMessage === 'string' && detailMessage)
499
+ return detailMessage;
459
500
  }
460
501
  try {
461
502
  return JSON.stringify(payload);
@@ -479,28 +520,46 @@ async function requestJson(url, init) {
479
520
  }
480
521
 
481
522
  async function prepareRuntime(baseUrl, token) {
482
- const modelsUrl = new URL("/internal/v2/fastapi/zenocode/models/api.json", baseUrl).toString();
523
+ const modelsUrl = new URL(
524
+ '/internal/v2/fastapi/zenocode/models/api.json',
525
+ baseUrl
526
+ ).toString();
483
527
  const { response, payload, text } = await requestJson(modelsUrl, {
484
- headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
528
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
485
529
  });
486
530
 
487
531
  if (!response.ok) {
488
532
  const body = payload ?? text;
489
- const message = extractErrorMessage(payload, String(body || "request failed"));
490
- if (response.status === 401 && message.includes("Email not confirmed")) {
533
+ const message = extractErrorMessage(
534
+ payload,
535
+ String(body || 'request failed')
536
+ );
537
+ if (response.status === 401 && message.includes('Email not confirmed')) {
491
538
  throw new Error(
492
- "Models fetch failed (401): Email is not confirmed for this account. Please verify the email address and try again.",
539
+ 'Models fetch failed (401): Email is not confirmed for this account. Please verify the email address and try again.'
493
540
  );
494
541
  }
495
542
  throw new Error(`Models fetch failed (${response.status}): ${message}`);
496
543
  }
497
544
 
498
545
  const provider = payload?.[providerID];
499
- if (!provider?.models || typeof provider.models !== "object") {
500
- throw new Error("Backend models response is missing textcortex provider data.");
546
+ if (!provider?.models || typeof provider.models !== 'object') {
547
+ throw new Error(
548
+ 'Backend models response is missing textcortex provider data.'
549
+ );
501
550
  }
502
551
 
503
- const { model, smallModel } = chooseDefaults(provider.models);
552
+ const supportedModels = Object.fromEntries(
553
+ Object.entries(provider.models).filter(([id]) => !retiredModelIDs.has(id))
554
+ );
555
+ const supportedPayload = {
556
+ ...payload,
557
+ [providerID]: {
558
+ ...provider,
559
+ models: supportedModels,
560
+ },
561
+ };
562
+ const { model, smallModel } = chooseDefaults(supportedModels);
504
563
  const config = buildOpenCodeConfig({
505
564
  baseUrl,
506
565
  providerID,
@@ -510,9 +569,21 @@ async function prepareRuntime(baseUrl, token) {
510
569
  const tuiConfig = buildOpenCodeTuiConfig();
511
570
 
512
571
  await fs.mkdir(runtimeDir, { recursive: true });
513
- await fs.writeFile(modelsPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
514
- await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
515
- await fs.writeFile(tuiConfigPath, `${JSON.stringify(tuiConfig, null, 2)}\n`, "utf-8");
572
+ await fs.writeFile(
573
+ modelsPath,
574
+ `${JSON.stringify(supportedPayload, null, 2)}\n`,
575
+ 'utf-8'
576
+ );
577
+ await fs.writeFile(
578
+ configPath,
579
+ `${JSON.stringify(config, null, 2)}\n`,
580
+ 'utf-8'
581
+ );
582
+ await fs.writeFile(
583
+ tuiConfigPath,
584
+ `${JSON.stringify(tuiConfig, null, 2)}\n`,
585
+ 'utf-8'
586
+ );
516
587
  return model;
517
588
  }
518
589
 
@@ -521,7 +592,7 @@ function sleep(ms) {
521
592
  }
522
593
 
523
594
  export function hasLocalBaseUrlFlag(args = []) {
524
- const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
595
+ const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
525
596
  return normalizedArgs.some((arg) => localBaseUrlFlags.has(arg));
526
597
  }
527
598
 
@@ -530,7 +601,7 @@ function stripLocalBaseUrlFlags(args = []) {
530
601
  }
531
602
 
532
603
  async function parseLoginArgs(args) {
533
- const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
604
+ const normalizedArgs = args[0] === '--' ? args.slice(1) : args;
534
605
  let emailHint = process.env.TEXTCORTEX_LOGIN_EMAIL || null;
535
606
  let launchBrowser = true;
536
607
 
@@ -539,32 +610,41 @@ async function parseLoginArgs(args) {
539
610
  if (localBaseUrlFlags.has(arg)) {
540
611
  continue;
541
612
  }
542
- if (arg === "--no-launch-browser") {
613
+ if (arg === '--no-launch-browser') {
543
614
  launchBrowser = false;
544
615
  continue;
545
616
  }
546
- if (arg === "--email") {
617
+ if (arg === '--email') {
547
618
  emailHint = normalizedArgs[idx + 1] || null;
548
619
  idx += 1;
549
620
  continue;
550
621
  }
551
- if (arg.startsWith("--email=")) {
552
- emailHint = arg.slice("--email=".length) || null;
622
+ if (arg.startsWith('--email=')) {
623
+ emailHint = arg.slice('--email='.length) || null;
553
624
  continue;
554
625
  }
555
- if (arg === "--help" || arg === "-h") {
556
- console.log("Zenocode login options:");
557
- console.log(" --email <address> Optional email hint for tenant/SSO routing");
558
- console.log(` --local Use the local FastAPI backend at ${localBaseUrlDefault}`);
559
- console.log(" --no-launch-browser Do not open browser automatically");
626
+ if (arg === '--help' || arg === '-h') {
627
+ console.log('Zenocode login options:');
628
+ console.log(
629
+ ' --email <address> Optional email hint for tenant/SSO routing'
630
+ );
631
+ console.log(
632
+ ` --local Use the local FastAPI backend at ${localBaseUrlDefault}`
633
+ );
634
+ console.log(' --no-launch-browser Do not open browser automatically');
560
635
  process.exit(0);
561
636
  }
562
637
  }
563
638
 
564
639
  if (!emailHint && process.stdin.isTTY) {
565
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
640
+ const rl = readline.createInterface({
641
+ input: process.stdin,
642
+ output: process.stdout,
643
+ });
566
644
  try {
567
- const response = (await rl.question("Email for tenant routing (optional): ")).trim();
645
+ const response = (
646
+ await rl.question('Email for tenant routing (optional): ')
647
+ ).trim();
568
648
  emailHint = response || null;
569
649
  } finally {
570
650
  rl.close();
@@ -576,15 +656,19 @@ async function parseLoginArgs(args) {
576
656
 
577
657
  function isFetchFailedError(error) {
578
658
  const message = error instanceof Error ? error.message : String(error);
579
- return message.includes("fetch failed");
659
+ return message.includes('fetch failed');
580
660
  }
581
661
 
582
662
  function isLoginRouteNotFoundError(error) {
583
663
  const message = error instanceof Error ? error.message : String(error);
584
- return message.includes("Login initiate failed (404)");
664
+ return message.includes('Login initiate failed (404)');
585
665
  }
586
666
 
587
- export function shouldFallbackLoginToCloud({ baseUrl, hasExplicitBaseUrl, error }) {
667
+ export function shouldFallbackLoginToCloud({
668
+ baseUrl,
669
+ hasExplicitBaseUrl,
670
+ error,
671
+ }) {
588
672
  if (hasExplicitBaseUrl || baseUrl !== localBaseUrlDefault) {
589
673
  return false;
590
674
  }
@@ -617,28 +701,28 @@ function _loginConnectivityHelp(baseUrl) {
617
701
  return [
618
702
  `Cannot reach Zenocode auth endpoint at ${baseUrl}.`,
619
703
  `Set TEXTCORTEX_BASE_URL to ${cloudBaseUrlDefault} or, for local development, run local backend FastAPI (\`cd backend && uv run dev_fastapi\`).`,
620
- ].join(" ");
704
+ ].join(' ');
621
705
  }
622
706
 
623
707
  async function openBrowser(url) {
624
708
  let cmd;
625
709
  let args;
626
- if (process.platform === "darwin") {
627
- cmd = "open";
710
+ if (process.platform === 'darwin') {
711
+ cmd = 'open';
628
712
  args = [url];
629
- } else if (process.platform === "win32") {
630
- cmd = "cmd";
631
- args = ["/c", "start", "", url];
713
+ } else if (process.platform === 'win32') {
714
+ cmd = 'cmd';
715
+ args = ['/c', 'start', '', url];
632
716
  } else {
633
- cmd = "xdg-open";
717
+ cmd = 'xdg-open';
634
718
  args = [url];
635
719
  }
636
720
 
637
721
  return new Promise((resolve) => {
638
722
  try {
639
- const child = spawn(cmd, args, { stdio: "ignore" });
640
- child.on("error", () => resolve(false));
641
- child.on("exit", (code) => resolve(code === 0));
723
+ const child = spawn(cmd, args, { stdio: 'ignore' });
724
+ child.on('error', () => resolve(false));
725
+ child.on('exit', (code) => resolve(code === 0));
642
726
  } catch {
643
727
  resolve(false);
644
728
  }
@@ -649,49 +733,67 @@ async function initiateDeviceLogin(baseUrl, emailHint) {
649
733
  const initiateUrl = new URL(oauthInitiatePath, baseUrl).toString();
650
734
  const body = emailHint ? { email_hint: emailHint } : {};
651
735
  const { response, payload, text } = await requestJson(initiateUrl, {
652
- method: "POST",
653
- headers: { "Content-Type": "application/json", Accept: "application/json" },
736
+ method: 'POST',
737
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
654
738
  body: JSON.stringify(body),
655
739
  });
656
740
 
657
741
  if (!response.ok) {
658
- const message = extractErrorMessage(payload, text || "request failed");
742
+ const message = extractErrorMessage(payload, text || 'request failed');
659
743
  throw new Error(`Login initiate failed (${response.status}): ${message}`);
660
744
  }
661
745
 
662
746
  const data = unwrapData(payload);
663
- if (!data || typeof data !== "object") {
664
- throw new Error("Login initiate failed: invalid response payload.");
747
+ if (!data || typeof data !== 'object') {
748
+ throw new Error('Login initiate failed: invalid response payload.');
665
749
  }
666
750
 
667
- const deviceCode = typeof data.device_code === "string" ? data.device_code : null;
668
- const userCode = typeof data.user_code === "string" ? data.user_code : null;
669
- const verificationUrl = typeof data.verification_url_complete === "string" ? data.verification_url_complete : null;
670
- const interval = Number.isFinite(Number(data.interval)) ? Number(data.interval) : 5;
671
- const expiresIn = Number.isFinite(Number(data.expires_in)) ? Number(data.expires_in) : 600;
751
+ const deviceCode =
752
+ typeof data.device_code === 'string' ? data.device_code : null;
753
+ const userCode = typeof data.user_code === 'string' ? data.user_code : null;
754
+ const verificationUrl =
755
+ typeof data.verification_url_complete === 'string'
756
+ ? data.verification_url_complete
757
+ : null;
758
+ const interval = Number.isFinite(Number(data.interval))
759
+ ? Number(data.interval)
760
+ : 5;
761
+ const expiresIn = Number.isFinite(Number(data.expires_in))
762
+ ? Number(data.expires_in)
763
+ : 600;
672
764
 
673
765
  if (!deviceCode || !userCode || !verificationUrl) {
674
- throw new Error("Login initiate failed: incomplete device authorization payload.");
766
+ throw new Error(
767
+ 'Login initiate failed: incomplete device authorization payload.'
768
+ );
675
769
  }
676
770
 
677
771
  return { deviceCode, userCode, verificationUrl, interval, expiresIn };
678
772
  }
679
773
 
680
- async function pollDeviceToken(baseUrl, deviceCode, intervalSeconds, expiresInSeconds) {
774
+ async function pollDeviceToken(
775
+ baseUrl,
776
+ deviceCode,
777
+ intervalSeconds,
778
+ expiresInSeconds
779
+ ) {
681
780
  const deadline = Date.now() + Math.max(expiresInSeconds, 1) * 1000;
682
781
  const pollUrl = new URL(oauthTokenPath, baseUrl).toString();
683
782
 
684
783
  while (Date.now() <= deadline) {
685
784
  const { response, payload, text } = await requestJson(pollUrl, {
686
- method: "POST",
687
- headers: { "Content-Type": "application/json", Accept: "application/json" },
785
+ method: 'POST',
786
+ headers: {
787
+ 'Content-Type': 'application/json',
788
+ Accept: 'application/json',
789
+ },
688
790
  body: JSON.stringify({ device_code: deviceCode }),
689
791
  });
690
792
 
691
793
  if (response.status === 200) {
692
794
  const data = unwrapData(payload);
693
795
  if (!data?.access_token || !data?.refresh_token) {
694
- throw new Error("Token endpoint returned incomplete credentials.");
796
+ throw new Error('Token endpoint returned incomplete credentials.');
695
797
  }
696
798
  return data;
697
799
  }
@@ -701,11 +803,13 @@ async function pollDeviceToken(baseUrl, deviceCode, intervalSeconds, expiresInSe
701
803
  continue;
702
804
  }
703
805
 
704
- const message = extractErrorMessage(payload, text || "request failed");
705
- throw new Error(`Login token exchange failed (${response.status}): ${message}`);
806
+ const message = extractErrorMessage(payload, text || 'request failed');
807
+ throw new Error(
808
+ `Login token exchange failed (${response.status}): ${message}`
809
+ );
706
810
  }
707
811
 
708
- throw new Error("Device code expired. Please run login again.");
812
+ throw new Error('Device code expired. Please run login again.');
709
813
  }
710
814
 
711
815
  async function saveRuntimeCredentials(baseUrl, tokenData) {
@@ -725,6 +829,7 @@ async function saveRuntimeCredentials(baseUrl, tokenData) {
725
829
  await clearLogoutMarker();
726
830
  }
727
831
 
832
+ /** Complete device login, persist its credentials, and return that exact session. */
728
833
  async function runLoginCommand(baseUrl, args, options = {}) {
729
834
  const preferLocalhost = options.preferLocalhost === true;
730
835
  const { emailHint, launchBrowser } = await parseLoginArgs(args);
@@ -733,15 +838,17 @@ async function runLoginCommand(baseUrl, args, options = {}) {
733
838
  try {
734
839
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
735
840
  } catch (error) {
736
- if (shouldFallbackLoginToCloud({
737
- baseUrl: resolvedBaseUrl,
738
- hasExplicitBaseUrl:
739
- Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
740
- error,
741
- })) {
841
+ if (
842
+ shouldFallbackLoginToCloud({
843
+ baseUrl: resolvedBaseUrl,
844
+ hasExplicitBaseUrl:
845
+ Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
846
+ error,
847
+ })
848
+ ) {
742
849
  resolvedBaseUrl = cloudBaseUrlDefault;
743
850
  console.log(
744
- `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`,
851
+ `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`
745
852
  );
746
853
  try {
747
854
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
@@ -762,16 +869,18 @@ async function runLoginCommand(baseUrl, args, options = {}) {
762
869
  }
763
870
  }
764
871
 
765
- console.log("\nComplete authorization in your browser:");
872
+ console.log('\nComplete authorization in your browser:');
766
873
  console.log(` ${login.verificationUrl}`);
767
874
  console.log(`User code: ${login.userCode}\n`);
768
875
 
769
876
  if (launchBrowser) {
770
877
  const launched = await openBrowser(login.verificationUrl);
771
878
  if (!launched) {
772
- console.log("Unable to open a browser automatically. Open the URL above manually.");
879
+ console.log(
880
+ 'Unable to open a browser automatically. Open the URL above manually.'
881
+ );
773
882
  } else {
774
- console.log("Opened browser for authentication.");
883
+ console.log('Opened browser for authentication.');
775
884
  }
776
885
  }
777
886
 
@@ -779,13 +888,14 @@ async function runLoginCommand(baseUrl, args, options = {}) {
779
888
  resolvedBaseUrl,
780
889
  login.deviceCode,
781
890
  login.interval,
782
- login.expiresIn,
891
+ login.expiresIn
783
892
  );
784
893
  await saveRuntimeCredentials(resolvedBaseUrl, tokenData);
785
894
 
786
895
  const account = resolveLoginSuccessIdentifier(tokenData);
787
896
  console.log(`Login successful for ${account}.`);
788
897
  console.log(`Credentials saved to ${runtimeCredentialsPath}`);
898
+ return { token: tokenData.access_token, baseUrl: resolvedBaseUrl };
789
899
  }
790
900
 
791
901
  async function runLogoutCommand() {
@@ -795,7 +905,7 @@ async function runLogoutCommand() {
795
905
  await fs.unlink(credentialsPath);
796
906
  removedAnyCredentials = true;
797
907
  } catch (error) {
798
- if (error?.code === "ENOENT") {
908
+ if (error?.code === 'ENOENT') {
799
909
  continue;
800
910
  }
801
911
  throw error;
@@ -804,25 +914,27 @@ async function runLogoutCommand() {
804
914
 
805
915
  const fallbackCredentialsPresent = (
806
916
  await Promise.all(
807
- fallbackCredentialFiles.map((credentialsPath) => _pathExists(credentialsPath)),
917
+ fallbackCredentialFiles.map((credentialsPath) =>
918
+ _pathExists(credentialsPath)
919
+ )
808
920
  )
809
921
  ).some(Boolean);
810
922
 
811
923
  if (removedAnyCredentials || fallbackCredentialsPresent) {
812
924
  await writeLogoutMarker();
813
- console.log("Zenocode session cleared.");
925
+ console.log('Zenocode session cleared.');
814
926
  return;
815
927
  }
816
928
 
817
- console.log("No local Zenocode credentials found.");
929
+ console.log('No local Zenocode credentials found.');
818
930
  }
819
931
 
820
932
  async function runChild(command, args, options) {
821
933
  return new Promise((resolve, reject) => {
822
934
  try {
823
935
  const child = spawn(command, args, options);
824
- child.on("error", reject);
825
- child.on("exit", (code, signal) => resolve({ code, signal }));
936
+ child.on('error', reject);
937
+ child.on('exit', (code, signal) => resolve({ code, signal }));
826
938
  } catch (error) {
827
939
  reject(error);
828
940
  }
@@ -830,19 +942,19 @@ async function runChild(command, args, options) {
830
942
  }
831
943
 
832
944
  function _buildScriptCommand(runtimeBinaryPath, args, transcriptPath) {
833
- if (process.platform === "darwin") {
945
+ if (process.platform === 'darwin') {
834
946
  return {
835
- command: "script",
836
- args: ["-q", "-F", "-t", "0", transcriptPath, runtimeBinaryPath, ...args],
947
+ command: 'script',
948
+ args: ['-q', '-F', '-t', '0', transcriptPath, runtimeBinaryPath, ...args],
837
949
  };
838
950
  }
839
- if (process.platform === "linux") {
951
+ if (process.platform === 'linux') {
840
952
  const shellCommand = [runtimeBinaryPath, ...args]
841
953
  .map((value) => `'${String(value).replaceAll("'", `'\\''`)}'`)
842
- .join(" ");
954
+ .join(' ');
843
955
  return {
844
- command: "script",
845
- args: ["-q", "-f", "-c", shellCommand, transcriptPath],
956
+ command: 'script',
957
+ args: ['-q', '-f', '-c', shellCommand, transcriptPath],
846
958
  };
847
959
  }
848
960
  return null;
@@ -851,10 +963,10 @@ function _buildScriptCommand(runtimeBinaryPath, args, transcriptPath) {
851
963
  async function _readTranscriptUpdate(transcriptPath, offset) {
852
964
  let handle;
853
965
  try {
854
- handle = await fs.open(transcriptPath, "r");
966
+ handle = await fs.open(transcriptPath, 'r');
855
967
  } catch (error) {
856
- if (error?.code === "ENOENT") {
857
- return { offset, text: "" };
968
+ if (error?.code === 'ENOENT') {
969
+ return { offset, text: '' };
858
970
  }
859
971
  throw error;
860
972
  }
@@ -862,13 +974,13 @@ async function _readTranscriptUpdate(transcriptPath, offset) {
862
974
  try {
863
975
  const stats = await handle.stat();
864
976
  if (stats.size <= offset) {
865
- return { offset, text: "" };
977
+ return { offset, text: '' };
866
978
  }
867
979
 
868
980
  const length = stats.size - offset;
869
981
  const buffer = Buffer.alloc(length);
870
982
  await handle.read(buffer, 0, length, offset);
871
- return { offset: stats.size, text: buffer.toString("utf-8") };
983
+ return { offset: stats.size, text: buffer.toString('utf-8') };
872
984
  } finally {
873
985
  await handle.close();
874
986
  }
@@ -879,44 +991,61 @@ async function _terminateChildProcess(child, exitPromise) {
879
991
  return exitPromise;
880
992
  }
881
993
 
882
- child.kill("SIGTERM");
883
- const exited = await Promise.race([exitPromise, sleep(1_500).then(() => null)]);
994
+ child.kill('SIGTERM');
995
+ const exited = await Promise.race([
996
+ exitPromise,
997
+ sleep(1_500).then(() => null),
998
+ ]);
884
999
  if (exited) {
885
1000
  return exited;
886
1001
  }
887
1002
 
888
- child.kill("SIGKILL");
1003
+ child.kill('SIGKILL');
889
1004
  return exitPromise;
890
1005
  }
891
1006
 
892
- async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options) {
893
- const transcriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-runtime-"));
894
- const transcriptPath = path.join(transcriptDir, "session.log");
1007
+ async function _runRuntimeWithTranscriptMonitor(
1008
+ runtimeBinaryPath,
1009
+ args,
1010
+ options
1011
+ ) {
1012
+ const transcriptDir = await fs.mkdtemp(
1013
+ path.join(os.tmpdir(), 'zenocode-runtime-')
1014
+ );
1015
+ const transcriptPath = path.join(transcriptDir, 'session.log');
895
1016
  const command = _buildScriptCommand(runtimeBinaryPath, args, transcriptPath);
896
1017
  if (!command) {
897
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1018
+ const result = await runChild(runtimeBinaryPath, args, {
1019
+ ...options,
1020
+ stdio: 'inherit',
1021
+ });
898
1022
  return { ...result, expiredSession: false };
899
1023
  }
900
1024
 
901
1025
  try {
902
1026
  const child = spawn(command.command, command.args, {
903
1027
  ...options,
904
- stdio: "inherit",
1028
+ stdio: 'inherit',
905
1029
  });
906
1030
  const exitPromise = new Promise((resolve, reject) => {
907
- child.on("error", reject);
908
- child.on("exit", (code, signal) => resolve({ code, signal, expiredSession: false }));
1031
+ child.on('error', reject);
1032
+ child.on('exit', (code, signal) =>
1033
+ resolve({ code, signal, expiredSession: false })
1034
+ );
909
1035
  });
910
1036
  let transcriptOffset = 0;
911
- let transcriptTail = "";
1037
+ let transcriptTail = '';
912
1038
 
913
1039
  while (true) {
914
1040
  const outcome = await Promise.race([
915
- exitPromise.then((result) => ({ type: "exit", result })),
916
- sleep(250).then(() => ({ type: "poll" })),
1041
+ exitPromise.then((result) => ({ type: 'exit', result })),
1042
+ sleep(250).then(() => ({ type: 'poll' })),
917
1043
  ]);
918
1044
 
919
- const update = await _readTranscriptUpdate(transcriptPath, transcriptOffset);
1045
+ const update = await _readTranscriptUpdate(
1046
+ transcriptPath,
1047
+ transcriptOffset
1048
+ );
920
1049
  transcriptOffset = update.offset;
921
1050
  if (update.text) {
922
1051
  transcriptTail = _appendTranscriptTail(transcriptTail, update.text);
@@ -927,15 +1056,18 @@ async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options
927
1056
  return { expiredSession: true };
928
1057
  }
929
1058
 
930
- if (outcome.type === "exit") {
1059
+ if (outcome.type === 'exit') {
931
1060
  return canRecoverRuntimeSessionFromTranscript(transcriptTail)
932
1061
  ? { expiredSession: true }
933
1062
  : outcome.result;
934
1063
  }
935
1064
  }
936
1065
  } catch (error) {
937
- if (error?.code === "ENOENT") {
938
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1066
+ if (error?.code === 'ENOENT') {
1067
+ const result = await runChild(runtimeBinaryPath, args, {
1068
+ ...options,
1069
+ stdio: 'inherit',
1070
+ });
939
1071
  return { ...result, expiredSession: false };
940
1072
  }
941
1073
  throw error;
@@ -944,9 +1076,17 @@ async function _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options
944
1076
  }
945
1077
  }
946
1078
 
947
- async function runRuntimeBinary(runtimeBinaryPath, args, options, monitorRuntimeSession) {
1079
+ async function runRuntimeBinary(
1080
+ runtimeBinaryPath,
1081
+ args,
1082
+ options,
1083
+ monitorRuntimeSession
1084
+ ) {
948
1085
  if (!monitorRuntimeSession) {
949
- const result = await runChild(runtimeBinaryPath, args, { ...options, stdio: "inherit" });
1086
+ const result = await runChild(runtimeBinaryPath, args, {
1087
+ ...options,
1088
+ stdio: 'inherit',
1089
+ });
950
1090
  return { ...result, expiredSession: false };
951
1091
  }
952
1092
  return _runRuntimeWithTranscriptMonitor(runtimeBinaryPath, args, options);
@@ -960,396 +1100,106 @@ function exitWithChildResult(result) {
960
1100
  process.exit(result.code ?? 0);
961
1101
  }
962
1102
 
963
- function _runnerCommand(command) {
964
- return process.platform === "win32" ? `${command}.cmd` : command;
965
- }
966
-
967
- function _runtimeBrandedBinaryPath() {
968
- const binaryName = process.platform === "win32" ? "opencode-runtime.exe" : "opencode-runtime";
969
- return path.join(runtimeDir, "bin", binaryName);
970
- }
971
-
972
- export function packageNameFromSpecifier(packageSpecifier) {
973
- if (!packageSpecifier.includes("@")) {
974
- return packageSpecifier;
975
- }
976
- if (!packageSpecifier.startsWith("@")) {
977
- return packageSpecifier.split("@")[0];
978
- }
979
-
980
- const versionSeparator = packageSpecifier.lastIndexOf("@");
981
- return versionSeparator > packageSpecifier.indexOf("/")
982
- ? packageSpecifier.slice(0, versionSeparator)
983
- : packageSpecifier;
984
- }
985
-
986
- function shouldPatchOpencodeRuntimePackage(packageName) {
987
- const runtimePackageName = packageNameFromSpecifier(packageName);
988
- return (
989
- runtimePackageName.endsWith("/opencode-ai") ||
990
- runtimePackageName.endsWith("/zenocode-ai") ||
991
- runtimePackageName === openCodePackageName
992
- );
993
- }
994
-
995
- async function _pathExists(pathToCheck) {
996
- try {
997
- await fs.access(pathToCheck);
998
- return true;
999
- } catch {
1000
- return false;
1001
- }
1002
- }
1003
-
1004
- async function _collectPnpmDlxPnpmDirs(rootDir) {
1005
- const queue = [{ dir: rootDir, depth: 0 }];
1006
- const pnpmDirs = [];
1007
-
1008
- while (queue.length) {
1009
- const current = queue.shift();
1010
- const nodeModulesPnpm = path.join(current.dir, "node_modules", ".pnpm");
1011
- if (await _pathExists(nodeModulesPnpm)) {
1012
- pnpmDirs.push(nodeModulesPnpm);
1013
- }
1014
-
1015
- if (current.depth >= 3) continue;
1016
- let entries = [];
1017
- try {
1018
- entries = await fs.readdir(current.dir, { withFileTypes: true });
1019
- } catch {
1020
- continue;
1021
- }
1022
- for (const entry of entries) {
1023
- if (!entry.isDirectory()) continue;
1024
- if (entry.name === "node_modules") continue;
1025
- queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
1026
- }
1027
- }
1028
-
1029
- return pnpmDirs;
1030
- }
1031
-
1032
- async function _collectNodeModulesDirs(rootDir) {
1033
- const queue = [{ dir: rootDir, depth: 0 }];
1034
- const nodeModulesDirs = [];
1035
-
1036
- while (queue.length) {
1037
- const current = queue.shift();
1038
- const nodeModulesPath = path.join(current.dir, "node_modules");
1039
- if (await _pathExists(nodeModulesPath)) {
1040
- nodeModulesDirs.push(nodeModulesPath);
1041
- }
1042
-
1043
- if (current.depth >= 3) continue;
1044
- let entries = [];
1045
- try {
1046
- entries = await fs.readdir(current.dir, { withFileTypes: true });
1047
- } catch {
1048
- continue;
1049
- }
1050
- for (const entry of entries) {
1051
- if (!entry.isDirectory()) continue;
1052
- if (entry.name === "node_modules") continue;
1053
- queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
1054
- }
1055
- }
1056
-
1057
- return nodeModulesDirs;
1058
- }
1059
-
1060
- async function _collectOpencodeBinariesFromNodeModules(nodeModulesPath) {
1061
- const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode";
1062
- const hiddenCachedBinaryName = ".opencode";
1063
- const candidates = [];
1064
-
1065
- let topLevel = [];
1066
- try {
1067
- topLevel = await fs.readdir(nodeModulesPath, { withFileTypes: true });
1068
- } catch {
1069
- return candidates;
1070
- }
1071
-
1072
- for (const moduleEntry of topLevel) {
1073
- if (!moduleEntry.isDirectory()) continue;
1074
-
1075
- if (moduleEntry.name === openCodePackageName) {
1076
- const cachedPath = path.join(nodeModulesPath, openCodePackageName, "bin", hiddenCachedBinaryName);
1077
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1078
- continue;
1079
- }
1080
-
1081
- if (moduleEntry.name.startsWith("opencode-")) {
1082
- const binaryPath = path.join(nodeModulesPath, moduleEntry.name, "bin", binaryName);
1083
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1084
- continue;
1085
- }
1086
-
1087
- if (!moduleEntry.name.startsWith("@")) continue;
1088
- const scopePath = path.join(nodeModulesPath, moduleEntry.name);
1089
- let scopedPackages = [];
1090
- try {
1091
- scopedPackages = await fs.readdir(scopePath, { withFileTypes: true });
1092
- } catch {
1093
- continue;
1094
- }
1095
- for (const scopedPackage of scopedPackages) {
1096
- if (!scopedPackage.isDirectory()) continue;
1097
- const scopedNodeModulesPath = path.join(scopePath, scopedPackage.name);
1098
-
1099
- if (scopedPackage.name === "opencode-ai") {
1100
- const cachedPath = path.join(scopedNodeModulesPath, "bin", hiddenCachedBinaryName);
1101
- if (await _pathExists(cachedPath)) candidates.push(cachedPath);
1102
- continue;
1103
- }
1104
-
1105
- if (!scopedPackage.name.startsWith("opencode-")) continue;
1106
- const binaryPath = path.join(scopedNodeModulesPath, "bin", binaryName);
1107
- if (await _pathExists(binaryPath)) candidates.push(binaryPath);
1108
- }
1103
+ function _windowsNpxCliCandidates(execPath, env) {
1104
+ const candidates = [
1105
+ path.win32.join(
1106
+ path.win32.dirname(execPath),
1107
+ 'node_modules',
1108
+ 'npm',
1109
+ 'bin',
1110
+ 'npx-cli.js'
1111
+ ),
1112
+ ];
1113
+ const npmExecPath = env.npm_execpath || '';
1114
+ if (/npm-cli\.js$/i.test(npmExecPath)) {
1115
+ candidates.push(
1116
+ path.win32.join(path.win32.dirname(npmExecPath), 'npx-cli.js')
1117
+ );
1109
1118
  }
1110
-
1111
1119
  return candidates;
1112
1120
  }
1113
1121
 
1114
- async function _collectPnpmDlxOpencodeBinaries() {
1115
- const dlxRoots = [
1116
- path.join(os.homedir(), "Library", "Caches", "pnpm", "dlx"),
1117
- path.join(os.homedir(), ".cache", "pnpm", "dlx"),
1118
- path.join(process.env.LOCALAPPDATA || "", "pnpm", "dlx"),
1119
- ].filter(Boolean);
1120
-
1121
- const candidates = [];
1122
-
1123
- for (const root of dlxRoots) {
1124
- if (!(await _pathExists(root))) continue;
1125
- const pnpmDirs = await _collectPnpmDlxPnpmDirs(root);
1126
- for (const pnpmDir of pnpmDirs) {
1127
- let entries = [];
1128
- try {
1129
- entries = await fs.readdir(pnpmDir, { withFileTypes: true });
1130
- } catch {
1131
- continue;
1132
- }
1133
-
1134
- for (const entry of entries) {
1135
- if (!entry.isDirectory()) continue;
1136
- const nodeModulesPath = path.join(pnpmDir, entry.name, "node_modules");
1137
- if (!(await _pathExists(nodeModulesPath))) continue;
1138
-
1139
- candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesPath));
1140
- }
1141
- }
1142
- }
1143
-
1144
- return [...new Set(candidates)];
1145
- }
1146
-
1147
- async function _collectNpxOpencodeBinaries() {
1148
- const npxRoots = [
1149
- path.join(os.homedir(), ".npm", "_npx"),
1150
- path.join(process.env.LOCALAPPDATA || "", "npm-cache", "_npx"),
1151
- ].filter(Boolean);
1152
- const candidates = [];
1153
-
1154
- for (const root of npxRoots) {
1155
- if (!(await _pathExists(root))) continue;
1156
- const nodeModulesDirs = await _collectNodeModulesDirs(root);
1157
- for (const nodeModulesDir of nodeModulesDirs) {
1158
- candidates.push(...await _collectOpencodeBinariesFromNodeModules(nodeModulesDir));
1159
- }
1160
- }
1161
-
1162
- return [...new Set(candidates)];
1163
- }
1164
-
1165
- function _buildPackageWarmupArgs(packageName, runner) {
1166
- if (runner.command === _runnerCommand("pnpm")) {
1167
- return ["dlx", packageName, "--version"];
1168
- }
1169
- if (runner.command === _runnerCommand("npx")) {
1170
- return ["--yes", packageName, "--version"];
1171
- }
1172
- return null;
1173
- }
1174
-
1175
- async function _collectRunnerOpencodeBinaries(runner) {
1176
- if (runner.command === _runnerCommand("pnpm")) {
1177
- return _collectPnpmDlxOpencodeBinaries();
1178
- }
1179
- if (runner.command === _runnerCommand("npx")) {
1180
- return _collectNpxOpencodeBinaries();
1122
+ /**
1123
+ * Return the package runners to try, in order, for launching `packageName`.
1124
+ *
1125
+ * On Windows, pnpm/npx are installed as `.cmd` shims, and Node >= 18.20.2 /
1126
+ * 20.12.2 refuses to spawn batch files without a shell (CVE-2024-27980),
1127
+ * failing with `spawn EINVAL`. Windows runners are therefore real executables:
1128
+ * the standalone `pnpm.exe`, then npm's `npx-cli.js` run through the current
1129
+ * Node binary. A shell is deliberately avoided so user arguments never pass
1130
+ * through cmd.exe parsing.
1131
+ */
1132
+ export function resolvePackageRunners(
1133
+ packageName,
1134
+ args,
1135
+ {
1136
+ platform = process.platform,
1137
+ execPath = process.execPath,
1138
+ env = process.env,
1139
+ fileExists = existsSync,
1140
+ } = {}
1141
+ ) {
1142
+ if (platform !== 'win32') {
1143
+ return [
1144
+ { command: 'pnpm', args: ['dlx', packageName, ...args] },
1145
+ { command: 'npx', args: ['--yes', packageName, ...args] },
1146
+ ];
1181
1147
  }
1182
- return [];
1183
- }
1184
1148
 
1185
- async function _adHocSignBinary(binaryPath) {
1186
- if (process.platform !== "darwin") return;
1187
- try {
1188
- await runChild("codesign", ["--force", "--sign", "-", binaryPath], {
1189
- stdio: "ignore",
1149
+ const runners = [
1150
+ { command: 'pnpm.exe', args: ['dlx', packageName, ...args] },
1151
+ ];
1152
+ const npxCli = _windowsNpxCliCandidates(execPath, env).find((candidate) =>
1153
+ fileExists(candidate)
1154
+ );
1155
+ if (npxCli) {
1156
+ runners.push({
1157
+ command: execPath,
1158
+ args: [npxCli, '--yes', packageName, ...args],
1190
1159
  });
1191
- } catch {
1192
- // If ad-hoc signing fails, keep going; runtime may still work on systems that do not enforce signatures.
1193
- }
1194
- }
1195
-
1196
- async function _patchOpencodeBinaryBranding(binaryPath) {
1197
- let buffer;
1198
- try {
1199
- buffer = await fs.readFile(binaryPath);
1200
- } catch {
1201
- return false;
1202
- }
1203
-
1204
- const originalLength = buffer.length;
1205
- let text = buffer.toString("latin1");
1206
- const patch = patchZenocodeBinaryText(text);
1207
- text = patch.text;
1208
- const patched = patch.patched;
1209
-
1210
- if (!patched) {
1211
- return false;
1212
- }
1213
-
1214
- buffer = Buffer.from(text, "latin1");
1215
- if (buffer.length !== originalLength) {
1216
- return false;
1217
- }
1218
-
1219
- await fs.writeFile(binaryPath, buffer);
1220
- if (process.platform !== "win32") {
1221
- await fs.chmod(binaryPath, 0o755);
1222
- }
1223
- await _adHocSignBinary(binaryPath);
1224
- return true;
1225
- }
1226
-
1227
- async function _preparePinnedRuntimeBinary(binaryCandidates) {
1228
- if (!binaryCandidates.length) return null;
1229
- const runtimeBinaryPath = _runtimeBrandedBinaryPath();
1230
- const stats = await Promise.all(binaryCandidates.map(async (binaryPath) => ({
1231
- binaryPath,
1232
- mtimeMs: (await fs.stat(binaryPath)).mtimeMs,
1233
- })));
1234
- stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
1235
-
1236
- await fs.mkdir(path.dirname(runtimeBinaryPath), { recursive: true });
1237
- await fs.copyFile(stats[0].binaryPath, runtimeBinaryPath);
1238
- if (process.platform !== "win32") {
1239
- await fs.chmod(runtimeBinaryPath, 0o755);
1240
- }
1241
- await _patchOpencodeBinaryBranding(runtimeBinaryPath);
1242
- await _adHocSignBinary(runtimeBinaryPath);
1243
- return runtimeBinaryPath;
1244
- }
1245
-
1246
- async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
1247
- if (!shouldPatchOpencodeRuntimePackage(packageName)) {
1248
- return null;
1249
- }
1250
- if (
1251
- process.env.ZENOCODE_DISABLE_OPENCODE_LOGO_PATCH === "1" ||
1252
- process.env.CODECORTEX_DISABLE_OPENCODE_LOGO_PATCH === "1"
1253
- ) {
1254
- return null;
1255
- }
1256
-
1257
- const warmupArgs = _buildPackageWarmupArgs(packageName, runner);
1258
- try {
1259
- if (warmupArgs) {
1260
- await runChild(runner.command, warmupArgs, {
1261
- ...options,
1262
- stdio: "ignore",
1263
- });
1264
- }
1265
- } catch {
1266
- // ignore warm-up failures and continue with best effort patching
1267
1160
  }
1268
- const binaryCandidates = await _collectRunnerOpencodeBinaries(runner);
1269
-
1270
- if (!binaryCandidates.length) {
1271
- return null;
1272
- }
1273
-
1274
- let patchedCount = 0;
1275
- for (const binaryPath of binaryCandidates) {
1276
- const patched = await _patchOpencodeBinaryBranding(binaryPath);
1277
- if (patched) patchedCount += 1;
1278
- }
1279
-
1280
- if (patchedCount > 0) {
1281
- console.log(`Patched Zenocode branding in ${patchedCount} OpenCode runtime ${patchedCount > 1 ? "binaries" : "binary"}.`);
1282
- }
1283
-
1284
- return _preparePinnedRuntimeBinary(binaryCandidates);
1161
+ return runners;
1285
1162
  }
1286
1163
 
1287
- /**
1288
- * Mirror direct runtime launches so fallback package runners stay interactive.
1289
- */
1290
- export function buildPackageLauncherChildOptions(options, pinnedRuntimePath) {
1164
+ export function buildPackageLauncherChildOptions(options) {
1291
1165
  return {
1292
1166
  ...options,
1293
- stdio: "inherit",
1167
+ stdio: 'inherit',
1294
1168
  env: {
1295
1169
  ...(options.env || {}),
1296
- ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
1297
1170
  },
1298
1171
  };
1299
1172
  }
1300
1173
 
1301
- export function buildPackageLauncherInvocation({ runner, args, pinnedRuntimePath }) {
1302
- if (pinnedRuntimePath) {
1303
- return {
1304
- command: pinnedRuntimePath,
1305
- args,
1306
- usePinnedRuntime: true,
1307
- };
1308
- }
1309
-
1174
+ export function buildPackageLauncherInvocation({ runner }) {
1310
1175
  return {
1311
1176
  command: runner.command,
1312
1177
  args: runner.args,
1313
- usePinnedRuntime: false,
1314
1178
  };
1315
1179
  }
1316
1180
 
1317
- async function runPackageLauncher(packageName, args, options) {
1318
- const runners = [
1319
- { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
1320
- { command: _runnerCommand("npx"), args: ["--yes", packageName, ...args] },
1321
- ];
1181
+ async function runPackageLauncher(
1182
+ packageName,
1183
+ args,
1184
+ options,
1185
+ monitorRuntimeSession = false
1186
+ ) {
1187
+ const runners = resolvePackageRunners(packageName, args);
1322
1188
  const missingRunners = [];
1323
1189
 
1324
1190
  for (const runner of runners) {
1325
1191
  try {
1326
- let pinnedRuntimePath = await _ensurePatchedOpencodeDlxBinaries(packageName, runner, options);
1327
- if (!pinnedRuntimePath && shouldPatchOpencodeRuntimePackage(packageName)) {
1328
- const existingPinnedPath = _runtimeBrandedBinaryPath();
1329
- if (await _pathExists(existingPinnedPath)) {
1330
- pinnedRuntimePath = existingPinnedPath;
1331
- }
1332
- }
1333
-
1334
1192
  const invocation = buildPackageLauncherInvocation({
1335
1193
  runner,
1336
- args,
1337
- pinnedRuntimePath,
1338
1194
  });
1339
- const childOptions = buildPackageLauncherChildOptions(
1340
- options,
1341
- invocation.usePinnedRuntime ? null : pinnedRuntimePath,
1195
+ return await runRuntimeBinary(
1196
+ invocation.command,
1197
+ invocation.args,
1198
+ buildPackageLauncherChildOptions(options),
1199
+ monitorRuntimeSession
1342
1200
  );
1343
-
1344
- const result = await runChild(invocation.command, invocation.args, childOptions);
1345
- if (result.signal) {
1346
- process.kill(process.pid, result.signal);
1347
- return;
1348
- }
1349
- process.exit(result.code ?? 0);
1350
- return;
1351
1201
  } catch (error) {
1352
- if (error?.code === "ENOENT") {
1202
+ if (error?.code === 'ENOENT') {
1353
1203
  missingRunners.push(runner.command);
1354
1204
  continue;
1355
1205
  }
@@ -1358,48 +1208,11 @@ async function runPackageLauncher(packageName, args, options) {
1358
1208
  }
1359
1209
 
1360
1210
  throw new Error(
1361
- `No package runner found (${missingRunners.join(", ")}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`,
1211
+ `No package runner found (${missingRunners.join(', ')}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`
1362
1212
  );
1363
1213
  }
1364
1214
 
1365
- async function resolvePinnedRuntimeBinary(packageName, options) {
1366
- const runners = [
1367
- { command: _runnerCommand("pnpm"), args: ["dlx", packageName] },
1368
- { command: _runnerCommand("npx"), args: ["--yes", packageName] },
1369
- ];
1370
- const missingRunners = [];
1371
-
1372
- for (const runner of runners) {
1373
- try {
1374
- let pinnedRuntimePath = await _ensurePatchedOpencodeDlxBinaries(packageName, runner, options);
1375
- if (!pinnedRuntimePath && shouldPatchOpencodeRuntimePackage(packageName)) {
1376
- const existingPinnedPath = _runtimeBrandedBinaryPath();
1377
- if (await _pathExists(existingPinnedPath)) {
1378
- pinnedRuntimePath = existingPinnedPath;
1379
- }
1380
- }
1381
-
1382
- if (pinnedRuntimePath) {
1383
- return pinnedRuntimePath;
1384
- }
1385
- } catch (error) {
1386
- if (error?.code === "ENOENT") {
1387
- missingRunners.push(runner.command);
1388
- continue;
1389
- }
1390
- throw error;
1391
- }
1392
- }
1393
-
1394
- if (missingRunners.length === runners.length) {
1395
- throw new Error(
1396
- `No package runner found (${missingRunners.join(", ")}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`,
1397
- );
1398
- }
1399
-
1400
- return null;
1401
- }
1402
-
1215
+ /** Relaunch the runtime with a refreshed or newly issued session after auth expiry. */
1403
1216
  export async function runRuntimeWithSessionRecovery({
1404
1217
  args,
1405
1218
  baseUrl,
@@ -1440,7 +1253,7 @@ export async function runRuntimeWithSessionRecovery({
1440
1253
 
1441
1254
  if (recoveryAttempts >= maxRecoveryAttempts) {
1442
1255
  throw new Error(
1443
- `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`,
1256
+ `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`
1444
1257
  );
1445
1258
  }
1446
1259
  recoveryAttempts += 1;
@@ -1452,7 +1265,7 @@ export async function runRuntimeWithSessionRecovery({
1452
1265
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1453
1266
  const retryDelayMs = Math.min(
1454
1267
  Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1455
- 3_000,
1268
+ 3_000
1456
1269
  );
1457
1270
  if (retryDelayMs > 0) {
1458
1271
  await sleepFn(retryDelayMs);
@@ -1464,21 +1277,30 @@ export async function runRuntimeWithSessionRecovery({
1464
1277
  }
1465
1278
  }
1466
1279
 
1467
- console.log("Zenocode session expired. Starting login flow...\n");
1468
- await runLogin(activeBaseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1469
- preferLocalhost,
1470
- });
1471
- activeToken = await resolveTokenFn();
1472
- activeBaseUrl =
1473
- resolveTextCortexBaseUrl({
1474
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1475
- storedBaseUrl: await resolveStoredBaseUrlFn(),
1280
+ console.log('Zenocode session expired. Starting login flow...\n');
1281
+ const loginResult = await runLogin(
1282
+ activeBaseUrl,
1283
+ buildAutoLoginArgs({ preferLocalhost }),
1284
+ {
1476
1285
  preferLocalhost,
1477
- }) || activeBaseUrl;
1286
+ }
1287
+ );
1288
+ if (loginResult?.token && loginResult?.baseUrl) {
1289
+ activeToken = loginResult.token;
1290
+ activeBaseUrl = loginResult.baseUrl;
1291
+ } else {
1292
+ activeToken = await resolveTokenFn();
1293
+ activeBaseUrl =
1294
+ resolveTextCortexBaseUrl({
1295
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1296
+ storedBaseUrl: await resolveStoredBaseUrlFn(),
1297
+ preferLocalhost,
1298
+ }) || activeBaseUrl;
1299
+ }
1478
1300
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1479
1301
  const retryDelayMs = Math.min(
1480
1302
  Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1481
- 3_000,
1303
+ 3_000
1482
1304
  );
1483
1305
  if (retryDelayMs > 0) {
1484
1306
  await sleepFn(retryDelayMs);
@@ -1486,71 +1308,57 @@ export async function runRuntimeWithSessionRecovery({
1486
1308
  }
1487
1309
  }
1488
1310
 
1489
- async function packageExistsOnNpm(packageName) {
1490
- const packageSpecName = packageNameFromSpecifier(packageName);
1491
- const controller = new AbortController();
1492
- const timeout = setTimeout(() => controller.abort(), 4_000);
1493
- try {
1494
- const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageSpecName)}`, {
1495
- method: "GET",
1496
- headers: { Accept: "application/json" },
1497
- signal: controller.signal,
1498
- });
1499
- return response.ok;
1500
- } catch {
1501
- return false;
1502
- } finally {
1503
- clearTimeout(timeout);
1504
- }
1311
+ export function resolveLaunchPackage() {
1312
+ return opencodePackage;
1505
1313
  }
1506
1314
 
1507
- async function resolveLaunchPackage() {
1508
- if (configuredOpencodePackage) {
1509
- return configuredOpencodePackage;
1315
+ /** Return launcher-owned guidance for runtime package-management commands. */
1316
+ export function zenocodeOwnedCommandMessage(args) {
1317
+ if (args[0] === 'upgrade') {
1318
+ return [
1319
+ 'Zenocode updates are managed by the @textcortex/zenocode package.',
1320
+ 'Run `npm install -g @textcortex/zenocode@latest` to update Zenocode and its managed runtime together.',
1321
+ ].join('\n');
1510
1322
  }
1511
-
1512
- const brandedAvailable = await packageExistsOnNpm(defaultBrandedOpencodePackage);
1513
- if (brandedAvailable) {
1514
- return defaultBrandedOpencodePackage;
1323
+ if (args[0] === 'uninstall') {
1324
+ return 'Run `npm uninstall -g @textcortex/zenocode` to uninstall Zenocode.';
1515
1325
  }
1516
-
1517
- const legacyBrandedAvailable = await packageExistsOnNpm(
1518
- legacyBrandedOpencodePackage,
1519
- );
1520
- if (legacyBrandedAvailable) {
1521
- console.warn(
1522
- `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${legacyBrandedOpencodePackage}.`,
1523
- );
1524
- return legacyBrandedOpencodePackage;
1525
- }
1526
-
1527
- console.warn(
1528
- `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${fallbackOpencodePackage}.`,
1529
- );
1530
- return fallbackOpencodePackage;
1326
+ return null;
1531
1327
  }
1532
1328
 
1533
1329
  function shouldRenderBanner(args) {
1534
1330
  if (
1535
- process.env.ZENOCODE_NO_BANNER === "1" ||
1536
- process.env.CODECORTEX_NO_BANNER === "1"
1331
+ process.env.ZENOCODE_NO_BANNER === '1' ||
1332
+ process.env.CODECORTEX_NO_BANNER === '1'
1537
1333
  ) {
1538
1334
  return false;
1539
1335
  }
1540
1336
  if (
1541
1337
  !process.stdout.isTTY &&
1542
- process.env.ZENOCODE_FORCE_BANNER !== "1" &&
1543
- process.env.CODECORTEX_FORCE_BANNER !== "1"
1338
+ process.env.ZENOCODE_FORCE_BANNER !== '1' &&
1339
+ process.env.CODECORTEX_FORCE_BANNER !== '1'
1544
1340
  ) {
1545
1341
  return false;
1546
1342
  }
1547
1343
 
1548
- const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1344
+ const suppressFlags = new Set([
1345
+ '--help',
1346
+ '-h',
1347
+ '--version',
1348
+ '-v',
1349
+ 'completion',
1350
+ ]);
1549
1351
  return !args.some((arg) => suppressFlags.has(arg));
1550
1352
  }
1551
1353
 
1552
1354
  export function shouldBypassZenocodePreparation(args) {
1553
- const metadataFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1355
+ const metadataFlags = new Set([
1356
+ '--help',
1357
+ '-h',
1358
+ '--version',
1359
+ '-v',
1360
+ 'completion',
1361
+ ]);
1554
1362
  return metadataFlags.has(args[0]);
1555
1363
  }
1556
1364
 
@@ -1564,39 +1372,48 @@ function maybeRenderBanner(args) {
1564
1372
  function buildAutoLoginArgs({ preferLocalhost = false } = {}) {
1565
1373
  const loginArgs = [];
1566
1374
  if (preferLocalhost) {
1567
- loginArgs.push("--local");
1375
+ loginArgs.push('--local');
1568
1376
  }
1569
1377
  if (
1570
- process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1571
- process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
1378
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === '1' ||
1379
+ process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === '1'
1572
1380
  ) {
1573
- loginArgs.push("--no-launch-browser");
1381
+ loginArgs.push('--no-launch-browser');
1574
1382
  }
1575
1383
  return loginArgs;
1576
1384
  }
1577
1385
 
1578
1386
  function isMissingTokenError(error) {
1579
1387
  const message = error instanceof Error ? error.message : String(error);
1580
- return message.includes("Missing API token");
1388
+ return message.includes('Missing API token');
1581
1389
  }
1582
1390
 
1583
- function isExpiredSessionError(error) {
1391
+ /** Return whether a models 401 can be repaired by refreshing or replacing auth. */
1392
+ export function isRecoverableSessionError(error) {
1584
1393
  const message = error instanceof Error ? error.message : String(error);
1585
- if (!message.includes("Models fetch failed (401)")) return false;
1586
- return /token has expired|expired token|token expired/i.test(message);
1394
+ if (!message.includes('Models fetch failed (401)')) return false;
1395
+ return /token has expired|expired token|token expired|invalid token format/i.test(
1396
+ message
1397
+ );
1587
1398
  }
1588
1399
 
1589
1400
  function canAutoLogin(args) {
1590
1401
  if (
1591
- process.env.ZENOCODE_AUTO_LOGIN === "0" ||
1592
- process.env.CODECORTEX_AUTO_LOGIN === "0"
1402
+ process.env.ZENOCODE_AUTO_LOGIN === '0' ||
1403
+ process.env.CODECORTEX_AUTO_LOGIN === '0'
1593
1404
  ) {
1594
1405
  return false;
1595
1406
  }
1596
1407
  if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
1597
- if (process.env.CI === "1" || process.env.CI === "true") return false;
1598
-
1599
- const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
1408
+ if (process.env.CI === '1' || process.env.CI === 'true') return false;
1409
+
1410
+ const suppressFlags = new Set([
1411
+ '--help',
1412
+ '-h',
1413
+ '--version',
1414
+ '-v',
1415
+ 'completion',
1416
+ ]);
1600
1417
  if (args.some((arg) => suppressFlags.has(arg))) return false;
1601
1418
  return true;
1602
1419
  }
@@ -1605,16 +1422,16 @@ const ansiSequencePattern =
1605
1422
  /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
1606
1423
 
1607
1424
  function _stripTerminalControlCharacters(text) {
1608
- let normalized = "";
1425
+ let normalized = '';
1609
1426
  for (const char of text) {
1610
- if (char === "\b" || char === "\u007f") {
1427
+ if (char === '\b' || char === '\u007f') {
1611
1428
  normalized = normalized.slice(0, -1);
1612
1429
  continue;
1613
1430
  }
1614
- if (char === "\r") {
1431
+ if (char === '\r') {
1615
1432
  continue;
1616
1433
  }
1617
- if (char < " " && char !== "\n" && char !== "\t") {
1434
+ if (char < ' ' && char !== '\n' && char !== '\t') {
1618
1435
  continue;
1619
1436
  }
1620
1437
  normalized += char;
@@ -1623,13 +1440,17 @@ function _stripTerminalControlCharacters(text) {
1623
1440
  }
1624
1441
 
1625
1442
  export function normalizeRuntimeTranscript(text) {
1626
- return _stripTerminalControlCharacters(text.replaceAll(ansiSequencePattern, ""));
1443
+ return _stripTerminalControlCharacters(
1444
+ text.replaceAll(ansiSequencePattern, '')
1445
+ );
1627
1446
  }
1628
1447
 
1629
1448
  export function canRecoverRuntimeSessionFromTranscript(text) {
1630
1449
  const normalized = normalizeRuntimeTranscript(text);
1631
1450
  return (
1632
- /Unauthorized:\s*\{\s*"detail"\s*:\s*"Token has expired"\s*\}/i.test(normalized) ||
1451
+ /Unauthorized:\s*\{\s*"detail"\s*:\s*"Token has expired"\s*\}/i.test(
1452
+ normalized
1453
+ ) ||
1633
1454
  /authentication token may be missing or expired/i.test(normalized) ||
1634
1455
  /server returned 401 after successful authentication/i.test(normalized)
1635
1456
  );
@@ -1645,6 +1466,7 @@ function shouldAttemptAutoLogin(error, args) {
1645
1466
  return canAutoLogin(args);
1646
1467
  }
1647
1468
 
1469
+ /** Resolve credentials, returning the exact session produced by automatic login. */
1648
1470
  async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1649
1471
  const preferLocalhost = options.preferLocalhost === true;
1650
1472
  try {
@@ -1655,27 +1477,23 @@ async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1655
1477
  throw error;
1656
1478
  }
1657
1479
 
1658
- console.log("No local Zenocode credentials found. Starting login flow...\n");
1659
- await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1660
- preferLocalhost,
1661
- });
1662
- const token = await resolveToken();
1663
- const persistedBaseUrl = resolveTextCortexBaseUrl({
1664
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1665
- storedBaseUrl: await resolveStoredBaseUrl(),
1480
+ console.log(
1481
+ 'No local Zenocode credentials found. Starting login flow...\n'
1482
+ );
1483
+ return runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1666
1484
  preferLocalhost,
1667
1485
  });
1668
- return { token, baseUrl: persistedBaseUrl };
1669
1486
  }
1670
1487
  }
1671
1488
 
1489
+ /** Prepare the runtime, refreshing or replacing rejected credentials when possible. */
1672
1490
  async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1673
1491
  const preferLocalhost = options.preferLocalhost === true;
1674
1492
  try {
1675
1493
  const model = await prepareRuntime(baseUrl, token);
1676
1494
  return { model, token, baseUrl };
1677
1495
  } catch (error) {
1678
- if (!isExpiredSessionError(error)) {
1496
+ if (!isRecoverableSessionError(error)) {
1679
1497
  throw error;
1680
1498
  }
1681
1499
 
@@ -1689,33 +1507,31 @@ async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1689
1507
  };
1690
1508
  } catch {
1691
1509
  if (!canAutoLogin(args)) {
1692
- throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1510
+ throw new Error(
1511
+ 'Zenocode session expired. Run `zenocode login` and try again.'
1512
+ );
1693
1513
  }
1694
1514
  }
1695
1515
 
1696
- console.log("Zenocode session expired. Starting login flow...\n");
1697
- await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1698
- preferLocalhost,
1699
- });
1700
- const refreshedToken = await resolveToken();
1701
- const refreshedBaseUrl = resolveTextCortexBaseUrl({
1702
- envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1703
- storedBaseUrl: await resolveStoredBaseUrl(),
1704
- preferLocalhost,
1705
- });
1706
- const model = await prepareRuntime(refreshedBaseUrl, refreshedToken);
1707
- return { model, token: refreshedToken, baseUrl: refreshedBaseUrl };
1516
+ console.log('Zenocode session expired. Starting login flow...\n');
1517
+ const loginResult = await runLoginCommand(
1518
+ baseUrl,
1519
+ buildAutoLoginArgs({ preferLocalhost }),
1520
+ { preferLocalhost }
1521
+ );
1522
+ const model = await prepareRuntime(loginResult.baseUrl, loginResult.token);
1523
+ return { model, token: loginResult.token, baseUrl: loginResult.baseUrl };
1708
1524
  }
1709
1525
  }
1710
1526
 
1711
1527
  async function main() {
1712
1528
  const args = process.argv.slice(2);
1713
- const prepareOnly = args.includes("--prepare-only");
1714
- const passthrough = args.filter((arg) => arg !== "--prepare-only");
1529
+ const prepareOnly = args.includes('--prepare-only');
1530
+ const passthrough = args.filter((arg) => arg !== '--prepare-only');
1715
1531
 
1716
1532
  // `pnpm run <script> -- ...` can forward a leading `--`; drop it so subcommands
1717
1533
  // like `run` / `models` / `login` are parsed here first.
1718
- if (passthrough[0] === "--") {
1534
+ if (passthrough[0] === '--') {
1719
1535
  passthrough.shift();
1720
1536
  }
1721
1537
 
@@ -1728,42 +1544,54 @@ async function main() {
1728
1544
  preferLocalhost,
1729
1545
  });
1730
1546
  const subcommand = runtimeArgs[0];
1547
+ let loginResult = null;
1731
1548
 
1732
- if (subcommand === "login") {
1733
- await runLoginCommand(
1549
+ const ownedCommandMessage = zenocodeOwnedCommandMessage(runtimeArgs);
1550
+ if (ownedCommandMessage) {
1551
+ console.log(ownedCommandMessage);
1552
+ return;
1553
+ }
1554
+
1555
+ if (subcommand === 'login') {
1556
+ loginResult = await runLoginCommand(
1734
1557
  resolveLoginBaseUrl({
1735
1558
  envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1736
1559
  preferLocalhost,
1737
1560
  }),
1738
1561
  runtimeArgs.slice(1),
1739
- { preferLocalhost },
1562
+ { preferLocalhost }
1740
1563
  );
1741
1564
  runtimeArgs = [];
1742
1565
  }
1743
1566
 
1744
- if (subcommand === "logout") {
1567
+ if (subcommand === 'logout') {
1745
1568
  await runLogoutCommand();
1746
1569
  return;
1747
1570
  }
1748
1571
 
1749
1572
  maybeRenderBanner(runtimeArgs);
1750
1573
  if (shouldBypassZenocodePreparation(runtimeArgs)) {
1751
- const launchPackage = await resolveLaunchPackage();
1752
- await runPackageLauncher(launchPackage, runtimeArgs, {
1574
+ const launchPackage = resolveLaunchPackage();
1575
+ const result = await runPackageLauncher(launchPackage, runtimeArgs, {
1753
1576
  cwd: process.cwd(),
1754
1577
  env: { ...process.env },
1755
1578
  });
1579
+ exitWithChildResult(result);
1756
1580
  return;
1757
1581
  }
1758
1582
 
1759
- const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1760
- preferLocalhost,
1761
- });
1583
+ const tokenResolution =
1584
+ loginResult ||
1585
+ (await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1586
+ preferLocalhost,
1587
+ }));
1762
1588
  const runtime = await prepareRuntimeWithAutoLogin(
1763
1589
  tokenResolution.baseUrl,
1764
1590
  tokenResolution.token,
1765
1591
  runtimeArgs,
1766
- { preferLocalhost },
1592
+ {
1593
+ preferLocalhost,
1594
+ }
1767
1595
  );
1768
1596
  const token = runtime.token;
1769
1597
  const model = runtime.model;
@@ -1778,6 +1606,7 @@ async function main() {
1778
1606
  OPENCODE_MODELS_PATH: modelsPath,
1779
1607
  OPENCODE_CONFIG: configPath,
1780
1608
  OPENCODE_TUI_CONFIG: tuiConfigPath,
1609
+ OPENCODE_DISABLE_AUTOUPDATE: 'true',
1781
1610
  TEXTCORTEX_API_KEY: token,
1782
1611
  },
1783
1612
  };
@@ -1792,29 +1621,34 @@ async function main() {
1792
1621
  preferLocalhost,
1793
1622
  canAutoLoginRuntime: monitorRuntimeSession,
1794
1623
  launchRuntimeFn: ({ args, childOptions }) =>
1795
- runRuntimeBinary(opencodeBinaryPath, args, childOptions, monitorRuntimeSession),
1624
+ runRuntimeBinary(
1625
+ opencodeBinaryPath,
1626
+ args,
1627
+ childOptions,
1628
+ monitorRuntimeSession
1629
+ ),
1796
1630
  });
1797
1631
  exitWithChildResult(result);
1798
1632
  return;
1799
1633
  }
1800
1634
 
1801
- const launchPackage = await resolveLaunchPackage();
1802
- const pinnedRuntimePath = await resolvePinnedRuntimeBinary(launchPackage, childOptions);
1803
- if (pinnedRuntimePath) {
1804
- const result = await runRuntimeWithSessionRecovery({
1805
- args: runtimeArgs,
1806
- baseUrl: runtime.baseUrl,
1807
- token,
1808
- childOptions,
1809
- preferLocalhost,
1810
- canAutoLoginRuntime: monitorRuntimeSession,
1811
- launchRuntimeFn: ({ args, childOptions }) =>
1812
- runRuntimeBinary(pinnedRuntimePath, args, childOptions, monitorRuntimeSession),
1813
- });
1814
- exitWithChildResult(result);
1815
- return;
1816
- }
1817
- await runPackageLauncher(launchPackage, runtimeArgs, childOptions);
1635
+ const launchPackage = resolveLaunchPackage();
1636
+ const result = await runRuntimeWithSessionRecovery({
1637
+ args: runtimeArgs,
1638
+ baseUrl: runtime.baseUrl,
1639
+ token,
1640
+ childOptions,
1641
+ preferLocalhost,
1642
+ canAutoLoginRuntime: monitorRuntimeSession,
1643
+ launchRuntimeFn: ({ args, childOptions }) =>
1644
+ runPackageLauncher(
1645
+ launchPackage,
1646
+ args,
1647
+ childOptions,
1648
+ monitorRuntimeSession
1649
+ ),
1650
+ });
1651
+ exitWithChildResult(result);
1818
1652
  }
1819
1653
 
1820
1654
  const resolveExecutablePath = (value) => {
@@ -1825,7 +1659,10 @@ const resolveExecutablePath = (value) => {
1825
1659
  }
1826
1660
  };
1827
1661
 
1828
- if (process.argv[1] && resolveExecutablePath(process.argv[1]) === currentFilePath) {
1662
+ if (
1663
+ process.argv[1] &&
1664
+ resolveExecutablePath(process.argv[1]) === currentFilePath
1665
+ ) {
1829
1666
  main().catch((error) => {
1830
1667
  console.error(error instanceof Error ? error.message : String(error));
1831
1668
  process.exit(1);