@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
|
@@ -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
|
+
});
|
package/scripts/run-zenocode.mjs
CHANGED
|
@@ -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/
|
|
49
|
-
const oauthTokenPath = "/internal/v1/fastapi/
|
|
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
|
|
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
|
|
158
|
-
const
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
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 (
|
|
1035
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1218
|
-
|
|
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 =
|
|
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 =
|
|
1243
|
-
|
|
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,
|
|
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(
|
|
1256
|
-
const tokenResolution = await resolveTokenWithAutoLogin(baseUrl,
|
|
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
|
-
|
|
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(
|
|
1615
|
+
const monitorRuntimeSession = canAutoLogin(runtimeArgs);
|
|
1278
1616
|
|
|
1279
1617
|
if (opencodeBinaryPath) {
|
|
1280
1618
|
const result = await runRuntimeWithSessionRecovery({
|
|
1281
|
-
args:
|
|
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:
|
|
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,
|
|
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("
|
|
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
|
|
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(
|
|
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(
|
|
222
|
-
{ access_token: "
|
|
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
|
});
|