@neuralnomads/codenomad-dev 0.14.0-dev-20260420-04fc28c4 → 0.14.0-dev-20260421-1c317df6
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/dist/index.js +3 -2
- package/dist/opencode-config/package.json +2 -2
- package/dist/opencode-config.js +3 -14
- package/dist/runtime-paths.js +67 -0
- package/dist/server/http-server.js +112 -42
- package/dist/server/routes/auth.js +7 -6
- package/dist/server/routes/settings.js +1 -1
- package/dist/workspaces/__tests__/spawn.test.js +139 -0
- package/dist/workspaces/manager.js +57 -0
- package/dist/workspaces/runtime.js +54 -77
- package/dist/workspaces/spawn.js +219 -0
- package/package.json +8 -6
- package/public/assets/{ChangesTab-eUJ7HXjk.js → ChangesTab-Dt3idhm2.js} +2 -2
- package/public/assets/{DiffToolbar-C2gN4-DU.js → DiffToolbar-C-rXHRQB.js} +1 -1
- package/public/assets/{FilesTab-BtxCG1pv.js → FilesTab-DKzbJvzM.js} +2 -2
- package/public/assets/{GitChangesTab-BVOgrozk.js → GitChangesTab-CgTxNlE0.js} +2 -2
- package/public/assets/{SplitFilePanel-vPBNqijZ.js → SplitFilePanel-DhUmaW0S.js} +1 -1
- package/public/assets/{StatusTab-BLnPWyWk.js → StatusTab-Dbx6AkDk.js} +1 -1
- package/public/assets/{bundle-full-Ddu6qye3.js → bundle-full-DEU8L99_.js} +1 -1
- package/public/assets/{diff-viewer-BZeFsDG4.js → diff-viewer-uToKxdD4.js} +1 -1
- package/public/assets/{index-CcYvh3Uc.js → index--oqVn_K6.js} +1 -1
- package/public/assets/index-Btyhpe4o.js +1 -0
- package/public/assets/index-C6C534z4.js +1 -0
- package/public/assets/index-CMh7_0rm.js +1 -0
- package/public/assets/{index-s309YxXV.css → index-CWmlJG88.css} +1 -1
- package/public/assets/index-D7RMnYOz.js +1 -0
- package/public/assets/index-DE6KDkkL.js +2 -0
- package/public/assets/{index-C72ltV-E.js → index-DbMNXyVd.js} +1 -1
- package/public/assets/index-NrX0Q8eA.js +1 -0
- package/public/assets/index-z-uWAw1M.js +1 -0
- package/public/assets/{loading-CXx1HTRq.js → loading-CjZdFQ4L.js} +1 -1
- package/public/assets/main-3oghJUx8.js +48 -0
- package/public/assets/{markdown-CB_rJhcV.js → markdown-Zg4mawQS.js} +3 -3
- package/public/assets/{monaco-viewer-BAhYjr-N.js → monaco-viewer-DANembz4.js} +1 -1
- package/public/assets/{todo-CAxz-1Tz.js → todo-kNdm04Eg.js} +1 -1
- package/public/assets/tool-call-2zz7f8xn.js +60 -0
- package/public/assets/{unified-picker-d0K5E9QI.js → unified-picker-Be5bKL4U.js} +1 -1
- package/public/assets/{wrap-text-CPc0o1wr.js → wrap-text-DngNWpNA.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/index-BGjy4SXE.js +0 -1
- package/public/assets/index-BWikFX-R.js +0 -1
- package/public/assets/index-C_TDxjfc.js +0 -2
- package/public/assets/index-CmAwr57o.js +0 -1
- package/public/assets/index-DL8_CQ8W.js +0 -1
- package/public/assets/index-DoLglUyU.js +0 -1
- package/public/assets/index-lqTLZJ_z.js +0 -1
- package/public/assets/main-DAirHb0u.js +0 -48
- package/public/assets/tool-call-ZqXDG9uG.js +0 -60
package/dist/index.js
CHANGED
|
@@ -28,11 +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
32
|
const require = createRequire(import.meta.url);
|
|
32
|
-
const packageJson =
|
|
33
|
+
const packageJson = { version: readServerPackageVersion(import.meta.url) };
|
|
33
34
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
35
|
const __dirname = path.dirname(__filename);
|
|
35
|
-
const DEFAULT_UI_STATIC_DIR =
|
|
36
|
+
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url);
|
|
36
37
|
const DEFAULT_HOST = "127.0.0.1";
|
|
37
38
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json";
|
|
38
39
|
const DEFAULT_HTTPS_PORT = 9898;
|
package/dist/opencode-config.js
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
4
2
|
import { createLogger } from "./logger";
|
|
3
|
+
import { resolveOpencodeTemplateDir } from "./runtime-paths";
|
|
5
4
|
const log = createLogger({ component: "opencode-config" });
|
|
6
|
-
const
|
|
7
|
-
const
|
|
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];
|
|
5
|
+
const templateDir = resolveOpencodeTemplateDir(import.meta.url);
|
|
6
|
+
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER);
|
|
18
7
|
export function getOpencodeConfigDir() {
|
|
19
8
|
if (!existsSync(templateDir)) {
|
|
20
9
|
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
function safeModuleDir(importMetaUrl) {
|
|
5
|
+
try {
|
|
6
|
+
return path.dirname(fileURLToPath(importMetaUrl));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function firstExistingPath(candidates, predicate) {
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
if (!candidate)
|
|
15
|
+
continue;
|
|
16
|
+
if (predicate(candidate)) {
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function getPackagedDistDir() {
|
|
23
|
+
return path.dirname(process.execPath);
|
|
24
|
+
}
|
|
25
|
+
export function resolveServerPackageRoot(importMetaUrl) {
|
|
26
|
+
const moduleDir = safeModuleDir(importMetaUrl);
|
|
27
|
+
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim();
|
|
28
|
+
const candidates = [
|
|
29
|
+
configuredRoot ? path.resolve(configuredRoot) : null,
|
|
30
|
+
moduleDir ? path.resolve(moduleDir, "..") : null,
|
|
31
|
+
path.resolve(getPackagedDistDir(), ".."),
|
|
32
|
+
];
|
|
33
|
+
return (firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
|
|
34
|
+
candidates.find((value) => Boolean(value)) ??
|
|
35
|
+
process.cwd());
|
|
36
|
+
}
|
|
37
|
+
export function resolveServerPublicDir(importMetaUrl) {
|
|
38
|
+
const moduleDir = safeModuleDir(importMetaUrl);
|
|
39
|
+
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")];
|
|
40
|
+
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1];
|
|
41
|
+
}
|
|
42
|
+
export function resolveAuthTemplatePath(importMetaUrl, fileName) {
|
|
43
|
+
const moduleDir = safeModuleDir(importMetaUrl);
|
|
44
|
+
const distDir = getPackagedDistDir();
|
|
45
|
+
const candidates = [
|
|
46
|
+
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
|
|
47
|
+
path.join(distDir, "auth-pages", fileName),
|
|
48
|
+
path.join(distDir, "server", "routes", "auth-pages", fileName),
|
|
49
|
+
];
|
|
50
|
+
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0];
|
|
51
|
+
}
|
|
52
|
+
export function resolveOpencodeTemplateDir(importMetaUrl) {
|
|
53
|
+
const moduleDir = safeModuleDir(importMetaUrl);
|
|
54
|
+
const resourcesPath = process.resourcesPath;
|
|
55
|
+
const candidates = [
|
|
56
|
+
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
|
|
57
|
+
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
|
|
58
|
+
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
|
|
59
|
+
path.join(getPackagedDistDir(), "opencode-config"),
|
|
60
|
+
];
|
|
61
|
+
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1];
|
|
62
|
+
}
|
|
63
|
+
export function readServerPackageVersion(importMetaUrl) {
|
|
64
|
+
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json");
|
|
65
|
+
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
66
|
+
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0";
|
|
67
|
+
}
|
|
@@ -5,6 +5,8 @@ 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";
|
|
8
10
|
import { connect as connectTls } from "tls";
|
|
9
11
|
import { fetch } from "undici";
|
|
10
12
|
import { isValidWorktreeSlug } from "../workspaces/git-worktrees";
|
|
@@ -489,47 +491,49 @@ async function proxyWorkspaceRequest(args) {
|
|
|
489
491
|
if (logger.isLevelEnabled("trace")) {
|
|
490
492
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload");
|
|
491
493
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
headers["
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|
|
533
537
|
}
|
|
534
538
|
function extractOpencodeDirectoryOverride(pathSuffix) {
|
|
535
539
|
if (!pathSuffix) {
|
|
@@ -692,12 +696,78 @@ function isApiRequest(rawUrl) {
|
|
|
692
696
|
function buildProxyHeaders(headers) {
|
|
693
697
|
const result = {};
|
|
694
698
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
695
|
-
|
|
699
|
+
const lower = key.toLowerCase();
|
|
700
|
+
if (!value || lower === "host" || isHopByHopHeader(lower))
|
|
696
701
|
continue;
|
|
697
702
|
result[key] = Array.isArray(value) ? value.join(",") : value;
|
|
698
703
|
}
|
|
699
704
|
return result;
|
|
700
705
|
}
|
|
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
|
+
}
|
|
701
771
|
async function proxySideCarRequest(args) {
|
|
702
772
|
const sidecarId = args.request.params.id ?? "";
|
|
703
773
|
const sidecar = await args.sidecarManager.get(sidecarId);
|
|
@@ -1,6 +1,7 @@
|
|
|
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";
|
|
4
5
|
const LoginSchema = z.object({
|
|
5
6
|
username: z.string().min(1),
|
|
6
7
|
password: z.string().min(1),
|
|
@@ -11,26 +12,26 @@ const TokenSchema = z.object({
|
|
|
11
12
|
const PasswordSchema = z.object({
|
|
12
13
|
password: z.string().min(8),
|
|
13
14
|
});
|
|
14
|
-
const
|
|
15
|
-
const
|
|
15
|
+
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html");
|
|
16
|
+
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html");
|
|
16
17
|
let cachedLoginTemplate = null;
|
|
17
18
|
let cachedTokenTemplate = null;
|
|
18
|
-
function readTemplate(
|
|
19
|
+
function readTemplate(filePath, cache) {
|
|
19
20
|
if (cache)
|
|
20
21
|
return cache;
|
|
21
|
-
const content = fs.readFileSync(
|
|
22
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
22
23
|
return content;
|
|
23
24
|
}
|
|
24
25
|
function getLoginHtml(defaultUsername) {
|
|
25
26
|
if (!cachedLoginTemplate) {
|
|
26
|
-
cachedLoginTemplate = readTemplate(
|
|
27
|
+
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null);
|
|
27
28
|
}
|
|
28
29
|
const escapedUsername = escapeHtml(defaultUsername);
|
|
29
30
|
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername);
|
|
30
31
|
}
|
|
31
32
|
function getTokenHtml() {
|
|
32
33
|
if (!cachedTokenTemplate) {
|
|
33
|
-
cachedTokenTemplate = readTemplate(
|
|
34
|
+
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null);
|
|
34
35
|
}
|
|
35
36
|
return cachedTokenTemplate;
|
|
36
37
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { probeBinaryVersion } from "../../workspaces/
|
|
2
|
+
import { probeBinaryVersion } from "../../workspaces/spawn";
|
|
3
3
|
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config";
|
|
4
4
|
const ValidateBinarySchema = z.object({
|
|
5
5
|
path: z.string(),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn";
|
|
4
|
+
describe("parseWslUncPath", () => {
|
|
5
|
+
it("parses WSL UNC paths into distro and linux path", () => {
|
|
6
|
+
assert.deepEqual(parseWslUncPath(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
|
7
|
+
distro: "Ubuntu",
|
|
8
|
+
linuxPath: "/home/dev/.opencode/bin/opencode",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
it("supports the legacy wsl$ UNC prefix", () => {
|
|
12
|
+
assert.deepEqual(parseWslUncPath(String.raw `\\wsl$\Ubuntu\home\dev`), {
|
|
13
|
+
distro: "Ubuntu",
|
|
14
|
+
linuxPath: "/home/dev",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe("resolveWslWorkingDirectory", () => {
|
|
19
|
+
it("keeps WSL workspace folders in the same distro", () => {
|
|
20
|
+
assert.equal(JSON.stringify(resolveWslWorkingDirectory(String.raw `\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")), JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }));
|
|
21
|
+
});
|
|
22
|
+
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
|
23
|
+
assert.equal(JSON.stringify(resolveWslWorkingDirectory(String.raw `C:\Users\dev\workspace`, "Ubuntu")), JSON.stringify({ kind: "windows", path: String.raw `C:\Users\dev\workspace` }));
|
|
24
|
+
});
|
|
25
|
+
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
|
26
|
+
assert.equal(JSON.stringify(resolveWslWorkingDirectory(String.raw `\\server\share\workspace`, "Ubuntu")), JSON.stringify({ kind: "windows", path: String.raw `\\server\share\workspace` }));
|
|
27
|
+
});
|
|
28
|
+
it("rejects WSL workspace folders from a different distro", () => {
|
|
29
|
+
assert.equal(resolveWslWorkingDirectory(String.raw `\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("buildWindowsSpawnSpec", () => {
|
|
33
|
+
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
|
34
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve", "--port", "0"], {
|
|
35
|
+
cwd: String.raw `\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
|
36
|
+
env: {
|
|
37
|
+
OPENCODE_CONFIG_DIR: String.raw `C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
|
38
|
+
CODENOMAD_INSTANCE_ID: "workspace-123",
|
|
39
|
+
OPENCODE_SERVER_PASSWORD: "secret",
|
|
40
|
+
},
|
|
41
|
+
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
|
42
|
+
});
|
|
43
|
+
assert.equal(spec.command, "wsl.exe");
|
|
44
|
+
assert.deepEqual(spec.args, [
|
|
45
|
+
"--distribution",
|
|
46
|
+
"Ubuntu",
|
|
47
|
+
"--cd",
|
|
48
|
+
"/home/dev/workspace",
|
|
49
|
+
"--exec",
|
|
50
|
+
"/home/dev/.opencode/bin/opencode",
|
|
51
|
+
"serve",
|
|
52
|
+
"--port",
|
|
53
|
+
"0",
|
|
54
|
+
]);
|
|
55
|
+
assert.equal(spec.cwd, undefined);
|
|
56
|
+
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD");
|
|
57
|
+
});
|
|
58
|
+
it("upgrades existing WSLENV path entries to include /p", () => {
|
|
59
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], {
|
|
60
|
+
env: {
|
|
61
|
+
OPENCODE_CONFIG_DIR: String.raw `C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
|
62
|
+
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
|
63
|
+
},
|
|
64
|
+
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
|
65
|
+
});
|
|
66
|
+
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u");
|
|
67
|
+
});
|
|
68
|
+
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
|
69
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], {
|
|
70
|
+
env: {
|
|
71
|
+
NODE_EXTRA_CA_CERTS: String.raw `C:\certs\root.pem`,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p");
|
|
75
|
+
});
|
|
76
|
+
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
|
77
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve", "--port", "0"], {
|
|
78
|
+
cwd: String.raw `C:\Users\dev\workspace`,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(spec.command, "wsl.exe");
|
|
81
|
+
assert.deepEqual(spec.args, [
|
|
82
|
+
"--distribution",
|
|
83
|
+
"Ubuntu",
|
|
84
|
+
"--exec",
|
|
85
|
+
"sh",
|
|
86
|
+
"-lc",
|
|
87
|
+
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
|
88
|
+
"codenomad-wsl-launch",
|
|
89
|
+
String.raw `C:\Users\dev\workspace`,
|
|
90
|
+
"/home/dev/.opencode/bin/opencode",
|
|
91
|
+
"serve",
|
|
92
|
+
"--port",
|
|
93
|
+
"0",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
it("uses wslpath for UNC network workspace folders", () => {
|
|
97
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], {
|
|
98
|
+
cwd: String.raw `\\server\share\workspace`,
|
|
99
|
+
});
|
|
100
|
+
assert.equal(spec.command, "wsl.exe");
|
|
101
|
+
assert.deepEqual(spec.args, [
|
|
102
|
+
"--distribution",
|
|
103
|
+
"Ubuntu",
|
|
104
|
+
"--exec",
|
|
105
|
+
"sh",
|
|
106
|
+
"-lc",
|
|
107
|
+
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
|
108
|
+
"codenomad-wsl-launch",
|
|
109
|
+
String.raw `\\server\share\workspace`,
|
|
110
|
+
"/home/dev/.opencode/bin/opencode",
|
|
111
|
+
"serve",
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
|
115
|
+
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], {
|
|
116
|
+
cwd: String.raw `\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
|
117
|
+
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
|
118
|
+
});
|
|
119
|
+
assert.equal(spec.command, "wsl.exe");
|
|
120
|
+
assert.deepEqual(spec.args, [
|
|
121
|
+
"--distribution",
|
|
122
|
+
"Ubuntu",
|
|
123
|
+
"--exec",
|
|
124
|
+
"sh",
|
|
125
|
+
"-lc",
|
|
126
|
+
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
|
127
|
+
"codenomad-wsl-launch",
|
|
128
|
+
"/home/dev/workspace",
|
|
129
|
+
"/home/dev/.opencode/bin/opencode",
|
|
130
|
+
"serve",
|
|
131
|
+
]);
|
|
132
|
+
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:");
|
|
133
|
+
});
|
|
134
|
+
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
|
135
|
+
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM");
|
|
136
|
+
assert.equal(spec.command, "wsl.exe");
|
|
137
|
+
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -8,6 +8,58 @@ import { WorkspaceRuntime } from "./runtime";
|
|
|
8
8
|
import { getOpencodeConfigDir } from "../opencode-config.js";
|
|
9
9
|
import { buildOpencodeBasicAuthHeader, DEFAULT_OPENCODE_USERNAME, generateOpencodeServerPassword, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, } 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
|
+
}
|
|
11
63
|
export class WorkspaceManager {
|
|
12
64
|
constructor(options) {
|
|
13
65
|
this.options = options;
|
|
@@ -200,6 +252,11 @@ export class WorkspaceManager {
|
|
|
200
252
|
catch (error) {
|
|
201
253
|
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH");
|
|
202
254
|
}
|
|
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
|
+
}
|
|
203
260
|
return identifier;
|
|
204
261
|
}
|
|
205
262
|
pickBinaryCandidate(candidates) {
|