From e06df140d2854597cc43f97982cc33a9c45c8e7c Mon Sep 17 00:00:00 2001 From: Daniil Koryto Date: Fri, 10 Apr 2026 19:39:40 +0300 Subject: [PATCH] fix: follow symlinked bin entrypoints --- CHANGELOG.md | 6 ++++++ bin/gonkagate-opencode.js | 6 ++---- src/cli.ts | 6 ++---- src/entrypoint.ts | 29 +++++++++++++++++++++++++++++ test/cli.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 5 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/entrypoint.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fccf4a5..776d2de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,12 @@ `lastDurableSetupAt` while keeping legacy `lastSuccessfulSetupAt` readable as a backward-compatible migration path +### Bug Fixes + +- make the published bin wrapper follow symlinked `.bin` entrypoints so + `npx @gonkagate/opencode-setup` and `node_modules/.bin/opencode-setup` + actually execute on Unix-like systems instead of exiting silently + ### Added - public curated model picker UI in the CLI, currently backed by one validated diff --git a/bin/gonkagate-opencode.js b/bin/gonkagate-opencode.js index f565437..92c3bd5 100755 --- a/bin/gonkagate-opencode.js +++ b/bin/gonkagate-opencode.js @@ -1,14 +1,12 @@ #!/usr/bin/env node import process from "node:process"; -import { pathToFileURL } from "node:url"; import { main, renderCliEntrypointError } from "../dist/cli.js"; +import { isEntrypointInvocation } from "../dist/entrypoint.js"; export { renderCliEntrypointError }; -const isEntrypoint = - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(process.argv[1]).href; +const isEntrypoint = isEntrypointInvocation(import.meta.url); function handleCliError(error) { const renderedError = renderCliEntrypointError(error); diff --git a/src/cli.ts b/src/cli.ts index 164b129..7f2f858 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,9 @@ import process from "node:process"; -import { pathToFileURL } from "node:url"; import { run as runCli } from "./cli/execute.js"; import { parseCliOptions } from "./cli/parse.js"; import { renderCliEntrypointError } from "./cli/render.js"; import type { CliRunOptions, CliRunResult } from "./cli/contracts.js"; +import { isEntrypointInvocation } from "./entrypoint.js"; export { parseCliOptions }; export { renderCliEntrypointError } from "./cli/render.js"; @@ -45,9 +45,7 @@ function handleCliError(error: unknown): void { process.exitCode = renderedError.exitCode; } -const isEntrypoint = - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(process.argv[1]).href; +const isEntrypoint = isEntrypointInvocation(import.meta.url); if (isEntrypoint) { main().catch(handleCliError); diff --git a/src/entrypoint.ts b/src/entrypoint.ts new file mode 100644 index 0000000..1e72270 --- /dev/null +++ b/src/entrypoint.ts @@ -0,0 +1,29 @@ +import { realpathSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +function tryResolveRealPath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + +export function isEntrypointInvocation( + importMetaUrl: string, + argv1 = process.argv[1], +): boolean { + if (argv1 === undefined) { + return false; + } + + const importMetaPath = fileURLToPath(importMetaUrl); + const argv1RealPath = tryResolveRealPath(argv1); + const importMetaRealPath = tryResolveRealPath(importMetaPath); + + if (argv1RealPath !== undefined && importMetaRealPath !== undefined) { + return argv1RealPath === importMetaRealPath; + } + + return importMetaUrl === pathToFileURL(argv1).href; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 1140e20..a884855 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { resolve } from "node:path"; +import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; import test from "node:test"; import { formatOpencodeModelRef } from "../src/constants/models.js"; @@ -161,6 +163,40 @@ test("CLI wrapper exposes the shipped help surface", () => { assert.match(helpResult.stdout, new RegExp(escapeRegExp(GONKAGATE_BASE_URL))); }); +test("CLI wrapper still runs when invoked through a symlinked bin path", (t) => { + const tempDir = mkdtempSync(join(tmpdir(), "gonkagate-opencode-bin-")); + const binPath = resolve(repoRoot, CONTRACT_METADATA.binPath); + const linkedBinPath = resolve(tempDir, CONTRACT_METADATA.binName); + + t.after(() => { + rmSync(tempDir, { force: true, recursive: true }); + }); + + try { + symlinkSync(binPath, linkedBinPath, "file"); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "EPERM" + ) { + t.skip("Symlinks are unavailable in this environment."); + return; + } + + throw error; + } + + const helpResult = spawnSync(process.execPath, [linkedBinPath, "--help"], { + cwd: repoRoot, + encoding: "utf8", + }); + + assert.equal(helpResult.status, 0); + assert.match(helpResult.stdout, /Usage: opencode-setup/i); + assert.match(helpResult.stdout, /Configure OpenCode to use GonkaGate/i); +}); + test("interactive runs show the public model picker even when one validated model is available", async () => { const promptMessages: string[] = []; const promptChoiceSnapshots: string[][] = [];