@textcortex/zenocode 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/zenocode",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Secure, EU-hosted coding agent for TextCortex customers that runs in your terminal, edits files, runs scripts, and more.",
5
5
  "keywords": [
6
6
  "ai",
@@ -237,6 +237,14 @@ async function packPackage(packageDir) {
237
237
  return path.join(packageDir, tarballs[tarballs.length - 1]);
238
238
  }
239
239
 
240
+ export function buildPublishCommandArgs(tarballPath, { tag } = {}) {
241
+ const args = ["publish", tarballPath, "--access", "public"];
242
+ if (tag) {
243
+ args.push("--tag", tag);
244
+ }
245
+ return args;
246
+ }
247
+
240
248
  async function publishTarballIfNeeded(packageName, version, tarballPath, cwd) {
241
249
  if (!publishEnabled) return { published: false, skipped: false };
242
250
 
@@ -254,11 +262,7 @@ async function publishTarballIfNeeded(packageName, version, tarballPath, cwd) {
254
262
  }
255
263
 
256
264
  const npmCommand = _command("npm");
257
- await run(
258
- npmCommand,
259
- ["publish", tarballPath, "--access", "public", "--tag", publishTag, "--provenance"],
260
- { cwd },
261
- );
265
+ await run(npmCommand, buildPublishCommandArgs(tarballPath, { tag: publishTag }), { cwd });
262
266
  return { published: true, skipped: false };
263
267
  }
264
268
 
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
+ buildPublishCommandArgs,
4
5
  buildWrapperBinMap,
5
6
  mapBrandedBinaryPackageName,
6
7
  } from "./build-branded-opencode.mjs";
@@ -23,3 +24,23 @@ test("buildWrapperBinMap includes package, opencode, and zenocode entrypoints",
23
24
  zenocode: "./bin/opencode",
24
25
  });
25
26
  });
27
+
28
+ test("buildPublishCommandArgs omits npm provenance for runtime tarball publishing", () => {
29
+ assert.deepEqual(buildPublishCommandArgs("package.tgz", { tag: "latest" }), [
30
+ "publish",
31
+ "package.tgz",
32
+ "--access",
33
+ "public",
34
+ "--tag",
35
+ "latest",
36
+ ]);
37
+ });
38
+
39
+ test("buildPublishCommandArgs omits tag arguments when no tag is provided", () => {
40
+ assert.deepEqual(buildPublishCommandArgs("package.tgz"), [
41
+ "publish",
42
+ "package.tgz",
43
+ "--access",
44
+ "public",
45
+ ]);
46
+ });
@@ -26,10 +26,12 @@ const legacyRuntimeCredentialsPath = path.join(
26
26
  legacyRuntimeDir,
27
27
  "credentials.json",
28
28
  );
29
+ const logoutMarkerPath = path.join(runtimeDir, "logout-marker.json");
29
30
  const modelsPath = path.join(runtimeDir, "models.json");
30
31
  const configPath = path.join(runtimeDir, "opencode.jsonc");
31
32
  const localBaseUrlDefault = "http://127.0.0.1:8080";
32
33
  const cloudBaseUrlDefault = "https://api.textcortex.com";
34
+ const localBaseUrlFlags = new Set(["--local", "--localhost"]);
33
35
 
34
36
  const providerID = "textcortex";
35
37
  const configuredOpencodePackage =
@@ -45,8 +47,8 @@ const opencodeBinaryPath =
45
47
  process.env.ZENOCODE_OPENCODE_BIN_PATH ||
46
48
  process.env.CODECORTEX_OPENCODE_BIN_PATH ||
47
49
  "";
48
- const oauthInitiatePath = "/internal/v1/fastapi/codecortex/oauth2/initiate";
49
- const oauthTokenPath = "/internal/v1/fastapi/codecortex/oauth2/token";
50
+ const oauthInitiatePath = "/internal/v1/fastapi/zenocode/oauth2/initiate";
51
+ const oauthTokenPath = "/internal/v1/fastapi/zenocode/oauth2/token";
50
52
  const defaultOrder = [
51
53
  "kimi-k2-5-thinking",
52
54
  "glm-5",
@@ -63,12 +65,15 @@ const devCredentialFiles =
63
65
  path.join(process.cwd(), ".credentials.json"),
64
66
  ]
65
67
  : [];
66
- const credentialFiles = [
68
+ const runtimeCredentialFiles = [
67
69
  runtimeCredentialsPath,
68
70
  legacyRuntimeCredentialsPath,
71
+ ].filter((value, index, values) => values.indexOf(value) === index);
72
+ const fallbackCredentialFiles = [
69
73
  path.join(os.homedir(), ".credentials.json"),
70
74
  ...devCredentialFiles,
71
75
  ].filter((value, index, values) => values.indexOf(value) === index);
76
+ const credentialFiles = [...runtimeCredentialFiles, ...fallbackCredentialFiles];
72
77
 
73
78
  function _decodeEscapedLogoLine(line) {
74
79
  return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, codePoint) =>
@@ -116,6 +121,28 @@ export async function writePrivateJsonFile(filePath, payload) {
116
121
  }
117
122
  }
118
123
 
124
+ async function clearLogoutMarker() {
125
+ try {
126
+ await fs.unlink(logoutMarkerPath);
127
+ } catch (error) {
128
+ if (error?.code !== "ENOENT") {
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+
134
+ async function writeLogoutMarker() {
135
+ await ensurePrivateDirectory(path.dirname(logoutMarkerPath));
136
+ await fs.writeFile(
137
+ logoutMarkerPath,
138
+ `${JSON.stringify({ logged_out_at: new Date().toISOString() }, null, 2)}\n`,
139
+ { encoding: "utf-8", mode: privateFileMode },
140
+ );
141
+ if (process.platform !== "win32") {
142
+ await fs.chmod(logoutMarkerPath, privateFileMode);
143
+ }
144
+ }
145
+
119
146
  function extractTokenFromCredentialPayload(parsed) {
120
147
  if (!parsed) return null;
121
148
  if (typeof parsed?.access_token === "string" && parsed.access_token) {
@@ -150,13 +177,191 @@ function extractBaseUrlFromCredentialPayload(parsed) {
150
177
  return null;
151
178
  }
152
179
 
180
+ export function resolveLoginSuccessIdentifier(tokenData) {
181
+ const authId =
182
+ typeof tokenData?.auth_id === "string" ? tokenData.auth_id.trim() : "";
183
+ return authId || "unknown";
184
+ }
185
+
186
+ function _isCredentialEntry(entry) {
187
+ return Boolean(
188
+ entry &&
189
+ typeof entry === "object" &&
190
+ !Array.isArray(entry) &&
191
+ (typeof entry.access_token === "string" ||
192
+ typeof entry.refresh_token === "string"),
193
+ );
194
+ }
195
+
196
+ function _selectCredentialEntry(parsed) {
197
+ if (_isCredentialEntry(parsed)) {
198
+ return {
199
+ entry: parsed,
200
+ apply(updatedEntry) {
201
+ return { ...parsed, ...updatedEntry };
202
+ },
203
+ };
204
+ }
205
+
206
+ if (Array.isArray(parsed?.accounts)) {
207
+ const index = parsed.accounts.findIndex((entry) => _isCredentialEntry(entry));
208
+ if (index !== -1) {
209
+ return {
210
+ entry: parsed.accounts[index],
211
+ apply(updatedEntry) {
212
+ const accounts = [...parsed.accounts];
213
+ accounts[index] = { ...accounts[index], ...updatedEntry };
214
+ return { ...parsed, accounts };
215
+ },
216
+ };
217
+ }
218
+ }
219
+
220
+ if (Array.isArray(parsed)) {
221
+ const index = parsed.findIndex((entry) => _isCredentialEntry(entry));
222
+ if (index !== -1) {
223
+ return {
224
+ entry: parsed[index],
225
+ apply(updatedEntry) {
226
+ const next = [...parsed];
227
+ next[index] = { ...next[index], ...updatedEntry };
228
+ return next;
229
+ },
230
+ };
231
+ }
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ async function loadStoredCredentialState(filePath) {
238
+ const parsed = await readJson(filePath);
239
+ const selected = _selectCredentialEntry(parsed);
240
+ if (!selected) {
241
+ return null;
242
+ }
243
+ return {
244
+ filePath,
245
+ parsed,
246
+ entry: selected.entry,
247
+ apply: selected.apply,
248
+ };
249
+ }
250
+
251
+ async function resolveCredentialFiles() {
252
+ if (await _pathExists(logoutMarkerPath)) {
253
+ return runtimeCredentialFiles;
254
+ }
255
+ return credentialFiles;
256
+ }
257
+
258
+ async function findStoredCredentialState() {
259
+ for (const filePath of await resolveCredentialFiles()) {
260
+ const state = await loadStoredCredentialState(filePath);
261
+ if (state) {
262
+ return state;
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ function _decodeBase64Url(value) {
269
+ const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
270
+ const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
271
+ return Buffer.from(`${normalized}${padding}`, "base64").toString("utf-8");
272
+ }
273
+
274
+ function extractAccessTokenExpiry(accessToken) {
275
+ try {
276
+ const [, payloadSegment] = String(accessToken || "").split(".");
277
+ if (!payloadSegment) return null;
278
+ const payload = JSON.parse(_decodeBase64Url(payloadSegment));
279
+ const exp = Number(payload?.exp);
280
+ if (!Number.isFinite(exp) || exp <= 0) {
281
+ return null;
282
+ }
283
+ return new Date(exp * 1000).toISOString();
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ async function exchangeRefreshToken(baseUrl, refreshToken) {
290
+ const refreshUrl = new URL("/auth/token/refresh/", baseUrl).toString();
291
+ const { response, payload, text } = await requestJson(refreshUrl, {
292
+ method: "POST",
293
+ headers: {
294
+ Authorization: `Bearer ${refreshToken}`,
295
+ Accept: "application/json",
296
+ "Content-Type": "application/json",
297
+ },
298
+ });
299
+
300
+ if (!response.ok) {
301
+ const message = extractErrorMessage(payload, text || "request failed");
302
+ throw new Error(`Token refresh failed (${response.status}): ${message}`);
303
+ }
304
+
305
+ const data = unwrapData(payload);
306
+ if (!data?.access_token) {
307
+ throw new Error("Token refresh failed: invalid response payload.");
308
+ }
309
+
310
+ return data;
311
+ }
312
+
313
+ async function refreshStoredCredentials(baseUrl) {
314
+ const state = await findStoredCredentialState();
315
+ const refreshToken =
316
+ typeof state?.entry?.refresh_token === "string" && state.entry.refresh_token
317
+ ? state.entry.refresh_token
318
+ : null;
319
+
320
+ if (!state || !refreshToken) {
321
+ throw new Error("No refresh token available.");
322
+ }
323
+
324
+ const resolvedBaseUrl =
325
+ process.env.TEXTCORTEX_BASE_URL ||
326
+ (typeof state.entry.base_url === "string" && state.entry.base_url) ||
327
+ baseUrl;
328
+ if (!resolvedBaseUrl) {
329
+ throw new Error("Missing base URL for token refresh.");
330
+ }
331
+
332
+ const refreshed = await exchangeRefreshToken(resolvedBaseUrl, refreshToken);
333
+ const updatedEntry = {
334
+ ...state.entry,
335
+ base_url: resolvedBaseUrl,
336
+ access_token: refreshed.access_token,
337
+ refresh_token:
338
+ typeof refreshed.refresh_token === "string" && refreshed.refresh_token
339
+ ? refreshed.refresh_token
340
+ : refreshToken,
341
+ auth_id: refreshed.auth_id || state.entry.auth_id || null,
342
+ email: refreshed.email || state.entry.email || null,
343
+ expires_at:
344
+ refreshed.expires_at ||
345
+ extractAccessTokenExpiry(refreshed.access_token) ||
346
+ state.entry.expires_at ||
347
+ null,
348
+ updated_at: new Date().toISOString(),
349
+ };
350
+ await writePrivateJsonFile(state.filePath, state.apply(updatedEntry));
351
+
352
+ return {
353
+ token: updatedEntry.access_token,
354
+ baseUrl: resolvedBaseUrl,
355
+ };
356
+ }
357
+
153
358
  async function resolveToken() {
154
359
  const envToken = process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
155
360
  if (envToken) return envToken;
156
361
 
157
- for (const filePath of credentialFiles) {
158
- const parsed = await readJson(filePath);
159
- const token = extractTokenFromCredentialPayload(parsed);
362
+ for (const filePath of await resolveCredentialFiles()) {
363
+ const state = await loadStoredCredentialState(filePath);
364
+ const token = extractTokenFromCredentialPayload(state?.parsed);
160
365
  if (token) return token;
161
366
  }
162
367
 
@@ -195,7 +400,7 @@ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel })
195
400
  [providerID]: {
196
401
  name: "Zenocode",
197
402
  options: {
198
- baseURL: new URL("/internal/v1/fastapi/codecortex/v1", baseUrl).toString(),
403
+ baseURL: new URL("/internal/v1/fastapi/zenocode/v1", baseUrl).toString(),
199
404
  },
200
405
  },
201
406
  // Older fallback opencode-ai builds can load the Codex auth plugin when
@@ -249,7 +454,7 @@ async function requestJson(url, init) {
249
454
  }
250
455
 
251
456
  async function prepareRuntime(baseUrl, token) {
252
- const modelsUrl = new URL("/internal/v1/fastapi/codecortex/models/api.json", baseUrl).toString();
457
+ const modelsUrl = new URL("/internal/v1/fastapi/zenocode/models/api.json", baseUrl).toString();
253
458
  const { response, payload, text } = await requestJson(modelsUrl, {
254
459
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
255
460
  });
@@ -288,6 +493,15 @@ function sleep(ms) {
288
493
  return new Promise((resolve) => setTimeout(resolve, ms));
289
494
  }
290
495
 
496
+ export function hasLocalBaseUrlFlag(args = []) {
497
+ const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
498
+ return normalizedArgs.some((arg) => localBaseUrlFlags.has(arg));
499
+ }
500
+
501
+ function stripLocalBaseUrlFlags(args = []) {
502
+ return args.filter((arg) => !localBaseUrlFlags.has(arg));
503
+ }
504
+
291
505
  async function parseLoginArgs(args) {
292
506
  const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
293
507
  let emailHint = process.env.TEXTCORTEX_LOGIN_EMAIL || null;
@@ -295,6 +509,9 @@ async function parseLoginArgs(args) {
295
509
 
296
510
  for (let idx = 0; idx < normalizedArgs.length; idx += 1) {
297
511
  const arg = normalizedArgs[idx];
512
+ if (localBaseUrlFlags.has(arg)) {
513
+ continue;
514
+ }
298
515
  if (arg === "--no-launch-browser") {
299
516
  launchBrowser = false;
300
517
  continue;
@@ -311,6 +528,7 @@ async function parseLoginArgs(args) {
311
528
  if (arg === "--help" || arg === "-h") {
312
529
  console.log("Zenocode login options:");
313
530
  console.log(" --email <address> Optional email hint for tenant/SSO routing");
531
+ console.log(` --local Use the local FastAPI backend at ${localBaseUrlDefault}`);
314
532
  console.log(" --no-launch-browser Do not open browser automatically");
315
533
  process.exit(0);
316
534
  }
@@ -339,10 +557,29 @@ function isLoginRouteNotFoundError(error) {
339
557
  return message.includes("Login initiate failed (404)");
340
558
  }
341
559
 
560
+ export function shouldFallbackLoginToCloud({ baseUrl, hasExplicitBaseUrl, error }) {
561
+ if (hasExplicitBaseUrl || baseUrl !== localBaseUrlDefault) {
562
+ return false;
563
+ }
564
+
565
+ return isFetchFailedError(error) || isLoginRouteNotFoundError(error);
566
+ }
567
+
568
+ export function resolveTextCortexBaseUrl({
569
+ envBaseUrl,
570
+ storedBaseUrl,
571
+ preferLocalhost = false,
572
+ } = {}) {
573
+ if (preferLocalhost) {
574
+ return localBaseUrlDefault;
575
+ }
576
+ return envBaseUrl || storedBaseUrl || cloudBaseUrlDefault;
577
+ }
578
+
342
579
  function _loginConnectivityHelp(baseUrl) {
343
580
  return [
344
581
  `Cannot reach Zenocode auth endpoint at ${baseUrl}.`,
345
- "Run local backend FastAPI (`cd backend && uv run dev_fastapi`) or set TEXTCORTEX_BASE_URL to a reachable backend API.",
582
+ `Set TEXTCORTEX_BASE_URL to ${cloudBaseUrlDefault} or, for local development, run local backend FastAPI (\`cd backend && uv run dev_fastapi\`).`,
346
583
  ].join(" ");
347
584
  }
348
585
 
@@ -441,24 +678,30 @@ async function saveRuntimeCredentials(baseUrl, tokenData) {
441
678
  refresh_token: tokenData.refresh_token,
442
679
  auth_id: tokenData.auth_id || null,
443
680
  email: tokenData.email || null,
444
- expires_at: tokenData.expires_at || null,
681
+ expires_at:
682
+ tokenData.expires_at ||
683
+ extractAccessTokenExpiry(tokenData.access_token) ||
684
+ null,
445
685
  updated_at: new Date().toISOString(),
446
686
  };
447
687
  await writePrivateJsonFile(runtimeCredentialsPath, payload);
688
+ await clearLogoutMarker();
448
689
  }
449
690
 
450
- async function runLoginCommand(baseUrl, args) {
691
+ async function runLoginCommand(baseUrl, args, options = {}) {
692
+ const preferLocalhost = options.preferLocalhost === true;
451
693
  const { emailHint, launchBrowser } = await parseLoginArgs(args);
452
- let resolvedBaseUrl = baseUrl;
694
+ let resolvedBaseUrl = preferLocalhost ? localBaseUrlDefault : baseUrl;
453
695
  let login;
454
696
  try {
455
697
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
456
698
  } catch (error) {
457
- if (
458
- !process.env.TEXTCORTEX_BASE_URL &&
459
- resolvedBaseUrl === localBaseUrlDefault &&
460
- isFetchFailedError(error)
461
- ) {
699
+ if (shouldFallbackLoginToCloud({
700
+ baseUrl: resolvedBaseUrl,
701
+ hasExplicitBaseUrl:
702
+ Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
703
+ error,
704
+ })) {
462
705
  resolvedBaseUrl = cloudBaseUrlDefault;
463
706
  console.log(
464
707
  `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`,
@@ -503,17 +746,14 @@ async function runLoginCommand(baseUrl, args) {
503
746
  );
504
747
  await saveRuntimeCredentials(resolvedBaseUrl, tokenData);
505
748
 
506
- const account = tokenData.email || tokenData.auth_id || "unknown";
749
+ const account = resolveLoginSuccessIdentifier(tokenData);
507
750
  console.log(`Login successful for ${account}.`);
508
751
  console.log(`Credentials saved to ${runtimeCredentialsPath}`);
509
752
  }
510
753
 
511
754
  async function runLogoutCommand() {
512
755
  let removedAnyCredentials = false;
513
- for (const credentialsPath of [
514
- runtimeCredentialsPath,
515
- legacyRuntimeCredentialsPath,
516
- ].filter((value, index, values) => values.indexOf(value) === index)) {
756
+ for (const credentialsPath of runtimeCredentialFiles) {
517
757
  try {
518
758
  await fs.unlink(credentialsPath);
519
759
  removedAnyCredentials = true;
@@ -525,8 +765,15 @@ async function runLogoutCommand() {
525
765
  }
526
766
  }
527
767
 
528
- if (removedAnyCredentials) {
529
- console.log("Zenocode credentials removed.");
768
+ const fallbackCredentialsPresent = (
769
+ await Promise.all(
770
+ fallbackCredentialFiles.map((credentialsPath) => _pathExists(credentialsPath)),
771
+ )
772
+ ).some(Boolean);
773
+
774
+ if (removedAnyCredentials || fallbackCredentialsPresent) {
775
+ await writeLogoutMarker();
776
+ console.log("Zenocode session cleared.");
530
777
  return;
531
778
  }
532
779
 
@@ -915,6 +1162,20 @@ async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
915
1162
  return _preparePinnedRuntimeBinary(binaryCandidates);
916
1163
  }
917
1164
 
1165
+ /**
1166
+ * Mirror direct runtime launches so fallback package runners stay interactive.
1167
+ */
1168
+ export function buildPackageLauncherChildOptions(options, pinnedRuntimePath) {
1169
+ return {
1170
+ ...options,
1171
+ stdio: "inherit",
1172
+ env: {
1173
+ ...(options.env || {}),
1174
+ ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
1175
+ },
1176
+ };
1177
+ }
1178
+
918
1179
  async function runPackageLauncher(packageName, args, options) {
919
1180
  const runners = [
920
1181
  { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
@@ -932,13 +1193,7 @@ async function runPackageLauncher(packageName, args, options) {
932
1193
  }
933
1194
  }
934
1195
 
935
- const childOptions = {
936
- ...options,
937
- env: {
938
- ...(options.env || {}),
939
- ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
940
- },
941
- };
1196
+ const childOptions = buildPackageLauncherChildOptions(options, pinnedRuntimePath);
942
1197
 
943
1198
  const result = await runChild(runner.command, runner.args, childOptions);
944
1199
  if (result.signal) {
@@ -1005,14 +1260,20 @@ export async function runRuntimeWithSessionRecovery({
1005
1260
  token,
1006
1261
  childOptions,
1007
1262
  canAutoLoginRuntime,
1263
+ preferLocalhost = false,
1264
+ refreshTokenFn = refreshStoredCredentials,
1008
1265
  runLogin = runLoginCommand,
1009
1266
  resolveTokenFn = resolveToken,
1010
1267
  resolveStoredBaseUrlFn = resolveStoredBaseUrl,
1011
1268
  prepareRuntimeFn = prepareRuntime,
1012
1269
  launchRuntimeFn,
1270
+ maxRecoveryAttempts = 3,
1271
+ recoveryDelayMs = 500,
1272
+ sleepFn = sleep,
1013
1273
  }) {
1014
1274
  let activeBaseUrl = baseUrl;
1015
1275
  let activeToken = token;
1276
+ let recoveryAttempts = 0;
1016
1277
 
1017
1278
  while (true) {
1018
1279
  const result = await launchRuntimeFn({
@@ -1031,15 +1292,51 @@ export async function runRuntimeWithSessionRecovery({
1031
1292
  return result;
1032
1293
  }
1033
1294
 
1034
- if (!canAutoLoginRuntime) {
1035
- return result;
1295
+ if (recoveryAttempts >= maxRecoveryAttempts) {
1296
+ throw new Error(
1297
+ `Zenocode session recovery failed after ${maxRecoveryAttempts} attempts. Run \`zenocode login\` and try again.`,
1298
+ );
1299
+ }
1300
+ recoveryAttempts += 1;
1301
+
1302
+ try {
1303
+ const refreshed = await refreshTokenFn(activeBaseUrl);
1304
+ activeToken = refreshed.token;
1305
+ activeBaseUrl = refreshed.baseUrl;
1306
+ await prepareRuntimeFn(activeBaseUrl, activeToken);
1307
+ const retryDelayMs = Math.min(
1308
+ Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1309
+ 3_000,
1310
+ );
1311
+ if (retryDelayMs > 0) {
1312
+ await sleepFn(retryDelayMs);
1313
+ }
1314
+ continue;
1315
+ } catch {
1316
+ if (!canAutoLoginRuntime) {
1317
+ return result;
1318
+ }
1036
1319
  }
1037
1320
 
1038
1321
  console.log("Zenocode session expired. Starting login flow...\n");
1039
- await runLogin(activeBaseUrl, buildAutoLoginArgs());
1322
+ await runLogin(activeBaseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1323
+ preferLocalhost,
1324
+ });
1040
1325
  activeToken = await resolveTokenFn();
1041
- activeBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrlFn()) || activeBaseUrl;
1326
+ activeBaseUrl =
1327
+ resolveTextCortexBaseUrl({
1328
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1329
+ storedBaseUrl: await resolveStoredBaseUrlFn(),
1330
+ preferLocalhost,
1331
+ }) || activeBaseUrl;
1042
1332
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1333
+ const retryDelayMs = Math.min(
1334
+ Math.max(recoveryDelayMs, 0) * recoveryAttempts,
1335
+ 3_000,
1336
+ );
1337
+ if (retryDelayMs > 0) {
1338
+ await sleepFn(retryDelayMs);
1339
+ }
1043
1340
  }
1044
1341
  }
1045
1342
 
@@ -1112,11 +1409,18 @@ function maybeRenderBanner(args) {
1112
1409
  console.log(`${buildZenocodeBanner()}\n`);
1113
1410
  }
1114
1411
 
1115
- function buildAutoLoginArgs() {
1116
- return process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1412
+ function buildAutoLoginArgs({ preferLocalhost = false } = {}) {
1413
+ const loginArgs = [];
1414
+ if (preferLocalhost) {
1415
+ loginArgs.push("--local");
1416
+ }
1417
+ if (
1418
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1117
1419
  process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
1118
- ? ["--no-launch-browser"]
1119
- : [];
1420
+ ) {
1421
+ loginArgs.push("--no-launch-browser");
1422
+ }
1423
+ return loginArgs;
1120
1424
  }
1121
1425
 
1122
1426
  function isMissingTokenError(error) {
@@ -1189,7 +1493,8 @@ function shouldAttemptAutoLogin(error, args) {
1189
1493
  return canAutoLogin(args);
1190
1494
  }
1191
1495
 
1192
- async function resolveTokenWithAutoLogin(baseUrl, args) {
1496
+ async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1497
+ const preferLocalhost = options.preferLocalhost === true;
1193
1498
  try {
1194
1499
  const token = await resolveToken();
1195
1500
  return { token, baseUrl };
@@ -1199,14 +1504,21 @@ async function resolveTokenWithAutoLogin(baseUrl, args) {
1199
1504
  }
1200
1505
 
1201
1506
  console.log("No local Zenocode credentials found. Starting login flow...\n");
1202
- await runLoginCommand(baseUrl, buildAutoLoginArgs());
1507
+ await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1508
+ preferLocalhost,
1509
+ });
1203
1510
  const token = await resolveToken();
1204
- const persistedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
1511
+ const persistedBaseUrl = resolveTextCortexBaseUrl({
1512
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1513
+ storedBaseUrl: await resolveStoredBaseUrl(),
1514
+ preferLocalhost,
1515
+ });
1205
1516
  return { token, baseUrl: persistedBaseUrl };
1206
1517
  }
1207
1518
  }
1208
1519
 
1209
- async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
1520
+ async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1521
+ const preferLocalhost = options.preferLocalhost === true;
1210
1522
  try {
1211
1523
  const model = await prepareRuntime(baseUrl, token);
1212
1524
  return { model, token, baseUrl };
@@ -1214,14 +1526,31 @@ async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
1214
1526
  if (!isExpiredSessionError(error)) {
1215
1527
  throw error;
1216
1528
  }
1217
- if (!canAutoLogin(args)) {
1218
- throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1529
+
1530
+ try {
1531
+ const refreshed = await refreshStoredCredentials(baseUrl);
1532
+ const model = await prepareRuntime(refreshed.baseUrl, refreshed.token);
1533
+ return {
1534
+ model,
1535
+ token: refreshed.token,
1536
+ baseUrl: refreshed.baseUrl,
1537
+ };
1538
+ } catch {
1539
+ if (!canAutoLogin(args)) {
1540
+ throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
1541
+ }
1219
1542
  }
1220
1543
 
1221
1544
  console.log("Zenocode session expired. Starting login flow...\n");
1222
- await runLoginCommand(baseUrl, buildAutoLoginArgs());
1545
+ await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1546
+ preferLocalhost,
1547
+ });
1223
1548
  const refreshedToken = await resolveToken();
1224
- const refreshedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
1549
+ const refreshedBaseUrl = resolveTextCortexBaseUrl({
1550
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1551
+ storedBaseUrl: await resolveStoredBaseUrl(),
1552
+ preferLocalhost,
1553
+ });
1225
1554
  const model = await prepareRuntime(refreshedBaseUrl, refreshedToken);
1226
1555
  return { model, token: refreshedToken, baseUrl: refreshedBaseUrl };
1227
1556
  }
@@ -1238,12 +1567,18 @@ async function main() {
1238
1567
  passthrough.shift();
1239
1568
  }
1240
1569
 
1570
+ const preferLocalhost = hasLocalBaseUrlFlag(passthrough);
1571
+ const runtimeArgs = stripLocalBaseUrlFlags(passthrough);
1241
1572
  const storedBaseUrl = await resolveStoredBaseUrl();
1242
- const baseUrl = process.env.TEXTCORTEX_BASE_URL || storedBaseUrl || localBaseUrlDefault;
1243
- const subcommand = passthrough[0];
1573
+ const baseUrl = resolveTextCortexBaseUrl({
1574
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1575
+ storedBaseUrl,
1576
+ preferLocalhost,
1577
+ });
1578
+ const subcommand = runtimeArgs[0];
1244
1579
 
1245
1580
  if (subcommand === "login") {
1246
- await runLoginCommand(baseUrl, passthrough.slice(1));
1581
+ await runLoginCommand(baseUrl, runtimeArgs.slice(1), { preferLocalhost });
1247
1582
  return;
1248
1583
  }
1249
1584
 
@@ -1252,12 +1587,15 @@ async function main() {
1252
1587
  return;
1253
1588
  }
1254
1589
 
1255
- maybeRenderBanner(passthrough);
1256
- const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, passthrough);
1590
+ maybeRenderBanner(runtimeArgs);
1591
+ const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1592
+ preferLocalhost,
1593
+ });
1257
1594
  const runtime = await prepareRuntimeWithAutoLogin(
1258
1595
  tokenResolution.baseUrl,
1259
1596
  tokenResolution.token,
1260
- passthrough,
1597
+ runtimeArgs,
1598
+ { preferLocalhost },
1261
1599
  );
1262
1600
  const token = runtime.token;
1263
1601
  const model = runtime.model;
@@ -1274,14 +1612,15 @@ async function main() {
1274
1612
  TEXTCORTEX_API_KEY: token,
1275
1613
  },
1276
1614
  };
1277
- const monitorRuntimeSession = canAutoLogin(passthrough);
1615
+ const monitorRuntimeSession = canAutoLogin(runtimeArgs);
1278
1616
 
1279
1617
  if (opencodeBinaryPath) {
1280
1618
  const result = await runRuntimeWithSessionRecovery({
1281
- args: passthrough,
1619
+ args: runtimeArgs,
1282
1620
  baseUrl: runtime.baseUrl,
1283
1621
  token,
1284
1622
  childOptions,
1623
+ preferLocalhost,
1285
1624
  canAutoLoginRuntime: monitorRuntimeSession,
1286
1625
  launchRuntimeFn: ({ args, childOptions }) =>
1287
1626
  runRuntimeBinary(opencodeBinaryPath, args, childOptions, monitorRuntimeSession),
@@ -1294,10 +1633,11 @@ async function main() {
1294
1633
  const pinnedRuntimePath = await resolvePinnedRuntimeBinary(launchPackage, childOptions);
1295
1634
  if (pinnedRuntimePath) {
1296
1635
  const result = await runRuntimeWithSessionRecovery({
1297
- args: passthrough,
1636
+ args: runtimeArgs,
1298
1637
  baseUrl: runtime.baseUrl,
1299
1638
  token,
1300
1639
  childOptions,
1640
+ preferLocalhost,
1301
1641
  canAutoLoginRuntime: monitorRuntimeSession,
1302
1642
  launchRuntimeFn: ({ args, childOptions }) =>
1303
1643
  runRuntimeBinary(pinnedRuntimePath, args, childOptions, monitorRuntimeSession),
@@ -1305,7 +1645,7 @@ async function main() {
1305
1645
  exitWithChildResult(result);
1306
1646
  return;
1307
1647
  }
1308
- await runPackageLauncher(launchPackage, passthrough, childOptions);
1648
+ await runPackageLauncher(launchPackage, runtimeArgs, childOptions);
1309
1649
  }
1310
1650
 
1311
1651
  const resolveExecutablePath = (value) => {
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import http from "node:http";
2
3
  import fs from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -10,10 +11,15 @@ import {
10
11
  } from "./branding-patch.mjs";
11
12
  import {
12
13
  buildOpenCodeConfig,
14
+ buildPackageLauncherChildOptions,
13
15
  canRecoverRuntimeSessionFromTranscript,
14
16
  buildZenocodeBanner,
15
17
  chooseDefaults,
18
+ hasLocalBaseUrlFlag,
19
+ resolveLoginSuccessIdentifier,
20
+ resolveTextCortexBaseUrl,
16
21
  runRuntimeWithSessionRecovery,
22
+ shouldFallbackLoginToCloud,
17
23
  writePrivateJsonFile,
18
24
  } from "./run-zenocode.mjs";
19
25
 
@@ -30,6 +36,65 @@ test("chooseDefaults prefers kimi k2.5 thinking for Zenocode", () => {
30
36
  });
31
37
  });
32
38
 
39
+ test("resolveTextCortexBaseUrl defaults to the cloud API for packaged usage", () => {
40
+ assert.equal(
41
+ resolveTextCortexBaseUrl({
42
+ envBaseUrl: "",
43
+ storedBaseUrl: null,
44
+ }),
45
+ "https://api.textcortex.com",
46
+ );
47
+ });
48
+
49
+ test("resolveTextCortexBaseUrl prefers the explicit env var over stored credentials", () => {
50
+ assert.equal(
51
+ resolveTextCortexBaseUrl({
52
+ envBaseUrl: "https://staging.textcortex.com",
53
+ storedBaseUrl: "http://127.0.0.1:8080",
54
+ }),
55
+ "https://staging.textcortex.com",
56
+ );
57
+ });
58
+
59
+ test("resolveTextCortexBaseUrl prefers localhost when the local flag is enabled", () => {
60
+ assert.equal(
61
+ resolveTextCortexBaseUrl({
62
+ envBaseUrl: "https://staging.textcortex.com",
63
+ storedBaseUrl: "https://api.textcortex.com",
64
+ preferLocalhost: true,
65
+ }),
66
+ "http://127.0.0.1:8080",
67
+ );
68
+ });
69
+
70
+ test("hasLocalBaseUrlFlag detects localhost flags", () => {
71
+ assert.equal(hasLocalBaseUrlFlag(["login", "--local"]), true);
72
+ assert.equal(hasLocalBaseUrlFlag(["--localhost", "run"]), true);
73
+ assert.equal(hasLocalBaseUrlFlag(["run", "--help"]), false);
74
+ });
75
+
76
+ test("shouldFallbackLoginToCloud retries the cloud API when the local auth route returns 404", () => {
77
+ assert.equal(
78
+ shouldFallbackLoginToCloud({
79
+ baseUrl: "http://127.0.0.1:8080",
80
+ hasExplicitBaseUrl: false,
81
+ error: new Error("Login initiate failed (404): not found"),
82
+ }),
83
+ true,
84
+ );
85
+ });
86
+
87
+ test("shouldFallbackLoginToCloud does not override an explicit base URL", () => {
88
+ assert.equal(
89
+ shouldFallbackLoginToCloud({
90
+ baseUrl: "http://127.0.0.1:8080",
91
+ hasExplicitBaseUrl: true,
92
+ error: new Error("fetch failed"),
93
+ }),
94
+ false,
95
+ );
96
+ });
97
+
33
98
  test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plugins", () => {
34
99
  const config = buildOpenCodeConfig({
35
100
  baseUrl: "http://127.0.0.1:8080",
@@ -44,6 +109,23 @@ test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plug
44
109
  assert.deepEqual(config.provider.openai.models, {});
45
110
  });
46
111
 
112
+ test("buildPackageLauncherChildOptions keeps fallback package launchers attached to the terminal", () => {
113
+ const childOptions = buildPackageLauncherChildOptions(
114
+ {
115
+ cwd: "/tmp/zenocode",
116
+ env: { TEXTCORTEX_API_KEY: "token-1" },
117
+ },
118
+ "/tmp/zenocode-runtime",
119
+ );
120
+
121
+ assert.equal(childOptions.stdio, "inherit");
122
+ assert.equal(childOptions.cwd, "/tmp/zenocode");
123
+ assert.deepEqual(childOptions.env, {
124
+ TEXTCORTEX_API_KEY: "token-1",
125
+ OPENCODE_BIN_PATH: "/tmp/zenocode-runtime",
126
+ });
127
+ });
128
+
47
129
  test("buildZenocodeBanner renders block logo art instead of plain text", () => {
48
130
  const banner = buildZenocodeBanner();
49
131
 
@@ -117,6 +199,22 @@ test("canRecoverRuntimeSessionFromTranscript detects expired session output", ()
117
199
  assert.equal(canRecoverRuntimeSessionFromTranscript(transcript), true);
118
200
  });
119
201
 
202
+ test("resolveLoginSuccessIdentifier avoids email fallback", () => {
203
+ assert.equal(
204
+ resolveLoginSuccessIdentifier({
205
+ auth_id: "auth_123",
206
+ email: "user@example.com",
207
+ }),
208
+ "auth_123",
209
+ );
210
+ assert.equal(
211
+ resolveLoginSuccessIdentifier({
212
+ email: "user@example.com",
213
+ }),
214
+ "unknown",
215
+ );
216
+ });
217
+
120
218
  test("runRuntimeWithSessionRecovery reruns login after runtime session expiry", async () => {
121
219
  const events = [];
122
220
  let launchCount = 0;
@@ -130,6 +228,10 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
130
228
  env: {},
131
229
  },
132
230
  canAutoLoginRuntime: true,
231
+ refreshTokenFn: async (baseUrl) => {
232
+ events.push(["refresh", baseUrl]);
233
+ throw new Error("refresh failed");
234
+ },
133
235
  runLogin: async (baseUrl, loginArgs) => {
134
236
  events.push(["login", baseUrl, loginArgs]);
135
237
  },
@@ -157,6 +259,7 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
157
259
 
158
260
  assert.deepEqual(events, [
159
261
  ["launch", 1, "token-1"],
262
+ ["refresh", "http://127.0.0.1:8080"],
160
263
  ["login", "http://127.0.0.1:8080", []],
161
264
  ["resolve-token"],
162
265
  ["resolve-base-url"],
@@ -166,11 +269,294 @@ test("runRuntimeWithSessionRecovery reruns login after runtime session expiry",
166
269
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
167
270
  });
168
271
 
169
- test("logout removes legacy credential fallbacks as well as Zenocode credentials", async (t) => {
272
+ test("runRuntimeWithSessionRecovery refreshes stored credentials before forcing login", async () => {
273
+ const events = [];
274
+ let launchCount = 0;
275
+
276
+ const result = await runRuntimeWithSessionRecovery({
277
+ args: ["run"],
278
+ baseUrl: "http://127.0.0.1:8080",
279
+ token: "token-1",
280
+ childOptions: {
281
+ cwd: process.cwd(),
282
+ env: {},
283
+ },
284
+ canAutoLoginRuntime: true,
285
+ refreshTokenFn: async (baseUrl) => {
286
+ events.push(["refresh", baseUrl]);
287
+ return {
288
+ token: "token-2",
289
+ baseUrl: "https://api.textcortex.com",
290
+ };
291
+ },
292
+ runLogin: async () => {
293
+ throw new Error("login should not be called");
294
+ },
295
+ prepareRuntimeFn: async (baseUrl, token) => {
296
+ events.push(["prepare", baseUrl, token]);
297
+ return "kimi-k2-5-thinking";
298
+ },
299
+ launchRuntimeFn: async ({ childOptions }) => {
300
+ launchCount += 1;
301
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
302
+ if (launchCount === 1) {
303
+ return { expiredSession: true };
304
+ }
305
+ return { code: 0, signal: null, expiredSession: false };
306
+ },
307
+ });
308
+
309
+ assert.deepEqual(events, [
310
+ ["launch", 1, "token-1"],
311
+ ["refresh", "http://127.0.0.1:8080"],
312
+ ["prepare", "https://api.textcortex.com", "token-2"],
313
+ ["launch", 2, "token-2"],
314
+ ]);
315
+ assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
316
+ });
317
+
318
+ test("runRuntimeWithSessionRecovery preserves explicit localhost preference during login recovery", async () => {
319
+ const events = [];
320
+ let launchCount = 0;
321
+
322
+ const result = await runRuntimeWithSessionRecovery({
323
+ args: ["run"],
324
+ baseUrl: "http://127.0.0.1:8080",
325
+ token: "token-1",
326
+ childOptions: {
327
+ cwd: process.cwd(),
328
+ env: {},
329
+ },
330
+ canAutoLoginRuntime: true,
331
+ preferLocalhost: true,
332
+ refreshTokenFn: async (baseUrl) => {
333
+ events.push(["refresh", baseUrl]);
334
+ throw new Error("refresh failed");
335
+ },
336
+ runLogin: async (baseUrl, loginArgs, options) => {
337
+ events.push(["login", baseUrl, loginArgs, options]);
338
+ },
339
+ resolveTokenFn: async () => {
340
+ events.push(["resolve-token"]);
341
+ return "token-2";
342
+ },
343
+ resolveStoredBaseUrlFn: async () => {
344
+ events.push(["resolve-base-url"]);
345
+ return "https://api.textcortex.com";
346
+ },
347
+ prepareRuntimeFn: async (baseUrl, token) => {
348
+ events.push(["prepare", baseUrl, token]);
349
+ return "kimi-k2-5-thinking";
350
+ },
351
+ launchRuntimeFn: async ({ childOptions }) => {
352
+ launchCount += 1;
353
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
354
+ if (launchCount === 1) {
355
+ return { expiredSession: true };
356
+ }
357
+ return { code: 0, signal: null, expiredSession: false };
358
+ },
359
+ });
360
+
361
+ assert.deepEqual(events, [
362
+ ["launch", 1, "token-1"],
363
+ ["refresh", "http://127.0.0.1:8080"],
364
+ ["login", "http://127.0.0.1:8080", ["--local"], { preferLocalhost: true }],
365
+ ["resolve-token"],
366
+ ["resolve-base-url"],
367
+ ["prepare", "http://127.0.0.1:8080", "token-2"],
368
+ ["launch", 2, "token-2"],
369
+ ]);
370
+ assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
371
+ });
372
+
373
+ test("runRuntimeWithSessionRecovery stops after repeated recovery failures", async () => {
374
+ const events = [];
375
+ let launchCount = 0;
376
+
377
+ await assert.rejects(
378
+ () =>
379
+ runRuntimeWithSessionRecovery({
380
+ args: ["run"],
381
+ baseUrl: "http://127.0.0.1:8080",
382
+ token: "token-1",
383
+ childOptions: {
384
+ cwd: process.cwd(),
385
+ env: {},
386
+ },
387
+ canAutoLoginRuntime: true,
388
+ maxRecoveryAttempts: 2,
389
+ sleepFn: async () => {
390
+ events.push(["sleep"]);
391
+ },
392
+ refreshTokenFn: async (baseUrl) => {
393
+ events.push(["refresh", baseUrl]);
394
+ throw new Error("refresh failed");
395
+ },
396
+ runLogin: async (baseUrl, loginArgs) => {
397
+ events.push(["login", baseUrl, loginArgs]);
398
+ },
399
+ resolveTokenFn: async () => {
400
+ events.push(["resolve-token"]);
401
+ return "token-2";
402
+ },
403
+ resolveStoredBaseUrlFn: async () => {
404
+ events.push(["resolve-base-url"]);
405
+ return "https://api.textcortex.com";
406
+ },
407
+ prepareRuntimeFn: async (baseUrl, token) => {
408
+ events.push(["prepare", baseUrl, token]);
409
+ return "kimi-k2-5-thinking";
410
+ },
411
+ launchRuntimeFn: async ({ childOptions }) => {
412
+ launchCount += 1;
413
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
414
+ return { expiredSession: true };
415
+ },
416
+ }),
417
+ /Zenocode session recovery failed after 2 attempts/,
418
+ );
419
+
420
+ assert.deepEqual(events, [
421
+ ["launch", 1, "token-1"],
422
+ ["refresh", "http://127.0.0.1:8080"],
423
+ ["login", "http://127.0.0.1:8080", []],
424
+ ["resolve-token"],
425
+ ["resolve-base-url"],
426
+ ["prepare", "https://api.textcortex.com", "token-2"],
427
+ ["sleep"],
428
+ ["launch", 2, "token-2"],
429
+ ["refresh", "https://api.textcortex.com"],
430
+ ["login", "https://api.textcortex.com", []],
431
+ ["resolve-token"],
432
+ ["resolve-base-url"],
433
+ ["prepare", "https://api.textcortex.com", "token-2"],
434
+ ["sleep"],
435
+ ["launch", 3, "token-2"],
436
+ ]);
437
+ });
438
+
439
+ test("prepare-only refreshes stored Zenocode credentials when the access token has expired", async (t) => {
440
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-refresh-"));
441
+ const zenocodeHome = path.join(tempDir, ".zenocode");
442
+ const credentialsPath = path.join(zenocodeHome, "credentials.json");
443
+ const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
444
+ let refreshCalls = 0;
445
+ let refreshedAuthHeader = null;
446
+ let modelAuthHeader = null;
447
+
448
+ t.after(async () => {
449
+ await fs.rm(tempDir, { recursive: true, force: true });
450
+ });
451
+
452
+ await fs.mkdir(zenocodeHome, { recursive: true });
453
+ await fs.writeFile(
454
+ credentialsPath,
455
+ JSON.stringify(
456
+ {
457
+ base_url: "http://127.0.0.1:1",
458
+ access_token: "expired-access",
459
+ refresh_token: "refresh-token",
460
+ expires_at: new Date(Date.now() - 60_000).toISOString(),
461
+ },
462
+ null,
463
+ 2,
464
+ ),
465
+ );
466
+
467
+ const server = http.createServer((req, res) => {
468
+ if (req.method === "POST" && req.url === "/auth/token/refresh/") {
469
+ refreshCalls += 1;
470
+ refreshedAuthHeader = req.headers.authorization || null;
471
+ res.writeHead(200, { "Content-Type": "application/json" });
472
+ res.end(
473
+ JSON.stringify({
474
+ access_token: "fresh-access",
475
+ refresh_token: "fresh-refresh",
476
+ }),
477
+ );
478
+ return;
479
+ }
480
+
481
+ if (
482
+ req.method === "GET" &&
483
+ req.url === "/internal/v1/fastapi/zenocode/models/api.json"
484
+ ) {
485
+ modelAuthHeader = req.headers.authorization || null;
486
+ if (modelAuthHeader === "Bearer expired-access") {
487
+ res.writeHead(401, { "Content-Type": "application/json" });
488
+ res.end(JSON.stringify({ detail: "Token has expired" }));
489
+ return;
490
+ }
491
+
492
+ res.writeHead(200, { "Content-Type": "application/json" });
493
+ res.end(
494
+ JSON.stringify({
495
+ textcortex: {
496
+ models: {
497
+ "kimi-k2-5-thinking": {},
498
+ "glm-5": {},
499
+ },
500
+ },
501
+ }),
502
+ );
503
+ return;
504
+ }
505
+
506
+ res.writeHead(404, { "Content-Type": "application/json" });
507
+ res.end(JSON.stringify({ detail: "not found" }));
508
+ });
509
+
510
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
511
+ const address = server.address();
512
+ const baseUrl = `http://127.0.0.1:${address.port}`;
513
+
514
+ t.after(async () => {
515
+ await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
516
+ });
517
+
518
+ const result = await new Promise((resolve, reject) => {
519
+ const child = spawn(
520
+ process.execPath,
521
+ [scriptPath.pathname, "--prepare-only"],
522
+ {
523
+ cwd: tempDir,
524
+ env: {
525
+ ...process.env,
526
+ ZENOCODE_HOME: zenocodeHome,
527
+ TEXTCORTEX_BASE_URL: baseUrl,
528
+ ZENOCODE_NO_BANNER: "1",
529
+ },
530
+ stdio: ["ignore", "pipe", "pipe"],
531
+ },
532
+ );
533
+ let stdout = "";
534
+ let stderr = "";
535
+ child.stdout.on("data", (chunk) => {
536
+ stdout += String(chunk);
537
+ });
538
+ child.stderr.on("data", (chunk) => {
539
+ stderr += String(chunk);
540
+ });
541
+ child.on("error", reject);
542
+ child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
543
+ });
544
+
545
+ assert.equal(result.code, 0);
546
+ assert.equal(refreshCalls, 1);
547
+ assert.equal(refreshedAuthHeader, "Bearer refresh-token");
548
+ assert.equal(modelAuthHeader, "Bearer fresh-access");
549
+
550
+ const savedCredentials = JSON.parse(await fs.readFile(credentialsPath, "utf-8"));
551
+ assert.equal(savedCredentials.access_token, "fresh-access");
552
+ assert.equal(savedCredentials.refresh_token, "fresh-refresh");
553
+ });
554
+
555
+ test("logout removes runtime credentials and blocks shared fallback credentials", async (t) => {
170
556
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-logout-"));
171
557
  const zenocodeHome = path.join(tempDir, ".zenocode");
172
558
  const codecortexHome = path.join(tempDir, ".codecortex");
173
- const repoCredentialsPath = path.join(tempDir, ".credentials.json");
559
+ const homeCredentialsPath = path.join(tempDir, ".credentials.json");
174
560
  const runtimeCredentialsPath = path.join(zenocodeHome, "credentials.json");
175
561
  const legacyRuntimeCredentialsPath = path.join(codecortexHome, "credentials.json");
176
562
  const scriptPath = new URL("./run-zenocode.mjs", import.meta.url);
@@ -186,7 +572,7 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
186
572
  legacyRuntimeCredentialsPath,
187
573
  JSON.stringify({ access_token: "legacy" }),
188
574
  );
189
- await fs.writeFile(repoCredentialsPath, JSON.stringify({ access_token: "repo" }));
575
+ await fs.writeFile(homeCredentialsPath, JSON.stringify({ access_token: "home" }));
190
576
 
191
577
  const result = await new Promise((resolve, reject) => {
192
578
  const child = spawn(
@@ -196,6 +582,7 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
196
582
  cwd: tempDir,
197
583
  env: {
198
584
  ...process.env,
585
+ HOME: tempDir,
199
586
  ZENOCODE_HOME: zenocodeHome,
200
587
  CODECORTEX_HOME: codecortexHome,
201
588
  },
@@ -218,7 +605,38 @@ test("logout removes legacy credential fallbacks as well as Zenocode credentials
218
605
  await assert.rejects(fs.stat(runtimeCredentialsPath), { code: "ENOENT" });
219
606
  await assert.rejects(fs.stat(legacyRuntimeCredentialsPath), { code: "ENOENT" });
220
607
  assert.deepEqual(
221
- JSON.parse(await fs.readFile(repoCredentialsPath, "utf-8")),
222
- { access_token: "repo" },
608
+ JSON.parse(await fs.readFile(homeCredentialsPath, "utf-8")),
609
+ { access_token: "home" },
223
610
  );
611
+
612
+ const prepareOnlyResult = await new Promise((resolve, reject) => {
613
+ const child = spawn(
614
+ process.execPath,
615
+ [scriptPath.pathname, "--prepare-only"],
616
+ {
617
+ cwd: tempDir,
618
+ env: {
619
+ ...process.env,
620
+ HOME: tempDir,
621
+ ZENOCODE_HOME: zenocodeHome,
622
+ CODECORTEX_HOME: codecortexHome,
623
+ ZENOCODE_NO_BANNER: "1",
624
+ },
625
+ stdio: ["ignore", "pipe", "pipe"],
626
+ },
627
+ );
628
+ let stdout = "";
629
+ let stderr = "";
630
+ child.stdout.on("data", (chunk) => {
631
+ stdout += String(chunk);
632
+ });
633
+ child.stderr.on("data", (chunk) => {
634
+ stderr += String(chunk);
635
+ });
636
+ child.on("error", reject);
637
+ child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr }));
638
+ });
639
+
640
+ assert.equal(prepareOnlyResult.code, 1);
641
+ assert.match(prepareOnlyResult.stderr, /Missing API token/);
224
642
  });