relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
157 lines (147 loc) • 6.74 kB
JavaScript
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { readCanonicalRuntime } from "./canonical-runtime.js";
import { companionPackageRoot, currentCompanionVersion, isManagedInstall, isNewerVersion } from "./auto-update.js";
import { STABLE_MCP_LAUNCHER_ENV } from "./mcp-launcher.js";
/**
* The `relay` command a person types is the npm global shim, and it stays at
* whatever version was last `npm install -g`'d — forever. The canonical
* runtime (~/.relay/runtime/releases/<id>) updates the daemon and pill by
* rewriting THEIR launchers to the new release, but nothing re-points the
* shim, so `relay doctor`, `relay uninstall`, even `relay update` run stale
* code on every machine, and `relay update` cannot repair itself because it
* is one of the stale commands.
*
* So the shim becomes a trampoline: at startup, if an active canonical
* release exists at a different path, hand the whole invocation to that
* release's bin. After the first install, `npm install -g` never matters
* again.
*
* Commands that must NOT hop, because they are launched by exact path on
* purpose and their identity is the whole point:
* - daemon: the service launcher names the exact release, and the daemon's
* own stale-process handoff depends on knowing which tree it booted from.
* The pill is intentionally NOT exempt: launchd / Scheduled Tasks start
* Electron directly, while a human typing `relay pill` may be entering
* through a years-old global npm shim. That command must open the active
* canonical pill, not resurrect the shim's bundled overlay.
* - mcp only when launched by Relay's stable MCP launcher, which activation
* already rewrites to the exact release bin. A person or third-party host
* invoking `npx relay-companion@latest mcp` has no such guarantee and must
* hop away from the deliberately old migration bridge.
* - repair-runtime / repair-desktop / self-update: invoked BY the updater
* during activation and rollback, sometimes deliberately from a tree that
* is not current. Hopping would defeat rollback.
* - claude-hook / codex-hook: launched by the stable hook launcher, same
* reasoning as mcp.
*
* Everything else hops — including commands that no longer exist: a human
* pasting a stale command from an old notification should land in the current
* release's usage text, not in stale code that still pretends to serve it.
*/
export const TRAMPOLINE_EXEMPT_COMMANDS = new Set([
"daemon",
"repair-runtime",
"repair-desktop",
"self-update",
"claude-hook",
"codex-hook",
]);
/** Set on the hop so the target can never hop again, whatever it reads. */
export const TRAMPOLINE_ENV = "RELAY_CLI_TRAMPOLINED";
/** Where the human's typed `relay` actually lives, for `doctor` to report. */
export const TRAMPOLINE_SHIM_VERSION_ENV = "RELAY_CLI_SHIM_VERSION";
export const TRAMPOLINE_SHIM_ROOT_ENV = "RELAY_CLI_SHIM_ROOT";
function samePath(left, right, platform) {
const a = path.resolve(String(left || ""));
const b = path.resolve(String(right || ""));
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* Decide whether this invocation should hand off, and to what. Pure apart
* from the injected readers, so the rules are testable without a runtime.
*
* Returns null to run in-process, or { node, bin, from, to, shimRoot } to hop.
*/
export function resolveCliTrampoline({
command,
argv = [],
env = process.env,
platform = process.platform,
homeDir = os.homedir(),
runningRoot = companionPackageRoot(),
runningVersion = null,
readCurrent = () => readCanonicalRuntime({ homeDir, platform }),
exists = fs.existsSync,
execPath = process.execPath,
} = {}) {
if (env[TRAMPOLINE_ENV]) return null;
// The documented escape hatch: run the tree the human actually invoked,
// e.g. a deliberately `npm install -g`'d older build under investigation.
if (argv.includes("--no-trampoline")) return null;
// A bare `relay` (usage text) and unknown commands hop too — the help a
// person reads should describe the code the machine runs.
if (command === "mcp" && env[STABLE_MCP_LAUNCHER_ENV] === "1") return null;
if (command && TRAMPOLINE_EXEMPT_COMMANDS.has(command)) return null;
// Only an npm-managed tree is "just a way in". A dev checkout
// (.../packages/companion, not under node_modules) is the developer's own
// code and must run as itself — and its package.json version cannot be
// trusted for the comparison below, because CI assigns versions on
// publish, so a checkout of main always looks older than the fleet.
if (!isManagedInstall(runningRoot)) return null;
const current = readCurrent();
if (!current || !current.bin || !current.packageRoot) return null;
if (samePath(current.packageRoot, runningRoot, platform)) return null;
// Mid-swap or half-deleted release: run what we have rather than fail.
if (!exists(current.bin)) return null;
// A NEWER invoker runs itself. That is a dev checkout under test, or a
// freshly `npm install -g`'d shim the canonical runtime has not caught up
// with yet — in both cases the newer code is the one the human means.
const mine = runningVersion ?? safeVersion(runningRoot);
if (mine && current.version && isNewerVersion(mine, current.version)) return null;
return {
node: current.node && exists(current.node) ? current.node : execPath,
bin: current.bin,
from: mine,
to: current.version,
shimRoot: path.resolve(String(runningRoot)),
};
}
function safeVersion(root) {
try {
return currentCompanionVersion(root);
} catch {
return null;
}
}
/**
* Perform the hop: run the canonical bin with the same argv, inherit stdio,
* mirror its exit. Windows has no exec(), so this is a blocking spawn — the
* shim stays alive as a thin parent, which also keeps Ctrl+C semantics
* intact because both processes share the console group.
*/
export function runCliTrampoline(hop, {
argv = process.argv.slice(2),
env = process.env,
spawn = spawnSync,
} = {}) {
const result = spawn(hop.node, [hop.bin, ...argv], {
stdio: "inherit",
env: {
...env,
[TRAMPOLINE_ENV]: "1",
// Let the target's `doctor` report the shim the human actually typed;
// without this the hop makes a frozen shim invisible.
[TRAMPOLINE_SHIM_VERSION_ENV]: hop.from || "",
[TRAMPOLINE_SHIM_ROOT_ENV]: hop.shimRoot || "",
},
});
if (result.error) throw result.error;
if (result.signal) {
process.kill(process.pid, result.signal);
return 1;
}
return result.status == null ? 1 : result.status;
}