Files
OpenQode/bin/goose-launch.mjs

423 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Goose Launcher (Windows-friendly)
*
* Starts a local OpenAI-compatible proxy backed by the same Qwen auth OpenQode uses,
* then launches Goose in "web" mode pointing to that proxy.
*
* Usage:
* node bin/goose-launch.mjs web [--port 3000] [--proxy-port 18181] [--model qwen-coder-plus] [--open] [--no-window]
* node bin/goose-launch.mjs status
* node bin/goose-launch.mjs stop
*/
import { spawn, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import net from 'net';
import { fileURLToPath } from 'url';
import http from 'http';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const OPENCODE_ROOT = path.resolve(__dirname, '..');
const STATE_DIR = path.join(OPENCODE_ROOT, '.opencode');
const PROXY_STATE = path.join(STATE_DIR, 'qwen-proxy.json');
const GOOSE_STATE = path.join(STATE_DIR, 'goose-web.json');
const GOOSE_ELECTRON_LOG = path.join(STATE_DIR, 'goose-electron.log');
const getCargoBinDir = () => {
if (process.platform !== 'win32') return null;
const home = process.env.USERPROFILE || process.env.HOME || '';
if (!home) return null;
const p = path.join(home, '.cargo', 'bin');
return fs.existsSync(p) ? p : null;
};
const withCargoOnPath = (env = process.env) => {
const cargoBin = getCargoBinDir();
if (!cargoBin) return { ...env };
const current = String(env.PATH || env.Path || '');
if (current.toLowerCase().includes(cargoBin.toLowerCase())) return { ...env };
return { ...env, PATH: `${cargoBin};${current}` };
};
const findVsDevCmd = () => {
if (process.platform !== 'win32') return null;
const pf86 = process.env['ProgramFiles(x86)'] || '';
const vswhere = path.join(pf86, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe');
if (!fs.existsSync(vswhere)) return null;
try {
const r = spawnSync(vswhere, [
'-latest',
'-products', '*',
'-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
'-property', 'installationPath'
], { encoding: 'utf8' });
const installPath = String(r.stdout || '').trim();
if (!installPath) return null;
const vsDevCmd = path.join(installPath, 'Common7', 'Tools', 'VsDevCmd.bat');
return fs.existsSync(vsDevCmd) ? vsDevCmd : null;
} catch (e) {
return null;
}
};
const ensureDir = (dir) => {
try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { }
};
const isPortInUse = (port, host = '127.0.0.1') => new Promise((resolve) => {
const socket = new net.Socket();
const done = (val) => {
try { socket.destroy(); } catch (e) { }
resolve(val);
};
socket.setTimeout(300);
socket.once('connect', () => done(true));
socket.once('timeout', () => done(false));
socket.once('error', () => done(false));
socket.connect(port, host);
});
const findFreePort = async (startPort, { host = '127.0.0.1', maxTries = 40 } = {}) => {
let p = Number(startPort) || 0;
for (let i = 0; i < maxTries; i++) {
// eslint-disable-next-line no-await-in-loop
const used = await isPortInUse(p, host);
if (!used) return p;
p += 1;
}
return Number(startPort) || 0;
};
const httpJson = (url, { timeoutMs = 600 } = {}) => new Promise((resolve) => {
try {
const req = http.get(url, (res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
try {
const body = Buffer.concat(chunks).toString('utf8');
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: JSON.parse(body) });
} catch (e) {
resolve({ ok: false, status: res.statusCode || 0, json: null });
}
});
});
req.on('error', () => resolve({ ok: false, status: 0, json: null }));
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch (e) { } resolve({ ok: false, status: 0, json: null }); });
} catch (e) {
resolve({ ok: false, status: 0, json: null });
}
});
const commandExists = (cmd, env = process.env) => {
try {
if (process.platform === 'win32') {
const r = spawnSync('where', [cmd], { stdio: 'ignore', env });
return r.status === 0;
}
const r = spawnSync('which', [cmd], { stdio: 'ignore', env });
return r.status === 0;
} catch (e) {
return false;
}
};
const parseArgs = () => {
const argv = process.argv.slice(2);
const cmd = (argv[0] || 'web').toLowerCase();
const get = (name, fallback) => {
const idx = argv.findIndex(a => a === `--${name}` || a === `-${name[0]}`);
if (idx === -1) return fallback;
const v = argv[idx + 1];
return v ?? fallback;
};
const has = (flag) => argv.includes(`--${flag}`);
return {
cmd,
goosePort: Number(get('port', '3000')) || 3000,
proxyPort: Number(get('proxy-port', '18181')) || 18181,
model: String(get('model', 'qwen-coder-plus') || 'qwen-coder-plus'),
open: has('open'),
window: !has('no-window'),
forcePort: has('force-port'),
};
};
const findGooseRoot = () => {
const candidates = [
path.join(OPENCODE_ROOT, '_refs', 'goose-block'),
path.join(OPENCODE_ROOT, '_refs', 'goose'),
path.join(OPENCODE_ROOT, '_refs', 'goose_block'),
];
for (const dir of candidates) {
try {
if (!fs.existsSync(dir)) continue;
if (!fs.existsSync(path.join(dir, 'Cargo.toml'))) continue;
// goose-cli exists in upstream layouts
if (fs.existsSync(path.join(dir, 'crates', 'goose-cli'))) return dir;
} catch (e) { }
}
return null;
};
const readState = (p) => {
try {
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
} catch (e) {
return null;
}
};
const writeState = (p, data) => {
ensureDir(path.dirname(p));
fs.writeFileSync(p, JSON.stringify(data, null, 2));
};
const tryKillPid = (pid) => {
if (!pid || typeof pid !== 'number') return false;
try {
process.kill(pid);
return true;
} catch (e) {
return false;
}
};
const startProxyIfNeeded = async ({ proxyPort }) => {
let port = Number(proxyPort) || 18181;
const up = await isPortInUse(port);
if (up) {
// If something is already listening, only reuse it if it looks like our proxy.
const health = await httpJson(`http://127.0.0.1:${port}/health`);
if (health.ok && health.json?.service === 'qwen-openai-proxy') {
return { started: false, port };
}
port = await findFreePort(port + 1);
}
const proxyScript = path.join(OPENCODE_ROOT, 'bin', 'qwen-openai-proxy.mjs');
const env = withCargoOnPath(process.env);
const child = spawn(process.execPath, [proxyScript, '--port', String(port)], {
cwd: OPENCODE_ROOT,
detached: true,
stdio: 'ignore',
env
});
child.unref();
writeState(PROXY_STATE, { pid: child.pid, port, startedAt: new Date().toISOString() });
return { started: true, port, pid: child.pid };
};
const launchGooseWeb = async ({ goosePort, proxyPort, model, open, window, forcePort = false }) => {
const gooseRoot = findGooseRoot();
if (!gooseRoot) throw new Error('Goose repo not found under `_refs/`.');
const env = withCargoOnPath(process.env);
const envVars = {
GOOSE_PROVIDER: 'openai',
GOOSE_MODEL: model,
OPENAI_HOST: `http://127.0.0.1:${proxyPort}`,
OPENAI_BASE_PATH: 'v1/chat/completions',
OPENAI_API_KEY: 'local',
};
let port = Number(goosePort) || 3000;
const inUse = await isPortInUse(port);
if (inUse && !forcePort) {
port = await findFreePort(port + 1);
}
const args = ['web', '--port', String(port)];
if (open) args.push('--open');
const hasGoose = commandExists('goose', env);
const hasCargo = commandExists('cargo', env);
if (!hasGoose && !hasCargo) {
const hasWinget = commandExists('winget');
const lines = [
'Neither `goose` nor `cargo` found on PATH.',
'',
'Install Rust toolchain (Cargo) then retry:',
process.platform === 'win32' && hasWinget
? ' winget install --id Rustlang.Rustup -e'
: process.platform === 'win32'
? ' Install from https://rustup.rs (rustup-init.exe)'
: ' Install from https://rustup.rs',
' rustup default stable',
' (restart terminal so PATH updates)',
'',
'Then re-run:',
' /goose',
'',
'Optional: if you already have Goose installed, ensure `goose` is on PATH.'
];
throw new Error(lines.join('\n'));
}
// Prefer a native goose binary if present.
let spawnCmd;
let spawnArgs;
let spawnCwd = gooseRoot;
if (hasGoose) {
spawnCmd = 'goose';
spawnArgs = args;
} else {
// Use Cargo. On Windows, prefer running through VsDevCmd if available to ensure link.exe is configured.
const vsDevCmd = findVsDevCmd();
if (process.platform === 'win32' && vsDevCmd) {
spawnCmd = 'cmd';
const cargoRun = ['cargo', 'run', '-p', 'goose-cli', '--', ...args].join(' ');
// Use `call` for .bat files so cmd continues after VsDevCmd sets environment.
spawnArgs = ['/c', `call \"${vsDevCmd}\" -arch=amd64 -host_arch=amd64 && ${cargoRun}`];
} else {
spawnCmd = 'cargo';
spawnArgs = ['run', '-p', 'goose-cli', '--', ...args];
}
}
// On Windows, open in a new terminal window for a more "app-like" feel.
if (process.platform === 'win32' && window) {
const psEnv = Object.entries(envVars)
.map(([k, v]) => `$env:${k}='${String(v).replace(/'/g, "''")}'`)
.join('; ');
const cd = `Set-Location -LiteralPath '${gooseRoot.replace(/'/g, "''")}'`;
const cmdLine = `${psEnv}; ${cd}; ${spawnCmd} ${spawnArgs.map(a => `'${String(a).replace(/'/g, "''")}'`).join(' ')}`;
const child = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `Start-Process powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-Command',\"${cmdLine.replace(/\"/g, '`\"')}\"`], {
cwd: OPENCODE_ROOT,
detached: true,
stdio: 'ignore',
env
});
child.unref();
writeState(GOOSE_STATE, { port, proxyPort, model, startedAt: new Date().toISOString(), mode: 'web', window: true });
return { started: true, port, proxyPort, model, window: true, url: `http://localhost:${port}` };
}
// Otherwise, launch detached in background.
const child = spawn(spawnCmd, spawnArgs, {
cwd: spawnCwd,
detached: true,
stdio: 'ignore',
env: { ...env, ...envVars }
});
child.unref();
writeState(GOOSE_STATE, { pid: child.pid, port, proxyPort, model, startedAt: new Date().toISOString(), mode: 'web', window: false });
return { started: true, pid: child.pid, port, proxyPort, model, window: false, url: `http://localhost:${port}` };
};
const launchElectron = async ({ url }) => {
const appDir = path.join(OPENCODE_ROOT, 'bin', 'goose-electron-app');
const pkg = path.join(appDir, 'package.json');
if (!fs.existsSync(pkg)) throw new Error('Electron wrapper missing (bin/goose-electron-app).');
// Some dev shells set `ELECTRON_RUN_AS_NODE=1` which prevents Electron from opening a GUI.
// Force GUI mode for Goose.
const env = { ...process.env, GOOSE_URL: url };
// Windows env var keys can be different-cased; delete case-insensitively.
for (const k of Object.keys(env)) {
if (k.toLowerCase() === 'electron_run_as_node') delete env[k];
}
const electronExe = process.platform === 'win32'
? path.join(appDir, 'node_modules', 'electron', 'dist', 'electron.exe')
: path.join(appDir, 'node_modules', '.bin', 'electron');
const electronFallback = process.platform === 'win32'
? path.join(appDir, 'node_modules', '.bin', 'electron.cmd')
: null;
const bin = fs.existsSync(electronExe) ? electronExe : electronFallback;
// Install electron on-demand (large); keep it scoped to the wrapper dir.
if (!fs.existsSync(bin)) {
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const res = spawnSync(npmCmd, ['install', '--silent'], { cwd: appDir, stdio: 'inherit' });
if (res.status !== 0) throw new Error('Failed to install Electron dependencies (npm install).');
}
ensureDir(STATE_DIR);
fs.appendFileSync(GOOSE_ELECTRON_LOG, `\n\n[${new Date().toISOString()}] Launching Electron UI for Goose at ${url}\n`);
const logFd = fs.openSync(GOOSE_ELECTRON_LOG, 'a');
const spawnArgs = fs.existsSync(electronExe) ? [appDir] : ['.'];
const child = spawn(bin, spawnArgs, {
cwd: appDir,
detached: true,
stdio: ['ignore', logFd, logFd],
env,
shell: Boolean(electronFallback && bin === electronFallback),
// `windowsHide` can sometimes suppress GUI visibility depending on how the parent process is launched.
// Only hide the console window; keep the app window visible.
windowsHide: false,
});
child.unref();
try { fs.closeSync(logFd); } catch (e) { }
return { started: true, pid: child.pid, url, log: GOOSE_ELECTRON_LOG };
};
const main = async () => {
const args = parseArgs();
ensureDir(STATE_DIR);
if (args.cmd === 'status') {
const proxy = readState(PROXY_STATE);
const goose = readState(GOOSE_STATE);
console.log(JSON.stringify({ proxy, goose }, null, 2));
return;
}
if (args.cmd === 'stop') {
const proxy = readState(PROXY_STATE);
const goose = readState(GOOSE_STATE);
const stopped = {
goose: goose?.pid ? tryKillPid(goose.pid) : false,
proxy: proxy?.pid ? tryKillPid(proxy.pid) : false,
};
try { fs.unlinkSync(GOOSE_STATE); } catch (e) { }
try { fs.unlinkSync(PROXY_STATE); } catch (e) { }
console.log(JSON.stringify({ stopped }, null, 2));
return;
}
if (args.cmd !== 'web') {
if (args.cmd !== 'app') throw new Error(`Unknown command: ${args.cmd} (expected: web|app|status|stop)`);
}
const proxy = await startProxyIfNeeded({ proxyPort: args.proxyPort });
const launched = await launchGooseWeb({
goosePort: args.goosePort,
proxyPort: proxy.port,
model: args.model,
open: args.cmd === 'web' ? args.open : false,
window: args.window,
forcePort: args.forcePort
});
let ui = null;
if (args.cmd === 'app') {
ui = await launchElectron({ url: launched.url });
}
console.log(JSON.stringify({
ok: true,
launched: { ...launched, proxyPort: proxy.port },
ui,
hints: {
gooseUrl: launched.url,
proxyHealth: `http://127.0.0.1:${proxy.port}/health`
}
}, null, 2));
};
main().catch((e) => {
console.error(String(e?.message || e));
process.exit(1);
});