Files
n8n/scripts/cloud-session-opencode.mjs
T

282 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
import { parseArgs } from 'node:util';
import { basicAuth, openCodeProxy } from './cloud-session-opencode-proxy.mjs';
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
export function parseOpenCodeArgs(args) {
let parsed;
try {
parsed = parseArgs({
args,
allowPositionals: true,
options: { web: { type: 'boolean' }, new: { type: 'boolean' }, port: { type: 'string' } },
});
} catch (error) {
throw new Error(`${error.message}. Use --help. Use --legacy for remote OpenCode CLI flags.`);
}
const { positionals, values } = parsed;
if (positionals.length > 1 || (positionals[0] && !/^\w[\w-]*$/.test(positionals[0])))
throw new Error('Use one workspace name with letters, digits, - or _.');
let port = values.web ? 4096 : 0;
if (values.port !== undefined) {
if (!/^\d+$/.test(values.port) || +values.port < 1 || +values.port > 65535)
throw new Error('--port must be 1–65535.');
port = +values.port;
}
return { name: positionals[0] ?? 'agent', web: !!values.web, fresh: !!values.new, port };
}
function localVersion() {
const result = spawnSync('opencode', ['--version'], { encoding: 'utf8' });
if (result.error || result.status !== 0)
throw new Error('Install OpenCode locally, or use --web to connect with a browser.');
const version = result.stdout.trim();
if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version))
throw new Error('Cannot read the local OpenCode version. Use --web or --legacy.');
return version;
}
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address();
await new Promise((resolve) => server.close(resolve));
return port;
}
function startChild(command, args, options = {}) {
const child = spawn(command, args, options);
let finished = false;
const done = new Promise((resolve) => {
child.once('error', (error) => {
finished = true;
resolve({ code: 1, error });
});
// Wait for stdout to close before parsing the SSH response.
child.once('close', (code, signal) => {
finished = true;
resolve({ code: code ?? (signal ? 1 : 0) });
});
});
const kill = (signal) => {
if (!child.pid) return;
try {
if (options.detached) process.kill(-child.pid, signal);
else child.kill(signal);
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
};
return {
child,
done,
get finished() {
return finished;
},
async stop() {
if (finished) return;
// gh starts ssh as a child. Terminate the process group to close the tunnel too.
kill('SIGTERM');
await Promise.race([done, delay(2000, undefined, { ref: false })]);
if (!finished) kill('SIGKILL');
},
};
}
async function bootstrap(codespace, options, signal) {
signal.throwIfAborted();
console.log(`Preparing OpenCode workspace '${options.name}' on ${codespace}…`);
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh} ${options.web}`;
const remote = startChild('gh', ['codespace', 'ssh', '-c', codespace, '--', command], {
stdio: ['pipe', 'pipe', 'inherit'],
detached: true,
});
let stdout = '';
remote.child.stdout.setEncoding('utf8');
remote.child.stdout.on('data', (chunk) => {
stdout += chunk;
});
remote.child.stdin.on('error', () => {});
const abort = () => {
void remote.stop();
};
signal.addEventListener('abort', abort, { once: true });
try {
remote.child.stdin.end(
readFileSync(new URL('../.devcontainer/codespaces/opencode-server.mjs', import.meta.url)),
);
const result = await remote.done;
signal.throwIfAborted();
if (result.code !== 0)
throw new Error('Could not prepare OpenCode. Check the SSH output above, then retry.');
let state;
try {
state = JSON.parse(stdout.trim().split('\n').at(-1));
} catch {
throw new Error('Invalid OpenCode server response.');
}
// The server owns the workspace layout and the credential format. Check the shape only.
const text = (value) => typeof value === 'string' && value.length > 0;
if (
!state ||
!Number.isInteger(state.port) ||
!text(state.password) ||
!text(state.directory) ||
(options.web && !text(state.sessionID))
) {
throw new Error('Invalid OpenCode server response.');
}
return state;
} finally {
signal.removeEventListener('abort', abort);
await remote.stop();
}
}
async function waitForTunnel(url, password, tunnel, signal) {
for (let attempt = 0; attempt < 60; attempt++) {
signal.throwIfAborted();
if (tunnel.finished)
throw new Error('The SSH tunnel closed. Check the SSH output above, then reconnect.');
let response;
try {
response = await fetch(`${url}/global/health`, {
headers: { authorization: basicAuth(password) },
signal: AbortSignal.any([signal, AbortSignal.timeout(1000)]),
});
} catch {
/* The listener is not ready yet. */
}
if (response) {
if (!response.ok)
throw new Error(
`OpenCode health check failed (${response.status}). Reconnect after checking the remote server.`,
);
const health = await response.json();
if (health.healthy !== true || typeof health.version !== 'string')
throw new Error('Invalid OpenCode health response.');
return health;
}
await delay(500, undefined, { signal });
}
throw new Error('Timed out while connecting to OpenCode. Run the session command again.');
}
function openBrowser(url) {
const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
const browser = spawn(command, [url], { stdio: 'ignore', detached: true });
browser.on('error', () => console.log('Open the URL above in your browser.'));
browser.on('exit', (code) => {
if (code) console.log('Open the URL above in your browser.');
});
browser.unref();
}
export async function connectOpenCode(options, ensureCodespace) {
if (process.platform === 'win32')
throw new Error('Run this command in WSL. Native Windows is not supported.');
const version = options.web ? undefined : localVersion();
const controller = new AbortController();
const interrupt = () => controller.abort();
for (const signal of SIGNALS) process.on(signal, interrupt);
let tunnel;
let client;
let proxy;
try {
const codespace = ensureCodespace();
const state = await bootstrap(codespace, options, controller.signal);
// Bind the browser port first so the tunnel cannot receive the same port.
if (options.web) proxy = await openCodeProxy({ password: state.password, port: options.port });
const port = !options.web && options.port ? options.port : await freePort();
const url = `http://127.0.0.1:${port}`;
tunnel = startChild(
'gh',
[
'codespace',
'ssh',
'-c',
codespace,
'--',
'-N',
'-o',
'ExitOnForwardFailure=yes',
'-o',
'ServerAliveInterval=15',
'-o',
'ServerAliveCountMax=3',
'-L',
`127.0.0.1:${port}:127.0.0.1:${state.port}`,
],
{ stdio: ['ignore', 'ignore', 'inherit'], detached: true },
);
const health = await waitForTunnel(url, state.password, tunnel, controller.signal);
if (version && health.version !== version) {
throw new Error(
`OpenCode versions differ: local ${version}, server ${health.version}. Install the matching local version, or use --web or --legacy. The remote server is still running.`,
);
}
const stopped = new Promise((resolve) =>
controller.signal.addEventListener('abort', () => resolve('interrupted'), { once: true }),
);
controller.signal.throwIfAborted();
if (proxy) {
proxy.targetPort = port;
const webUrl = `${proxy.origin}/${Buffer.from(state.directory).toString('base64url')}/session/${state.sessionID}`;
console.log(
`OpenCode: ${webUrl}\nKeep this command running. Press Ctrl-C to disconnect. The remote server stays running.`,
);
openBrowser(webUrl);
} else {
console.log(`Connecting to OpenCode ${health.version} in ${state.directory}…`);
// OpenCode's own resume spans all worktrees of the repository. The
// server resolved the workspace's latest conversation instead, so
// target it directly. A fresh TUI conversation needs no target.
client = startChild(
'opencode',
[
'attach',
url,
'--dir',
state.directory,
...(state.sessionID ? ['--session', state.sessionID] : []),
],
{
stdio: 'inherit',
env: {
...process.env,
OPENCODE_SERVER_PASSWORD: state.password,
OPENCODE_SERVER_USERNAME: 'opencode',
},
},
);
}
const result = await Promise.race([
stopped,
tunnel.done.then(() => 'tunnel'),
...(client ? [client.done] : []),
]);
if (typeof result === 'object' && result.code !== 0)
throw new Error(
'The local OpenCode client exited with an error. The remote session is saved.',
);
if (result === 'tunnel')
throw new Error(
'The SSH connection closed. Run the same command to reconnect to the saved session.',
);
} catch (error) {
if (!controller.signal.aborted) throw error;
} finally {
proxy?.close();
await Promise.all([client?.stop(), tunnel?.stop()]);
for (const signal of SIGNALS) process.removeListener(signal, interrupt);
}
}