From 8791e46cf31a6162392a3e10061faea7574154bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 02:48:25 +0100 Subject: [PATCH] fix: resolve npx gateway daemon install --- CHANGELOG.md | 3 ++ src/daemon/program-args.test.ts | 71 +++++++++++++++++++++++++++++++++ src/daemon/program-args.ts | 71 ++++++++++++++++++++++++++++----- 3 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/daemon/program-args.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 075a14948..a784a3eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## Unreleased +### Fixes +- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. + ## 2026.1.5 ### Highlights diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts new file mode 100644 index 000000000..e5ec99f96 --- /dev/null +++ b/src/daemon/program-args.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const access = vi.fn(); +const realpath = vi.fn(); + +vi.mock("node:fs/promises", () => ({ + default: { access, realpath }, + access, + realpath, +})); + +import { resolveGatewayProgramArguments } from "./program-args.js"; + +const originalArgv = [...process.argv]; + +afterEach(() => { + process.argv = [...originalArgv]; + vi.resetAllMocks(); +}); + +describe("resolveGatewayProgramArguments", () => { + it("uses realpath-resolved dist entry when running via npx shim", async () => { + process.argv = [ + "node", + "/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot", + ]; + realpath.mockResolvedValue( + "/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/entry.js", + ); + access.mockImplementation(async (target: string) => { + if (target === "/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/entry.js") { + return; + } + throw new Error("missing"); + }); + + const result = await resolveGatewayProgramArguments({ port: 18789 }); + + expect(result.programArguments).toEqual([ + process.execPath, + "/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/entry.js", + "gateway-daemon", + "--port", + "18789", + ]); + }); + + it("falls back to node_modules package dist when .bin path is not resolved", async () => { + process.argv = [ + "node", + "/tmp/.npm/_npx/63c3/node_modules/.bin/clawdbot", + ]; + realpath.mockRejectedValue(new Error("no realpath")); + access.mockImplementation(async (target: string) => { + if (target === "/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js") { + return; + } + throw new Error("missing"); + }); + + const result = await resolveGatewayProgramArguments({ port: 18789 }); + + expect(result.programArguments).toEqual([ + process.execPath, + "/tmp/.npm/_npx/63c3/node_modules/clawdbot/dist/index.js", + "gateway-daemon", + "--port", + "18789", + ]); + }); +}); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 8e1fcb785..b2dddfb6c 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -16,18 +16,14 @@ async function resolveCliEntrypointPathForService(): Promise { if (!argv1) throw new Error("Unable to resolve CLI entrypoint path"); const normalized = path.resolve(argv1); - const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized); + const resolvedPath = await resolveRealpathSafe(normalized); + const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(resolvedPath); if (looksLikeDist) { - await fs.access(normalized); - return normalized; + await fs.access(resolvedPath); + return resolvedPath; } - const distCandidates = [ - path.resolve(path.dirname(normalized), "..", "dist", "index.js"), - path.resolve(path.dirname(normalized), "..", "dist", "index.mjs"), - path.resolve(path.dirname(normalized), "dist", "index.js"), - path.resolve(path.dirname(normalized), "dist", "index.mjs"), - ]; + const distCandidates = buildDistCandidates(resolvedPath, normalized); for (const candidate of distCandidates) { try { @@ -43,6 +39,63 @@ async function resolveCliEntrypointPathForService(): Promise { ); } +async function resolveRealpathSafe(inputPath: string): Promise { + try { + return await fs.realpath(inputPath); + } catch { + return inputPath; + } +} + +function buildDistCandidates(...inputs: string[]): string[] { + const candidates: string[] = []; + const seen = new Set(); + + for (const inputPath of inputs) { + if (!inputPath) continue; + const baseDir = path.dirname(inputPath); + appendDistCandidates(candidates, seen, path.resolve(baseDir, "..")); + appendDistCandidates(candidates, seen, baseDir); + appendNodeModulesBinCandidates(candidates, seen, inputPath); + } + + return candidates; +} + +function appendDistCandidates( + candidates: string[], + seen: Set, + baseDir: string, +): void { + const distDir = path.resolve(baseDir, "dist"); + const distEntries = [ + path.join(distDir, "index.js"), + path.join(distDir, "index.mjs"), + path.join(distDir, "entry.js"), + path.join(distDir, "entry.mjs"), + ]; + for (const entry of distEntries) { + if (seen.has(entry)) continue; + seen.add(entry); + candidates.push(entry); + } +} + +function appendNodeModulesBinCandidates( + candidates: string[], + seen: Set, + inputPath: string, +): void { + const parts = inputPath.split(path.sep); + const binIndex = parts.lastIndexOf(".bin"); + if (binIndex <= 0) return; + if (parts[binIndex - 1] !== "node_modules") return; + const binName = path.basename(inputPath); + const nodeModulesDir = parts.slice(0, binIndex).join(path.sep); + const packageRoot = path.join(nodeModulesDir, binName); + appendDistCandidates(candidates, seen, packageRoot); +} + function resolveRepoRootForDev(): string { const argv1 = process.argv[1]; if (!argv1) throw new Error("Unable to resolve repo root");