Files
diegosouzapw--omniroute/bin/cli/commands/setup-open-code.mjs
T
2026-07-13 13:39:12 +08:00

399 lines
15 KiB
JavaScript

/**
* omniroute setup opencode — Wire the bundled @omniroute/opencode-plugin
* into a local OpenCode install.
*
* Closes the gap where `npm install -g omniroute` ships the plugin
* inside the omniroute package (`@omniroute/opencode-plugin/dist/`) but
* OpenCode discovers plugins via `~/.config/opencode/plugins/` or
* via entries in `opencode.json`. Without this command, the user has
* to extract the tarball and wire it up by hand (see the plugin README,
* "Install" section).
*
* What it does, in order:
* 1. Resolves the bundled plugin path (source + built dist).
* 2. Resolves the OpenCode config directory (XDG-aware).
* 3. Copies the built plugin into `<opencode>/plugins/omniroute/`.
* 4. Creates or updates `opencode.json` with a single `plugin` entry
* pointing at the local copy (so OC ≥1.15 picks it up).
* 5. Optionally runs `opencode auth login --provider omniroute`
* so the next `opencode` invocation already has the API key.
*
* Idempotent: re-running with the same `--provider-id` updates the
* entry in place (path + baseURL) without duplicating it.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// We walk up from this file to find the omniroute package root. The script
// lives at `<omniroute>/bin/cli/commands/setup-open-code.mjs`, so the
// package root is three levels up. Using import.meta.url (not process.cwd())
// means the command works the same way whether you run it from the source
// repo, a global install, or a symlinked location.
const PACKAGE_ROOT = resolve(__dirname, "..", "..", "..");
// The bundled plugin ships at PACKAGE_ROOT/@omniroute/opencode-plugin/
// (see root package.json `files`: ["@omniroute/", ...]). The env override
// exists so tests can point at a fixture without building the real plugin.
const BUNDLED_PLUGIN_DIR =
process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR || join(PACKAGE_ROOT, "@omniroute", "opencode-plugin");
/**
* Resolve the OpenCode config directory. Honours XDG_CONFIG_HOME and the
* platform-specific defaults documented at https://opencode.ai/.
*
* @returns {{ configDir: string, dataDir: string }}
*/
function resolveOpenCodeDirs() {
const home = os.homedir();
const xdgConfig = process.env.XDG_CONFIG_HOME;
const xdgData = process.env.XDG_DATA_HOME;
const platform = process.platform;
let configDir;
let dataDir;
if (platform === "darwin") {
// macOS: ~/Library/Application Support/opencode
configDir = join(home, "Library", "Application Support", "opencode");
dataDir = configDir; // OC uses the same root for config + data on macOS
} else if (platform === "win32") {
const appdata = process.env.APPDATA || join(home, "AppData", "Roaming");
const localAppdata = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
configDir = join(appdata, "opencode");
dataDir = join(localAppdata, "opencode");
} else {
// Linux + everything else: XDG-style
configDir = xdgConfig ? join(xdgConfig, "opencode") : join(home, ".config", "opencode");
dataDir = xdgData ? join(xdgData, "opencode") : join(home, ".local", "share", "opencode");
}
return { configDir, dataDir };
}
/**
* Locate the bundled @omniroute/opencode-plugin dist. The plugin may be
* present in two states:
*
* - Built (`dist/index.cjs` + `dist/index.js` exist) — preferred,
* ships from a published omniroute tarball after Step 8.8 of
* `scripts/build/prepublish.ts` runs.
* - Unbuilt (only `src/index.ts`) — local dev / fresh clone. We surface
* a clear error instead of running tsup here, because the CLI runtime
* may not have tsup available (it's a devDependency).
*
* @returns {{ distEntry: string, packageDir: string }}
*/
function resolveBundledPlugin() {
if (!existsSync(BUNDLED_PLUGIN_DIR)) {
throw new Error(
`Bundled @omniroute/opencode-plugin not found at ${BUNDLED_PLUGIN_DIR}.\n` +
`This usually means omniroute was installed from a source tree that does not ` +
`include the workspace package. Try reinstalling omniroute (npm install -g omniroute) ` +
`or run \`cd @omniroute/opencode-plugin && npm install && npm run build\` from the source repo.`
);
}
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
if (!existsSync(esmEntry)) {
throw new Error(
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
);
}
// ESM-only build (CJS was dropped in tsup config). OpenCode (>=1.15) loads
// ESM modules natively.
return { distEntry: esmEntry, packageDir: BUNDLED_PLUGIN_DIR };
}
/**
* Copy the plugin package into `<opencodeConfig>/plugins/omniroute/`. We
* copy the entire package (dist/ + package.json) so the dist file's
* require/import of `zod` and `@opencode-ai/plugin` resolves against the
* copy's own node_modules. Without the copy, OpenCode would need to
* resolve the peer deps from the omniroute package's tree, which is
* unreliable.
*/
function installPluginToOpenCode(pluginInfo, opencodeConfigDir) {
const targetDir = join(opencodeConfigDir, "plugins", "omniroute");
mkdirSync(dirname(targetDir), { recursive: true });
mkdirSync(targetDir, { recursive: true });
// Copy package.json + dist/. We intentionally do NOT recursively copy
// node_modules from the source — `peerDependenciesMeta` declares zod +
// @opencode-ai/plugin as peers, and the user's OpenCode install already
// provides them. Copying our own node_modules would risk duplicate zod
// instances (the @opencode-ai/plugin contract uses a singleton).
const packageJsonSrc = join(pluginInfo.packageDir, "package.json");
const distSrc = join(pluginInfo.packageDir, "dist");
cpSync(packageJsonSrc, join(targetDir, "package.json"));
cpSync(distSrc, join(targetDir, "dist"), { recursive: true });
return targetDir;
}
/**
* Update `opencode.json` to register the plugin. Idempotent: if an entry
* for the same `providerId` already exists, replace it in place. If the
* user has any other plugin entries, preserve them.
*
* @returns {{ configPath: string, changed: boolean }}
*/
function registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
}) {
const configPath = join(opencodeConfigDir, "opencode.json");
let cfg = {};
if (existsSync(configPath)) {
try {
cfg = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(
`Failed to parse existing ${configPath}: ${err.message}\n` +
`Fix or remove the file manually, then re-run \`omniroute setup opencode\`.`
);
}
}
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
// Plugin entries can be either a string ("@some/pkg") or a tuple
// ("@some/pkg", { options }). The README documents the tuple form, so
// we use that. The "module path" is a file:// URL relative to the
// opencode config dir — that is what opencode ≥1.15 resolves.
const entry = [
`./plugins/omniroute/dist/index.js`,
{
providerId,
baseURL,
...(displayName ? { displayName } : {}),
},
];
// Idempotency: drop any prior entry for the same providerId. We also
// drop a legacy `opencode-omniroute-auth` entry if present — that
// package is the obsolete predecessor of @omniroute/opencode-plugin
// and was the root cause of issue #3711.
const filtered = plugins.filter((p) => {
if (typeof p === "string") {
return !p.includes("opencode-omniroute-auth");
}
if (Array.isArray(p) && p[1] && typeof p[1] === "object") {
const pid = p[1].providerId;
if (pid === providerId) return false;
// Also drop the legacy auth plugin if it's there.
if (typeof p[0] === "string" && p[0].includes("opencode-omniroute-auth")) {
return false;
}
}
return true;
});
filtered.push(entry);
cfg.plugin = filtered;
// Make sure the config dir exists, then write the updated config.
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
return { configPath, changed: true };
}
/**
* Optionally invoke `opencode auth login --provider <providerId>`. We
* shell out (instead of importing) so this command works even if
* OpenCode's CLI surface shifts between minor versions — the user gets
* a clear "could not run opencode" message instead of a hard import
* failure.
*/
function runOpenCodeAuth(providerId) {
const isWin = process.platform === "win32";
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
stdio: "inherit",
shell: false,
});
if (res.error) {
// ENOENT = opencode is not on PATH
if (res.error.code === "ENOENT") {
printInfo(
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.`
);
return 1;
}
printError(`opencode auth login failed: ${res.error.message}`);
return 1;
}
return typeof res.status === "number" ? res.status : 1;
}
/**
* Top-level action handler. Kept exported so the integration test can
* drive it without spawning a subprocess.
*
* @param {object} opts
* @param {string} [opts.providerId="omniroute"]
* @param {string} [opts.baseURL="http://localhost:20128"] (Commander camelCases
* `--base-url` into `baseUrl`, so both spellings are accepted.)
* @param {string} [opts.configDir] Override the OpenCode config dir (tests / non-standard installs).
* @param {string} [opts.displayName]
* @param {boolean} [opts.auth=false] Run `opencode auth login` after wiring.
* @param {boolean} [opts.nonInteractive=false] Skip prompts.
* @returns {Promise<{ exitCode: number, configPath?: string, pluginTargetDir?: string }>}
*/
export async function runSetupOpenCodeCommand(opts = {}) {
const providerId = opts.providerId || "omniroute";
// Remote-aware: explicit --remote/--base-url → active context → localhost.
let baseURL = opts.remote || opts.baseURL || opts.baseUrl;
if (!baseURL) {
try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
baseURL = ctx?.baseUrl;
} catch {
/* no context */
}
}
if (!baseURL) baseURL = "http://localhost:20128";
const displayName = opts.displayName || null;
const wantsAuth = Boolean(opts.auth);
const nonInteractive = Boolean(opts.nonInteractive);
printHeading("OmniRoute → OpenCode Plugin Setup");
const resolvedDirs = resolveOpenCodeDirs();
const opencodeConfigDir = opts.configDir || resolvedDirs.configDir;
const opencodeDataDir = resolvedDirs.dataDir;
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
printInfo(`OpenCode data dir: ${opencodeDataDir}`);
// 1. Resolve bundled plugin
let pluginInfo;
try {
pluginInfo = resolveBundledPlugin();
} catch (err) {
printError(err.message);
return { exitCode: 1 };
}
printInfo(`Bundled plugin: ${pluginInfo.distEntry}`);
// 2. Ensure OpenCode config dir exists (opencode will create it on
// first run, but creating it now means we can write opencode.json
// even if OC has never been launched).
if (!existsSync(opencodeConfigDir)) {
mkdirSync(opencodeConfigDir, { recursive: true });
printInfo(`Created OpenCode config dir (didn't exist yet).`);
}
// 3. Copy plugin into OpenCode's plugin dir
let pluginTargetDir;
try {
pluginTargetDir = installPluginToOpenCode(pluginInfo, opencodeConfigDir);
printSuccess(`Plugin installed at ${pluginTargetDir}`);
} catch (err) {
printError(`Failed to install plugin: ${err.message}`);
return { exitCode: 1 };
}
// 4. Register in opencode.json
let configPath;
try {
const reg = registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
});
configPath = reg.configPath;
printSuccess(`opencode.json updated at ${configPath}`);
} catch (err) {
printError(`Failed to update opencode.json: ${err.message}`);
return { exitCode: 1, pluginTargetDir };
}
// 5. Optionally run auth login
if (wantsAuth) {
if (nonInteractive) {
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
printInfo(`Run manually: opencode auth login --provider ${providerId}`);
} else {
printHeading("Authenticating with OpenCode");
const authExit = runOpenCodeAuth(providerId);
if (authExit !== 0) {
return { exitCode: authExit, configPath, pluginTargetDir };
}
}
} else {
printInfo(
`Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)`
);
}
printSuccess("OpenCode plugin setup complete");
printInfo(`Restart OpenCode to pick up the new plugin entry.`);
return { exitCode: 0, configPath, pluginTargetDir };
}
/**
* Register the `omniroute setup opencode` subcommand on the parent
* `setup` command. Commander builds the doc/help from the chain, so
* `omniroute setup --help` automatically shows the new subcommand.
*
* @param {import("commander").Command} setupCommand the registered `setup` command
*/
export function registerSetupOpenCode(setupCommand) {
setupCommand
.command("opencode")
.description(
t("setup.opencode") ||
"Install and register the bundled @omniroute/opencode-plugin with a local OpenCode install"
)
.option(
"--provider-id <id>",
"OpenCode provider id to register (default: omniroute)",
"omniroute"
)
.option(
"--base-url <url>",
"OmniRoute base URL the plugin should talk to (default: active context or http://localhost:20128)"
)
.option(
"--remote <url>",
"Remote OmniRoute URL, e.g. http://192.168.0.15:20128 (overrides --base-url and the context)"
)
.option("--display-name <name>", "Display name in the OpenCode UI (optional)")
.option(
"--auth",
"Run `opencode auth login --provider <providerId>` after wiring (interactive)",
false
)
.option("--non-interactive", "Do not prompt; skip the auth login step", false)
.action(async (opts, cmd) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// that here so global flags (--json, --base-url, --api-key) still
// flow through to the runner.
const globalOpts = cmd.parent?.parent?.optsWithGlobals?.() ?? {};
const merged = {
...opts,
output: globalOpts.output,
apiKey: opts.apiKey ?? globalOpts.apiKey,
baseUrl: opts.baseUrl ?? globalOpts.baseUrl,
context: globalOpts.context ?? opts.context,
};
const { exitCode } = await runSetupOpenCodeCommand(merged);
if (exitCode !== 0) process.exit(exitCode);
});
}