@textcortex/zenocode 0.1.2

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.
@@ -0,0 +1,1081 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { realpathSync } from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import readline from "node:readline/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { padBinaryReplacement, patchOpenCodeVersionFooterText } from "./branding-patch.mjs";
11
+
12
+ const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
13
+ const __dirname = path.dirname(currentFilePath);
14
+ const appRoot = path.resolve(__dirname, "..");
15
+ const runtimeDir =
16
+ process.env.ZENOCODE_HOME ||
17
+ process.env.CODECORTEX_HOME ||
18
+ path.join(os.homedir(), ".zenocode");
19
+ const legacyRuntimeDir =
20
+ process.env.CODECORTEX_HOME || path.join(os.homedir(), ".codecortex");
21
+ const runtimeCredentialsPath = path.join(runtimeDir, "credentials.json");
22
+ const legacyRuntimeCredentialsPath = path.join(
23
+ legacyRuntimeDir,
24
+ "credentials.json",
25
+ );
26
+ const modelsPath = path.join(runtimeDir, "models.json");
27
+ const configPath = path.join(runtimeDir, "opencode.jsonc");
28
+ const localBaseUrlDefault = "http://127.0.0.1:8080";
29
+ const cloudBaseUrlDefault = "https://api.textcortex.com";
30
+
31
+ const providerID = "textcortex";
32
+ const configuredOpencodePackage =
33
+ process.env.ZENOCODE_OPENCODE_PACKAGE ||
34
+ process.env.CODECORTEX_OPENCODE_PACKAGE ||
35
+ process.env.OPENCODE_PACKAGE ||
36
+ null;
37
+ const defaultBrandedOpencodePackage = "@textcortex/zenocode-ai";
38
+ const legacyBrandedOpencodePackage = "@textcortex/opencode-ai";
39
+ const fallbackOpencodePackage = "opencode-ai";
40
+ const opencodePackage = configuredOpencodePackage || defaultBrandedOpencodePackage;
41
+ const opencodeBinaryPath =
42
+ process.env.ZENOCODE_OPENCODE_BIN_PATH ||
43
+ process.env.CODECORTEX_OPENCODE_BIN_PATH ||
44
+ "";
45
+ const oauthInitiatePath = "/internal/v1/fastapi/codecortex/oauth2/initiate";
46
+ const oauthTokenPath = "/internal/v1/fastapi/codecortex/oauth2/token";
47
+ const defaultOrder = [
48
+ "kimi-k2-5-thinking",
49
+ "glm-5",
50
+ "gpt-5-2",
51
+ "gpt-5-1",
52
+ "gpt-5",
53
+ "claude-4-6-sonnet",
54
+ "gpt-5-mini",
55
+ ];
56
+ const devCredentialFiles =
57
+ process.env.ZENOCODE_DEV_MODE === "1"
58
+ ? [
59
+ path.join(process.cwd(), "backend", ".credentials.json"),
60
+ path.join(process.cwd(), ".credentials.json"),
61
+ ]
62
+ : [];
63
+ const credentialFiles = [
64
+ runtimeCredentialsPath,
65
+ legacyRuntimeCredentialsPath,
66
+ path.join(os.homedir(), ".credentials.json"),
67
+ ...devCredentialFiles,
68
+ ].filter((value, index, values) => values.indexOf(value) === index);
69
+ const opencodeLogoSnippet = `var logo = {
70
+ left: [" ", "\\u2588\\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580\\u2584", "\\u2588__\\u2588 \\u2588__\\u2588 \\u2588^^^ \\u2588__\\u2588", "\\u2580\\u2580\\u2580\\u2580 \\u2588\\u2580\\u2580\\u2580 \\u2580\\u2580\\u2580\\u2580 \\u2580~~\\u2580"],
71
+ right: [" \\u2584 ", "\\u2588\\u2580\\u2580\\u2580 \\u2588\\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580\\u2588 \\u2588\\u2580\\u2580\\u2588", "\\u2588___ \\u2588__\\u2588 \\u2588__\\u2588 \\u2588^^^", "\\u2580\\u2580\\u2580\\u2580 \\u2580\\u2580\\u2580\\u2580 \\u2580\\u2580\\u2580\\u2580 \\u2580\\u2580\\u2580\\u2580"]
72
+ };
73
+ var marks = "_^~";`;
74
+ const zenocodeTextLogoSnippetCore = `var logo = {
75
+ left: [" ", " Zenocode ", " Zenocode ", " "],
76
+ right: [" ", " ", " ", " "]
77
+ };
78
+ var marks = "_^~";`;
79
+ const zenocodeBanner = `
80
+ Z E N O C O D E
81
+ `;
82
+ const privateDirectoryMode = 0o700;
83
+ const privateFileMode = 0o600;
84
+
85
+ async function readJson(filePath) {
86
+ try {
87
+ return JSON.parse(await fs.readFile(filePath, "utf-8"));
88
+ } catch (error) {
89
+ if (error?.code === "ENOENT") return null;
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ async function ensurePrivateDirectory(dirPath) {
95
+ await fs.mkdir(dirPath, { recursive: true, mode: privateDirectoryMode });
96
+ if (process.platform !== "win32") {
97
+ await fs.chmod(dirPath, privateDirectoryMode);
98
+ }
99
+ }
100
+
101
+ export async function writePrivateJsonFile(filePath, payload) {
102
+ await ensurePrivateDirectory(path.dirname(filePath));
103
+ await fs.writeFile(
104
+ filePath,
105
+ `${JSON.stringify(payload, null, 2)}\n`,
106
+ { encoding: "utf-8", mode: privateFileMode },
107
+ );
108
+ if (process.platform !== "win32") {
109
+ await fs.chmod(filePath, privateFileMode);
110
+ }
111
+ }
112
+
113
+ function extractTokenFromCredentialPayload(parsed) {
114
+ if (!parsed) return null;
115
+ if (typeof parsed?.access_token === "string" && parsed.access_token) {
116
+ return parsed.access_token;
117
+ }
118
+ if (Array.isArray(parsed?.accounts)) {
119
+ const active = parsed.accounts.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
120
+ if (active) return active.access_token;
121
+ }
122
+ if (Array.isArray(parsed)) {
123
+ const match = parsed.find((entry) => typeof entry?.access_token === "string" && entry.access_token);
124
+ if (match) return match.access_token;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function extractBaseUrlFromCredentialPayload(parsed) {
130
+ if (!parsed) return null;
131
+ if (typeof parsed?.base_url === "string" && parsed.base_url) {
132
+ return parsed.base_url;
133
+ }
134
+ if (Array.isArray(parsed?.accounts)) {
135
+ const active = parsed.accounts.find(
136
+ (entry) => typeof entry?.base_url === "string" && entry.base_url,
137
+ );
138
+ if (active) return active.base_url;
139
+ }
140
+ if (Array.isArray(parsed)) {
141
+ const match = parsed.find((entry) => typeof entry?.base_url === "string" && entry.base_url);
142
+ if (match) return match.base_url;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ async function resolveToken() {
148
+ const envToken = process.env.TEXTCORTEX_API_KEY || process.env.TEXTCORTEX_API_TOKEN;
149
+ if (envToken) return envToken;
150
+
151
+ for (const filePath of credentialFiles) {
152
+ const parsed = await readJson(filePath);
153
+ const token = extractTokenFromCredentialPayload(parsed);
154
+ if (token) return token;
155
+ }
156
+
157
+ throw new Error(
158
+ [
159
+ "Missing API token.",
160
+ "Run `zenocode login` first,",
161
+ "or set TEXTCORTEX_API_KEY / TEXTCORTEX_API_TOKEN.",
162
+ ].join(" "),
163
+ );
164
+ }
165
+
166
+ async function resolveStoredBaseUrl() {
167
+ const runtimeCredentials =
168
+ (await readJson(runtimeCredentialsPath)) ||
169
+ (await readJson(legacyRuntimeCredentialsPath));
170
+ const baseUrl = extractBaseUrlFromCredentialPayload(runtimeCredentials);
171
+ return baseUrl || null;
172
+ }
173
+
174
+ export function chooseDefaults(models) {
175
+ const ids = Object.keys(models);
176
+ if (!ids.length) throw new Error("No models were returned by the backend.");
177
+ const model = defaultOrder.find((id) => ids.includes(id)) || ids[0];
178
+ const smallModel = ids.find((id) => id.includes("mini") || id.includes("haiku")) || model;
179
+ return { model, smallModel };
180
+ }
181
+
182
+ export function buildOpenCodeConfig({ baseUrl, providerID, model, smallModel }) {
183
+ return {
184
+ $schema: "https://opencode.ai/config.json",
185
+ enabled_providers: [providerID],
186
+ model: `${providerID}/${model}`,
187
+ small_model: `${providerID}/${smallModel}`,
188
+ provider: {
189
+ [providerID]: {
190
+ name: "Zenocode",
191
+ options: {
192
+ baseURL: new URL("/internal/v1/fastapi/codecortex/v1", baseUrl).toString(),
193
+ },
194
+ },
195
+ // Older fallback opencode-ai builds can load the Codex auth plugin when
196
+ // a local OpenAI OAuth session exists. They assume `database.openai`
197
+ // exists and crash on `provider.models` otherwise, so keep a harmless
198
+ // empty provider entry around even though Zenocode only enables
199
+ // `textcortex`.
200
+ openai: {
201
+ models: {},
202
+ },
203
+ },
204
+ };
205
+ }
206
+
207
+ function unwrapData(payload) {
208
+ if (payload && typeof payload === "object" && payload.data && typeof payload.data === "object") {
209
+ return payload.data;
210
+ }
211
+ return payload;
212
+ }
213
+
214
+ function extractErrorMessage(payload, fallbackMessage) {
215
+ if (payload && typeof payload === "object") {
216
+ const errorMessage = payload?.error?.message;
217
+ if (typeof errorMessage === "string" && errorMessage) return errorMessage;
218
+ if (typeof payload.message === "string" && payload.message) return payload.message;
219
+ if (typeof payload.detail === "string" && payload.detail) return payload.detail;
220
+ if (payload.detail && typeof payload.detail === "object") {
221
+ const detailMessage = payload.detail.message;
222
+ if (typeof detailMessage === "string" && detailMessage) return detailMessage;
223
+ }
224
+ try {
225
+ return JSON.stringify(payload);
226
+ } catch {
227
+ return fallbackMessage;
228
+ }
229
+ }
230
+ return fallbackMessage;
231
+ }
232
+
233
+ async function requestJson(url, init) {
234
+ const response = await fetch(url, init);
235
+ const text = await response.text();
236
+ let payload = null;
237
+ try {
238
+ payload = text ? JSON.parse(text) : null;
239
+ } catch {
240
+ payload = null;
241
+ }
242
+ return { response, payload, text };
243
+ }
244
+
245
+ async function prepareRuntime(baseUrl, token) {
246
+ const modelsUrl = new URL("/internal/v1/fastapi/codecortex/models/api.json", baseUrl).toString();
247
+ const { response, payload, text } = await requestJson(modelsUrl, {
248
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
249
+ });
250
+
251
+ if (!response.ok) {
252
+ const body = payload ?? text;
253
+ const message = extractErrorMessage(payload, String(body || "request failed"));
254
+ if (response.status === 401 && message.includes("Email not confirmed")) {
255
+ throw new Error(
256
+ "Models fetch failed (401): Email is not confirmed for this account. Please verify the email address and try again.",
257
+ );
258
+ }
259
+ throw new Error(`Models fetch failed (${response.status}): ${message}`);
260
+ }
261
+
262
+ const provider = payload?.[providerID];
263
+ if (!provider?.models || typeof provider.models !== "object") {
264
+ throw new Error("Backend models response is missing textcortex provider data.");
265
+ }
266
+
267
+ const { model, smallModel } = chooseDefaults(provider.models);
268
+ const config = buildOpenCodeConfig({
269
+ baseUrl,
270
+ providerID,
271
+ model,
272
+ smallModel,
273
+ });
274
+
275
+ await fs.mkdir(runtimeDir, { recursive: true });
276
+ await fs.writeFile(modelsPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
277
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
278
+ return model;
279
+ }
280
+
281
+ function sleep(ms) {
282
+ return new Promise((resolve) => setTimeout(resolve, ms));
283
+ }
284
+
285
+ async function parseLoginArgs(args) {
286
+ const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
287
+ let emailHint = process.env.TEXTCORTEX_LOGIN_EMAIL || null;
288
+ let launchBrowser = true;
289
+
290
+ for (let idx = 0; idx < normalizedArgs.length; idx += 1) {
291
+ const arg = normalizedArgs[idx];
292
+ if (arg === "--no-launch-browser") {
293
+ launchBrowser = false;
294
+ continue;
295
+ }
296
+ if (arg === "--email") {
297
+ emailHint = normalizedArgs[idx + 1] || null;
298
+ idx += 1;
299
+ continue;
300
+ }
301
+ if (arg.startsWith("--email=")) {
302
+ emailHint = arg.slice("--email=".length) || null;
303
+ continue;
304
+ }
305
+ if (arg === "--help" || arg === "-h") {
306
+ console.log("Zenocode login options:");
307
+ console.log(" --email <address> Optional email hint for tenant/SSO routing");
308
+ console.log(" --no-launch-browser Do not open browser automatically");
309
+ process.exit(0);
310
+ }
311
+ }
312
+
313
+ if (!emailHint && process.stdin.isTTY) {
314
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
315
+ try {
316
+ const response = (await rl.question("Email for tenant routing (optional): ")).trim();
317
+ emailHint = response || null;
318
+ } finally {
319
+ rl.close();
320
+ }
321
+ }
322
+
323
+ return { emailHint, launchBrowser };
324
+ }
325
+
326
+ function isFetchFailedError(error) {
327
+ const message = error instanceof Error ? error.message : String(error);
328
+ return message.includes("fetch failed");
329
+ }
330
+
331
+ function isLoginRouteNotFoundError(error) {
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ return message.includes("Login initiate failed (404)");
334
+ }
335
+
336
+ function _loginConnectivityHelp(baseUrl) {
337
+ return [
338
+ `Cannot reach Zenocode auth endpoint at ${baseUrl}.`,
339
+ "Run local backend FastAPI (`cd backend && uv run dev_fastapi`) or set TEXTCORTEX_BASE_URL to a reachable backend API.",
340
+ ].join(" ");
341
+ }
342
+
343
+ async function openBrowser(url) {
344
+ let cmd;
345
+ let args;
346
+ if (process.platform === "darwin") {
347
+ cmd = "open";
348
+ args = [url];
349
+ } else if (process.platform === "win32") {
350
+ cmd = "cmd";
351
+ args = ["/c", "start", "", url];
352
+ } else {
353
+ cmd = "xdg-open";
354
+ args = [url];
355
+ }
356
+
357
+ return new Promise((resolve) => {
358
+ try {
359
+ const child = spawn(cmd, args, { stdio: "ignore" });
360
+ child.on("error", () => resolve(false));
361
+ child.on("exit", (code) => resolve(code === 0));
362
+ } catch {
363
+ resolve(false);
364
+ }
365
+ });
366
+ }
367
+
368
+ async function initiateDeviceLogin(baseUrl, emailHint) {
369
+ const initiateUrl = new URL(oauthInitiatePath, baseUrl).toString();
370
+ const body = emailHint ? { email_hint: emailHint } : {};
371
+ const { response, payload, text } = await requestJson(initiateUrl, {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
374
+ body: JSON.stringify(body),
375
+ });
376
+
377
+ if (!response.ok) {
378
+ const message = extractErrorMessage(payload, text || "request failed");
379
+ throw new Error(`Login initiate failed (${response.status}): ${message}`);
380
+ }
381
+
382
+ const data = unwrapData(payload);
383
+ if (!data || typeof data !== "object") {
384
+ throw new Error("Login initiate failed: invalid response payload.");
385
+ }
386
+
387
+ const deviceCode = typeof data.device_code === "string" ? data.device_code : null;
388
+ const userCode = typeof data.user_code === "string" ? data.user_code : null;
389
+ const verificationUrl = typeof data.verification_url_complete === "string" ? data.verification_url_complete : null;
390
+ const interval = Number.isFinite(Number(data.interval)) ? Number(data.interval) : 5;
391
+ const expiresIn = Number.isFinite(Number(data.expires_in)) ? Number(data.expires_in) : 600;
392
+
393
+ if (!deviceCode || !userCode || !verificationUrl) {
394
+ throw new Error("Login initiate failed: incomplete device authorization payload.");
395
+ }
396
+
397
+ return { deviceCode, userCode, verificationUrl, interval, expiresIn };
398
+ }
399
+
400
+ async function pollDeviceToken(baseUrl, deviceCode, intervalSeconds, expiresInSeconds) {
401
+ const deadline = Date.now() + Math.max(expiresInSeconds, 1) * 1000;
402
+ const pollUrl = new URL(oauthTokenPath, baseUrl).toString();
403
+
404
+ while (Date.now() <= deadline) {
405
+ const { response, payload, text } = await requestJson(pollUrl, {
406
+ method: "POST",
407
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
408
+ body: JSON.stringify({ device_code: deviceCode }),
409
+ });
410
+
411
+ if (response.status === 200) {
412
+ const data = unwrapData(payload);
413
+ if (!data?.access_token || !data?.refresh_token) {
414
+ throw new Error("Token endpoint returned incomplete credentials.");
415
+ }
416
+ return data;
417
+ }
418
+
419
+ if (response.status === 428) {
420
+ await sleep(Math.max(intervalSeconds, 1) * 1000);
421
+ continue;
422
+ }
423
+
424
+ const message = extractErrorMessage(payload, text || "request failed");
425
+ throw new Error(`Login token exchange failed (${response.status}): ${message}`);
426
+ }
427
+
428
+ throw new Error("Device code expired. Please run login again.");
429
+ }
430
+
431
+ async function saveRuntimeCredentials(baseUrl, tokenData) {
432
+ const payload = {
433
+ base_url: baseUrl,
434
+ access_token: tokenData.access_token,
435
+ refresh_token: tokenData.refresh_token,
436
+ auth_id: tokenData.auth_id || null,
437
+ email: tokenData.email || null,
438
+ expires_at: tokenData.expires_at || null,
439
+ updated_at: new Date().toISOString(),
440
+ };
441
+ await writePrivateJsonFile(runtimeCredentialsPath, payload);
442
+ }
443
+
444
+ async function runLoginCommand(baseUrl, args) {
445
+ const { emailHint, launchBrowser } = await parseLoginArgs(args);
446
+ let resolvedBaseUrl = baseUrl;
447
+ let login;
448
+ try {
449
+ login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
450
+ } catch (error) {
451
+ if (
452
+ !process.env.TEXTCORTEX_BASE_URL &&
453
+ resolvedBaseUrl === localBaseUrlDefault &&
454
+ isFetchFailedError(error)
455
+ ) {
456
+ resolvedBaseUrl = cloudBaseUrlDefault;
457
+ console.log(
458
+ `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`,
459
+ );
460
+ try {
461
+ login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
462
+ } catch (fallbackError) {
463
+ if (
464
+ isFetchFailedError(fallbackError) ||
465
+ isLoginRouteNotFoundError(fallbackError)
466
+ ) {
467
+ throw new Error(_loginConnectivityHelp(resolvedBaseUrl));
468
+ }
469
+ throw fallbackError;
470
+ }
471
+ } else {
472
+ if (isFetchFailedError(error) || isLoginRouteNotFoundError(error)) {
473
+ throw new Error(_loginConnectivityHelp(resolvedBaseUrl));
474
+ }
475
+ throw error;
476
+ }
477
+ }
478
+
479
+ console.log("\nComplete authorization in your browser:");
480
+ console.log(` ${login.verificationUrl}`);
481
+ console.log(`User code: ${login.userCode}\n`);
482
+
483
+ if (launchBrowser) {
484
+ const launched = await openBrowser(login.verificationUrl);
485
+ if (!launched) {
486
+ console.log("Unable to open a browser automatically. Open the URL above manually.");
487
+ } else {
488
+ console.log("Opened browser for authentication.");
489
+ }
490
+ }
491
+
492
+ const tokenData = await pollDeviceToken(
493
+ resolvedBaseUrl,
494
+ login.deviceCode,
495
+ login.interval,
496
+ login.expiresIn,
497
+ );
498
+ await saveRuntimeCredentials(resolvedBaseUrl, tokenData);
499
+
500
+ const account = tokenData.email || tokenData.auth_id || "unknown";
501
+ console.log(`Login successful for ${account}.`);
502
+ console.log(`Credentials saved to ${runtimeCredentialsPath}`);
503
+ }
504
+
505
+ async function runLogoutCommand() {
506
+ try {
507
+ await fs.unlink(runtimeCredentialsPath);
508
+ console.log("Zenocode credentials removed.");
509
+ } catch (error) {
510
+ if (error?.code === "ENOENT") {
511
+ console.log("No local Zenocode credentials found.");
512
+ return;
513
+ }
514
+ throw error;
515
+ }
516
+ }
517
+
518
+ async function runChild(command, args, options) {
519
+ return new Promise((resolve, reject) => {
520
+ try {
521
+ const child = spawn(command, args, options);
522
+ child.on("error", reject);
523
+ child.on("exit", (code, signal) => resolve({ code, signal }));
524
+ } catch (error) {
525
+ reject(error);
526
+ }
527
+ });
528
+ }
529
+
530
+ async function runBrandedBinary(args, options) {
531
+ const result = await runChild(opencodeBinaryPath, args, options);
532
+ if (result.signal) {
533
+ process.kill(process.pid, result.signal);
534
+ return;
535
+ }
536
+ process.exit(result.code ?? 0);
537
+ }
538
+
539
+ function _runnerCommand(command) {
540
+ return process.platform === "win32" ? `${command}.cmd` : command;
541
+ }
542
+
543
+ function _runtimeBrandedBinaryPath() {
544
+ const binaryName = process.platform === "win32" ? "opencode-runtime.exe" : "opencode-runtime";
545
+ return path.join(runtimeDir, "bin", binaryName);
546
+ }
547
+
548
+ function shouldPatchOpencodeRuntimePackage(packageName) {
549
+ return (
550
+ packageName.endsWith("/opencode-ai") ||
551
+ packageName.endsWith("/zenocode-ai") ||
552
+ packageName === "opencode-ai"
553
+ );
554
+ }
555
+
556
+ function _buildPaddedLogoSnippet(coreSnippet) {
557
+ return padBinaryReplacement(opencodeLogoSnippet, coreSnippet);
558
+ }
559
+
560
+ function _buildZenocodeLogoSnippet() {
561
+ return _buildPaddedLogoSnippet(zenocodeTextLogoSnippetCore);
562
+ }
563
+
564
+ async function _pathExists(pathToCheck) {
565
+ try {
566
+ await fs.access(pathToCheck);
567
+ return true;
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+
573
+ async function _collectPnpmDlxPnpmDirs(rootDir) {
574
+ const queue = [{ dir: rootDir, depth: 0 }];
575
+ const pnpmDirs = [];
576
+
577
+ while (queue.length) {
578
+ const current = queue.shift();
579
+ const nodeModulesPnpm = path.join(current.dir, "node_modules", ".pnpm");
580
+ if (await _pathExists(nodeModulesPnpm)) {
581
+ pnpmDirs.push(nodeModulesPnpm);
582
+ }
583
+
584
+ if (current.depth >= 3) continue;
585
+ let entries = [];
586
+ try {
587
+ entries = await fs.readdir(current.dir, { withFileTypes: true });
588
+ } catch {
589
+ continue;
590
+ }
591
+ for (const entry of entries) {
592
+ if (!entry.isDirectory()) continue;
593
+ if (entry.name === "node_modules") continue;
594
+ queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
595
+ }
596
+ }
597
+
598
+ return pnpmDirs;
599
+ }
600
+
601
+ async function _collectPnpmDlxOpencodeBinaries() {
602
+ const dlxRoots = [
603
+ path.join(os.homedir(), "Library", "Caches", "pnpm", "dlx"),
604
+ path.join(os.homedir(), ".cache", "pnpm", "dlx"),
605
+ path.join(process.env.LOCALAPPDATA || "", "pnpm", "dlx"),
606
+ ].filter(Boolean);
607
+
608
+ const binaryName = process.platform === "win32" ? "opencode.exe" : "opencode";
609
+ const hiddenCachedBinaryName = ".opencode";
610
+ const candidates = [];
611
+
612
+ for (const root of dlxRoots) {
613
+ if (!(await _pathExists(root))) continue;
614
+ const pnpmDirs = await _collectPnpmDlxPnpmDirs(root);
615
+ for (const pnpmDir of pnpmDirs) {
616
+ let entries = [];
617
+ try {
618
+ entries = await fs.readdir(pnpmDir, { withFileTypes: true });
619
+ } catch {
620
+ continue;
621
+ }
622
+
623
+ for (const entry of entries) {
624
+ if (!entry.isDirectory()) continue;
625
+ const nodeModulesPath = path.join(pnpmDir, entry.name, "node_modules");
626
+ if (!(await _pathExists(nodeModulesPath))) continue;
627
+
628
+ let topLevel = [];
629
+ try {
630
+ topLevel = await fs.readdir(nodeModulesPath, { withFileTypes: true });
631
+ } catch {
632
+ continue;
633
+ }
634
+
635
+ for (const moduleEntry of topLevel) {
636
+ if (!moduleEntry.isDirectory()) continue;
637
+
638
+ if (moduleEntry.name === "opencode-ai") {
639
+ const cachedPath = path.join(nodeModulesPath, "opencode-ai", "bin", hiddenCachedBinaryName);
640
+ if (await _pathExists(cachedPath)) candidates.push(cachedPath);
641
+ continue;
642
+ }
643
+
644
+ if (moduleEntry.name.startsWith("opencode-")) {
645
+ const binaryPath = path.join(nodeModulesPath, moduleEntry.name, "bin", binaryName);
646
+ if (await _pathExists(binaryPath)) candidates.push(binaryPath);
647
+ continue;
648
+ }
649
+
650
+ if (!moduleEntry.name.startsWith("@")) continue;
651
+ const scopePath = path.join(nodeModulesPath, moduleEntry.name);
652
+ let scopedPackages = [];
653
+ try {
654
+ scopedPackages = await fs.readdir(scopePath, { withFileTypes: true });
655
+ } catch {
656
+ continue;
657
+ }
658
+ for (const scopedPackage of scopedPackages) {
659
+ if (!scopedPackage.isDirectory()) continue;
660
+ const scopedNodeModulesPath = path.join(scopePath, scopedPackage.name);
661
+
662
+ if (scopedPackage.name === "opencode-ai") {
663
+ const cachedPath = path.join(scopedNodeModulesPath, "bin", hiddenCachedBinaryName);
664
+ if (await _pathExists(cachedPath)) candidates.push(cachedPath);
665
+ continue;
666
+ }
667
+
668
+ if (!scopedPackage.name.startsWith("opencode-")) continue;
669
+ const binaryPath = path.join(scopedNodeModulesPath, "bin", binaryName);
670
+ if (await _pathExists(binaryPath)) candidates.push(binaryPath);
671
+ }
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ return [...new Set(candidates)];
678
+ }
679
+
680
+ async function _adHocSignBinary(binaryPath) {
681
+ if (process.platform !== "darwin") return;
682
+ try {
683
+ await runChild("codesign", ["--force", "--sign", "-", binaryPath], {
684
+ stdio: "ignore",
685
+ });
686
+ } catch {
687
+ // If ad-hoc signing fails, keep going; runtime may still work on systems that do not enforce signatures.
688
+ }
689
+ }
690
+
691
+ function _patchLogoSnippetText(text) {
692
+ const replacement = _buildZenocodeLogoSnippet();
693
+ if (!replacement || text.includes(replacement)) {
694
+ return { patched: false, text };
695
+ }
696
+
697
+ const patchTargets = [opencodeLogoSnippet].filter(Boolean);
698
+ let logoOffset = -1;
699
+ for (const target of patchTargets) {
700
+ logoOffset = text.indexOf(target);
701
+ if (logoOffset !== -1) break;
702
+ }
703
+ if (logoOffset === -1) {
704
+ return { patched: false, text };
705
+ }
706
+
707
+ const nextText = `${text.slice(0, logoOffset)}${replacement}${text.slice(logoOffset + replacement.length)}`;
708
+ return { patched: true, text: nextText };
709
+ }
710
+
711
+ async function _patchOpencodeBinaryBranding(binaryPath) {
712
+ let buffer;
713
+ try {
714
+ buffer = await fs.readFile(binaryPath);
715
+ } catch {
716
+ return false;
717
+ }
718
+
719
+ const originalLength = buffer.length;
720
+ let text = buffer.toString("latin1");
721
+ let patched = false;
722
+
723
+ const logoPatch = _patchLogoSnippetText(text);
724
+ if (logoPatch.patched) {
725
+ text = logoPatch.text;
726
+ patched = true;
727
+ }
728
+
729
+ const footerPatch = patchOpenCodeVersionFooterText(text);
730
+ if (footerPatch.patched) {
731
+ text = footerPatch.text;
732
+ patched = true;
733
+ }
734
+
735
+ if (!patched) {
736
+ return false;
737
+ }
738
+
739
+ buffer = Buffer.from(text, "latin1");
740
+ if (buffer.length !== originalLength) {
741
+ return false;
742
+ }
743
+
744
+ await fs.writeFile(binaryPath, buffer);
745
+ if (process.platform !== "win32") {
746
+ await fs.chmod(binaryPath, 0o755);
747
+ }
748
+ await _adHocSignBinary(binaryPath);
749
+ return true;
750
+ }
751
+
752
+ async function _preparePinnedRuntimeBinary(binaryCandidates) {
753
+ if (!binaryCandidates.length) return null;
754
+ const runtimeBinaryPath = _runtimeBrandedBinaryPath();
755
+ const stats = await Promise.all(binaryCandidates.map(async (binaryPath) => ({
756
+ binaryPath,
757
+ mtimeMs: (await fs.stat(binaryPath)).mtimeMs,
758
+ })));
759
+ stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
760
+
761
+ await fs.mkdir(path.dirname(runtimeBinaryPath), { recursive: true });
762
+ await fs.copyFile(stats[0].binaryPath, runtimeBinaryPath);
763
+ if (process.platform !== "win32") {
764
+ await fs.chmod(runtimeBinaryPath, 0o755);
765
+ }
766
+ await _patchOpencodeBinaryBranding(runtimeBinaryPath);
767
+ await _adHocSignBinary(runtimeBinaryPath);
768
+ return runtimeBinaryPath;
769
+ }
770
+
771
+ async function _ensurePatchedOpencodeDlxBinaries(packageName, runner, options) {
772
+ if (!shouldPatchOpencodeRuntimePackage(packageName)) {
773
+ return null;
774
+ }
775
+ if (runner.command !== _runnerCommand("pnpm")) {
776
+ return null;
777
+ }
778
+ if (
779
+ process.env.ZENOCODE_DISABLE_OPENCODE_LOGO_PATCH === "1" ||
780
+ process.env.CODECORTEX_DISABLE_OPENCODE_LOGO_PATCH === "1"
781
+ ) {
782
+ return null;
783
+ }
784
+
785
+ let binaryCandidates = await _collectPnpmDlxOpencodeBinaries();
786
+ if (!binaryCandidates.length) {
787
+ // Warm cache so we have a concrete binary to patch.
788
+ try {
789
+ await runChild(runner.command, ["dlx", packageName, "--version"], {
790
+ ...options,
791
+ stdio: "ignore",
792
+ });
793
+ } catch {
794
+ // ignore warm-up failures and continue with best effort patching
795
+ }
796
+ binaryCandidates = await _collectPnpmDlxOpencodeBinaries();
797
+ }
798
+
799
+ if (!binaryCandidates.length) {
800
+ return null;
801
+ }
802
+
803
+ let patchedCount = 0;
804
+ for (const binaryPath of binaryCandidates) {
805
+ const patched = await _patchOpencodeBinaryBranding(binaryPath);
806
+ if (patched) patchedCount += 1;
807
+ }
808
+
809
+ if (patchedCount > 0) {
810
+ console.log(`Patched Zenocode branding in ${patchedCount} OpenCode runtime ${patchedCount > 1 ? "binaries" : "binary"}.`);
811
+ }
812
+
813
+ return _preparePinnedRuntimeBinary(binaryCandidates);
814
+ }
815
+
816
+ async function runPackageLauncher(packageName, args, options) {
817
+ const runners = [
818
+ { command: _runnerCommand("pnpm"), args: ["dlx", packageName, ...args] },
819
+ { command: _runnerCommand("npx"), args: ["--yes", packageName, ...args] },
820
+ ];
821
+ const missingRunners = [];
822
+
823
+ for (const runner of runners) {
824
+ try {
825
+ let pinnedRuntimePath = await _ensurePatchedOpencodeDlxBinaries(packageName, runner, options);
826
+ if (!pinnedRuntimePath && shouldPatchOpencodeRuntimePackage(packageName)) {
827
+ const existingPinnedPath = _runtimeBrandedBinaryPath();
828
+ if (await _pathExists(existingPinnedPath)) {
829
+ pinnedRuntimePath = existingPinnedPath;
830
+ }
831
+ }
832
+
833
+ const childOptions = {
834
+ ...options,
835
+ env: {
836
+ ...(options.env || {}),
837
+ ...(pinnedRuntimePath ? { OPENCODE_BIN_PATH: pinnedRuntimePath } : {}),
838
+ },
839
+ };
840
+
841
+ const result = await runChild(runner.command, runner.args, childOptions);
842
+ if (result.signal) {
843
+ process.kill(process.pid, result.signal);
844
+ return;
845
+ }
846
+ process.exit(result.code ?? 0);
847
+ return;
848
+ } catch (error) {
849
+ if (error?.code === "ENOENT") {
850
+ missingRunners.push(runner.command);
851
+ continue;
852
+ }
853
+ throw error;
854
+ }
855
+ }
856
+
857
+ throw new Error(
858
+ `No package runner found (${missingRunners.join(", ")}). Install pnpm or npm, or set ZENOCODE_OPENCODE_BIN_PATH/CODECORTEX_OPENCODE_BIN_PATH.`,
859
+ );
860
+ }
861
+
862
+ async function packageExistsOnNpm(packageName) {
863
+ const controller = new AbortController();
864
+ const timeout = setTimeout(() => controller.abort(), 4_000);
865
+ try {
866
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
867
+ method: "GET",
868
+ headers: { Accept: "application/json" },
869
+ signal: controller.signal,
870
+ });
871
+ return response.ok;
872
+ } catch {
873
+ return false;
874
+ } finally {
875
+ clearTimeout(timeout);
876
+ }
877
+ }
878
+
879
+ async function resolveLaunchPackage() {
880
+ if (configuredOpencodePackage) {
881
+ return configuredOpencodePackage;
882
+ }
883
+
884
+ const brandedAvailable = await packageExistsOnNpm(defaultBrandedOpencodePackage);
885
+ if (brandedAvailable) {
886
+ return defaultBrandedOpencodePackage;
887
+ }
888
+
889
+ const legacyBrandedAvailable = await packageExistsOnNpm(
890
+ legacyBrandedOpencodePackage,
891
+ );
892
+ if (legacyBrandedAvailable) {
893
+ console.warn(
894
+ `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${legacyBrandedOpencodePackage}.`,
895
+ );
896
+ return legacyBrandedOpencodePackage;
897
+ }
898
+
899
+ console.warn(
900
+ `Package ${defaultBrandedOpencodePackage} was not found on npm. Falling back to ${fallbackOpencodePackage}.`,
901
+ );
902
+ return fallbackOpencodePackage;
903
+ }
904
+
905
+ function shouldRenderBanner(args) {
906
+ if (
907
+ process.env.ZENOCODE_NO_BANNER === "1" ||
908
+ process.env.CODECORTEX_NO_BANNER === "1"
909
+ ) {
910
+ return false;
911
+ }
912
+ if (
913
+ !process.stdout.isTTY &&
914
+ process.env.ZENOCODE_FORCE_BANNER !== "1" &&
915
+ process.env.CODECORTEX_FORCE_BANNER !== "1"
916
+ ) {
917
+ return false;
918
+ }
919
+
920
+ const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
921
+ return !args.some((arg) => suppressFlags.has(arg));
922
+ }
923
+
924
+ function maybeRenderBanner(args) {
925
+ if (!shouldRenderBanner(args)) {
926
+ return;
927
+ }
928
+ console.log(`${zenocodeBanner}\n`);
929
+ }
930
+
931
+ function isMissingTokenError(error) {
932
+ const message = error instanceof Error ? error.message : String(error);
933
+ return message.includes("Missing API token");
934
+ }
935
+
936
+ function isExpiredSessionError(error) {
937
+ const message = error instanceof Error ? error.message : String(error);
938
+ if (!message.includes("Models fetch failed (401)")) return false;
939
+ return /token has expired|expired token|token expired/i.test(message);
940
+ }
941
+
942
+ function canAutoLogin(args) {
943
+ if (
944
+ process.env.ZENOCODE_AUTO_LOGIN === "0" ||
945
+ process.env.CODECORTEX_AUTO_LOGIN === "0"
946
+ ) {
947
+ return false;
948
+ }
949
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
950
+ if (process.env.CI === "1" || process.env.CI === "true") return false;
951
+
952
+ const suppressFlags = new Set(["--help", "-h", "--version", "-v", "completion"]);
953
+ if (args.some((arg) => suppressFlags.has(arg))) return false;
954
+ return true;
955
+ }
956
+
957
+ function shouldAttemptAutoLogin(error, args) {
958
+ if (!isMissingTokenError(error)) return false;
959
+ return canAutoLogin(args);
960
+ }
961
+
962
+ async function resolveTokenWithAutoLogin(baseUrl, args) {
963
+ try {
964
+ const token = await resolveToken();
965
+ return { token, baseUrl };
966
+ } catch (error) {
967
+ if (!shouldAttemptAutoLogin(error, args)) {
968
+ throw error;
969
+ }
970
+
971
+ console.log("No local Zenocode credentials found. Starting login flow...\n");
972
+ const loginArgs =
973
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
974
+ process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
975
+ ? ["--no-launch-browser"]
976
+ : [];
977
+ await runLoginCommand(baseUrl, loginArgs);
978
+ const token = await resolveToken();
979
+ const persistedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
980
+ return { token, baseUrl: persistedBaseUrl };
981
+ }
982
+ }
983
+
984
+ async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
985
+ try {
986
+ const model = await prepareRuntime(baseUrl, token);
987
+ return { model, token, baseUrl };
988
+ } catch (error) {
989
+ if (!isExpiredSessionError(error)) {
990
+ throw error;
991
+ }
992
+ if (!canAutoLogin(args)) {
993
+ throw new Error("Zenocode session expired. Run `zenocode login` and try again.");
994
+ }
995
+
996
+ console.log("Zenocode session expired. Starting login flow...\n");
997
+ const loginArgs =
998
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
999
+ process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
1000
+ ? ["--no-launch-browser"]
1001
+ : [];
1002
+ await runLoginCommand(baseUrl, loginArgs);
1003
+ const refreshedToken = await resolveToken();
1004
+ const refreshedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
1005
+ const model = await prepareRuntime(refreshedBaseUrl, refreshedToken);
1006
+ return { model, token: refreshedToken, baseUrl: refreshedBaseUrl };
1007
+ }
1008
+ }
1009
+
1010
+ async function main() {
1011
+ const args = process.argv.slice(2);
1012
+ const prepareOnly = args.includes("--prepare-only");
1013
+ const passthrough = args.filter((arg) => arg !== "--prepare-only");
1014
+
1015
+ // `pnpm run <script> -- ...` can forward a leading `--`; drop it so subcommands
1016
+ // like `run` / `models` / `login` are parsed here first.
1017
+ if (passthrough[0] === "--") {
1018
+ passthrough.shift();
1019
+ }
1020
+
1021
+ const storedBaseUrl = await resolveStoredBaseUrl();
1022
+ const baseUrl = process.env.TEXTCORTEX_BASE_URL || storedBaseUrl || localBaseUrlDefault;
1023
+ const subcommand = passthrough[0];
1024
+
1025
+ if (subcommand === "login") {
1026
+ await runLoginCommand(baseUrl, passthrough.slice(1));
1027
+ return;
1028
+ }
1029
+
1030
+ if (subcommand === "logout") {
1031
+ await runLogoutCommand();
1032
+ return;
1033
+ }
1034
+
1035
+ maybeRenderBanner(passthrough);
1036
+ const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, passthrough);
1037
+ const runtime = await prepareRuntimeWithAutoLogin(
1038
+ tokenResolution.baseUrl,
1039
+ tokenResolution.token,
1040
+ passthrough,
1041
+ );
1042
+ const token = runtime.token;
1043
+ const model = runtime.model;
1044
+ console.log(`Zenocode config ready at ${configPath}`);
1045
+ console.log(`Default model: ${model}`);
1046
+ if (prepareOnly) return;
1047
+
1048
+ const childOptions = {
1049
+ cwd: process.cwd(),
1050
+ stdio: "inherit",
1051
+ env: {
1052
+ ...process.env,
1053
+ OPENCODE_MODELS_PATH: modelsPath,
1054
+ OPENCODE_CONFIG: configPath,
1055
+ TEXTCORTEX_API_KEY: token,
1056
+ },
1057
+ };
1058
+
1059
+ if (opencodeBinaryPath) {
1060
+ await runBrandedBinary(passthrough, childOptions);
1061
+ return;
1062
+ }
1063
+
1064
+ const launchPackage = await resolveLaunchPackage();
1065
+ await runPackageLauncher(launchPackage, passthrough, childOptions);
1066
+ }
1067
+
1068
+ const resolveExecutablePath = (value) => {
1069
+ try {
1070
+ return realpathSync(value);
1071
+ } catch {
1072
+ return path.resolve(value);
1073
+ }
1074
+ };
1075
+
1076
+ if (process.argv[1] && resolveExecutablePath(process.argv[1]) === currentFilePath) {
1077
+ main().catch((error) => {
1078
+ console.error(error instanceof Error ? error.message : String(error));
1079
+ process.exit(1);
1080
+ });
1081
+ }