@textcortex/zenocode 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
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",
@@ -30,6 +30,7 @@
30
30
  "scripts": {
31
31
  "build-branded-opencode": "node scripts/build-branded-opencode.mjs",
32
32
  "prepare-config": "node scripts/run-zenocode.mjs --prepare-only",
33
+ "prepare-release-version": "node scripts/prepare-release-version.mjs",
33
34
  "dev": "node scripts/run-zenocode.mjs",
34
35
  "login": "node scripts/run-zenocode.mjs login",
35
36
  "logout": "node scripts/run-zenocode.mjs logout"
@@ -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
+ });
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const defaultPackageJsonPath = path.resolve(__dirname, "..", "package.json");
9
+
10
+ export function parseSemver(value) {
11
+ const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
12
+ if (!match) {
13
+ throw new Error(`Invalid semver: ${value}`);
14
+ }
15
+ return {
16
+ major: Number(match[1]),
17
+ minor: Number(match[2]),
18
+ patch: Number(match[3]),
19
+ prerelease: match[4] ?? null,
20
+ };
21
+ }
22
+
23
+ export function compareSemver(left, right) {
24
+ const lhs = parseSemver(left);
25
+ const rhs = parseSemver(right);
26
+
27
+ for (const key of ["major", "minor", "patch"]) {
28
+ if (lhs[key] > rhs[key]) return 1;
29
+ if (lhs[key] < rhs[key]) return -1;
30
+ }
31
+
32
+ if (lhs.prerelease === rhs.prerelease) return 0;
33
+ if (lhs.prerelease === null) return 1;
34
+ if (rhs.prerelease === null) return -1;
35
+
36
+ const leftParts = lhs.prerelease.split(".");
37
+ const rightParts = rhs.prerelease.split(".");
38
+ const length = Math.max(leftParts.length, rightParts.length);
39
+ for (let index = 0; index < length; index += 1) {
40
+ const leftPart = leftParts[index];
41
+ const rightPart = rightParts[index];
42
+ if (leftPart === undefined) return -1;
43
+ if (rightPart === undefined) return 1;
44
+ const leftNumeric = /^\d+$/.test(leftPart);
45
+ const rightNumeric = /^\d+$/.test(rightPart);
46
+ if (leftNumeric && rightNumeric) {
47
+ const leftValue = Number(leftPart);
48
+ const rightValue = Number(rightPart);
49
+ if (leftValue > rightValue) return 1;
50
+ if (leftValue < rightValue) return -1;
51
+ continue;
52
+ }
53
+ if (leftNumeric && !rightNumeric) return -1;
54
+ if (!leftNumeric && rightNumeric) return 1;
55
+ if (leftPart > rightPart) return 1;
56
+ if (leftPart < rightPart) return -1;
57
+ }
58
+
59
+ return 0;
60
+ }
61
+
62
+ export function incrementPatchVersion(version) {
63
+ const parsed = parseSemver(version);
64
+ return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
65
+ }
66
+
67
+ export function resolveReleaseVersion(localVersion, publishedVersion) {
68
+ if (!publishedVersion) {
69
+ return localVersion;
70
+ }
71
+
72
+ const comparison = compareSemver(localVersion, publishedVersion);
73
+ if (comparison > 0) {
74
+ return localVersion;
75
+ }
76
+
77
+ return incrementPatchVersion(publishedVersion);
78
+ }
79
+
80
+ export async function prepareReleasePackageVersion({
81
+ packageJsonPath = defaultPackageJsonPath,
82
+ publishedVersion = null,
83
+ } = {}) {
84
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8"));
85
+ const packageName = String(packageJson.name || "").trim();
86
+ const localVersion = String(packageJson.version || "").trim();
87
+
88
+ if (!packageName) {
89
+ throw new Error(`Missing package name in ${packageJsonPath}`);
90
+ }
91
+ if (!localVersion) {
92
+ throw new Error(`Missing package version in ${packageJsonPath}`);
93
+ }
94
+
95
+ parseSemver(localVersion);
96
+ if (publishedVersion) {
97
+ parseSemver(publishedVersion);
98
+ }
99
+
100
+ const nextVersion = resolveReleaseVersion(localVersion, publishedVersion);
101
+ const updated = nextVersion !== localVersion;
102
+
103
+ if (updated) {
104
+ packageJson.version = nextVersion;
105
+ await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
106
+ }
107
+
108
+ return {
109
+ packageName,
110
+ localVersion,
111
+ nextVersion,
112
+ publishedVersion,
113
+ updated,
114
+ };
115
+ }
116
+
117
+ export function parseArgs(argv) {
118
+ const options = {
119
+ packageJsonPath: defaultPackageJsonPath,
120
+ publishedVersion: null,
121
+ };
122
+
123
+ for (let index = 0; index < argv.length; index += 1) {
124
+ const arg = argv[index];
125
+ if (arg === "--package-json") {
126
+ if (!argv[index + 1]) {
127
+ throw new Error("--package-json requires a value");
128
+ }
129
+ options.packageJsonPath = path.resolve(argv[index + 1]);
130
+ index += 1;
131
+ continue;
132
+ }
133
+ if (arg === "--published-version") {
134
+ options.publishedVersion = (argv[index + 1] || "").trim() || null;
135
+ index += 1;
136
+ continue;
137
+ }
138
+ throw new Error(`Unknown argument: ${arg}`);
139
+ }
140
+
141
+ return options;
142
+ }
143
+
144
+ async function main() {
145
+ const result = await prepareReleasePackageVersion(parseArgs(process.argv.slice(2)));
146
+ console.log(
147
+ JSON.stringify(
148
+ {
149
+ package_name: result.packageName,
150
+ local_version: result.localVersion,
151
+ next_version: result.nextVersion,
152
+ published_version: result.publishedVersion,
153
+ updated: result.updated,
154
+ },
155
+ null,
156
+ 2,
157
+ ),
158
+ );
159
+ }
160
+
161
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
162
+ if (invokedPath === fileURLToPath(import.meta.url)) {
163
+ await main();
164
+ }
@@ -0,0 +1,96 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import {
8
+ parseArgs,
9
+ prepareReleasePackageVersion,
10
+ resolveReleaseVersion,
11
+ } from "./prepare-release-version.mjs";
12
+
13
+ test("resolveReleaseVersion keeps the local version when nothing is published yet", () => {
14
+ assert.equal(resolveReleaseVersion("0.1.10", null), "0.1.10");
15
+ });
16
+
17
+ test("resolveReleaseVersion bumps the patch version when the local version matches the published version", () => {
18
+ assert.equal(resolveReleaseVersion("0.1.10", "0.1.10"), "0.1.11");
19
+ });
20
+
21
+ test("resolveReleaseVersion bumps from the published version when the local version is behind", () => {
22
+ assert.equal(resolveReleaseVersion("0.1.9", "0.1.10"), "0.1.11");
23
+ });
24
+
25
+ test("resolveReleaseVersion keeps an already-ahead local version", () => {
26
+ assert.equal(resolveReleaseVersion("0.1.11", "0.1.10"), "0.1.11");
27
+ });
28
+
29
+ test("resolveReleaseVersion rejects invalid semver values", () => {
30
+ assert.throws(() => resolveReleaseVersion("invalid", "0.1.10"), /Invalid semver/);
31
+ });
32
+
33
+ test("prepareReleasePackageVersion rewrites package.json when an automatic bump is required", async () => {
34
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-version-"));
35
+ const packageJsonPath = path.join(tempDir, "package.json");
36
+ await fs.writeFile(
37
+ packageJsonPath,
38
+ `${JSON.stringify(
39
+ {
40
+ name: "@textcortex/zenocode",
41
+ version: "0.1.10",
42
+ },
43
+ null,
44
+ 2,
45
+ )}\n`,
46
+ "utf-8",
47
+ );
48
+
49
+ const result = await prepareReleasePackageVersion({
50
+ packageJsonPath,
51
+ publishedVersion: "0.1.10",
52
+ });
53
+
54
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8"));
55
+ assert.deepEqual(result, {
56
+ packageName: "@textcortex/zenocode",
57
+ localVersion: "0.1.10",
58
+ nextVersion: "0.1.11",
59
+ publishedVersion: "0.1.10",
60
+ updated: true,
61
+ });
62
+ assert.equal(packageJson.version, "0.1.11");
63
+ });
64
+
65
+ test("prepareReleasePackageVersion leaves package.json untouched when the local version is already ahead", async () => {
66
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zenocode-version-"));
67
+ const packageJsonPath = path.join(tempDir, "package.json");
68
+ const originalContents = `${JSON.stringify(
69
+ {
70
+ name: "@textcortex/zenocode",
71
+ version: "0.1.11",
72
+ },
73
+ null,
74
+ 2,
75
+ )}\n`;
76
+ await fs.writeFile(packageJsonPath, originalContents, "utf-8");
77
+
78
+ const result = await prepareReleasePackageVersion({
79
+ packageJsonPath,
80
+ publishedVersion: "0.1.10",
81
+ });
82
+
83
+ const packageJson = await fs.readFile(packageJsonPath, "utf-8");
84
+ assert.deepEqual(result, {
85
+ packageName: "@textcortex/zenocode",
86
+ localVersion: "0.1.11",
87
+ nextVersion: "0.1.11",
88
+ publishedVersion: "0.1.10",
89
+ updated: false,
90
+ });
91
+ assert.equal(packageJson, originalContents);
92
+ });
93
+
94
+ test("parseArgs requires a value after --package-json", () => {
95
+ assert.throws(() => parseArgs(["--package-json"]), /--package-json requires a value/);
96
+ });
@@ -31,6 +31,7 @@ const modelsPath = path.join(runtimeDir, "models.json");
31
31
  const configPath = path.join(runtimeDir, "opencode.jsonc");
32
32
  const localBaseUrlDefault = "http://127.0.0.1:8080";
33
33
  const cloudBaseUrlDefault = "https://api.textcortex.com";
34
+ const localBaseUrlFlags = new Set(["--local", "--localhost"]);
34
35
 
35
36
  const providerID = "textcortex";
36
37
  const configuredOpencodePackage =
@@ -492,6 +493,15 @@ function sleep(ms) {
492
493
  return new Promise((resolve) => setTimeout(resolve, ms));
493
494
  }
494
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
+
495
505
  async function parseLoginArgs(args) {
496
506
  const normalizedArgs = args[0] === "--" ? args.slice(1) : args;
497
507
  let emailHint = process.env.TEXTCORTEX_LOGIN_EMAIL || null;
@@ -499,6 +509,9 @@ async function parseLoginArgs(args) {
499
509
 
500
510
  for (let idx = 0; idx < normalizedArgs.length; idx += 1) {
501
511
  const arg = normalizedArgs[idx];
512
+ if (localBaseUrlFlags.has(arg)) {
513
+ continue;
514
+ }
502
515
  if (arg === "--no-launch-browser") {
503
516
  launchBrowser = false;
504
517
  continue;
@@ -515,6 +528,7 @@ async function parseLoginArgs(args) {
515
528
  if (arg === "--help" || arg === "-h") {
516
529
  console.log("Zenocode login options:");
517
530
  console.log(" --email <address> Optional email hint for tenant/SSO routing");
531
+ console.log(` --local Use the local FastAPI backend at ${localBaseUrlDefault}`);
518
532
  console.log(" --no-launch-browser Do not open browser automatically");
519
533
  process.exit(0);
520
534
  }
@@ -543,10 +557,39 @@ function isLoginRouteNotFoundError(error) {
543
557
  return message.includes("Login initiate failed (404)");
544
558
  }
545
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
+
579
+ export function resolveLoginBaseUrl({
580
+ envBaseUrl,
581
+ preferLocalhost = false,
582
+ } = {}) {
583
+ if (preferLocalhost) {
584
+ return localBaseUrlDefault;
585
+ }
586
+ return envBaseUrl || cloudBaseUrlDefault;
587
+ }
588
+
546
589
  function _loginConnectivityHelp(baseUrl) {
547
590
  return [
548
591
  `Cannot reach Zenocode auth endpoint at ${baseUrl}.`,
549
- "Run local backend FastAPI (`cd backend && uv run dev_fastapi`) or set TEXTCORTEX_BASE_URL to a reachable backend API.",
592
+ `Set TEXTCORTEX_BASE_URL to ${cloudBaseUrlDefault} or, for local development, run local backend FastAPI (\`cd backend && uv run dev_fastapi\`).`,
550
593
  ].join(" ");
551
594
  }
552
595
 
@@ -655,18 +698,20 @@ async function saveRuntimeCredentials(baseUrl, tokenData) {
655
698
  await clearLogoutMarker();
656
699
  }
657
700
 
658
- async function runLoginCommand(baseUrl, args) {
701
+ async function runLoginCommand(baseUrl, args, options = {}) {
702
+ const preferLocalhost = options.preferLocalhost === true;
659
703
  const { emailHint, launchBrowser } = await parseLoginArgs(args);
660
- let resolvedBaseUrl = baseUrl;
704
+ let resolvedBaseUrl = preferLocalhost ? localBaseUrlDefault : baseUrl;
661
705
  let login;
662
706
  try {
663
707
  login = await initiateDeviceLogin(resolvedBaseUrl, emailHint);
664
708
  } catch (error) {
665
- if (
666
- !process.env.TEXTCORTEX_BASE_URL &&
667
- resolvedBaseUrl === localBaseUrlDefault &&
668
- isFetchFailedError(error)
669
- ) {
709
+ if (shouldFallbackLoginToCloud({
710
+ baseUrl: resolvedBaseUrl,
711
+ hasExplicitBaseUrl:
712
+ Boolean(process.env.TEXTCORTEX_BASE_URL) || preferLocalhost,
713
+ error,
714
+ })) {
670
715
  resolvedBaseUrl = cloudBaseUrlDefault;
671
716
  console.log(
672
717
  `Local backend not reachable at ${localBaseUrlDefault}. Falling back to ${cloudBaseUrlDefault}.`,
@@ -1225,6 +1270,7 @@ export async function runRuntimeWithSessionRecovery({
1225
1270
  token,
1226
1271
  childOptions,
1227
1272
  canAutoLoginRuntime,
1273
+ preferLocalhost = false,
1228
1274
  refreshTokenFn = refreshStoredCredentials,
1229
1275
  runLogin = runLoginCommand,
1230
1276
  resolveTokenFn = resolveToken,
@@ -1283,9 +1329,16 @@ export async function runRuntimeWithSessionRecovery({
1283
1329
  }
1284
1330
 
1285
1331
  console.log("Zenocode session expired. Starting login flow...\n");
1286
- await runLogin(activeBaseUrl, buildAutoLoginArgs());
1332
+ await runLogin(activeBaseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1333
+ preferLocalhost,
1334
+ });
1287
1335
  activeToken = await resolveTokenFn();
1288
- activeBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrlFn()) || activeBaseUrl;
1336
+ activeBaseUrl =
1337
+ resolveTextCortexBaseUrl({
1338
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1339
+ storedBaseUrl: await resolveStoredBaseUrlFn(),
1340
+ preferLocalhost,
1341
+ }) || activeBaseUrl;
1289
1342
  await prepareRuntimeFn(activeBaseUrl, activeToken);
1290
1343
  const retryDelayMs = Math.min(
1291
1344
  Math.max(recoveryDelayMs, 0) * recoveryAttempts,
@@ -1366,11 +1419,18 @@ function maybeRenderBanner(args) {
1366
1419
  console.log(`${buildZenocodeBanner()}\n`);
1367
1420
  }
1368
1421
 
1369
- function buildAutoLoginArgs() {
1370
- return process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1422
+ function buildAutoLoginArgs({ preferLocalhost = false } = {}) {
1423
+ const loginArgs = [];
1424
+ if (preferLocalhost) {
1425
+ loginArgs.push("--local");
1426
+ }
1427
+ if (
1428
+ process.env.ZENOCODE_AUTO_LOGIN_NO_BROWSER === "1" ||
1371
1429
  process.env.CODECORTEX_AUTO_LOGIN_NO_BROWSER === "1"
1372
- ? ["--no-launch-browser"]
1373
- : [];
1430
+ ) {
1431
+ loginArgs.push("--no-launch-browser");
1432
+ }
1433
+ return loginArgs;
1374
1434
  }
1375
1435
 
1376
1436
  function isMissingTokenError(error) {
@@ -1443,7 +1503,8 @@ function shouldAttemptAutoLogin(error, args) {
1443
1503
  return canAutoLogin(args);
1444
1504
  }
1445
1505
 
1446
- async function resolveTokenWithAutoLogin(baseUrl, args) {
1506
+ async function resolveTokenWithAutoLogin(baseUrl, args, options = {}) {
1507
+ const preferLocalhost = options.preferLocalhost === true;
1447
1508
  try {
1448
1509
  const token = await resolveToken();
1449
1510
  return { token, baseUrl };
@@ -1453,14 +1514,21 @@ async function resolveTokenWithAutoLogin(baseUrl, args) {
1453
1514
  }
1454
1515
 
1455
1516
  console.log("No local Zenocode credentials found. Starting login flow...\n");
1456
- await runLoginCommand(baseUrl, buildAutoLoginArgs());
1517
+ await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1518
+ preferLocalhost,
1519
+ });
1457
1520
  const token = await resolveToken();
1458
- const persistedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
1521
+ const persistedBaseUrl = resolveTextCortexBaseUrl({
1522
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1523
+ storedBaseUrl: await resolveStoredBaseUrl(),
1524
+ preferLocalhost,
1525
+ });
1459
1526
  return { token, baseUrl: persistedBaseUrl };
1460
1527
  }
1461
1528
  }
1462
1529
 
1463
- async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
1530
+ async function prepareRuntimeWithAutoLogin(baseUrl, token, args, options = {}) {
1531
+ const preferLocalhost = options.preferLocalhost === true;
1464
1532
  try {
1465
1533
  const model = await prepareRuntime(baseUrl, token);
1466
1534
  return { model, token, baseUrl };
@@ -1484,9 +1552,15 @@ async function prepareRuntimeWithAutoLogin(baseUrl, token, args) {
1484
1552
  }
1485
1553
 
1486
1554
  console.log("Zenocode session expired. Starting login flow...\n");
1487
- await runLoginCommand(baseUrl, buildAutoLoginArgs());
1555
+ await runLoginCommand(baseUrl, buildAutoLoginArgs({ preferLocalhost }), {
1556
+ preferLocalhost,
1557
+ });
1488
1558
  const refreshedToken = await resolveToken();
1489
- const refreshedBaseUrl = process.env.TEXTCORTEX_BASE_URL || (await resolveStoredBaseUrl()) || baseUrl;
1559
+ const refreshedBaseUrl = resolveTextCortexBaseUrl({
1560
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1561
+ storedBaseUrl: await resolveStoredBaseUrl(),
1562
+ preferLocalhost,
1563
+ });
1490
1564
  const model = await prepareRuntime(refreshedBaseUrl, refreshedToken);
1491
1565
  return { model, token: refreshedToken, baseUrl: refreshedBaseUrl };
1492
1566
  }
@@ -1503,12 +1577,25 @@ async function main() {
1503
1577
  passthrough.shift();
1504
1578
  }
1505
1579
 
1580
+ const preferLocalhost = hasLocalBaseUrlFlag(passthrough);
1581
+ const runtimeArgs = stripLocalBaseUrlFlags(passthrough);
1506
1582
  const storedBaseUrl = await resolveStoredBaseUrl();
1507
- const baseUrl = process.env.TEXTCORTEX_BASE_URL || storedBaseUrl || localBaseUrlDefault;
1508
- const subcommand = passthrough[0];
1583
+ const baseUrl = resolveTextCortexBaseUrl({
1584
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1585
+ storedBaseUrl,
1586
+ preferLocalhost,
1587
+ });
1588
+ const subcommand = runtimeArgs[0];
1509
1589
 
1510
1590
  if (subcommand === "login") {
1511
- await runLoginCommand(baseUrl, passthrough.slice(1));
1591
+ await runLoginCommand(
1592
+ resolveLoginBaseUrl({
1593
+ envBaseUrl: process.env.TEXTCORTEX_BASE_URL,
1594
+ preferLocalhost,
1595
+ }),
1596
+ runtimeArgs.slice(1),
1597
+ { preferLocalhost },
1598
+ );
1512
1599
  return;
1513
1600
  }
1514
1601
 
@@ -1517,12 +1604,15 @@ async function main() {
1517
1604
  return;
1518
1605
  }
1519
1606
 
1520
- maybeRenderBanner(passthrough);
1521
- const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, passthrough);
1607
+ maybeRenderBanner(runtimeArgs);
1608
+ const tokenResolution = await resolveTokenWithAutoLogin(baseUrl, runtimeArgs, {
1609
+ preferLocalhost,
1610
+ });
1522
1611
  const runtime = await prepareRuntimeWithAutoLogin(
1523
1612
  tokenResolution.baseUrl,
1524
1613
  tokenResolution.token,
1525
- passthrough,
1614
+ runtimeArgs,
1615
+ { preferLocalhost },
1526
1616
  );
1527
1617
  const token = runtime.token;
1528
1618
  const model = runtime.model;
@@ -1539,14 +1629,15 @@ async function main() {
1539
1629
  TEXTCORTEX_API_KEY: token,
1540
1630
  },
1541
1631
  };
1542
- const monitorRuntimeSession = canAutoLogin(passthrough);
1632
+ const monitorRuntimeSession = canAutoLogin(runtimeArgs);
1543
1633
 
1544
1634
  if (opencodeBinaryPath) {
1545
1635
  const result = await runRuntimeWithSessionRecovery({
1546
- args: passthrough,
1636
+ args: runtimeArgs,
1547
1637
  baseUrl: runtime.baseUrl,
1548
1638
  token,
1549
1639
  childOptions,
1640
+ preferLocalhost,
1550
1641
  canAutoLoginRuntime: monitorRuntimeSession,
1551
1642
  launchRuntimeFn: ({ args, childOptions }) =>
1552
1643
  runRuntimeBinary(opencodeBinaryPath, args, childOptions, monitorRuntimeSession),
@@ -1559,10 +1650,11 @@ async function main() {
1559
1650
  const pinnedRuntimePath = await resolvePinnedRuntimeBinary(launchPackage, childOptions);
1560
1651
  if (pinnedRuntimePath) {
1561
1652
  const result = await runRuntimeWithSessionRecovery({
1562
- args: passthrough,
1653
+ args: runtimeArgs,
1563
1654
  baseUrl: runtime.baseUrl,
1564
1655
  token,
1565
1656
  childOptions,
1657
+ preferLocalhost,
1566
1658
  canAutoLoginRuntime: monitorRuntimeSession,
1567
1659
  launchRuntimeFn: ({ args, childOptions }) =>
1568
1660
  runRuntimeBinary(pinnedRuntimePath, args, childOptions, monitorRuntimeSession),
@@ -1570,7 +1662,7 @@ async function main() {
1570
1662
  exitWithChildResult(result);
1571
1663
  return;
1572
1664
  }
1573
- await runPackageLauncher(launchPackage, passthrough, childOptions);
1665
+ await runPackageLauncher(launchPackage, runtimeArgs, childOptions);
1574
1666
  }
1575
1667
 
1576
1668
  const resolveExecutablePath = (value) => {
@@ -15,8 +15,12 @@ import {
15
15
  canRecoverRuntimeSessionFromTranscript,
16
16
  buildZenocodeBanner,
17
17
  chooseDefaults,
18
+ hasLocalBaseUrlFlag,
19
+ resolveLoginBaseUrl,
18
20
  resolveLoginSuccessIdentifier,
21
+ resolveTextCortexBaseUrl,
19
22
  runRuntimeWithSessionRecovery,
23
+ shouldFallbackLoginToCloud,
20
24
  writePrivateJsonFile,
21
25
  } from "./run-zenocode.mjs";
22
26
 
@@ -33,6 +37,93 @@ test("chooseDefaults prefers kimi k2.5 thinking for Zenocode", () => {
33
37
  });
34
38
  });
35
39
 
40
+ test("resolveTextCortexBaseUrl defaults to the cloud API for packaged usage", () => {
41
+ assert.equal(
42
+ resolveTextCortexBaseUrl({
43
+ envBaseUrl: "",
44
+ storedBaseUrl: null,
45
+ }),
46
+ "https://api.textcortex.com",
47
+ );
48
+ });
49
+
50
+ test("resolveTextCortexBaseUrl prefers the explicit env var over stored credentials", () => {
51
+ assert.equal(
52
+ resolveTextCortexBaseUrl({
53
+ envBaseUrl: "https://staging.textcortex.com",
54
+ storedBaseUrl: "http://127.0.0.1:8080",
55
+ }),
56
+ "https://staging.textcortex.com",
57
+ );
58
+ });
59
+
60
+ test("resolveTextCortexBaseUrl prefers localhost when the local flag is enabled", () => {
61
+ assert.equal(
62
+ resolveTextCortexBaseUrl({
63
+ envBaseUrl: "https://staging.textcortex.com",
64
+ storedBaseUrl: "https://api.textcortex.com",
65
+ preferLocalhost: true,
66
+ }),
67
+ "http://127.0.0.1:8080",
68
+ );
69
+ });
70
+
71
+ test("resolveLoginBaseUrl ignores stored credentials and defaults explicit login to cloud", () => {
72
+ assert.equal(
73
+ resolveLoginBaseUrl({
74
+ envBaseUrl: "",
75
+ }),
76
+ "https://api.textcortex.com",
77
+ );
78
+ });
79
+
80
+ test("resolveLoginBaseUrl still respects explicit env overrides", () => {
81
+ assert.equal(
82
+ resolveLoginBaseUrl({
83
+ envBaseUrl: "https://staging.textcortex.com",
84
+ }),
85
+ "https://staging.textcortex.com",
86
+ );
87
+ });
88
+
89
+ test("resolveLoginBaseUrl prefers localhost when the local flag is enabled", () => {
90
+ assert.equal(
91
+ resolveLoginBaseUrl({
92
+ envBaseUrl: "https://staging.textcortex.com",
93
+ preferLocalhost: true,
94
+ }),
95
+ "http://127.0.0.1:8080",
96
+ );
97
+ });
98
+
99
+ test("hasLocalBaseUrlFlag detects localhost flags", () => {
100
+ assert.equal(hasLocalBaseUrlFlag(["login", "--local"]), true);
101
+ assert.equal(hasLocalBaseUrlFlag(["--localhost", "run"]), true);
102
+ assert.equal(hasLocalBaseUrlFlag(["run", "--help"]), false);
103
+ });
104
+
105
+ test("shouldFallbackLoginToCloud retries the cloud API when the local auth route returns 404", () => {
106
+ assert.equal(
107
+ shouldFallbackLoginToCloud({
108
+ baseUrl: "http://127.0.0.1:8080",
109
+ hasExplicitBaseUrl: false,
110
+ error: new Error("Login initiate failed (404): not found"),
111
+ }),
112
+ true,
113
+ );
114
+ });
115
+
116
+ test("shouldFallbackLoginToCloud does not override an explicit base URL", () => {
117
+ assert.equal(
118
+ shouldFallbackLoginToCloud({
119
+ baseUrl: "http://127.0.0.1:8080",
120
+ hasExplicitBaseUrl: true,
121
+ error: new Error("fetch failed"),
122
+ }),
123
+ false,
124
+ );
125
+ });
126
+
36
127
  test("buildOpenCodeConfig includes an openai stub for fallback runtime auth plugins", () => {
37
128
  const config = buildOpenCodeConfig({
38
129
  baseUrl: "http://127.0.0.1:8080",
@@ -253,6 +344,61 @@ test("runRuntimeWithSessionRecovery refreshes stored credentials before forcing
253
344
  assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
254
345
  });
255
346
 
347
+ test("runRuntimeWithSessionRecovery preserves explicit localhost preference during login recovery", async () => {
348
+ const events = [];
349
+ let launchCount = 0;
350
+
351
+ const result = await runRuntimeWithSessionRecovery({
352
+ args: ["run"],
353
+ baseUrl: "http://127.0.0.1:8080",
354
+ token: "token-1",
355
+ childOptions: {
356
+ cwd: process.cwd(),
357
+ env: {},
358
+ },
359
+ canAutoLoginRuntime: true,
360
+ preferLocalhost: true,
361
+ refreshTokenFn: async (baseUrl) => {
362
+ events.push(["refresh", baseUrl]);
363
+ throw new Error("refresh failed");
364
+ },
365
+ runLogin: async (baseUrl, loginArgs, options) => {
366
+ events.push(["login", baseUrl, loginArgs, options]);
367
+ },
368
+ resolveTokenFn: async () => {
369
+ events.push(["resolve-token"]);
370
+ return "token-2";
371
+ },
372
+ resolveStoredBaseUrlFn: async () => {
373
+ events.push(["resolve-base-url"]);
374
+ return "https://api.textcortex.com";
375
+ },
376
+ prepareRuntimeFn: async (baseUrl, token) => {
377
+ events.push(["prepare", baseUrl, token]);
378
+ return "kimi-k2-5-thinking";
379
+ },
380
+ launchRuntimeFn: async ({ childOptions }) => {
381
+ launchCount += 1;
382
+ events.push(["launch", launchCount, childOptions.env.TEXTCORTEX_API_KEY]);
383
+ if (launchCount === 1) {
384
+ return { expiredSession: true };
385
+ }
386
+ return { code: 0, signal: null, expiredSession: false };
387
+ },
388
+ });
389
+
390
+ assert.deepEqual(events, [
391
+ ["launch", 1, "token-1"],
392
+ ["refresh", "http://127.0.0.1:8080"],
393
+ ["login", "http://127.0.0.1:8080", ["--local"], { preferLocalhost: true }],
394
+ ["resolve-token"],
395
+ ["resolve-base-url"],
396
+ ["prepare", "http://127.0.0.1:8080", "token-2"],
397
+ ["launch", 2, "token-2"],
398
+ ]);
399
+ assert.deepEqual(result, { code: 0, signal: null, expiredSession: false });
400
+ });
401
+
256
402
  test("runRuntimeWithSessionRecovery stops after repeated recovery failures", async () => {
257
403
  const events = [];
258
404
  let launchCount = 0;