@neuralnomads/codenomad-dev 0.14.0-dev-20260422-e708c565 → 0.14.0-dev-20260427-0ba13713

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.
Files changed (61) hide show
  1. package/dist/cli-upgrade.js +53 -0
  2. package/dist/cli-upgrade.test.js +31 -0
  3. package/dist/filesystem/browser.js +15 -1
  4. package/dist/index.js +17 -6
  5. package/dist/opencode-config/package.json +2 -2
  6. package/dist/opencode-config.js +14 -3
  7. package/dist/server/http-server.js +42 -112
  8. package/dist/server/routes/auth.js +6 -7
  9. package/dist/workspaces/__tests__/git-worktrees.test.js +43 -0
  10. package/dist/workspaces/git-worktrees.js +2 -2
  11. package/dist/workspaces/manager.js +5 -60
  12. package/dist/workspaces/opencode-auth.js +17 -0
  13. package/dist/workspaces/opencode-auth.test.js +34 -0
  14. package/package.json +6 -8
  15. package/public/assets/ChangesTab-lyvm8KrD.js +2 -0
  16. package/public/assets/DiffToolbar-C_u9j1B7.js +1 -0
  17. package/public/assets/FilesTab-Cff47qHk.js +2 -0
  18. package/public/assets/GitChangesTab-DktjnpZJ.js +2 -0
  19. package/public/assets/{SplitFilePanel-DhUmaW0S.js → SplitFilePanel-BOHH3F--.js} +1 -1
  20. package/public/assets/StatusTab-CMVYg97L.js +1 -0
  21. package/public/assets/align-justify-CA09D616.js +1 -0
  22. package/public/assets/{bundle-full-DEU8L99_.js → bundle-full-xFW13Lny.js} +1 -1
  23. package/public/assets/{diff-viewer-DEO4RYPx.js → diff-viewer-BkQOVrnt.js} +1 -1
  24. package/public/assets/index-7DpT014-.js +1 -0
  25. package/public/assets/index-BDK4V9L0.js +1 -0
  26. package/public/assets/index-Bx-Bezt2.js +1 -0
  27. package/public/assets/{index-DbMNXyVd.js → index-CCAZZY0l.js} +1 -1
  28. package/public/assets/index-CEcZq5WB.js +1 -0
  29. package/public/assets/index-CQCmSDHL.js +1 -0
  30. package/public/assets/{index--oqVn_K6.js → index-Cz_fl8aY.js} +1 -1
  31. package/public/assets/index-Dcgftm1m.css +1 -0
  32. package/public/assets/index-m4C3yx3J.js +1 -0
  33. package/public/assets/index-mh_z9p40.js +2 -0
  34. package/public/assets/{loading-CjZdFQ4L.js → loading-HUhLEgb_.js} +1 -1
  35. package/public/assets/main-B2dfx3K6.js +48 -0
  36. package/public/assets/{markdown-B5oFzzk8.js → markdown-DIZIkFnr.js} +22 -22
  37. package/public/assets/monaco-viewer-BgP6xpZ6.js +26 -0
  38. package/public/assets/{todo-BCVtJDU0.js → todo-CznQMY4c.js} +1 -1
  39. package/public/assets/{tool-call-Be4o4PeC.js → tool-call-B_fK5Sov.js} +25 -25
  40. package/public/assets/{unified-picker-Be5bKL4U.js → unified-picker-DuNuUpLy.js} +1 -1
  41. package/public/assets/wrap-text-bFf5tsQM.js +1 -0
  42. package/public/index.html +4 -4
  43. package/public/loading.html +4 -4
  44. package/public/sw.js +1 -1
  45. package/dist/runtime-paths.js +0 -67
  46. package/public/assets/ChangesTab-CVfOyRjB.js +0 -2
  47. package/public/assets/DiffToolbar-BqTuTuLi.js +0 -1
  48. package/public/assets/FilesTab-BCaD4sK4.js +0 -2
  49. package/public/assets/GitChangesTab-GgUiE4m7.js +0 -2
  50. package/public/assets/StatusTab-CZk-cmFJ.js +0 -1
  51. package/public/assets/index-Btyhpe4o.js +0 -1
  52. package/public/assets/index-C6C534z4.js +0 -1
  53. package/public/assets/index-CMh7_0rm.js +0 -1
  54. package/public/assets/index-CWmlJG88.css +0 -1
  55. package/public/assets/index-D7RMnYOz.js +0 -1
  56. package/public/assets/index-DE6KDkkL.js +0 -2
  57. package/public/assets/index-NrX0Q8eA.js +0 -1
  58. package/public/assets/index-z-uWAw1M.js +0 -1
  59. package/public/assets/main-BIaFBThA.js +0 -48
  60. package/public/assets/monaco-viewer-DANembz4.js +0 -26
  61. package/public/assets/wrap-text-BTyaDmSX.js +0 -1
@@ -0,0 +1,53 @@
1
+ import { spawn } from "child_process";
2
+ const CODENOMAD_PACKAGE_NAME = "@neuralnomads/codenomad";
3
+ function detectFromText(value) {
4
+ const lower = (value ?? "").toLowerCase();
5
+ if (!lower)
6
+ return null;
7
+ if (lower.includes("pnpm"))
8
+ return "pnpm";
9
+ if (lower.includes("bun"))
10
+ return "bun";
11
+ if (lower.includes("npm"))
12
+ return "npm";
13
+ return null;
14
+ }
15
+ export function detectPackageManager(env = process.env) {
16
+ return detectFromText(env.npm_config_user_agent) ?? detectFromText(env.npm_execpath) ?? "npm";
17
+ }
18
+ export function buildUpgradeCommand(version, packageManager = detectPackageManager()) {
19
+ const targetVersion = (version ?? "").trim() || "latest";
20
+ const packageSpec = `${CODENOMAD_PACKAGE_NAME}@${targetVersion}`;
21
+ const args = packageManager === "bun" ? ["add", "-g", packageSpec] : ["install", "-g", packageSpec];
22
+ return {
23
+ command: packageManager,
24
+ args,
25
+ packageSpec,
26
+ };
27
+ }
28
+ export function formatUpgradeCommand(command) {
29
+ return [command.command, ...command.args].join(" ");
30
+ }
31
+ export function runCliUpgrade(version, env = process.env) {
32
+ const upgrade = buildUpgradeCommand(version, detectPackageManager(env));
33
+ console.log(`Upgrading CodeNomad with: ${formatUpgradeCommand(upgrade)}`);
34
+ return new Promise((resolve) => {
35
+ const child = spawn(upgrade.command, upgrade.args, {
36
+ env,
37
+ shell: process.platform === "win32",
38
+ stdio: "inherit",
39
+ });
40
+ child.on("exit", (code, signal) => {
41
+ if (signal) {
42
+ console.error(`Upgrade command stopped by signal ${signal}`);
43
+ resolve(1);
44
+ return;
45
+ }
46
+ resolve(code ?? 0);
47
+ });
48
+ child.on("error", (error) => {
49
+ console.error("Failed to launch upgrade command", error);
50
+ resolve(1);
51
+ });
52
+ });
53
+ }
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buildUpgradeCommand, detectPackageManager, formatUpgradeCommand } from "./cli-upgrade";
4
+ describe("cli upgrade", () => {
5
+ it("defaults to npm when no package manager can be detected", () => {
6
+ assert.equal(detectPackageManager({}), "npm");
7
+ });
8
+ it("detects package managers from npm user agent", () => {
9
+ assert.equal(detectPackageManager({ npm_config_user_agent: "pnpm/9.0.0 node/v22" }), "pnpm");
10
+ assert.equal(detectPackageManager({ npm_config_user_agent: "bun/1.0.0" }), "bun");
11
+ assert.equal(detectPackageManager({ npm_config_user_agent: "npm/10.0.0 node/v22" }), "npm");
12
+ });
13
+ it("builds latest upgrade command by default", () => {
14
+ const command = buildUpgradeCommand(undefined, "npm");
15
+ assert.equal(command.packageSpec, "@neuralnomads/codenomad@latest");
16
+ assert.deepEqual(command.args, ["install", "-g", "@neuralnomads/codenomad@latest"]);
17
+ assert.equal(formatUpgradeCommand(command), "npm install -g @neuralnomads/codenomad@latest");
18
+ });
19
+ it("builds a versioned upgrade command", () => {
20
+ const command = buildUpgradeCommand("0.10.5", "pnpm");
21
+ assert.equal(command.packageSpec, "@neuralnomads/codenomad@0.10.5");
22
+ assert.deepEqual(command.args, ["install", "-g", "@neuralnomads/codenomad@0.10.5"]);
23
+ assert.equal(formatUpgradeCommand(command), "pnpm install -g @neuralnomads/codenomad@0.10.5");
24
+ });
25
+ it("uses bun add for Bun installs", () => {
26
+ const command = buildUpgradeCommand("0.10.5", "bun");
27
+ assert.equal(command.packageSpec, "@neuralnomads/codenomad@0.10.5");
28
+ assert.deepEqual(command.args, ["add", "-g", "@neuralnomads/codenomad@0.10.5"]);
29
+ assert.equal(formatUpgradeCommand(command), "bun add -g @neuralnomads/codenomad@0.10.5");
30
+ });
31
+ });
@@ -206,6 +206,17 @@ export class FileSystemBrowser {
206
206
  if (!input || input === "." || input === "./" || input === "/") {
207
207
  return ".";
208
208
  }
209
+ if (path.isAbsolute(input)) {
210
+ const resolved = path.resolve(input);
211
+ const relativeToRoot = path.relative(this.root, resolved);
212
+ if (relativeToRoot === "") {
213
+ return ".";
214
+ }
215
+ if (this.isOutsideRoot(relativeToRoot)) {
216
+ throw new Error("Access outside of root is not allowed");
217
+ }
218
+ return relativeToRoot.replace(/\\+/g, "/");
219
+ }
209
220
  let normalized = input.replace(/\\+/g, "/");
210
221
  if (normalized.startsWith("./")) {
211
222
  normalized = normalized.replace(/^\.\/+/, "");
@@ -232,11 +243,14 @@ export class FileSystemBrowser {
232
243
  const normalized = this.normalizeRelativePath(relativePath);
233
244
  const target = path.resolve(this.root, normalized);
234
245
  const relativeToRoot = path.relative(this.root, target);
235
- if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
246
+ if (this.isOutsideRoot(relativeToRoot)) {
236
247
  throw new Error("Access outside of root is not allowed");
237
248
  }
238
249
  return target;
239
250
  }
251
+ isOutsideRoot(relativeToRoot) {
252
+ return relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot);
253
+ }
240
254
  resolveUnrestrictedPath(input) {
241
255
  if (!input || input === "." || input === "./") {
242
256
  return this.homeDir;
package/dist/index.js CHANGED
@@ -28,12 +28,12 @@ import { SideCarManager } from "./sidecars/manager";
28
28
  import { ClientConnectionManager } from "./clients/connection-manager";
29
29
  import { PluginChannelManager } from "./plugins/channel";
30
30
  import { VoiceModeManager } from "./plugins/voice-mode";
31
- import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths";
31
+ import { runCliUpgrade } from "./cli-upgrade";
32
32
  const require = createRequire(import.meta.url);
33
- const packageJson = { version: readServerPackageVersion(import.meta.url) };
33
+ const packageJson = require("../package.json");
34
34
  const __filename = fileURLToPath(import.meta.url);
35
35
  const __dirname = path.dirname(__filename);
36
- const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url);
36
+ const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public");
37
37
  const DEFAULT_HOST = "127.0.0.1";
38
38
  const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
39
39
  const DEFAULT_HTTPS_PORT = 9898;
@@ -76,9 +76,11 @@ function parseCliOptions(argv) {
76
76
  .default(false))
77
77
  .addOption(new Option("--dangerously-skip-auth", "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).")
78
78
  .env("CODENOMAD_SKIP_AUTH")
79
- .default(false));
79
+ .default(false))
80
+ .addOption(new Option("--upgrade [version]", "Upgrade the global CodeNomad CLI server package and exit"));
80
81
  program.parse(argv, { from: "user" });
81
82
  const parsed = program.opts();
83
+ const upgrade = parsed.upgrade;
82
84
  const parseBooleanEnv = (value) => {
83
85
  const normalized = (value ?? "").trim().toLowerCase();
84
86
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on";
@@ -89,7 +91,7 @@ function parseCliOptions(argv) {
89
91
  const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes";
90
92
  const httpsEnabled = parseBooleanEnv(parsed.https);
91
93
  const httpEnabled = parseBooleanEnv(parsed.http);
92
- if (!httpsEnabled && !httpEnabled) {
94
+ if (upgrade === undefined && !httpsEnabled && !httpEnabled) {
93
95
  throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)");
94
96
  }
95
97
  return {
@@ -118,6 +120,7 @@ function parseCliOptions(argv) {
118
120
  authCookieName: parsed.authCookieName,
119
121
  generateToken: Boolean(parsed.generateToken),
120
122
  dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
123
+ upgrade,
121
124
  };
122
125
  }
123
126
  function parsePort(input) {
@@ -144,6 +147,11 @@ function programHasArg(argv, flag) {
144
147
  }
145
148
  async function main() {
146
149
  const options = parseCliOptions(process.argv.slice(2));
150
+ if (options.upgrade !== undefined) {
151
+ const version = typeof options.upgrade === "string" ? options.upgrade : undefined;
152
+ process.exitCode = await runCliUpgrade(version);
153
+ return;
154
+ }
147
155
  const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" });
148
156
  const workspaceLogger = logger.child({ component: "workspace" });
149
157
  const configLogger = logger.child({ component: "config" });
@@ -211,7 +219,10 @@ async function main() {
211
219
  getServerBaseUrl: () => serverMeta.localUrl,
212
220
  nodeExtraCaCertsPath,
213
221
  });
214
- const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot });
222
+ const fileSystemBrowser = new FileSystemBrowser({
223
+ rootDir: options.rootDir,
224
+ unrestricted: options.unrestrictedRoot,
225
+ });
215
226
  const instanceStore = new InstanceStore(configLocation.instancesDir);
216
227
  const speechService = new SpeechService(settings, logger.child({ component: "speech" }));
217
228
  const sidecarManager = new SideCarManager({
@@ -4,6 +4,6 @@
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "dependencies": {
7
- "@opencode-ai/plugin": "1.14.19"
7
+ "@opencode-ai/plugin": "1.3.7"
8
8
  }
9
- }
9
+ }
@@ -1,9 +1,20 @@
1
1
  import { existsSync } from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
2
4
  import { createLogger } from "./logger";
3
- import { resolveOpencodeTemplateDir } from "./runtime-paths";
4
5
  const log = createLogger({ component: "opencode-config" });
5
- const templateDir = resolveOpencodeTemplateDir(import.meta.url);
6
- const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER);
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const devTemplateDir = path.resolve(__dirname, "../../opencode-config");
9
+ const resourcesPath = process.resourcesPath;
10
+ const prodTemplateDirs = [
11
+ resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
12
+ path.resolve(__dirname, "opencode-config"),
13
+ ].filter((dir) => Boolean(dir));
14
+ const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir);
15
+ const templateDir = isDevBuild
16
+ ? devTemplateDir
17
+ : prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0];
7
18
  export function getOpencodeConfigDir() {
8
19
  if (!existsSync(templateDir)) {
9
20
  throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`);
@@ -5,8 +5,6 @@ import replyFrom from "@fastify/reply-from";
5
5
  import fs from "fs";
6
6
  import { connect as connectTcp } from "net";
7
7
  import path from "path";
8
- import { Readable } from "stream";
9
- import { pipeline } from "stream/promises";
10
8
  import { connect as connectTls } from "tls";
11
9
  import { fetch } from "undici";
12
10
  import { isValidWorktreeSlug } from "../workspaces/git-worktrees";
@@ -491,49 +489,47 @@ async function proxyWorkspaceRequest(args) {
491
489
  if (logger.isLevelEnabled("trace")) {
492
490
  logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload");
493
491
  }
494
- const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory);
495
- if (logger.isLevelEnabled("trace")) {
496
- logger.trace({
497
- workspaceId,
498
- method: request.method,
499
- targetUrl,
500
- worktreeSlug,
501
- directory,
502
- contentType: request.headers["content-type"],
503
- body: bodyToJson(request.body),
504
- headers: redactProxyHeadersForLogs(headers),
505
- }, "Proxy -> OpenCode request");
506
- }
507
- const init = {
508
- method: request.method,
509
- headers,
510
- redirect: "manual",
511
- };
512
- if (request.method !== "GET" && request.method !== "HEAD") {
513
- const body = toProxyRequestBody(request.body);
514
- if (body !== undefined) {
515
- init.body = body;
516
- init.duplex = "half";
517
- }
518
- }
519
- try {
520
- const response = await fetch(targetUrl, init);
521
- reply.code(response.status);
522
- applyInstanceProxyResponseHeaders(reply, response);
523
- if (!response.body || request.method === "HEAD") {
524
- reply.send();
525
- return;
526
- }
527
- reply.hijack();
528
- reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()));
529
- await pipeline(Readable.fromWeb(response.body), reply.raw);
530
- }
531
- catch (error) {
532
- logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request");
533
- if (!reply.sent) {
534
- reply.code(502).send({ error: "Workspace instance proxy failed" });
535
- }
536
- }
492
+ return reply.from(targetUrl, {
493
+ rewriteRequestHeaders: (_originalRequest, headers) => {
494
+ if (instanceAuthHeader) {
495
+ headers.authorization = instanceAuthHeader;
496
+ }
497
+ // OpenCode expects the *full* path; we send it via header to avoid query tampering.
498
+ const isNonASCII = /[^\x00-\x7F]/.test(directory);
499
+ const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory;
500
+ headers["x-opencode-directory"] = encodedDirectory;
501
+ if (logger.isLevelEnabled("trace")) {
502
+ const outgoing = {};
503
+ for (const [key, value] of Object.entries(headers)) {
504
+ outgoing[key] = value;
505
+ }
506
+ // Redact sensitive headers.
507
+ for (const key of Object.keys(outgoing)) {
508
+ const lower = key.toLowerCase();
509
+ if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
510
+ outgoing[key] = "<redacted>";
511
+ }
512
+ }
513
+ logger.trace({
514
+ workspaceId,
515
+ method: request.method,
516
+ targetUrl,
517
+ worktreeSlug,
518
+ directory,
519
+ contentType: request.headers["content-type"],
520
+ body: bodyToJson(request.body),
521
+ headers: outgoing,
522
+ }, "Proxy -> OpenCode request");
523
+ }
524
+ return headers;
525
+ },
526
+ onError: (proxyReply, { error }) => {
527
+ logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request");
528
+ if (!proxyReply.sent) {
529
+ proxyReply.code(502).send({ error: "Workspace instance proxy failed" });
530
+ }
531
+ },
532
+ });
537
533
  }
538
534
  function extractOpencodeDirectoryOverride(pathSuffix) {
539
535
  if (!pathSuffix) {
@@ -696,78 +692,12 @@ function isApiRequest(rawUrl) {
696
692
  function buildProxyHeaders(headers) {
697
693
  const result = {};
698
694
  for (const [key, value] of Object.entries(headers ?? {})) {
699
- const lower = key.toLowerCase();
700
- if (!value || lower === "host" || isHopByHopHeader(lower))
695
+ if (!value || key.toLowerCase() === "host")
701
696
  continue;
702
697
  result[key] = Array.isArray(value) ? value.join(",") : value;
703
698
  }
704
699
  return result;
705
700
  }
706
- function toProxyRequestBody(body) {
707
- if (body == null) {
708
- return undefined;
709
- }
710
- if (typeof body.pipe === "function") {
711
- return body;
712
- }
713
- if (typeof body[Symbol.asyncIterator] === "function") {
714
- return body;
715
- }
716
- if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
717
- return body;
718
- }
719
- return JSON.stringify(body);
720
- }
721
- function buildWorkspaceInstanceProxyHeaders(headers, instanceAuthHeader, directory) {
722
- const next = buildProxyHeaders(headers);
723
- if (instanceAuthHeader) {
724
- next.authorization = instanceAuthHeader;
725
- }
726
- const isNonASCII = /[^\x00-\x7F]/.test(directory);
727
- next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory;
728
- return next;
729
- }
730
- function redactProxyHeadersForLogs(headers) {
731
- const outgoing = { ...headers };
732
- for (const key of Object.keys(outgoing)) {
733
- const lower = key.toLowerCase();
734
- if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
735
- outgoing[key] = "<redacted>";
736
- }
737
- }
738
- return outgoing;
739
- }
740
- function applyInstanceProxyResponseHeaders(reply, response) {
741
- response.headers.forEach((value, key) => {
742
- const lower = key.toLowerCase();
743
- if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
744
- return;
745
- }
746
- reply.header(key, value);
747
- });
748
- }
749
- function toOutgoingHeaders(headers) {
750
- const next = {};
751
- for (const [key, value] of Object.entries(headers)) {
752
- if (value === undefined) {
753
- continue;
754
- }
755
- next[key] = Array.isArray(value) ? value.map(String) : String(value);
756
- }
757
- return next;
758
- }
759
- function isHopByHopHeader(name) {
760
- return new Set([
761
- "connection",
762
- "keep-alive",
763
- "proxy-authenticate",
764
- "proxy-authorization",
765
- "te",
766
- "trailer",
767
- "transfer-encoding",
768
- "upgrade",
769
- ]).has(name);
770
- }
771
701
  async function proxySideCarRequest(args) {
772
702
  const sidecarId = args.request.params.id ?? "";
773
703
  const sidecar = await args.sidecarManager.get(sidecarId);
@@ -1,7 +1,6 @@
1
1
  import fs from "fs";
2
2
  import { z } from "zod";
3
3
  import { isLoopbackAddress } from "../../auth/http-auth";
4
- import { resolveAuthTemplatePath } from "../../runtime-paths";
5
4
  const LoginSchema = z.object({
6
5
  username: z.string().min(1),
7
6
  password: z.string().min(1),
@@ -12,26 +11,26 @@ const TokenSchema = z.object({
12
11
  const PasswordSchema = z.object({
13
12
  password: z.string().min(8),
14
13
  });
15
- const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html");
16
- const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html");
14
+ const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url);
15
+ const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url);
17
16
  let cachedLoginTemplate = null;
18
17
  let cachedTokenTemplate = null;
19
- function readTemplate(filePath, cache) {
18
+ function readTemplate(url, cache) {
20
19
  if (cache)
21
20
  return cache;
22
- const content = fs.readFileSync(filePath, "utf-8");
21
+ const content = fs.readFileSync(url, "utf-8");
23
22
  return content;
24
23
  }
25
24
  function getLoginHtml(defaultUsername) {
26
25
  if (!cachedLoginTemplate) {
27
- cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null);
26
+ cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null);
28
27
  }
29
28
  const escapedUsername = escapeHtml(defaultUsername);
30
29
  return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername);
31
30
  }
32
31
  function getTokenHtml() {
33
32
  if (!cachedTokenTemplate) {
34
- cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null);
33
+ cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null);
35
34
  }
36
35
  return cachedTokenTemplate;
37
36
  }
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { listWorktrees } from "../git-worktrees";
7
+ describe("listWorktrees", () => {
8
+ it("uses the selected workspace folder for the root worktree directory", async () => {
9
+ const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-"));
10
+ const binDir = path.join(temp, "bin");
11
+ const repoRoot = path.join(temp, "repo");
12
+ const workspaceFolder = path.join(repoRoot, "proj-1");
13
+ const originalPath = process.env.PATH;
14
+ try {
15
+ mkdirSync(binDir, { recursive: true });
16
+ mkdirSync(workspaceFolder, { recursive: true });
17
+ const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git");
18
+ const porcelain = [
19
+ `worktree ${repoRoot}`,
20
+ "HEAD 1111111",
21
+ "branch refs/heads/main",
22
+ "",
23
+ ].join("\n");
24
+ if (process.platform === "win32") {
25
+ writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`);
26
+ }
27
+ else {
28
+ writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 });
29
+ }
30
+ process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
31
+ const worktrees = await listWorktrees({ repoRoot, workspaceFolder });
32
+ assert.equal(worktrees[0]?.slug, "root");
33
+ assert.equal(worktrees[0]?.directory, workspaceFolder);
34
+ assert.equal(worktrees[0]?.kind, "root");
35
+ assert.equal(worktrees[0]?.branch, "main");
36
+ assert.notEqual(worktrees[0]?.directory, repoRoot);
37
+ }
38
+ finally {
39
+ process.env.PATH = originalPath;
40
+ rmSync(temp, { recursive: true, force: true });
41
+ }
42
+ });
43
+ });
@@ -87,7 +87,7 @@ export async function listWorktrees(params) {
87
87
  const { repoRoot, workspaceFolder, logger } = params;
88
88
  const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder);
89
89
  if (!result.ok) {
90
- const rootDescriptor = { slug: "root", directory: repoRoot, kind: "root" };
90
+ const rootDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" };
91
91
  logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only");
92
92
  return [rootDescriptor];
93
93
  }
@@ -95,7 +95,7 @@ export async function listWorktrees(params) {
95
95
  const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot));
96
96
  const rootDescriptor = {
97
97
  slug: "root",
98
- directory: repoRoot,
98
+ directory: workspaceFolder,
99
99
  kind: "root",
100
100
  branch: rootRecord?.branch,
101
101
  };
@@ -6,60 +6,8 @@ import { searchWorkspaceFiles } from "../filesystem/search";
6
6
  import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
7
7
  import { WorkspaceRuntime } from "./runtime";
8
8
  import { getOpencodeConfigDir } from "../opencode-config.js";
9
- import { buildOpencodeBasicAuthHeader, DEFAULT_OPENCODE_USERNAME, generateOpencodeServerPassword, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, } from "./opencode-auth";
9
+ import { buildOpencodeBasicAuthHeader, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth";
10
10
  const STARTUP_STABILITY_DELAY_MS = 1500;
11
- function defaultShellPath() {
12
- const configured = process.env.SHELL?.trim();
13
- if (configured) {
14
- return configured;
15
- }
16
- return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
17
- }
18
- function shellEscape(input) {
19
- if (!input)
20
- return "''";
21
- return `'${input.replace(/'/g, `'\\''`)}'`;
22
- }
23
- function wrapCommandForShell(command, shellPath) {
24
- const shellName = path.basename(shellPath).toLowerCase();
25
- if (shellName.includes("bash")) {
26
- return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`;
27
- }
28
- if (shellName.includes("zsh")) {
29
- return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`;
30
- }
31
- return command;
32
- }
33
- function buildShellArgs(shellPath, command) {
34
- const shellName = path.basename(shellPath).toLowerCase();
35
- if (shellName.includes("zsh")) {
36
- return ["-l", "-i", "-c", command];
37
- }
38
- return ["-l", "-c", command];
39
- }
40
- function resolveBinaryPathFromUserShell(identifier) {
41
- if (process.platform === "win32") {
42
- return null;
43
- }
44
- const shellPath = defaultShellPath();
45
- const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath);
46
- const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
47
- encoding: "utf8",
48
- env: {
49
- ...process.env,
50
- npm_config_prefix: undefined,
51
- NPM_CONFIG_PREFIX: undefined,
52
- },
53
- });
54
- if (result.status !== 0) {
55
- return null;
56
- }
57
- const resolved = String(result.stdout ?? "")
58
- .split(/\r?\n/)
59
- .map((line) => line.trim())
60
- .find((line) => line.length > 0);
61
- return resolved ?? null;
62
- }
63
11
  export class WorkspaceManager {
64
12
  constructor(options) {
65
13
  this.options = options;
@@ -129,8 +77,10 @@ export class WorkspaceManager {
129
77
  const serverConfig = this.options.settings.getOwner("config", "server");
130
78
  const envVars = serverConfig?.environmentVariables;
131
79
  const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? envVars : {};
132
- const opencodeUsername = DEFAULT_OPENCODE_USERNAME;
133
- const opencodePassword = generateOpencodeServerPassword();
80
+ const { username: opencodeUsername, password: opencodePassword } = resolveOpencodeServerAuth({
81
+ userEnvironment,
82
+ processEnv: process.env,
83
+ });
134
84
  const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword });
135
85
  if (!authorization) {
136
86
  throw new Error("Failed to build OpenCode auth header");
@@ -252,11 +202,6 @@ export class WorkspaceManager {
252
202
  catch (error) {
253
203
  this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH");
254
204
  }
255
- const shellResolved = resolveBinaryPathFromUserShell(identifier);
256
- if (shellResolved) {
257
- this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell");
258
- return shellResolved;
259
- }
260
205
  return identifier;
261
206
  }
262
207
  pickBinaryCandidate(candidates) {
@@ -5,6 +5,23 @@ export const DEFAULT_OPENCODE_USERNAME = "codenomad";
5
5
  export function generateOpencodeServerPassword() {
6
6
  return crypto.randomBytes(32).toString("base64url");
7
7
  }
8
+ function readConfiguredValue(key, ...sources) {
9
+ for (const source of sources) {
10
+ const value = source?.[key];
11
+ if (typeof value === "string" && value.trim().length > 0) {
12
+ return value;
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ export function resolveOpencodeServerAuth(options = {}) {
18
+ const generatePassword = options.generatePassword ?? generateOpencodeServerPassword;
19
+ const username = readConfiguredValue(OPENCODE_SERVER_USERNAME_ENV, options.userEnvironment, options.processEnv) ??
20
+ DEFAULT_OPENCODE_USERNAME;
21
+ const password = readConfiguredValue(OPENCODE_SERVER_PASSWORD_ENV, options.userEnvironment, options.processEnv) ??
22
+ generatePassword();
23
+ return { username, password };
24
+ }
8
25
  export function buildOpencodeBasicAuthHeader(params) {
9
26
  const username = params.username;
10
27
  const password = params.password;
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { resolveOpencodeServerAuth } from "./opencode-auth";
4
+ describe("resolveOpencodeServerAuth", () => {
5
+ it("uses configured OpenCode auth from workspace environment", () => {
6
+ const auth = resolveOpencodeServerAuth({
7
+ userEnvironment: {
8
+ OPENCODE_SERVER_USERNAME: "alice",
9
+ OPENCODE_SERVER_PASSWORD: "secret",
10
+ },
11
+ processEnv: {},
12
+ generatePassword: () => "generated",
13
+ });
14
+ assert.deepEqual(auth, { username: "alice", password: "secret" });
15
+ });
16
+ it("uses process environment when workspace environment does not provide credentials", () => {
17
+ const auth = resolveOpencodeServerAuth({
18
+ userEnvironment: {},
19
+ processEnv: {
20
+ OPENCODE_SERVER_PASSWORD: "process-secret",
21
+ },
22
+ generatePassword: () => "generated",
23
+ });
24
+ assert.deepEqual(auth, { username: "codenomad", password: "process-secret" });
25
+ });
26
+ it("falls back to generated credentials", () => {
27
+ const auth = resolveOpencodeServerAuth({
28
+ userEnvironment: {},
29
+ processEnv: {},
30
+ generatePassword: () => "generated",
31
+ });
32
+ assert.deepEqual(auth, { username: "codenomad", password: "generated" });
33
+ });
34
+ });