@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ // Node lifecycle: attach when a XERJ node already answers, otherwise spawn one
2
+ // detached so it outlives the OpenCode server and the index survives restarts.
3
+ // Verified on v1.0.0-rc.100: `--port` claims PORT, PORT+1 (native REST) and
4
+ // PORT+2 (gRPC), and startup fails if any is taken.
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
8
+ import { connect } from "node:net";
9
+ import { join } from "node:path";
10
+ import type { Logger } from "../log.ts";
11
+ import { nodeDataDir, nodeLogPath, nodePidPath } from "../paths.ts";
12
+
13
+ export type NodeState = "xerj" | "foreign" | "down";
14
+
15
+ export interface NodeHandle {
16
+ readonly baseUrl: string;
17
+ readonly port: number;
18
+ readonly pid: number | null;
19
+ readonly spawned: boolean;
20
+ readonly version: string;
21
+ }
22
+
23
+ export interface EnsureNodeOptions {
24
+ readonly binaryPath: string;
25
+ /** XERJ release the binary reports; the banner only carries the wire version. */
26
+ readonly binaryVersion: string;
27
+ readonly root: string;
28
+ readonly dataDir?: string | null;
29
+ readonly preferredPort: number;
30
+ readonly persistedPort: number | null;
31
+ readonly onPort?: (port: number) => Promise<void>;
32
+ readonly logger: Logger;
33
+ readonly explicitUrl?: string | null;
34
+ readonly startupTimeoutMs?: number;
35
+ }
36
+
37
+ export class NodeError extends Error {
38
+ constructor(message: string, override readonly cause?: unknown) {
39
+ super(message);
40
+ this.name = "NodeError";
41
+ }
42
+ }
43
+
44
+ const PORT_SCAN_START = 9200;
45
+ const PORT_SCAN_END = 9399;
46
+ const PORT_STRIDE = 3;
47
+ const HEALTH_POLL_MS = 250;
48
+ const SPAWN_LOCK_MS = 60_000;
49
+
50
+ /** Identifies a node by `GET /`, so an Elasticsearch on 9200 is not mistaken for XERJ. */
51
+ export async function probeNode(baseUrl: string, timeoutMs = 800): Promise<NodeState> {
52
+ try {
53
+ const response = await fetch(`${baseUrl}/`, {
54
+ signal: AbortSignal.timeout(timeoutMs),
55
+ });
56
+ if (!response.ok) return "foreign";
57
+
58
+ const body = await response.json().catch(() => null);
59
+ if (isRecord(body) && body.cluster_name === "xerj") return "xerj";
60
+ return "foreign";
61
+ } catch {
62
+ return "down";
63
+ }
64
+ }
65
+
66
+ export async function ensureNode(options: EnsureNodeOptions): Promise<NodeHandle> {
67
+ const { logger } = options;
68
+
69
+ if (options.explicitUrl) {
70
+ const url = options.explicitUrl.replace(/\/+$/, "");
71
+ if ((await probeNode(url)) !== "xerj") {
72
+ throw new NodeError(`XERJ is not answering at ${url}`);
73
+ }
74
+ return {
75
+ baseUrl: url,
76
+ port: Number(new URL(url).port || 9200),
77
+ pid: null,
78
+ spawned: false,
79
+ version: options.binaryVersion,
80
+ };
81
+ }
82
+
83
+ const dataDir = options.dataDir ?? nodeDataDir(options.root);
84
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
85
+
86
+ const candidate = options.persistedPort ?? options.preferredPort;
87
+ const attached = await attach(candidate, options.binaryVersion);
88
+ if (attached) return attached;
89
+
90
+ const port = await choosePort(candidate);
91
+ if (port === null) {
92
+ throw new NodeError(`no free port for XERJ between ${PORT_SCAN_START} and ${PORT_SCAN_END}`);
93
+ }
94
+
95
+ const lock = join(dataDir, ".spawn-lock");
96
+ if (!(await acquireLock(lock))) {
97
+ // Another process is spawning; wait for it rather than starting a second node.
98
+ const existing = await waitForNode(port, options.startupTimeoutMs ?? 30_000, options.binaryVersion);
99
+ if (existing) return existing;
100
+ throw new NodeError("another process is starting the XERJ node and it did not come up");
101
+ }
102
+
103
+ try {
104
+ const second = await attach(port, options.binaryVersion);
105
+ if (second) return second;
106
+
107
+ const pid = await spawnNode(options.binaryPath, dataDir, port, options.root);
108
+ await options.onPort?.(port);
109
+
110
+ const node = await waitForNode(port, options.startupTimeoutMs ?? 30_000, options.binaryVersion);
111
+ if (!node) {
112
+ throw new NodeError(`xerj did not answer on port ${port} within 30s`);
113
+ }
114
+
115
+ await writeFile(nodePidPath(options.root), String(pid), { mode: 0o600 }).catch(() => {});
116
+ logger.info("started the xerj node", { port, pid });
117
+ return { ...node, pid, spawned: true };
118
+ } finally {
119
+ await rm(lock, { recursive: true, force: true }).catch(() => {});
120
+ }
121
+ }
122
+
123
+ async function attach(port: number, binaryVersion: string): Promise<NodeHandle | null> {
124
+ const baseUrl = `http://127.0.0.1:${port}`;
125
+ if ((await probeNode(baseUrl)) !== "xerj") return null;
126
+
127
+ return {
128
+ baseUrl,
129
+ port,
130
+ pid: null,
131
+ spawned: false,
132
+ version: binaryVersion,
133
+ };
134
+ }
135
+
136
+ async function waitForNode(
137
+ port: number,
138
+ timeoutMs: number,
139
+ binaryVersion: string,
140
+ ): Promise<NodeHandle | null> {
141
+ const deadline = Date.now() + timeoutMs;
142
+ while (Date.now() < deadline) {
143
+ const found = await attach(port, binaryVersion);
144
+ if (found) return found;
145
+ await sleep(HEALTH_POLL_MS);
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** A base port is usable only when PORT, PORT+1 and PORT+2 are all free. */
151
+ async function choosePort(preferred: number): Promise<number | null> {
152
+ if (preferred >= PORT_SCAN_START && (await portTripleFree(preferred))) return preferred;
153
+
154
+ const start = Math.max(PORT_SCAN_START, Math.ceil(preferred / PORT_STRIDE) * PORT_STRIDE);
155
+ for (let port = start; port + 2 <= PORT_SCAN_END; port += PORT_STRIDE) {
156
+ if (await portTripleFree(port)) return port;
157
+ }
158
+
159
+ for (let port = PORT_SCAN_START; port + 2 <= PORT_SCAN_END; port += PORT_STRIDE) {
160
+ if (await portTripleFree(port)) return port;
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ async function portTripleFree(base: number): Promise<boolean> {
167
+ for (let offset = 0; offset < 3; offset++) {
168
+ if (!(await portFree(base + offset))) return false;
169
+ }
170
+ return true;
171
+ }
172
+
173
+ function portFree(port: number): Promise<boolean> {
174
+ return new Promise((resolvePromise) => {
175
+ const socket = connect({ host: "127.0.0.1", port });
176
+ const finish = (free: boolean): void => {
177
+ socket.destroy();
178
+ resolvePromise(free);
179
+ };
180
+
181
+ socket.setTimeout(400);
182
+ socket.once("connect", () => finish(false));
183
+ socket.once("timeout", () => finish(false));
184
+ socket.once("error", () => finish(true));
185
+ });
186
+ }
187
+
188
+ async function spawnNode(
189
+ binary: string,
190
+ dataDir: string,
191
+ port: number,
192
+ root: string,
193
+ ): Promise<number> {
194
+ await mkdir(dataDir, { recursive: true, mode: 0o700 });
195
+
196
+ const log = await open(nodeLogPath(root), "a", 0o600);
197
+ const child = spawn(
198
+ binary,
199
+ [
200
+ "--insecure",
201
+ "--port",
202
+ String(port),
203
+ "--data-dir",
204
+ dataDir,
205
+ "--disable-feedback",
206
+ ],
207
+ {
208
+ detached: true,
209
+ stdio: ["ignore", log.fd, log.fd],
210
+ env: { ...process.env, XERJ_DISABLE_FEEDBACK: "true" },
211
+ },
212
+ );
213
+
214
+ child.unref();
215
+ await log.close().catch(() => {});
216
+ if (child.pid === undefined) throw new NodeError("xerj exited before it had a pid");
217
+ return child.pid;
218
+ }
219
+
220
+ /** `mkdir` is the atomic primitive; a lock older than a minute is stale. */
221
+ async function acquireLock(path: string): Promise<boolean> {
222
+ try {
223
+ await mkdir(path, { recursive: false });
224
+ return true;
225
+ } catch {
226
+ const info = await stat(path).catch(() => null);
227
+ if (info && Date.now() - info.mtimeMs > SPAWN_LOCK_MS) {
228
+ await rm(path, { recursive: true, force: true }).catch(() => {});
229
+ return acquireLock(path);
230
+ }
231
+ return false;
232
+ }
233
+ }
234
+
235
+ export async function readPersistedPort(root: string): Promise<number | null> {
236
+ const raw = await readFile(join(nodeDataDir(root), "port"), "utf8").catch(() => null);
237
+ if (raw === null) return null;
238
+ const port = Number(raw.trim());
239
+ return Number.isInteger(port) && port > 0 && port < 65_536 ? port : null;
240
+ }
241
+
242
+ export async function persistPort(root: string, port: number): Promise<void> {
243
+ await mkdir(nodeDataDir(root), { recursive: true, mode: 0o700 });
244
+ await writeFile(join(nodeDataDir(root), "port"), String(port), { mode: 0o600 });
245
+ }
246
+
247
+ export async function stopNode(root: string): Promise<boolean> {
248
+ const pid = Number(await readFile(nodePidPath(root), "utf8").catch(() => ""));
249
+ if (!Number.isInteger(pid) || pid <= 0) return false;
250
+
251
+ try {
252
+ process.kill(pid, "SIGTERM");
253
+ await rm(nodePidPath(root), { force: true });
254
+ return true;
255
+ } catch {
256
+ await rm(nodePidPath(root), { force: true }).catch(() => {});
257
+ return false;
258
+ }
259
+ }
260
+
261
+ function sleep(ms: number): Promise<void> {
262
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
263
+ }
264
+
265
+ function isRecord(value: unknown): value is Record<string, unknown> {
266
+ return typeof value === "object" && value !== null;
267
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,202 @@
1
+ // Filesystem layout and stable names. Pure so the tests can pin the naming
2
+ // without touching disk: index prefixes are the only identity XERJ keeps
3
+ // between runs, and one wrong character means a second copy of the corpus.
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { homedir } from "node:os";
7
+ import { basename, isAbsolute, join, resolve, sep } from "node:path";
8
+
9
+ export interface Environment {
10
+ readonly XDG_DATA_HOME?: string;
11
+ readonly LOCALAPPDATA?: string;
12
+ readonly HOME?: string;
13
+ }
14
+
15
+ /** Picks the variables that matter out of a wider process environment. */
16
+ export function environmentFrom(env: Readonly<Record<string, string | undefined>>): Environment {
17
+ return {
18
+ XDG_DATA_HOME: env.XDG_DATA_HOME,
19
+ LOCALAPPDATA: env.LOCALAPPDATA,
20
+ HOME: env.HOME,
21
+ };
22
+ }
23
+
24
+ const APP_DIR = "opencode/xerj";
25
+ const PREFIX_ROOT = "xop";
26
+ const MAX_SLUG_LENGTH = 24;
27
+ const HASH_LENGTH = 8;
28
+
29
+ /** Root for the binary, node data, and spool. Independent of any project. */
30
+ export function dataRoot(env: Environment, platform: NodeJS.Platform = process.platform): string {
31
+ if (platform === "win32") {
32
+ const local = env.LOCALAPPDATA ?? join(env.HOME ?? homedir(), "AppData", "Local");
33
+ return join(local, APP_DIR);
34
+ }
35
+
36
+ if (platform === "darwin") {
37
+ const base = env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), "Library", "Application Support");
38
+ return join(base, APP_DIR);
39
+ }
40
+
41
+ const base = env.XDG_DATA_HOME ?? join(env.HOME ?? homedir(), ".local", "share");
42
+ return join(base, APP_DIR);
43
+ }
44
+
45
+ export function binaryPath(
46
+ root: string,
47
+ version: string,
48
+ platform: NodeJS.Platform,
49
+ target: string,
50
+ ): string {
51
+ // The target is part of the directory so a shared data root can hold more
52
+ // than one platform's binary without one overwriting the other.
53
+ return join(root, "bin", `${version}-${target}`, binaryName(platform));
54
+ }
55
+
56
+ export function binaryName(platform: NodeJS.Platform): string {
57
+ return platform === "win32" ? "xerj.exe" : "xerj";
58
+ }
59
+
60
+ export function nodeDataDir(root: string): string {
61
+ return join(root, "data");
62
+ }
63
+
64
+ export function nodeLogPath(root: string): string {
65
+ return join(root, "data", "node.log");
66
+ }
67
+
68
+ export function nodePidPath(root: string): string {
69
+ return join(root, "data", "node.pid");
70
+ }
71
+
72
+ export function releaseCachePath(root: string): string {
73
+ return join(root, "release-cache.json");
74
+ }
75
+
76
+ export function spoolDir(root: string, prefix: string): string {
77
+ return join(root, "spool", prefix);
78
+ }
79
+
80
+ /** Project-local state: ignore file and caches. Never the engine index. */
81
+ export function projectStateDir(projectRoot: string): string {
82
+ return join(projectRoot, ".xerj");
83
+ }
84
+
85
+
86
+ /** Lowercase alphanumeric slug, joined by single dashes. */
87
+ export function slug(name: string, maxLength = MAX_SLUG_LENGTH): string {
88
+ const cleaned = name
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, "-")
91
+ .replace(/^-+|-+$/g, "");
92
+
93
+ if (cleaned.length === 0) return "project";
94
+ return cleaned.slice(0, maxLength).replace(/-+$/g, "");
95
+ }
96
+
97
+ /** First `length` hex characters of the canonical path's SHA-256. */
98
+ export function pathHash(directory: string, length = HASH_LENGTH): string {
99
+ const canonical = resolve(directory).split(sep).join("/");
100
+ return createHash("sha256").update(canonical).digest("hex").slice(0, length);
101
+ }
102
+
103
+ /**
104
+ * One prefix per folder. XERJ refuses to index two folders under one prefix,
105
+ * so the path hash is part of the name, not a nicety.
106
+ */
107
+ export function indexPrefix(directory: string): string {
108
+ return `${PREFIX_ROOT}-${slug(basename(resolve(directory)))}-${pathHash(directory)}`;
109
+ }
110
+
111
+ export function webPrefix(directory: string): string {
112
+ return `${PREFIX_ROOT}-web-${pathHash(directory)}`;
113
+ }
114
+
115
+ export function externalPrefix(directory: string): string {
116
+ return `${PREFIX_ROOT}-ext-${pathHash(directory)}`;
117
+ }
118
+
119
+ export function brainPrefix(directory: string, brain: string, hashLength = 6): string {
120
+ return (
121
+ `${PREFIX_ROOT}-${slug(brain, 16)}-` +
122
+ `${pathHash(directory, hashLength)}`
123
+ );
124
+ }
125
+
126
+ export interface DocumentRoot {
127
+ readonly label: string;
128
+ readonly directory: string;
129
+ }
130
+
131
+ /** Resolves configured folders relative to the project, dropping duplicates. */
132
+ export function documentRoots(
133
+ projectRoot: string,
134
+ labels: ReadonlyArray<string>,
135
+ ): DocumentRoot[] {
136
+ const seen = new Set<string>();
137
+
138
+ return labels.flatMap((label) => {
139
+ const trimmed = label.trim();
140
+ const directory = isAbsolute(trimmed)
141
+ ? resolve(trimmed)
142
+ : resolve(join(projectRoot, trimmed));
143
+ if (seen.has(directory)) return [];
144
+ seen.add(directory);
145
+ return [{ label: trimmed, directory }];
146
+ });
147
+ }
148
+
149
+ export interface BrainSpec {
150
+ /** Graph name, also the `--brain` argument. */
151
+ readonly name: string;
152
+ /** The configured value, e.g. `.agents` or `docs/notes`. */
153
+ readonly label: string;
154
+ readonly directory: string;
155
+ readonly prefix: string;
156
+ }
157
+
158
+ /**
159
+ * One brain per configured folder. The graph name is the folder's basename, so
160
+ * `index.brain: [".agents"]` produces brain `agents`; duplicate basenames get a
161
+ * numeric suffix rather than silently sharing an edge index.
162
+ */
163
+ export function brainSpecs(
164
+ projectRoot: string,
165
+ labels: ReadonlyArray<string>,
166
+ ): BrainSpec[] {
167
+ const used = new Map<string, number>();
168
+ const seenDirs = new Set<string>();
169
+
170
+ return labels.flatMap((label) => {
171
+ const trimmed = label.trim();
172
+ const directory = isAbsolute(trimmed)
173
+ ? resolve(trimmed)
174
+ : resolve(join(projectRoot, trimmed));
175
+
176
+ // The same folder configured twice is one brain, not two graphs.
177
+ if (seenDirs.has(directory)) return [];
178
+ seenDirs.add(directory);
179
+
180
+ // The graph name is global to the node, while projects share one node.
181
+ // A folder hash keeps two projects' `agents` brains from sharing edges.
182
+ const folder = slug(basename(directory), 12);
183
+ const base = `${folder}-${pathHash(directory, 4)}`;
184
+ const seen = (used.get(base) ?? 0) + 1;
185
+ used.set(base, seen);
186
+ const name = seen === 1 ? base : `${base}-${seen}`;
187
+
188
+ return [
189
+ {
190
+ name,
191
+ label: trimmed,
192
+ directory,
193
+ prefix: brainPrefix(directory, folder),
194
+ },
195
+ ];
196
+ });
197
+ }
198
+
199
+ /** Memory namespaces are their own index; the project hash keeps them apart. */
200
+ export function memoryNamespace(directory: string): string {
201
+ return `proj-${slug(basename(resolve(directory)))}-${pathHash(directory)}`;
202
+ }
@@ -0,0 +1,88 @@
1
+ // Release target selection. Mirrors the official installer: Linux defaults to
2
+ // the static musl build, and a Rosetta shell on Apple Silicon gets the native
3
+ // arm64 binary rather than a translated x86_64 one.
4
+
5
+ export type ArchiveKind = "tar.gz" | "zip";
6
+ export type Libc = "musl" | "gnu";
7
+
8
+ export interface PlatformInfo {
9
+ readonly target: string;
10
+ readonly archive: ArchiveKind;
11
+ readonly binary: string;
12
+ }
13
+
14
+ const ARCH: Record<string, string> = {
15
+ x64: "x86_64",
16
+ arm64: "aarch64",
17
+ };
18
+
19
+ /** Returns null for a platform XERJ does not publish. */
20
+ export function releaseTarget(
21
+ platform: NodeJS.Platform,
22
+ arch: string,
23
+ rosetta = false,
24
+ libc: Libc = "musl",
25
+ ): PlatformInfo | null {
26
+ let cpu = ARCH[arch];
27
+ if (!cpu) return null;
28
+
29
+ if (platform === "darwin" || platform === "linux") {
30
+ if (platform === "darwin" && rosetta) cpu = "aarch64";
31
+ const suffix =
32
+ platform === "darwin"
33
+ ? "apple-darwin"
34
+ : libc === "gnu"
35
+ ? "unknown-linux-gnu"
36
+ : "unknown-linux-musl";
37
+ return { target: `${cpu}-${suffix}`, archive: "tar.gz", binary: "xerj" };
38
+ }
39
+
40
+ if (platform === "win32") {
41
+ return { target: `${cpu}-pc-windows-msvc`, archive: "zip", binary: "xerj.exe" };
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ /** Builds PlatformInfo from an explicit target, for pinned or exotic hosts. */
48
+ export function platformInfoForTarget(
49
+ target: string,
50
+ platform: NodeJS.Platform,
51
+ ): PlatformInfo | null {
52
+ const trimmed = target.trim();
53
+ if (!/^(x86_64|aarch64)-[a-z0-9-]+$/.test(trimmed)) return null;
54
+
55
+ const windows = trimmed.includes("windows");
56
+ if (windows && platform !== "win32") return null;
57
+ if (!windows && platform === "win32") return null;
58
+
59
+ return {
60
+ target: trimmed,
61
+ archive: windows ? "zip" : "tar.gz",
62
+ binary: windows ? "xerj.exe" : "xerj",
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Node reports the architecture of the process, which lies in two cases: an
68
+ * x86_64 Node under Rosetta 2 on Apple Silicon, and an x64 Node on Windows
69
+ * ARM64. Both should install the native arm64 build.
70
+ */
71
+ export function effectiveArch(
72
+ platform: NodeJS.Platform,
73
+ arch: string,
74
+ env: Readonly<Record<string, string | undefined>>,
75
+ rosetta = false,
76
+ ): string {
77
+ if (platform === "darwin" && rosetta) return "arm64";
78
+ if (platform !== "win32") return arch;
79
+
80
+ const reported = (env.PROCESSOR_ARCHITECTURE ?? env.PROCESSOR_ARCHITEW6432 ?? "").toUpperCase();
81
+ if (reported === "ARM64" && arch === "x64") return "arm64";
82
+ return arch;
83
+ }
84
+
85
+ /** Rosetta detection is a runtime fact; keep the pure selection separate. */
86
+ export function isRosetta(sysctlOutput: string): boolean {
87
+ return sysctlOutput.trim() === "1";
88
+ }