mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
600 lines
21 KiB
TypeScript
600 lines
21 KiB
TypeScript
|
|
import { OPENCODE_PINNED_VERSION, OPENCODE_RELEASE_API_URL_TEMPLATE } from "@/constants";
|
||
|
|
import { logError, logInfo, logWarn } from "@/logger";
|
||
|
|
import type CopilotPlugin from "@/main";
|
||
|
|
import { getSettings, setSettings, type OpencodeBackendSettings } from "@/settings/model";
|
||
|
|
import { execFile, spawn } from "node:child_process";
|
||
|
|
import { randomBytes } from "node:crypto";
|
||
|
|
import * as fs from "node:fs";
|
||
|
|
import * as https from "node:https";
|
||
|
|
import { IncomingMessage } from "node:http";
|
||
|
|
import { FileSystemAdapter, requestUrl } from "obsidian";
|
||
|
|
import * as path from "node:path";
|
||
|
|
import { promisify } from "node:util";
|
||
|
|
import { renameWithRetry } from "@/agentMode/skills/renameWithRetry";
|
||
|
|
import { expectedBinaryName, resolveOpencodeTarget } from "./platformResolver";
|
||
|
|
|
||
|
|
const execFileAsync = promisify(execFile);
|
||
|
|
const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 30_000;
|
||
|
|
// Generous: first-run on Windows (Defender real-time scan) and macOS
|
||
|
|
// (Gatekeeper translocation) can add a few seconds before the binary responds.
|
||
|
|
const VERIFY_BINARY_TIMEOUT_MS = 8_000;
|
||
|
|
|
||
|
|
export type ProgressEvent =
|
||
|
|
| { phase: "resolve"; message: string }
|
||
|
|
| { phase: "download"; received: number; total?: number; assetName: string }
|
||
|
|
| { phase: "extract"; message: string }
|
||
|
|
| { phase: "done"; version: string; path: string };
|
||
|
|
|
||
|
|
export interface InstallOptions {
|
||
|
|
onProgress?: (e: ProgressEvent) => void;
|
||
|
|
signal?: AbortSignal;
|
||
|
|
/** Override pinned version. Defaults to OPENCODE_PINNED_VERSION. */
|
||
|
|
version?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type InstallState =
|
||
|
|
| { kind: "absent" }
|
||
|
|
| { kind: "installed"; version: string; path: string; source: "managed" | "custom" };
|
||
|
|
|
||
|
|
interface GithubAsset {
|
||
|
|
name: string;
|
||
|
|
size: number;
|
||
|
|
browser_download_url: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface GithubRelease {
|
||
|
|
tag_name: string;
|
||
|
|
assets: GithubAsset[];
|
||
|
|
}
|
||
|
|
|
||
|
|
interface InstallManifest {
|
||
|
|
version: string;
|
||
|
|
assetName: string;
|
||
|
|
installedAt: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export class AbortError extends Error {
|
||
|
|
constructor() {
|
||
|
|
super("Aborted");
|
||
|
|
this.name = "AbortError";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function pickMatchingAsset(release: GithubRelease, candidates: string[]): GithubAsset {
|
||
|
|
const ARCHIVE_EXTS = [".zip", ".tar.gz", ".tar.xz", ".tgz"];
|
||
|
|
const stemOf = (name: string): string => {
|
||
|
|
for (const ext of ARCHIVE_EXTS) {
|
||
|
|
if (name.endsWith(ext)) return name.slice(0, -ext.length);
|
||
|
|
}
|
||
|
|
return name;
|
||
|
|
};
|
||
|
|
|
||
|
|
const byStem = new Map<string, GithubAsset>();
|
||
|
|
for (const a of release.assets) {
|
||
|
|
byStem.set(stemOf(a.name), a);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const stem of candidates) {
|
||
|
|
const asset = byStem.get(stem);
|
||
|
|
if (asset) return asset;
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error(
|
||
|
|
`No matching opencode release asset found. Tried: ${candidates.join(", ")}. ` +
|
||
|
|
`Available: ${release.assets.map((a) => a.name).join(", ")}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Derive the install state from the persisted `agentMode.backends.opencode` slice. */
|
||
|
|
export function computeInstallState(opencode: OpencodeBackendSettings | undefined): InstallState {
|
||
|
|
const s = opencode ?? {};
|
||
|
|
if (s.binaryPath && s.binaryVersion) {
|
||
|
|
return {
|
||
|
|
kind: "installed",
|
||
|
|
version: s.binaryVersion,
|
||
|
|
path: s.binaryPath,
|
||
|
|
source: s.binarySource ?? "managed",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return { kind: "absent" };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Read the OpenCode-specific settings slice from current settings. */
|
||
|
|
export function readOpencodeSettings(): OpencodeBackendSettings {
|
||
|
|
return getSettings().agentMode?.backends?.opencode ?? {};
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateOpencodeFields(partial: Partial<OpencodeBackendSettings>): void {
|
||
|
|
setSettings((cur) => ({
|
||
|
|
agentMode: {
|
||
|
|
...cur.agentMode,
|
||
|
|
backends: {
|
||
|
|
...cur.agentMode.backends,
|
||
|
|
opencode: { ...(cur.agentMode.backends?.opencode ?? {}), ...partial },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearOpencodeBinary(): void {
|
||
|
|
updateOpencodeFields({
|
||
|
|
binaryVersion: undefined,
|
||
|
|
binaryPath: undefined,
|
||
|
|
binarySource: undefined,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Manages the lifecycle of the opencode binary on disk: platform-aware
|
||
|
|
* download from GitHub releases, extraction into the plugin data dir, and
|
||
|
|
* persistence of the install location into `settings.agentMode`. Desktop-only.
|
||
|
|
*/
|
||
|
|
export class OpencodeBinaryManager {
|
||
|
|
constructor(private readonly plugin: CopilotPlugin) {}
|
||
|
|
|
||
|
|
getInstallState(): InstallState {
|
||
|
|
return computeInstallState(readOpencodeSettings());
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reconcile persisted install state with what's actually on disk. If we
|
||
|
|
* believe a managed install exists but the binary is gone (user deleted it,
|
||
|
|
* restored a vault from backup, etc.), demote to `absent`. Skipped for
|
||
|
|
* custom-source installs — re-checking on every plugin load would punish
|
||
|
|
* users for transient filesystem hiccups (network mounts, etc.).
|
||
|
|
*/
|
||
|
|
async refreshInstallState(): Promise<void> {
|
||
|
|
const state = this.getInstallState();
|
||
|
|
if (state.kind !== "installed") return;
|
||
|
|
if (state.source !== "managed") return;
|
||
|
|
if (await fileExists(state.path)) return;
|
||
|
|
logWarn(`[AgentMode] persisted opencode binary missing at ${state.path}; clearing settings.`);
|
||
|
|
clearOpencodeBinary();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Absolute path to `<vault>/.obsidian/plugins/<id>/data/opencode`. */
|
||
|
|
getDataDir(): string {
|
||
|
|
const adapter = this.plugin.app.vault.adapter;
|
||
|
|
if (!(adapter instanceof FileSystemAdapter)) {
|
||
|
|
throw new Error("Agent Mode requires desktop Obsidian (FileSystemAdapter).");
|
||
|
|
}
|
||
|
|
const base = adapter.getBasePath();
|
||
|
|
const id = this.plugin.manifest.id;
|
||
|
|
return path.join(base, this.plugin.app.vault.configDir, "plugins", id, "data", "opencode");
|
||
|
|
}
|
||
|
|
|
||
|
|
getPinnedVersion(): string {
|
||
|
|
return OPENCODE_PINNED_VERSION;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Full install pipeline: resolve target → fetch release metadata →
|
||
|
|
* download → extract → atomic rename → persist settings. Idempotent when
|
||
|
|
* an existing install matches the pinned manifest.
|
||
|
|
*/
|
||
|
|
async install(opts: InstallOptions = {}): Promise<{ version: string; path: string }> {
|
||
|
|
const version = opts.version ?? OPENCODE_PINNED_VERSION;
|
||
|
|
const dataDir = this.getDataDir();
|
||
|
|
const versionDir = path.join(dataDir, version);
|
||
|
|
|
||
|
|
opts.onProgress?.({ phase: "resolve", message: "Resolving platform asset…" });
|
||
|
|
const { target, candidates } = await resolveOpencodeTarget();
|
||
|
|
this.throwIfAborted(opts.signal);
|
||
|
|
|
||
|
|
const release = await this.fetchReleaseMetadata(version);
|
||
|
|
this.throwIfAborted(opts.signal);
|
||
|
|
|
||
|
|
const asset = pickMatchingAsset(release, candidates);
|
||
|
|
|
||
|
|
const binName = expectedBinaryName(target.platform);
|
||
|
|
const finalBinPath = path.join(versionDir, "bin", binName);
|
||
|
|
|
||
|
|
// Idempotency: if the existing manifest matches and the binary is in place, no-op.
|
||
|
|
const existing = await readManifest(path.join(versionDir, "install-manifest.json"));
|
||
|
|
if (existing && existing.assetName === asset.name && (await fileExists(finalBinPath))) {
|
||
|
|
logInfo(`[AgentMode] opencode ${version} already installed at ${finalBinPath}`);
|
||
|
|
// Skip the write when settings already match — avoids spuriously waking
|
||
|
|
// every settings subscriber on healthy plugin loads.
|
||
|
|
const cur = readOpencodeSettings();
|
||
|
|
if (
|
||
|
|
cur.binaryVersion !== version ||
|
||
|
|
cur.binaryPath !== finalBinPath ||
|
||
|
|
cur.binarySource !== "managed"
|
||
|
|
) {
|
||
|
|
updateOpencodeFields({
|
||
|
|
binaryVersion: version,
|
||
|
|
binaryPath: finalBinPath,
|
||
|
|
binarySource: "managed",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
|
||
|
|
return { version, path: finalBinPath };
|
||
|
|
}
|
||
|
|
|
||
|
|
await fs.promises.mkdir(dataDir, { recursive: true });
|
||
|
|
const tmpDir = path.join(dataDir, `.tmp-${version}-${randomBytes(4).toString("hex")}`);
|
||
|
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||
|
|
|
||
|
|
try {
|
||
|
|
const archivePath = path.join(tmpDir, asset.name);
|
||
|
|
await downloadToFile(asset.browser_download_url, archivePath, asset.size, opts);
|
||
|
|
this.throwIfAborted(opts.signal);
|
||
|
|
|
||
|
|
opts.onProgress?.({ phase: "extract", message: "Extracting archive…" });
|
||
|
|
const extractDir = path.join(tmpDir, "extract");
|
||
|
|
await fs.promises.mkdir(extractDir, { recursive: true });
|
||
|
|
await extractArchive(archivePath, extractDir);
|
||
|
|
this.throwIfAborted(opts.signal);
|
||
|
|
|
||
|
|
const extractedBin = await locateFile(extractDir, binName);
|
||
|
|
if (target.platform !== "windows") {
|
||
|
|
await fs.promises.chmod(extractedBin, 0o755);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stage the final layout under tmpDir, then atomically rename into place.
|
||
|
|
const stageDir = path.join(tmpDir, "stage");
|
||
|
|
const stageBinDir = path.join(stageDir, "bin");
|
||
|
|
await fs.promises.mkdir(stageBinDir, { recursive: true });
|
||
|
|
await fs.promises.rename(extractedBin, path.join(stageBinDir, binName));
|
||
|
|
|
||
|
|
const manifest: InstallManifest = {
|
||
|
|
version,
|
||
|
|
assetName: asset.name,
|
||
|
|
installedAt: new Date().toISOString(),
|
||
|
|
};
|
||
|
|
await fs.promises.writeFile(
|
||
|
|
path.join(stageDir, "install-manifest.json"),
|
||
|
|
JSON.stringify(manifest, null, 2)
|
||
|
|
);
|
||
|
|
|
||
|
|
// Rename-aside-then-rename: move any existing versionDir out of the way,
|
||
|
|
// promote the staged dir into place, and only then delete the old one.
|
||
|
|
// If the second rename fails, we restore the original so the user keeps
|
||
|
|
// a working install instead of a half-deleted one.
|
||
|
|
let asideDir: string | null = null;
|
||
|
|
if (await fileExists(versionDir)) {
|
||
|
|
asideDir = `${versionDir}.old-${randomBytes(4).toString("hex")}`;
|
||
|
|
await renameWithRetry(versionDir, asideDir);
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await renameWithRetry(stageDir, versionDir);
|
||
|
|
} catch (e) {
|
||
|
|
if (asideDir) {
|
||
|
|
await renameWithRetry(asideDir, versionDir).catch((restoreErr) =>
|
||
|
|
logError("[AgentMode] failed to restore previous opencode install", restoreErr)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
if (asideDir) {
|
||
|
|
await removeDir(asideDir).catch((rmErr) =>
|
||
|
|
logWarn(`[AgentMode] failed to remove ${asideDir}: ${rmErr}`)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Smoke-test the installed binary. Catches corrupt extracts and
|
||
|
|
// platform/libc mismatches before the user hits them at ACP boot —
|
||
|
|
// failures here surface in the install Modal where a Retry is one click
|
||
|
|
// away.
|
||
|
|
await verifyOpencodeBinary(finalBinPath);
|
||
|
|
this.throwIfAborted(opts.signal);
|
||
|
|
|
||
|
|
updateOpencodeFields({
|
||
|
|
binaryVersion: version,
|
||
|
|
binaryPath: finalBinPath,
|
||
|
|
binarySource: "managed",
|
||
|
|
});
|
||
|
|
opts.onProgress?.({ phase: "done", version, path: finalBinPath });
|
||
|
|
logInfo(`[AgentMode] opencode ${version} installed at ${finalBinPath}`);
|
||
|
|
return { version, path: finalBinPath };
|
||
|
|
} catch (err) {
|
||
|
|
logError("[AgentMode] opencode install failed", err);
|
||
|
|
throw err;
|
||
|
|
} finally {
|
||
|
|
await removeDir(tmpDir).catch(() => {});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove the active managed version dir and clear settings. For a custom
|
||
|
|
* binary this only clears settings — the binary on disk belongs to the user
|
||
|
|
* and we don't touch it. Other version dirs are kept either way.
|
||
|
|
*/
|
||
|
|
async uninstall(): Promise<void> {
|
||
|
|
const s = readOpencodeSettings();
|
||
|
|
if (s.binarySource === "managed" && s.binaryVersion) {
|
||
|
|
const versionDir = path.join(this.getDataDir(), s.binaryVersion);
|
||
|
|
await removeDir(versionDir).catch((e) =>
|
||
|
|
logError(`[AgentMode] failed to remove ${versionDir}`, e)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
clearOpencodeBinary();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Point Agent Mode at a user-supplied opencode binary. Pass `null` to
|
||
|
|
* clear the override (without removing any managed install on disk).
|
||
|
|
* Performs filesystem checks plus a `--version` smoke test so misconfigured
|
||
|
|
* paths are caught at config time rather than later when ACP tries to boot.
|
||
|
|
*/
|
||
|
|
async setCustomBinaryPath(p: string | null): Promise<void> {
|
||
|
|
if (p === null) {
|
||
|
|
clearOpencodeBinary();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const stat = await fs.promises.stat(p).catch(() => null);
|
||
|
|
if (!stat || !stat.isFile()) {
|
||
|
|
throw new Error(`No file at ${p}`);
|
||
|
|
}
|
||
|
|
if (process.platform !== "win32") {
|
||
|
|
try {
|
||
|
|
await fs.promises.access(p, fs.constants.X_OK);
|
||
|
|
} catch {
|
||
|
|
throw new Error(`${p} is not executable. chmod +x and try again.`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const { stdout } = await verifyOpencodeBinary(p);
|
||
|
|
const version = parseVersionFromStdout(stdout);
|
||
|
|
if (!version) {
|
||
|
|
throw new Error(
|
||
|
|
`${p} --version output didn't include a version number. Is this an opencode binary?`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
updateOpencodeFields({ binaryVersion: version, binaryPath: p, binarySource: "custom" });
|
||
|
|
}
|
||
|
|
|
||
|
|
private async fetchReleaseMetadata(version: string): Promise<GithubRelease> {
|
||
|
|
const url = OPENCODE_RELEASE_API_URL_TEMPLATE.replace("{version}", version);
|
||
|
|
const res = await requestUrl({
|
||
|
|
url,
|
||
|
|
method: "GET",
|
||
|
|
headers: { Accept: "application/vnd.github+json" },
|
||
|
|
throw: false,
|
||
|
|
});
|
||
|
|
if (res.status === 403) {
|
||
|
|
throw new Error(
|
||
|
|
"GitHub API rate-limited (60/hour for unauthenticated requests). Set GITHUB_TOKEN or retry later."
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (res.status === 404) {
|
||
|
|
throw new Error(`opencode release v${version} not found on GitHub.`);
|
||
|
|
}
|
||
|
|
if (res.status < 200 || res.status >= 300) {
|
||
|
|
throw new Error(`GitHub release fetch failed with status ${res.status}`);
|
||
|
|
}
|
||
|
|
return res.json as GithubRelease;
|
||
|
|
}
|
||
|
|
|
||
|
|
private throwIfAborted(signal?: AbortSignal): void {
|
||
|
|
if (signal?.aborted) throw new AbortError();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Tolerant of leading `v`, build metadata, etc. — keeps the parser working
|
||
|
|
// across opencode releases that decorate the version string differently.
|
||
|
|
export function parseVersionFromStdout(stdout: string): string | undefined {
|
||
|
|
const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
|
||
|
|
return match?.[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fileExists(p: string): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
await fs.promises.access(p);
|
||
|
|
return true;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readManifest(p: string): Promise<InstallManifest | null> {
|
||
|
|
try {
|
||
|
|
const raw = await fs.promises.readFile(p, "utf-8");
|
||
|
|
return JSON.parse(raw) as InstallManifest;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function removeDir(p: string): Promise<void> {
|
||
|
|
await fs.promises.rm(p, { recursive: true, force: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Issue a GET against `url`, following up to `maxRedirects` 3xx hops. Resolves
|
||
|
|
* with the response stream on a 2xx or rejects on any other terminal status.
|
||
|
|
* Honors the supplied AbortSignal at every step.
|
||
|
|
*/
|
||
|
|
function httpsGetWithRedirects(
|
||
|
|
url: string,
|
||
|
|
signal: AbortSignal | undefined,
|
||
|
|
maxRedirects = 5
|
||
|
|
): Promise<IncomingMessage> {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const request = (currentUrl: string, redirectsLeft: number): void => {
|
||
|
|
let onAbort: (() => void) | null = null;
|
||
|
|
const detachAbort = (): void => {
|
||
|
|
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||
|
|
onAbort = null;
|
||
|
|
};
|
||
|
|
const req = https.get(currentUrl, (res) => {
|
||
|
|
const status = res.statusCode ?? 0;
|
||
|
|
if (status >= 300 && status < 400 && res.headers.location) {
|
||
|
|
detachAbort();
|
||
|
|
if (redirectsLeft <= 0) {
|
||
|
|
res.resume();
|
||
|
|
reject(new Error(`Too many redirects fetching ${url}`));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
res.resume();
|
||
|
|
const next = new URL(res.headers.location, currentUrl).toString();
|
||
|
|
request(next, redirectsLeft - 1);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (status !== 200) {
|
||
|
|
detachAbort();
|
||
|
|
res.resume();
|
||
|
|
reject(new Error(`HTTP ${status} fetching ${currentUrl}`));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// Hand the response off without detaching: the consumer may still
|
||
|
|
// need to abort mid-stream. Cleanup happens on res 'close'/'end'.
|
||
|
|
res.on("close", detachAbort);
|
||
|
|
resolve(res);
|
||
|
|
});
|
||
|
|
req.on("error", (e) => {
|
||
|
|
detachAbort();
|
||
|
|
reject(e);
|
||
|
|
});
|
||
|
|
if (signal) {
|
||
|
|
if (signal.aborted) {
|
||
|
|
req.destroy(new AbortError());
|
||
|
|
} else {
|
||
|
|
onAbort = (): void => {
|
||
|
|
req.destroy(new AbortError());
|
||
|
|
};
|
||
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
request(url, maxRedirects);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stream a remote asset to `dest`, emitting progress events and aborting if
|
||
|
|
* no bytes arrive for `DOWNLOAD_INACTIVITY_TIMEOUT_MS`. Stalled connections
|
||
|
|
* surface a clear "download stalled" error instead of hanging forever.
|
||
|
|
*/
|
||
|
|
async function downloadToFile(
|
||
|
|
url: string,
|
||
|
|
dest: string,
|
||
|
|
expectedSize: number | undefined,
|
||
|
|
opts: InstallOptions
|
||
|
|
): Promise<void> {
|
||
|
|
const assetName = path.basename(dest);
|
||
|
|
const res = await httpsGetWithRedirects(url, opts.signal);
|
||
|
|
const total =
|
||
|
|
expectedSize ??
|
||
|
|
(res.headers["content-length"] ? Number(res.headers["content-length"]) : undefined);
|
||
|
|
|
||
|
|
let received = 0;
|
||
|
|
let stalled = false;
|
||
|
|
let inactivityTimer: number | null = null;
|
||
|
|
const out = fs.createWriteStream(dest);
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
const clearInactivity = (): void => {
|
||
|
|
if (inactivityTimer) {
|
||
|
|
window.clearTimeout(inactivityTimer);
|
||
|
|
inactivityTimer = null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const armInactivity = (): void => {
|
||
|
|
clearInactivity();
|
||
|
|
inactivityTimer = window.setTimeout(() => {
|
||
|
|
stalled = true;
|
||
|
|
res.destroy(
|
||
|
|
new Error(
|
||
|
|
`Download stalled — no bytes received for ${Math.round(DOWNLOAD_INACTIVITY_TIMEOUT_MS / 1000)}s. Check your network and retry.`
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}, DOWNLOAD_INACTIVITY_TIMEOUT_MS);
|
||
|
|
};
|
||
|
|
armInactivity();
|
||
|
|
res.on("data", (chunk: Uint8Array) => {
|
||
|
|
received += chunk.length;
|
||
|
|
armInactivity();
|
||
|
|
opts.onProgress?.({ phase: "download", received, total, assetName });
|
||
|
|
});
|
||
|
|
const fail = (e: Error): void => {
|
||
|
|
clearInactivity();
|
||
|
|
reject(e);
|
||
|
|
};
|
||
|
|
res.on("error", fail);
|
||
|
|
out.on("error", fail);
|
||
|
|
out.on("finish", () => {
|
||
|
|
clearInactivity();
|
||
|
|
if (stalled) return; // already rejected via res.destroy()
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
res.pipe(out);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract `archivePath` into `destDir` by shelling out to the system `tar`
|
||
|
|
* (bsdtar on Windows 10 1803+). Distinguishes "tar not found" from
|
||
|
|
* non-zero exits so the user gets actionable error text.
|
||
|
|
*
|
||
|
|
* Path-traversal note: both GNU tar and bsdtar strip leading `/` and refuse
|
||
|
|
* to follow `..` outside the extraction root by default, so a malicious
|
||
|
|
* archive cannot escape `destDir`. We rely on that default rather than
|
||
|
|
* re-implementing extraction in JS.
|
||
|
|
*/
|
||
|
|
async function extractArchive(archivePath: string, destDir: string): Promise<void> {
|
||
|
|
// Cross-platform: macOS and Linux ship `tar`; Windows 10 1803+ ships `tar.exe`
|
||
|
|
// built in (`bsdtar`), which handles .zip / .tar.gz / .tar.xz transparently.
|
||
|
|
await new Promise<void>((resolve, reject) => {
|
||
|
|
const proc = spawn("tar", ["-xf", archivePath, "-C", destDir], {
|
||
|
|
stdio: ["ignore", "pipe", "pipe"],
|
||
|
|
});
|
||
|
|
let stderr = "";
|
||
|
|
proc.stderr.on("data", (d: Uint8Array) => {
|
||
|
|
stderr += Buffer.from(d).toString();
|
||
|
|
});
|
||
|
|
proc.on("error", (e: NodeJS.ErrnoException) => {
|
||
|
|
if (e.code === "ENOENT") {
|
||
|
|
reject(
|
||
|
|
new Error(
|
||
|
|
"`tar` was not found on PATH. macOS/Linux ship it by default; on Windows you need 10 1803+ (which ships `tar.exe`/bsdtar) or to install bsdtar manually."
|
||
|
|
)
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
reject(new Error(`Failed to launch tar: ${e instanceof Error ? e.message : String(e)}`));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
proc.on("close", (code) => {
|
||
|
|
if (code === 0) resolve();
|
||
|
|
else reject(new Error(`tar exited with code ${code}: ${stderr.slice(0, 500)}`));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// BFS because the binary's depth inside the upstream archive varies across
|
||
|
|
// opencode releases — first match wins.
|
||
|
|
async function locateFile(root: string, name: string): Promise<string> {
|
||
|
|
const queue: string[] = [root];
|
||
|
|
while (queue.length > 0) {
|
||
|
|
const dir = queue.shift() as string;
|
||
|
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||
|
|
for (const e of entries) {
|
||
|
|
const full = path.join(dir, e.name);
|
||
|
|
if (e.isDirectory()) queue.push(full);
|
||
|
|
else if (e.isFile() && e.name === name) return full;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw new Error(`File "${name}" not found anywhere under ${root}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function verifyOpencodeBinary(p: string): Promise<{ stdout: string }> {
|
||
|
|
try {
|
||
|
|
const { stdout } = await execFileAsync(p, ["--version"], {
|
||
|
|
timeout: VERIFY_BINARY_TIMEOUT_MS,
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
return { stdout: stdout.toString().trim() };
|
||
|
|
} catch (e) {
|
||
|
|
const err = e as NodeJS.ErrnoException & { signal?: string; code?: string | number };
|
||
|
|
if (err.code === "ENOENT") {
|
||
|
|
throw new Error(`No file at ${p}`);
|
||
|
|
}
|
||
|
|
if (err.signal === "SIGTERM") {
|
||
|
|
throw new Error(
|
||
|
|
`${p} did not respond to --version within ${VERIFY_BINARY_TIMEOUT_MS}ms. Is this an opencode binary?`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
throw new Error(
|
||
|
|
`${p} --version failed: ${err.message ?? String(err)}. Is this an opencode binary?`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|