@mrdrmccoy/xerj-opencode-plugin 0.0.0-stage → 0.4.1
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/LICENSE +201 -0
- package/README.md +284 -2
- package/package.json +63 -4
- package/src/binary/detect.ts +55 -0
- package/src/binary/manager.ts +348 -0
- package/src/binary/release.ts +64 -0
- package/src/config.ts +381 -0
- package/src/hooks/session.ts +165 -0
- package/src/index/brain.ts +225 -0
- package/src/index/corpus.ts +164 -0
- package/src/index/hidden.ts +318 -0
- package/src/index/watcher.ts +333 -0
- package/src/index.ts +723 -0
- package/src/log.ts +88 -0
- package/src/memory/capture.ts +72 -0
- package/src/memory/store.ts +116 -0
- package/src/node/client.ts +361 -0
- package/src/node/supervisor.ts +267 -0
- package/src/paths.ts +202 -0
- package/src/platform.ts +88 -0
- package/src/remote/shell.ts +338 -0
- package/src/remote/spool.ts +217 -0
- package/src/rpc.ts +250 -0
- package/src/search/brains.ts +306 -0
- package/src/search/rank.ts +61 -0
- package/src/search/service.ts +589 -0
- package/src/semver.ts +117 -0
- package/src/stats/counters.ts +55 -0
- package/src/stats/ledger.ts +344 -0
- package/src/telemetry/anomaly.ts +211 -0
- package/src/tools/search.ts +384 -0
- package/src/tui.tsx +362 -0
- package/src/ui/format.ts +234 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Binary lifecycle: find, download, verify, extract, upgrade. Verified against
|
|
2
|
+
// XERJ v1.0.0-rc.100 release assets. Everything here is best-effort except the
|
|
3
|
+
// first install: a failed upgrade keeps the working binary.
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { createWriteStream } from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
chmod,
|
|
10
|
+
mkdir,
|
|
11
|
+
readFile,
|
|
12
|
+
readdir,
|
|
13
|
+
rename,
|
|
14
|
+
rm,
|
|
15
|
+
stat,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from "node:fs/promises";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { Readable } from "node:stream";
|
|
20
|
+
import { pipeline } from "node:stream/promises";
|
|
21
|
+
import type { Logger } from "../log.ts";
|
|
22
|
+
import { binaryPath, releaseCachePath } from "../paths.ts";
|
|
23
|
+
import { highestVersion, normalizeVersion, parseVersion, shouldUpgrade } from "../semver.ts";
|
|
24
|
+
import type { PlatformInfo } from "../platform.ts";
|
|
25
|
+
import {
|
|
26
|
+
archiveRoot,
|
|
27
|
+
assetName,
|
|
28
|
+
assetUrl,
|
|
29
|
+
cacheIsFresh,
|
|
30
|
+
checksumFrom,
|
|
31
|
+
latestReleaseUrl,
|
|
32
|
+
parseReleaseCache,
|
|
33
|
+
versionFromLatestUrl,
|
|
34
|
+
type ReleaseCache,
|
|
35
|
+
} from "./release.ts";
|
|
36
|
+
|
|
37
|
+
export class BinaryError extends Error {
|
|
38
|
+
constructor(message: string, override readonly cause?: unknown) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "BinaryError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface InstalledBinary {
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly upgraded: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface EnsureBinaryOptions {
|
|
51
|
+
readonly root: string;
|
|
52
|
+
readonly requested: string;
|
|
53
|
+
readonly platform: NodeJS.Platform;
|
|
54
|
+
readonly info: PlatformInfo;
|
|
55
|
+
readonly logger: Logger;
|
|
56
|
+
readonly now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
60
|
+
|
|
61
|
+
export async function ensureBinary(options: EnsureBinaryOptions): Promise<InstalledBinary> {
|
|
62
|
+
const { root, requested, platform, info, logger } = options;
|
|
63
|
+
const now = options.now ?? Date.now;
|
|
64
|
+
|
|
65
|
+
const installed = await findInstalled(root, platform, info.target);
|
|
66
|
+
|
|
67
|
+
if (requested !== "auto") {
|
|
68
|
+
const version = normalizeVersion(requested);
|
|
69
|
+
if (installed && installed.version === version) {
|
|
70
|
+
return { ...installed, upgraded: false };
|
|
71
|
+
}
|
|
72
|
+
const path = await install(root, version, info, logger);
|
|
73
|
+
return { version, path, upgraded: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const latest = await resolveLatest(root, logger, now);
|
|
77
|
+
if (!latest) {
|
|
78
|
+
if (installed) return { ...installed, upgraded: false };
|
|
79
|
+
throw new BinaryError("no installed xerj binary and the release channel is unreachable");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (installed && !shouldUpgrade(installed.version, latest)) {
|
|
83
|
+
return { ...installed, upgraded: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const path = await install(root, latest, info, logger);
|
|
88
|
+
logger.info(`installed xerj ${latest}`, { path });
|
|
89
|
+
return { version: latest, path, upgraded: installed !== null };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (installed) {
|
|
92
|
+
logger.warn(`xerj ${latest} install failed; keeping ${installed.version}`, String(error));
|
|
93
|
+
return { ...installed, upgraded: false };
|
|
94
|
+
}
|
|
95
|
+
throw new BinaryError(`could not install xerj ${latest}`, error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Runs `xerj --version` and returns the parsed version. A binary that does
|
|
100
|
+
* not run yields null rather than throwing; callers decide what that means. */
|
|
101
|
+
export async function probeBinaryVersion(path: string): Promise<string | null> {
|
|
102
|
+
try {
|
|
103
|
+
const output = await run(path, ["--version"]);
|
|
104
|
+
const match = /v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(output);
|
|
105
|
+
return match?.[1] ?? null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Uses a user-supplied binary, skipping download, upgrade, and detection. */
|
|
112
|
+
export async function externalBinary(path: string, logger: Logger): Promise<InstalledBinary> {
|
|
113
|
+
const version = await probeBinaryVersion(path);
|
|
114
|
+
if (!version) {
|
|
115
|
+
throw new BinaryError(`binary.path did not run or report a version: ${path}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
logger.info("using the configured xerj binary", { path, version });
|
|
119
|
+
return { version, path, upgraded: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function findInstalled(
|
|
123
|
+
root: string,
|
|
124
|
+
platform: NodeJS.Platform,
|
|
125
|
+
target: string,
|
|
126
|
+
): Promise<InstalledBinary | null> {
|
|
127
|
+
const binDir = join(root, "bin");
|
|
128
|
+
const entries = await readdir(binDir, { withFileTypes: true }).catch(() => []);
|
|
129
|
+
|
|
130
|
+
// Directory names are `<version>-<target>`; only this target's binaries run
|
|
131
|
+
// here, so a shared data root may hold others without confusing the choice.
|
|
132
|
+
const suffix = `-${target}`;
|
|
133
|
+
const versions = entries
|
|
134
|
+
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
|
135
|
+
.map((entry) => entry.name.slice(0, -suffix.length));
|
|
136
|
+
|
|
137
|
+
const best = highestVersion(versions);
|
|
138
|
+
if (!best) return null;
|
|
139
|
+
|
|
140
|
+
const path = binaryPath(root, best, platform, target);
|
|
141
|
+
const version = await probeBinaryVersion(path);
|
|
142
|
+
if (!version) return null;
|
|
143
|
+
return { version, path, upgraded: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function resolveLatest(
|
|
147
|
+
root: string,
|
|
148
|
+
logger: Logger,
|
|
149
|
+
now: () => number,
|
|
150
|
+
): Promise<string | null> {
|
|
151
|
+
const cachePath = releaseCachePath(root);
|
|
152
|
+
const cached = parseReleaseCache(await readFileJson(cachePath));
|
|
153
|
+
|
|
154
|
+
if (cached && cacheIsFresh(cached, now())) return cached.version;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(latestReleaseUrl(), {
|
|
158
|
+
redirect: "follow",
|
|
159
|
+
signal: AbortSignal.timeout(10_000),
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
162
|
+
|
|
163
|
+
const version = versionFromLatestUrl(response.url);
|
|
164
|
+
if (!version) throw new Error("release redirect did not carry a version");
|
|
165
|
+
|
|
166
|
+
const next: ReleaseCache = { checkedAt: now(), version };
|
|
167
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
168
|
+
await writeFile(cachePath, JSON.stringify(next), { mode: 0o600 });
|
|
169
|
+
return version;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
logger.warn("could not check the latest xerj release", String(error));
|
|
172
|
+
return cached?.version ?? null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function install(
|
|
177
|
+
root: string,
|
|
178
|
+
version: string,
|
|
179
|
+
info: PlatformInfo,
|
|
180
|
+
logger: Logger,
|
|
181
|
+
): Promise<string> {
|
|
182
|
+
if (!parseVersion(version)) throw new BinaryError(`invalid version: ${version}`);
|
|
183
|
+
|
|
184
|
+
const asset = assetName(version, info.target, info.archive);
|
|
185
|
+
const url = assetUrl(version, asset);
|
|
186
|
+
const downloadPath = join(root, "bin", asset);
|
|
187
|
+
const partialDir = join(root, "bin", `.${version}-${info.target}.partial`);
|
|
188
|
+
const targetDir = join(root, "bin", `${version}-${info.target}`);
|
|
189
|
+
|
|
190
|
+
await mkdir(dirname(downloadPath), { recursive: true, mode: 0o700 });
|
|
191
|
+
await rm(partialDir, { recursive: true, force: true });
|
|
192
|
+
await mkdir(partialDir, { recursive: true, mode: 0o700 });
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
logger.info(`downloading ${asset}`);
|
|
196
|
+
await download(url, downloadPath);
|
|
197
|
+
await verifyChecksum(`${url}.sha256`, downloadPath);
|
|
198
|
+
|
|
199
|
+
await extractArchive(downloadPath, partialDir, info, logger);
|
|
200
|
+
|
|
201
|
+
const expected = join(partialDir, archiveRoot(version, info.target), info.binary);
|
|
202
|
+
const binary = (await isFile(expected)) ? expected : await findFile(partialDir, info.binary);
|
|
203
|
+
if (!binary) throw new BinaryError(`archive ${asset} did not contain ${info.binary}`);
|
|
204
|
+
|
|
205
|
+
await chmod(binary, 0o755);
|
|
206
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
207
|
+
await rename(dirname(binary), targetDir);
|
|
208
|
+
|
|
209
|
+
const installed = join(targetDir, info.binary);
|
|
210
|
+
// A binary for the wrong architecture installs and then fails to exec, so
|
|
211
|
+
// prove it runs before declaring success.
|
|
212
|
+
const reported = await probeBinaryVersion(installed);
|
|
213
|
+
if (!reported) {
|
|
214
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
215
|
+
throw new BinaryError(
|
|
216
|
+
`the ${info.target} binary did not run on this machine; set binary.target or install the native build`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return installed;
|
|
221
|
+
} finally {
|
|
222
|
+
await rm(partialDir, { recursive: true, force: true });
|
|
223
|
+
await rm(downloadPath, { force: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Extracts a release archive. `tar -xf` handles tar.gz everywhere and zip on
|
|
229
|
+
* Windows (bsdtar ships with Windows 10+). If tar is missing or refuses the
|
|
230
|
+
* zip, PowerShell's Expand-Archive is the fallback.
|
|
231
|
+
*/
|
|
232
|
+
async function extractArchive(
|
|
233
|
+
archivePath: string,
|
|
234
|
+
destination: string,
|
|
235
|
+
info: PlatformInfo,
|
|
236
|
+
logger: Logger,
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
try {
|
|
239
|
+
await run("tar", ["-xf", archivePath, "-C", destination]);
|
|
240
|
+
return;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (info.archive !== "zip" || process.platform !== "win32") throw error;
|
|
243
|
+
logger.warn("tar could not extract the archive; trying PowerShell", String(error));
|
|
244
|
+
await run(
|
|
245
|
+
"powershell.exe",
|
|
246
|
+
[
|
|
247
|
+
"-NoProfile",
|
|
248
|
+
"-NonInteractive",
|
|
249
|
+
"-Command",
|
|
250
|
+
"Expand-Archive -LiteralPath $env:XERJ_ZIP -DestinationPath $env:XERJ_DST -Force",
|
|
251
|
+
],
|
|
252
|
+
{ env: { ...process.env, XERJ_ZIP: archivePath, XERJ_DST: destination } },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function download(url: string, destination: string): Promise<void> {
|
|
258
|
+
const response = await fetch(url, {
|
|
259
|
+
redirect: "follow",
|
|
260
|
+
signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
|
|
261
|
+
});
|
|
262
|
+
if (!response.ok || !response.body) {
|
|
263
|
+
throw new BinaryError(`download failed: HTTP ${response.status}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(destination, { mode: 0o600 }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function verifyChecksum(checksumUrl: string, filePath: string): Promise<void> {
|
|
270
|
+
const response = await fetch(checksumUrl, { signal: AbortSignal.timeout(10_000) });
|
|
271
|
+
if (!response.ok) throw new BinaryError(`checksum download failed: HTTP ${response.status}`);
|
|
272
|
+
|
|
273
|
+
const expected = checksumFrom(await response.text());
|
|
274
|
+
if (!expected) throw new BinaryError("malformed .sha256 sidecar");
|
|
275
|
+
|
|
276
|
+
const actual = createHash("sha256")
|
|
277
|
+
.update(await readFile(filePath))
|
|
278
|
+
.digest("hex");
|
|
279
|
+
|
|
280
|
+
if (actual !== expected) {
|
|
281
|
+
throw new BinaryError(`checksum mismatch (want ${expected}, got ${actual})`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function findFile(directory: string, name: string, depth = 4): Promise<string | null> {
|
|
286
|
+
if (depth < 0) return null;
|
|
287
|
+
|
|
288
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const path = join(directory, entry.name);
|
|
291
|
+
if (entry.isFile() && entry.name === name) return path;
|
|
292
|
+
if (entry.isDirectory()) {
|
|
293
|
+
const found = await findFile(path, name, depth - 1);
|
|
294
|
+
if (found) return found;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function isFile(path: string): Promise<boolean> {
|
|
302
|
+
const info = await stat(path).catch(() => null);
|
|
303
|
+
return info?.isFile() ?? false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readFileJson(path: string): Promise<unknown> {
|
|
307
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
308
|
+
if (text === null) return null;
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(text);
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
interface RunOptions {
|
|
317
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function run(
|
|
321
|
+
command: string,
|
|
322
|
+
args: ReadonlyArray<string>,
|
|
323
|
+
options: RunOptions = {},
|
|
324
|
+
): Promise<string> {
|
|
325
|
+
return new Promise((resolvePromise, reject) => {
|
|
326
|
+
const child = spawn(command, [...args], {
|
|
327
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
328
|
+
env: options.env,
|
|
329
|
+
windowsHide: true,
|
|
330
|
+
});
|
|
331
|
+
let stdout = "";
|
|
332
|
+
let stderr = "";
|
|
333
|
+
|
|
334
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), COMMAND_TIMEOUT_MS);
|
|
335
|
+
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
|
|
336
|
+
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
|
|
337
|
+
child.on("error", (error) => {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
reject(new BinaryError(`${command} failed to start`, error));
|
|
340
|
+
});
|
|
341
|
+
child.on("close", (code) => {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
if (code === 0) resolvePromise(stdout.trim());
|
|
344
|
+
else reject(new BinaryError(`${command} exited ${code}: ${stderr.trim().slice(0, 500)}`));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Release metadata, kept pure: asset naming, checksum parsing, and upgrade
|
|
2
|
+
// decisions. Verified against XERJ v1.0.0-rc.100:
|
|
3
|
+
// xerj-<version>-<target>.tar.gz
|
|
4
|
+
// xerj-<version>-<target>.tar.gz.sha256 ("<hex> <filename>")
|
|
5
|
+
// The archive root is xerj-<version>-<target>/.
|
|
6
|
+
|
|
7
|
+
import type { ArchiveKind } from "../platform.ts";
|
|
8
|
+
|
|
9
|
+
const RELEASE_BASE = "https://github.com/xerj-org/xerj/releases/download";
|
|
10
|
+
const LATEST_URL = "https://github.com/xerj-org/xerj/releases/latest";
|
|
11
|
+
|
|
12
|
+
export function latestReleaseUrl(): string {
|
|
13
|
+
return LATEST_URL;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Parses the final URL of the `releases/latest` redirect into a version. */
|
|
17
|
+
export function versionFromLatestUrl(finalUrl: string): string | null {
|
|
18
|
+
const match = /\/tag\/(v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\/?$/.exec(finalUrl);
|
|
19
|
+
if (!match?.[1]) return null;
|
|
20
|
+
return match[1].replace(/^v/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assetName(version: string, target: string, archive: ArchiveKind): string {
|
|
24
|
+
return `xerj-${version}-${target}.${archive}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assetUrl(version: string, asset: string): string {
|
|
28
|
+
return `${RELEASE_BASE}/v${version}/${asset}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extracts the 64-hex digest from a `.sha256` sidecar. */
|
|
32
|
+
export function checksumFrom(text: string): string | null {
|
|
33
|
+
const first = text.trim().split(/\s+/)[0]?.toLowerCase();
|
|
34
|
+
if (!first || !/^[0-9a-f]{64}$/.test(first)) return null;
|
|
35
|
+
return first;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Archive root, e.g. `xerj-1.0.0-rc.100-x86_64-unknown-linux-musl`. */
|
|
39
|
+
export function archiveRoot(version: string, target: string): string {
|
|
40
|
+
return `xerj-${version}-${target}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ReleaseCache {
|
|
44
|
+
readonly checkedAt: number;
|
|
45
|
+
readonly version: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const RELEASE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
export function cacheIsFresh(cache: ReleaseCache | null, now: number): boolean {
|
|
51
|
+
if (!cache) return false;
|
|
52
|
+
if (!Number.isFinite(cache.checkedAt)) return false;
|
|
53
|
+
return now - cache.checkedAt < RELEASE_CACHE_TTL_MS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseReleaseCache(value: unknown): ReleaseCache | null {
|
|
57
|
+
if (typeof value !== "object" || value === null) return null;
|
|
58
|
+
const record = value as Record<string, unknown>;
|
|
59
|
+
const checkedAt = record.checkedAt;
|
|
60
|
+
const version = record.version;
|
|
61
|
+
if (typeof checkedAt !== "number" || typeof version !== "string") return null;
|
|
62
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) return null;
|
|
63
|
+
return { checkedAt, version };
|
|
64
|
+
}
|