Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 28 additions & 85 deletions packages/extension/src/chat_bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from "node:path";
import * as os from "node:os";
import * as fs from "node:fs";
import { opencodeDataDir, opencodeConfigDir } from "./opencode_xdg";
import { findForkedOpencodeBinary } from "./opencode_binary";
import {
readSkillProviders,
addSkillProvider,
Expand Down Expand Up @@ -323,15 +324,17 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
return true;
}

// Developer Tools settings: validate paths, write VS Code settings, restart
// server / prompt reload as appropriate. The app posts on blur and on toggle.
// Developer Tools settings: validate paths, swap the opencode binary +
// restart its server as appropriate. The app posts on blur and on toggle.
// Committing the amicode path is validate-only — no build, no reload; see
// the note at that branch below (#941).
if (msg.kind === "dev-tools-update") {
const enabled = (msg as { enabled?: unknown }).enabled === true;
const opencodePath = typeof (msg as { opencodePath?: unknown }).opencodePath === "string"
? (msg as unknown as { opencodePath: string }).opencodePath.trim()
? (msg as unknown as { opencodePath: string }).opencodePath.trim().replace(/^~/, os.homedir())
: "";
const amicodePath = typeof (msg as { amicodePath?: unknown }).amicodePath === "string"
? (msg as unknown as { amicodePath: string }).amicodePath.trim()
? (msg as unknown as { amicodePath: string }).amicodePath.trim().replace(/^~/, os.homedir())
: "";

const reply: {
Expand Down Expand Up @@ -390,32 +393,13 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
// Validate opencode path: resolve the binary from the repo root
let resolvedBinary = "";
if (opencodePath) {
// The dev binary lives at <root>/packages/opencode/dist/opencode/bin/opencode
// or <root>/cmd/opencode (Go), or the user may point directly at a binary.
const candidates = [
path.join(opencodePath, "packages", "opencode", "dist", "opencode", "bin", "opencode"),
path.join(opencodePath, "dist", "opencode", "bin", "opencode"),
opencodePath, // direct binary path
];
for (const candidate of candidates) {
try {
const stat = fs.statSync(candidate);
if (stat.isFile()) {
// Check executable bit (unix)
try {
fs.accessSync(candidate, fs.constants.X_OK);
resolvedBinary = candidate;
break;
} catch {
reply.opencodeValid = false;
reply.opencodeError = "Binary exists but is not executable";
}
}
} catch {
// not found, try next
}
}
if (!resolvedBinary && reply.opencodeValid) {
const resolution = findForkedOpencodeBinary(opencodePath);
if (resolution.found) {
resolvedBinary = resolution.path;
} else if (resolution.reason === "not-executable") {
reply.opencodeValid = false;
reply.opencodeError = "Binary exists but is not executable";
} else {
reply.opencodeValid = false;
reply.opencodeError = "Binary not found at this path";
}
Expand Down Expand Up @@ -463,55 +447,21 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
reply.serverRestarted = true;
}

if (reply.amicodeValid && amicodePath) {
// Run a full extension build in the amicode repo root, then set devAssetRoot
// to the built extension directory and prompt a reload with deep-link to
// the developer tools section for continuity.
const extensionDir = path.join(amicodePath, "packages", "extension");

// Notify the app that a build is in progress
io.postToWebview({ ...reply, building: true });

// Build + reload is async; fire-and-forget from the sync handler.
void (async () => {
const { exec } = await import("child_process");
const buildResult = await new Promise<{ ok: boolean; error?: string }>((resolve) => {
exec("bun run build", { cwd: amicodePath, timeout: 120_000 }, (err, _stdout, stderr) => {
if (err) {
resolve({ ok: false, error: stderr?.trim() || err.message });
} else {
resolve({ ok: true });
}
});
});

if (!buildResult.ok) {
reply.amicodeValid = false;
reply.amicodeError = `Build failed: ${buildResult.error?.slice(0, 200) ?? "unknown error"}`;
io.postToWebview(reply);
return;
}

void vscode.workspace.getConfiguration("amicode").update(
"devAssetRoot", extensionDir, vscode.ConfigurationTarget.Global,
);
reply.reloadNeeded = true;
io.postToWebview(reply);

// Auto-reload after a short delay so the webview can persist state.
setTimeout(() => {
void vscode.commands.executeCommand("workbench.action.reloadWindow");
}, 500);
})();
} else if (reply.amicodeValid && !amicodePath) {
// Committing the amicode path only validates it (above) — it never
// builds or reloads on its own (#941). Blurring a path field used to
// eagerly run a real `bun run build` and auto-reload the window with no
// confirmation, and since the app always resends BOTH current paths on
// any field's blur, even an unrelated edit to the opencode field would
// retrigger it. Building is now exclusively an explicit action — the
// "Rebuild Locally" / "Rebuild from Main" buttons (dev-tools-rebuild,
// below), which are self-sufficient and don't depend on anything set
// here. Clearing the path to empty still clears any devAssetRoot
// override, since that's just removing a setting, not building one.
if (reply.amicodeValid && !amicodePath) {
void vscode.workspace.getConfiguration("amicode").update("devAssetRoot", "", vscode.ConfigurationTarget.Global);
}

// For the async build case, the reply is posted from within the IIFE.
// For all other cases, post the reply here.
if (!(reply.amicodeValid && amicodePath)) {
io.postToWebview(reply);
}
io.postToWebview(reply);
return true;
}

Expand Down Expand Up @@ -662,15 +612,8 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
}

// ── Resolve and codesign the built binary ──
const candidates = [
path.join(opencodePath, "packages", "opencode", "dist", `opencode-darwin-arm64`, "bin", "opencode"),
path.join(opencodePath, "packages", "opencode", "dist", `opencode-darwin-x64`, "bin", "opencode"),
path.join(opencodePath, "packages", "opencode", "dist", "opencode", "bin", "opencode"),
];
let resolvedBinary = "";
for (const c of candidates) {
try { if (fs.statSync(c).isFile()) { resolvedBinary = c; break; } } catch { /* next */ }
}
const resolution = findForkedOpencodeBinary(opencodePath);
const resolvedBinary = resolution.found ? resolution.path : "";
if (resolvedBinary) {
await run(`codesign --sign - --force "${resolvedBinary}"`, opencodePath).catch(() => {});
}
Expand Down
53 changes: 52 additions & 1 deletion packages/extension/src/opencode_binary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { accessSync, constants } from "node:fs";
import { accessSync, constants, statSync } from "node:fs";
import { join } from "node:path";

export class OpencodeMissingError extends Error {}
Expand Down Expand Up @@ -52,3 +52,54 @@ export function resolveOpencodeBinary(extensionRoot: string, configValue: string
}
return { path: vendored, source: "vendored" };
}

export type ForkBinaryResolution =
| { found: true; path: string }
| { found: false; reason: "not-found" }
| { found: false; reason: "not-executable"; path: string };

/**
* Resolve the built opencode binary from a FORK CHECKOUT's dist output —
* distinct from resolveOpencodeBinary() above, which resolves the VENDORED
* binary shipped with the extension itself.
*
* Used by the Developer Tools dev-tools-update (path validation) and
* dev-tools-rebuild (post-build codesign) handlers, which used to each
* carry their own, independently-drifting candidate list (#943):
* dev-tools-update never checked the platform-suffixed dist directory a
* real build actually produces, so a genuinely valid repo path was
* indistinguishable from a typo; dev-tools-rebuild's own list only covered
* darwin, missing both Linux targets. Both now share this one list,
* sourced from SUPPORTED.
*
* Checks every supported platform's suffixed directory (not just the
* current host's — the repo root being validated need not have been built
* on this machine) before falling back to the legacy unsuffixed layout an
* older build script once produced, and finally treats the input itself as
* a direct binary path in case the user pointed straight at one instead of
* a repo root.
*/
export function findForkedOpencodeBinary(repoRoot: string): ForkBinaryResolution {
const candidates = [
...SUPPORTED.map((key) => join(repoRoot, "packages", "opencode", "dist", `opencode-${key}`, "bin", "opencode")),
join(repoRoot, "packages", "opencode", "dist", "opencode", "bin", "opencode"),
join(repoRoot, "dist", "opencode", "bin", "opencode"),
repoRoot, // direct binary path — the field also accepts pointing straight at a binary
];

let notExecutable: string | undefined;
for (const candidate of candidates) {
try {
if (!statSync(candidate).isFile()) continue;
} catch {
continue;
}
try {
accessSync(candidate, constants.X_OK);
return { found: true, path: candidate };
} catch {
notExecutable ??= candidate;
}
}
return notExecutable ? { found: false, reason: "not-executable", path: notExecutable } : { found: false, reason: "not-found" };
}
Loading
Loading