@neuralnomads/codenomad-dev 0.18.0-dev-20260721-d74a8a39 → 0.18.0-dev-20260731-1d4f7786
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/background-processes/manager.js +15 -30
- package/dist/background-processes/manager.test.js +123 -0
- package/dist/config/schema.js +2 -1
- package/dist/config/schema.test.js +13 -0
- package/dist/filesystem/__tests__/search-cache.test.js +19 -7
- package/dist/filesystem/__tests__/search.test.js +53 -0
- package/dist/filesystem/search-cache.js +5 -9
- package/dist/filesystem/search.js +22 -9
- package/dist/server/routes/auth-pages/login.html +3 -3
- package/dist/server/routes/auth.js +1 -1
- package/dist/server/routes/auth.test.js +21 -0
- package/dist/workspaces/instance-client.js +6 -5
- package/dist/workspaces/instance-client.test.js +149 -0
- package/dist/workspaces/instance-events.js +2 -2
- package/dist/workspaces/loopback.js +8 -0
- package/dist/workspaces/manager.js +3 -2
- package/dist/workspaces/process-identity.js +28 -10
- package/dist/workspaces/process-identity.test.js +29 -0
- package/dist/workspaces/runtime.js +1 -1
- package/dist/workspaces/runtime.test.js +8 -0
- package/package.json +1 -1
- package/public/assets/{FilesTab-DRWiHv0M.js → FilesTab-pl_3_Ppx.js} +2 -2
- package/public/assets/{GitChangesTab-Bby-dRmu.js → GitChangesTab-COJ_xDva.js} +2 -2
- package/public/assets/{SplitFilePanel-C1aKE1hq.js → SplitFilePanel-B5nrgBs3.js} +1 -1
- package/public/assets/StatusTab-rXgEwwYW.js +1 -0
- package/public/assets/{align-justify-Ce1uZY4E.js → align-justify-CKDczyVy.js} +1 -1
- package/public/assets/{bundle-full-CWkY9-v4.js → bundle-full-CuWt9dTK.js} +1 -1
- package/public/assets/{diff-viewer-ChdWzmb2.js → diff-viewer-drQ_Yje8.js} +1 -1
- package/public/assets/index-Bt9PdFAD.css +1 -0
- package/public/assets/index-BymyGk2C.js +1 -0
- package/public/assets/index-CEIEYgCg.js +1 -0
- package/public/assets/{index-DOprq6Gt.js → index-CozaqOFM.js} +1 -1
- package/public/assets/index-Cqo6ftJT.js +1 -0
- package/public/assets/index-Cs1fQOLl.js +1 -0
- package/public/assets/{index-DM1cpVNo.js → index-Cs2gBaWC.js} +1 -1
- package/public/assets/index-CwhvpTeE.js +1 -0
- package/public/assets/index-D0xuNxlo.js +2 -0
- package/public/assets/index-D1HW4nyh.js +1 -0
- package/public/assets/index-DBw0YE-2.js +1 -0
- package/public/assets/index-OYNGtp3z.js +1 -0
- package/public/assets/{loading-B8b-5OAQ.js → loading-Cz4dKVP8.js} +1 -1
- package/public/assets/main-BEYytlgD.js +68 -0
- package/public/assets/{markdown-DfOGkX-q.js → markdown-DUHZyBXo.js} +3 -3
- package/public/assets/{monaco-viewer-DM35aobI.js → monaco-viewer-CiFg18Ak.js} +11 -11
- package/public/assets/tool-call-C7FlzFBf.js +59 -0
- package/public/assets/unified-picker-CpU6qugd.js +1 -0
- package/public/assets/{wrap-text-D7C2RpdK.js → wrap-text-CS5K8Uhi.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/StatusTab-DGAe2jhy.js +0 -1
- package/public/assets/index-60Fu5kUB.js +0 -1
- package/public/assets/index-BhJHlWzk.js +0 -1
- package/public/assets/index-Cce2Hk2L.js +0 -1
- package/public/assets/index-CpB67B7z.js +0 -1
- package/public/assets/index-D3fvGcC7.js +0 -1
- package/public/assets/index-D8WMdSS9.js +0 -2
- package/public/assets/index-D_qqDz2d.js +0 -1
- package/public/assets/index-E0A5Kg-d.js +0 -1
- package/public/assets/index-L7fX3W8k.css +0 -1
- package/public/assets/index-j4B2FQw9.js +0 -1
- package/public/assets/main-B9uXiXu7.js +0 -68
- package/public/assets/tool-call-BpefPuGQ.js +0 -59
- package/public/assets/unified-picker-CXhvV1sd.js +0 -1
|
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "child_process";
|
|
|
2
2
|
import { createWriteStream, existsSync, promises as fs } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
+
import { createInstanceClient } from "../workspaces/instance-client";
|
|
5
6
|
const ROOT_DIR = ".codenomad/background_processes";
|
|
6
7
|
const INDEX_FILE = "index.json";
|
|
7
8
|
const OUTPUT_FILE = "output.txt";
|
|
@@ -511,38 +512,22 @@ export class BackgroundProcessManager {
|
|
|
511
512
|
const notify = record.notify;
|
|
512
513
|
if (!notify || !record.terminalReason)
|
|
513
514
|
return;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
const port = this.deps.workspaceManager.getInstancePort(workspaceId);
|
|
518
|
-
if (!port) {
|
|
519
|
-
throw new Error("Workspace instance is not ready");
|
|
520
|
-
}
|
|
521
|
-
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`;
|
|
522
|
-
const headers = {
|
|
523
|
-
"content-type": "application/json",
|
|
524
|
-
};
|
|
525
|
-
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
|
|
526
|
-
if (authorization) {
|
|
527
|
-
headers.authorization = authorization;
|
|
528
|
-
}
|
|
529
|
-
const response = await fetch(targetUrl, {
|
|
530
|
-
method: "POST",
|
|
531
|
-
headers,
|
|
532
|
-
body: JSON.stringify({
|
|
533
|
-
parts: [
|
|
534
|
-
{
|
|
535
|
-
type: "text",
|
|
536
|
-
text: this.buildSyntheticCompletionPrompt(record),
|
|
537
|
-
synthetic: true,
|
|
538
|
-
},
|
|
539
|
-
],
|
|
540
|
-
}),
|
|
515
|
+
const client = createInstanceClient(this.deps.workspaceManager, workspaceId, {
|
|
516
|
+
directory: notify.directory,
|
|
541
517
|
});
|
|
542
|
-
if (!
|
|
543
|
-
|
|
544
|
-
throw new Error(message || `Prompt request failed with ${response.status}`);
|
|
518
|
+
if (!client) {
|
|
519
|
+
throw new Error("Workspace instance is not ready");
|
|
545
520
|
}
|
|
521
|
+
await client.session.promptAsync({
|
|
522
|
+
sessionID: notify.sessionID,
|
|
523
|
+
parts: [
|
|
524
|
+
{
|
|
525
|
+
type: "text",
|
|
526
|
+
text: this.buildSyntheticCompletionPrompt(record),
|
|
527
|
+
synthetic: true,
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
}, { throwOnError: true });
|
|
546
531
|
}
|
|
547
532
|
buildCompletionPrompt(record) {
|
|
548
533
|
const ref = `Background process "${record.title}" (${record.id})`;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { BackgroundProcessManager } from "./manager";
|
|
7
|
+
const WORKSPACE_ID = "ws-test";
|
|
8
|
+
const SESSION_ID = "sess-1";
|
|
9
|
+
const INSTANCE_PORT = 9999;
|
|
10
|
+
const AUTH_HEADER = "Basic test-auth";
|
|
11
|
+
const TERMINAL_TIMEOUT_MS = 3000;
|
|
12
|
+
/**
|
|
13
|
+
* Drives the real {@link BackgroundProcessManager} lifecycle (spawn a
|
|
14
|
+
* fast-exiting command with notify enabled) against a mocked transport, so the
|
|
15
|
+
* migrated `sendCompletionPrompt` path — factory + SDK client + `fetch` — is
|
|
16
|
+
* exercised end to end without touching production wiring.
|
|
17
|
+
*
|
|
18
|
+
* The workspace temp directory is intentionally left in place (under
|
|
19
|
+
* `os.tmpdir()`, OS-reaped): removing it from the test races the manager's
|
|
20
|
+
* asynchronous finalization writes, which intermittently fail with ENOENT.
|
|
21
|
+
*/
|
|
22
|
+
async function runCompletionPrompt(fetchImpl) {
|
|
23
|
+
const requests = [];
|
|
24
|
+
const originalFetch = globalThis.fetch;
|
|
25
|
+
// Captured now but swapped in only inside the try below, so a failure during
|
|
26
|
+
// setup (mkdtemp, manager construction) can't leak the mocked fetch.
|
|
27
|
+
const fetchMock = (async (input, init) => {
|
|
28
|
+
const req = input instanceof Request ? input : new Request(String(input), init);
|
|
29
|
+
requests.push({
|
|
30
|
+
method: req.method,
|
|
31
|
+
url: req.url,
|
|
32
|
+
headers: req.headers,
|
|
33
|
+
body: await req.text(),
|
|
34
|
+
});
|
|
35
|
+
return fetchImpl(input instanceof Request ? input : req, init);
|
|
36
|
+
});
|
|
37
|
+
let warned = false;
|
|
38
|
+
const logger = {
|
|
39
|
+
warn: () => { warned = true; },
|
|
40
|
+
debug: () => { },
|
|
41
|
+
trace: () => { },
|
|
42
|
+
info: () => { },
|
|
43
|
+
error: () => { },
|
|
44
|
+
fatal: () => { },
|
|
45
|
+
isLevelEnabled: () => false,
|
|
46
|
+
level: "info",
|
|
47
|
+
child: () => logger,
|
|
48
|
+
};
|
|
49
|
+
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "bp-test-"));
|
|
50
|
+
// Distinct from the workspace root so the directory-override assertion is
|
|
51
|
+
// discriminating: if `sendCompletionPrompt` stops passing `notify.directory`,
|
|
52
|
+
// the factory would fall back to `workspacePath` and the header check fails.
|
|
53
|
+
const sessionDir = path.join(workspacePath, "session-worktree");
|
|
54
|
+
// Resolve once the manager publishes a terminal (non-running) status update.
|
|
55
|
+
let resolveTerminal = () => { };
|
|
56
|
+
const terminal = new Promise((resolve) => { resolveTerminal = resolve; });
|
|
57
|
+
const eventBus = {
|
|
58
|
+
on: () => { },
|
|
59
|
+
publish: (event) => {
|
|
60
|
+
if (event?.type === "instance.event") {
|
|
61
|
+
const type = event?.event?.type;
|
|
62
|
+
const status = event?.event?.properties?.process?.status;
|
|
63
|
+
if (type === "background.process.removed" || (status && status !== "running"))
|
|
64
|
+
resolveTerminal();
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const workspaceManager = {
|
|
70
|
+
get: () => ({ path: workspacePath }),
|
|
71
|
+
getInstancePort: () => INSTANCE_PORT,
|
|
72
|
+
getInstanceAuthorizationHeader: () => AUTH_HEADER,
|
|
73
|
+
};
|
|
74
|
+
const manager = new BackgroundProcessManager({ workspaceManager, eventBus, logger });
|
|
75
|
+
try {
|
|
76
|
+
globalThis.fetch = fetchMock;
|
|
77
|
+
await manager.start(WORKSPACE_ID, "test-proc", "true", {
|
|
78
|
+
notify: true,
|
|
79
|
+
notification: { sessionID: SESSION_ID, directory: sessionDir },
|
|
80
|
+
});
|
|
81
|
+
// The terminal status update is published at the very end of finalize, so
|
|
82
|
+
// resolving on it is a deterministic completion signal. Fail loudly rather
|
|
83
|
+
// than racing a silent timeout that could mask a hang.
|
|
84
|
+
let timeoutHandle;
|
|
85
|
+
const reachedTerminal = await Promise.race([
|
|
86
|
+
terminal.then(() => true),
|
|
87
|
+
new Promise((resolve) => {
|
|
88
|
+
timeoutHandle = setTimeout(() => resolve(false), TERMINAL_TIMEOUT_MS);
|
|
89
|
+
}),
|
|
90
|
+
]);
|
|
91
|
+
if (timeoutHandle)
|
|
92
|
+
clearTimeout(timeoutHandle);
|
|
93
|
+
if (!reachedTerminal) {
|
|
94
|
+
throw new Error("background process did not reach a terminal state in time");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
globalThis.fetch = originalFetch;
|
|
99
|
+
}
|
|
100
|
+
return { requests, warned, directory: sessionDir };
|
|
101
|
+
}
|
|
102
|
+
describe("BackgroundProcessManager.sendCompletionPrompt", () => {
|
|
103
|
+
it("posts the synthetic completion prompt to the instance via the SDK route", async () => {
|
|
104
|
+
const { requests, directory } = await runCompletionPrompt(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }));
|
|
105
|
+
const promptCall = requests.find((r) => r.url.includes("/prompt_async"));
|
|
106
|
+
assert.ok(promptCall, "expected a prompt_async request");
|
|
107
|
+
assert.equal(promptCall.method, "POST");
|
|
108
|
+
assert.equal(promptCall.url, `http://127.0.0.1:${INSTANCE_PORT}/session/${SESSION_ID}/prompt_async`);
|
|
109
|
+
assert.equal(promptCall.headers.get("authorization"), AUTH_HEADER);
|
|
110
|
+
// The prompt is scoped to the session's directory (a POST keeps the
|
|
111
|
+
// directory as a header — the SDK only rewrites header→query for GET/HEAD).
|
|
112
|
+
assert.equal(promptCall.headers.get("x-opencode-directory"), encodeURIComponent(directory));
|
|
113
|
+
const body = JSON.parse(promptCall.body);
|
|
114
|
+
assert.equal(body.parts.length, 1);
|
|
115
|
+
assert.equal(body.parts[0].type, "text");
|
|
116
|
+
assert.equal(body.parts[0].synthetic, true);
|
|
117
|
+
assert.match(body.parts[0].text, /test-proc/);
|
|
118
|
+
});
|
|
119
|
+
it("swallows a failed prompt and logs it without aborting finalization", async () => {
|
|
120
|
+
const { warned } = await runCompletionPrompt(async () => new Response("boom", { status: 500 }));
|
|
121
|
+
assert.equal(warned, true);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/dist/config/schema.js
CHANGED
|
@@ -19,8 +19,9 @@ const PreferencesSchema = z
|
|
|
19
19
|
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
|
20
20
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
|
21
21
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
|
22
|
-
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
|
22
|
+
diagnosticsExpansion: z.enum(["hidden", "expanded", "collapsed"]).default("expanded"),
|
|
23
23
|
showUsageMetrics: z.boolean().default(true),
|
|
24
|
+
usageMetricsExpansion: z.enum(["expanded", "collapsed"]).default("collapsed"),
|
|
24
25
|
autoCleanupBlankSessions: z.boolean().default(true),
|
|
25
26
|
listeningMode: z.enum(["local", "all"]).default("local"),
|
|
26
27
|
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { PreferencesSchema } from "./schema";
|
|
4
|
+
describe("chat visibility preferences", () => {
|
|
5
|
+
it("accepts hidden diagnostics and collapsed usage metrics", () => {
|
|
6
|
+
const preferences = PreferencesSchema.parse({
|
|
7
|
+
diagnosticsExpansion: "hidden",
|
|
8
|
+
usageMetricsExpansion: "collapsed",
|
|
9
|
+
});
|
|
10
|
+
assert.equal(preferences.diagnosticsExpansion, "hidden");
|
|
11
|
+
assert.equal(preferences.usageMetricsExpansion, "collapsed");
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -8,25 +8,37 @@ describe("workspace search cache", () => {
|
|
|
8
8
|
it("expires cached candidates after the TTL", () => {
|
|
9
9
|
const workspacePath = "/tmp/workspace";
|
|
10
10
|
const startTime = 1_000;
|
|
11
|
-
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
|
|
12
|
-
const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
|
|
11
|
+
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], startTime);
|
|
12
|
+
const beforeExpiry = getWorkspaceCandidates(workspacePath, "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
|
|
13
13
|
assert.ok(beforeExpiry);
|
|
14
14
|
assert.equal(beforeExpiry.length, 1);
|
|
15
15
|
assert.equal(beforeExpiry[0].name, "file-a");
|
|
16
|
-
const afterExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
|
|
16
|
+
const afterExpiry = getWorkspaceCandidates(workspacePath, "query-a", startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
|
|
17
17
|
assert.equal(afterExpiry, undefined);
|
|
18
18
|
});
|
|
19
19
|
it("replaces cached entries when manually refreshed", () => {
|
|
20
20
|
const workspacePath = "/tmp/workspace";
|
|
21
|
-
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000);
|
|
22
|
-
const initial = getWorkspaceCandidates(workspacePath, 5_001);
|
|
21
|
+
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000);
|
|
22
|
+
const initial = getWorkspaceCandidates(workspacePath, "query-a", 5_001);
|
|
23
23
|
assert.ok(initial);
|
|
24
24
|
assert.equal(initial[0].name, "file-a");
|
|
25
|
-
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000);
|
|
26
|
-
const refreshed = getWorkspaceCandidates(workspacePath, 6_001);
|
|
25
|
+
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-b")], 6_000);
|
|
26
|
+
const refreshed = getWorkspaceCandidates(workspacePath, "query-a", 6_001);
|
|
27
27
|
assert.ok(refreshed);
|
|
28
28
|
assert.equal(refreshed[0].name, "file-b");
|
|
29
29
|
});
|
|
30
|
+
it("does not reuse candidates across query scopes", () => {
|
|
31
|
+
const workspacePath = "/tmp/workspace";
|
|
32
|
+
refreshWorkspaceCandidates(workspacePath, "query-a", () => [createEntry("file-a")], 5_000);
|
|
33
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001)?.[0].name, "file-a");
|
|
34
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined);
|
|
35
|
+
refreshWorkspaceCandidates(workspacePath, "query-b", () => [createEntry("file-b")], 5_000);
|
|
36
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined);
|
|
37
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001)?.[0].name, "file-b");
|
|
38
|
+
clearWorkspaceSearchCache(workspacePath);
|
|
39
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-a", 5_001), undefined);
|
|
40
|
+
assert.equal(getWorkspaceCandidates(workspacePath, "query-b", 5_001), undefined);
|
|
41
|
+
});
|
|
30
42
|
});
|
|
31
43
|
function createEntry(name) {
|
|
32
44
|
return {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { after, test } from "node:test";
|
|
6
|
+
import { searchWorkspaceFiles } from "../search";
|
|
7
|
+
import { getWorkspaceCandidates } from "../search-cache";
|
|
8
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-"));
|
|
9
|
+
after(() => fs.rmSync(workspace, { recursive: true, force: true }));
|
|
10
|
+
test("finds a matching file after more than 8000 non-matching entries", () => {
|
|
11
|
+
fs.mkdirSync(path.join(workspace, "a"));
|
|
12
|
+
fs.mkdirSync(path.join(workspace, "b"));
|
|
13
|
+
const [targetDirName, fillerDirName] = fs.readdirSync(workspace);
|
|
14
|
+
const fillerDir = path.join(workspace, fillerDirName);
|
|
15
|
+
const targetDir = path.join(workspace, targetDirName);
|
|
16
|
+
for (let index = 0; index < 8_001; index += 1) {
|
|
17
|
+
fs.writeFileSync(path.join(fillerDir, `filler-${index}.txt`), "");
|
|
18
|
+
}
|
|
19
|
+
fs.writeFileSync(path.join(targetDir, "unique-search-target.txt"), "");
|
|
20
|
+
const results = searchWorkspaceFiles(workspace, "unique-search-target", {
|
|
21
|
+
type: "file",
|
|
22
|
+
refresh: true,
|
|
23
|
+
});
|
|
24
|
+
assert.equal(results.some((entry) => entry.name === "unique-search-target.txt"), true);
|
|
25
|
+
searchWorkspaceFiles(workspace, "filler", { type: "file", refresh: true });
|
|
26
|
+
assert.equal(getWorkspaceCandidates(workspace, "file\0filler")?.length, 8_000);
|
|
27
|
+
});
|
|
28
|
+
test("does not revisit directory links", () => {
|
|
29
|
+
const cyclicWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-cycle-"));
|
|
30
|
+
try {
|
|
31
|
+
fs.symlinkSync(cyclicWorkspace, path.join(cyclicWorkspace, "cycle"), process.platform === "win32" ? "junction" : "dir");
|
|
32
|
+
assert.deepEqual(searchWorkspaceFiles(cyclicWorkspace, "not-present", { refresh: true }), []);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
fs.rmSync(cyclicWorkspace, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
test("indexes both real and linked directory paths", () => {
|
|
39
|
+
const linkedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-search-link-"));
|
|
40
|
+
try {
|
|
41
|
+
const realDirectory = path.join(linkedWorkspace, "b");
|
|
42
|
+
fs.mkdirSync(realDirectory);
|
|
43
|
+
fs.writeFileSync(path.join(realDirectory, "needle.txt"), "");
|
|
44
|
+
fs.symlinkSync(realDirectory, path.join(linkedWorkspace, "a"), process.platform === "win32" ? "junction" : "dir");
|
|
45
|
+
const linkedResults = searchWorkspaceFiles(linkedWorkspace, "a/needle", { type: "file", refresh: true });
|
|
46
|
+
const realResults = searchWorkspaceFiles(linkedWorkspace, "b/needle", { type: "file", refresh: true });
|
|
47
|
+
assert.equal(linkedResults.some((entry) => entry.path === "a/needle.txt"), true);
|
|
48
|
+
assert.equal(realResults.some((entry) => entry.path === "b/needle.txt"), true);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
fs.rmSync(linkedWorkspace, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000;
|
|
3
3
|
const workspaceCandidateCache = new Map();
|
|
4
|
-
export function getWorkspaceCandidates(rootDir, now = Date.now()) {
|
|
4
|
+
export function getWorkspaceCandidates(rootDir, scope, now = Date.now()) {
|
|
5
5
|
const key = normalizeKey(rootDir);
|
|
6
6
|
const cached = workspaceCandidateCache.get(key);
|
|
7
|
-
if (!cached) {
|
|
7
|
+
if (!cached || cached.scope !== scope) {
|
|
8
8
|
return undefined;
|
|
9
9
|
}
|
|
10
10
|
if (cached.expiresAt <= now) {
|
|
@@ -13,15 +13,12 @@ export function getWorkspaceCandidates(rootDir, now = Date.now()) {
|
|
|
13
13
|
}
|
|
14
14
|
return cloneEntries(cached.candidates);
|
|
15
15
|
}
|
|
16
|
-
export function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
|
|
16
|
+
export function refreshWorkspaceCandidates(rootDir, scope, builder, now = Date.now()) {
|
|
17
17
|
const key = normalizeKey(rootDir);
|
|
18
18
|
const freshCandidates = builder();
|
|
19
|
-
if (!freshCandidates || freshCandidates.length === 0) {
|
|
20
|
-
workspaceCandidateCache.delete(key);
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
19
|
const storedCandidates = cloneEntries(freshCandidates);
|
|
24
20
|
workspaceCandidateCache.set(key, {
|
|
21
|
+
scope,
|
|
25
22
|
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
|
26
23
|
candidates: storedCandidates,
|
|
27
24
|
});
|
|
@@ -32,8 +29,7 @@ export function clearWorkspaceSearchCache(rootDir) {
|
|
|
32
29
|
workspaceCandidateCache.clear();
|
|
33
30
|
return;
|
|
34
31
|
}
|
|
35
|
-
|
|
36
|
-
workspaceCandidateCache.delete(key);
|
|
32
|
+
workspaceCandidateCache.delete(normalizeKey(rootDir));
|
|
37
33
|
}
|
|
38
34
|
function cloneEntries(entries) {
|
|
39
35
|
return entries.map((entry) => ({ ...entry }));
|
|
@@ -15,13 +15,14 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
|
|
|
15
15
|
const limit = normalizeLimit(options.limit);
|
|
16
16
|
const typeFilter = options.type ?? "all";
|
|
17
17
|
const refreshRequested = options.refresh === true;
|
|
18
|
+
const cacheScope = `${typeFilter}\0${trimmedQuery.toLowerCase()}`;
|
|
18
19
|
let entries;
|
|
19
20
|
try {
|
|
20
21
|
if (!refreshRequested) {
|
|
21
|
-
entries = getWorkspaceCandidates(normalizedRoot);
|
|
22
|
+
entries = getWorkspaceCandidates(normalizedRoot, cacheScope);
|
|
22
23
|
}
|
|
23
24
|
if (!entries) {
|
|
24
|
-
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
|
|
25
|
+
entries = refreshWorkspaceCandidates(normalizedRoot, cacheScope, () => collectCandidates(normalizedRoot, trimmedQuery, typeFilter));
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
catch (error) {
|
|
@@ -29,7 +30,6 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
|
|
|
29
30
|
throw error;
|
|
30
31
|
}
|
|
31
32
|
if (!entries || entries.length === 0) {
|
|
32
|
-
clearWorkspaceSearchCache(normalizedRoot);
|
|
33
33
|
return [];
|
|
34
34
|
}
|
|
35
35
|
const candidates = buildCandidateEntries(entries, typeFilter);
|
|
@@ -45,14 +45,23 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
return matches.map((match) => match.obj.entry);
|
|
47
47
|
}
|
|
48
|
-
function collectCandidates(rootDir) {
|
|
49
|
-
const queue = [
|
|
48
|
+
function collectCandidates(rootDir, query, filter) {
|
|
49
|
+
const queue = [
|
|
50
|
+
{ relativeDir: "", ancestors: new Set() },
|
|
51
|
+
];
|
|
50
52
|
const entries = [];
|
|
51
53
|
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
|
52
|
-
const
|
|
54
|
+
const queuedDirectory = queue.pop();
|
|
55
|
+
const { relativeDir, ancestors } = queuedDirectory;
|
|
53
56
|
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;
|
|
54
57
|
let dirents;
|
|
58
|
+
let branchAncestors;
|
|
55
59
|
try {
|
|
60
|
+
const realDir = normalizeDirectoryIdentity(fs.realpathSync.native(absoluteDir));
|
|
61
|
+
if (ancestors.has(realDir)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
branchAncestors = new Set([...ancestors, realDir]);
|
|
56
65
|
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
|
57
66
|
}
|
|
58
67
|
catch {
|
|
@@ -75,9 +84,7 @@ function collectCandidates(rootDir) {
|
|
|
75
84
|
}
|
|
76
85
|
const isDirectory = stats.isDirectory();
|
|
77
86
|
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
|
78
|
-
|
|
79
|
-
queue.push(relativePath);
|
|
80
|
-
}
|
|
87
|
+
queue.push({ relativeDir: relativePath, ancestors: branchAncestors });
|
|
81
88
|
}
|
|
82
89
|
const entryType = isDirectory ? "directory" : "file";
|
|
83
90
|
const normalizedPath = normalizeRelativeEntryPath(relativePath);
|
|
@@ -89,6 +96,9 @@ function collectCandidates(rootDir) {
|
|
|
89
96
|
size: entryType === "file" ? stats.size : undefined,
|
|
90
97
|
modifiedAt: stats.mtime.toISOString(),
|
|
91
98
|
};
|
|
99
|
+
if (!shouldInclude(entry.type, filter) || !fuzzysort.single(query, buildSearchKey(entry))) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
92
102
|
entries.push(entry);
|
|
93
103
|
if (entries.length >= MAX_CANDIDATES) {
|
|
94
104
|
break;
|
|
@@ -133,3 +143,6 @@ function normalizeRelativeEntryPath(relativePath) {
|
|
|
133
143
|
function buildSearchKey(entry) {
|
|
134
144
|
return entry.path.toLowerCase();
|
|
135
145
|
}
|
|
146
|
+
function normalizeDirectoryIdentity(directoryPath) {
|
|
147
|
+
return process.platform === "win32" ? directoryPath.toLowerCase() : directoryPath;
|
|
148
|
+
}
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
|
73
73
|
|
|
74
74
|
<label for="username">Username</label>
|
|
75
|
-
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
|
75
|
+
<input id="username" autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="{{DEFAULT_USERNAME}}" value="{{DEFAULT_USERNAME}}" />
|
|
76
76
|
|
|
77
77
|
<label for="password">Password</label>
|
|
78
|
-
<input id="password" type="password" autocomplete="current-password" value="" />
|
|
78
|
+
<input id="password" type="password" autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false" value="" />
|
|
79
79
|
|
|
80
80
|
<button id="submit" type="button">Continue</button>
|
|
81
81
|
<div id="error" class="error" style="display: none"></div>
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
|
|
96
96
|
async function submit() {
|
|
97
97
|
hideError()
|
|
98
|
-
const username = $("username").value
|
|
98
|
+
const username = $("username").value
|
|
99
99
|
const password = $("password").value
|
|
100
100
|
if (!username || !password) {
|
|
101
101
|
showError("Username and password are required.")
|
|
@@ -26,7 +26,7 @@ function getLoginHtml(defaultUsername) {
|
|
|
26
26
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null);
|
|
27
27
|
}
|
|
28
28
|
const escapedUsername = escapeHtml(defaultUsername);
|
|
29
|
-
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername);
|
|
29
|
+
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, () => escapedUsername);
|
|
30
30
|
}
|
|
31
31
|
function getTokenHtml() {
|
|
32
32
|
if (!cachedTokenTemplate) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { it } from "node:test";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { registerAuthRoutes } from "./auth";
|
|
5
|
+
it("renders the escaped default username literally as the login field value", async () => {
|
|
6
|
+
const app = Fastify({ logger: false });
|
|
7
|
+
const username = " code$&\"nomad ";
|
|
8
|
+
const authManager = {
|
|
9
|
+
getSessionFromRequest: () => null,
|
|
10
|
+
getStatus: () => ({ username }),
|
|
11
|
+
};
|
|
12
|
+
registerAuthRoutes(app, { authManager });
|
|
13
|
+
const response = await app.inject({ method: "GET", url: "/login" });
|
|
14
|
+
assert.equal(response.statusCode, 200);
|
|
15
|
+
assert.ok(response.body.includes('value=" code$&"nomad "'));
|
|
16
|
+
assert.match(response.body, /const username = \$\("username"\)\.value\s*\n/);
|
|
17
|
+
assert.equal(response.body.match(/autocapitalize="none"/g)?.length, 2);
|
|
18
|
+
assert.equal(response.body.match(/autocorrect="off"/g)?.length, 2);
|
|
19
|
+
assert.equal(response.body.match(/spellcheck="false"/g)?.length, 2);
|
|
20
|
+
await app.close();
|
|
21
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
|
2
|
-
|
|
2
|
+
import { LOOPBACK_HOST } from "./loopback";
|
|
3
3
|
const LOOPBACK_TIMEOUT_MS = 10_000;
|
|
4
4
|
/**
|
|
5
5
|
* Creates an OpenCode SDK client for direct loopback communication with a
|
|
@@ -11,8 +11,8 @@ const LOOPBACK_TIMEOUT_MS = 10_000;
|
|
|
11
11
|
* OpenCode instance directly should use this factory rather than building
|
|
12
12
|
* `http://127.0.0.1:{port}/...` URLs by hand.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* near-instant; a hang indicates a stuck instance.
|
|
14
|
+
* Requests carry a 10-second timeout (configurable via `timeoutMs`) —
|
|
15
|
+
* loopback calls should be near-instant; a hang indicates a stuck instance.
|
|
16
16
|
*
|
|
17
17
|
* The client is cheap to create (object only, no connection); create one per
|
|
18
18
|
* call or cache per instance as needed. Returns `null` when the instance has
|
|
@@ -29,13 +29,14 @@ export function createInstanceClient(workspaceManager, instanceId, options = {})
|
|
|
29
29
|
}
|
|
30
30
|
const workspace = workspaceManager.get(instanceId);
|
|
31
31
|
const timeoutMs = options.timeoutMs ?? LOOPBACK_TIMEOUT_MS;
|
|
32
|
+
const directory = options.directory ?? workspace?.path;
|
|
32
33
|
return createOpencodeClient({
|
|
33
|
-
baseUrl: `http://${
|
|
34
|
+
baseUrl: `http://${LOOPBACK_HOST}:${port}/`,
|
|
34
35
|
headers,
|
|
35
36
|
fetch: (url, init) => fetch(url, {
|
|
36
37
|
...init,
|
|
37
38
|
signal: init?.signal ?? AbortSignal.timeout(timeoutMs),
|
|
38
39
|
}),
|
|
39
|
-
...(
|
|
40
|
+
...(directory ? { directory } : {}),
|
|
40
41
|
});
|
|
41
42
|
}
|