Files
nanoclaw/setup/onecli.ts
T
gavrielc 8326b4c0be fix(setup): offer to reuse an existing OneCLI instead of clobbering it
Before: setup/onecli.ts ran `curl -fsSL onecli.sh/install | sh` unconditionally. For users with OneCLI already running and bound to a specific listener (host-accessible, shared with other apps), re-running the installer rebound the gateway and broke those consumers.

Now: auto.ts probes for an existing install (`onecli version` + `onecli config get api-host`). If detected, clack asks: use the existing instance (recommended) or install a fresh one. The new --reuse flag in the onecli step skips the installer, reads the configured api-host, writes ONECLI_URL to .env, and moves on without touching the running gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:48:24 +03:00

246 lines
7.5 KiB
TypeScript

/**
* Step: onecli — Install + configure the OneCLI gateway and CLI.
*
* Two modes:
* (default) run the OneCLI installer, configure api-host, write .env.
* --reuse skip the installer; reuse the onecli instance already running
* on the host. Required for users who have other apps bound to
* an existing gateway, since re-running the installer rebinds
* the listener and breaks those consumers.
*
* Emits ONECLI_URL and polls /health so downstream steps (auth, service)
* get a ready gateway.
*/
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { log } from '../src/log.js';
import { emitStatus } from './status.js';
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
function childEnv(): NodeJS.ProcessEnv {
const parts = [LOCAL_BIN];
if (process.env.PATH) parts.push(process.env.PATH);
return { ...process.env, PATH: parts.join(path.delimiter) };
}
function onecliVersion(): string | null {
try {
return execFileSync('onecli', ['version'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
/**
* Ask the installed onecli CLI for its configured api-host. Returns null if
* onecli isn't on PATH, errors, or has no api-host configured.
*
* Tolerates both JSON output (onecli 1.3+) and older raw-text output.
*/
export function getOnecliApiHost(): string | null {
try {
const out = execFileSync('onecli', ['config', 'get', 'api-host'], {
encoding: 'utf-8',
env: childEnv(),
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
try {
const parsed = JSON.parse(out) as { data?: unknown; value?: unknown };
const val = parsed.data ?? parsed.value;
if (typeof val === 'string' && val.trim()) return val.trim();
} catch {
// not JSON — fall through to URL extraction
}
return extractUrlFromOutput(out);
} catch {
return null;
}
}
function extractUrlFromOutput(output: string): string | null {
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
return match ? match[0] : null;
}
function ensureShellProfilePath(): void {
const home = os.homedir();
const line = 'export PATH="$HOME/.local/bin:$PATH"';
for (const profile of [path.join(home, '.bashrc'), path.join(home, '.zshrc')]) {
try {
const content = fs.existsSync(profile) ? fs.readFileSync(profile, 'utf-8') : '';
if (!content.includes('.local/bin')) {
fs.appendFileSync(profile, `\n${line}\n`);
log.info('Added ~/.local/bin to PATH in shell profile', { profile });
}
} catch (err) {
log.warn('Could not update shell profile', { profile, err });
}
}
}
function writeEnvOnecliUrl(url: string): void {
const envFile = path.join(process.cwd(), '.env');
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
if (/^ONECLI_URL=/m.test(content)) {
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
} else {
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
}
fs.writeFileSync(envFile, content);
}
function installOnecli(): { stdout: string; ok: boolean } {
// OneCLI's own install script handles gateway + CLI + PATH.
// We run the two canonical installers in sequence and capture stdout so
// we can extract the printed URL as a fallback to `onecli config get`.
let stdout = '';
try {
stdout += execSync('curl -fsSL onecli.sh/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
stdout += execSync('curl -fsSL onecli.sh/cli/install | sh', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { stdout, ok: true };
} catch (err) {
const e = err as { stdout?: string; stderr?: string };
log.error('OneCLI install failed', { stderr: e.stderr });
return { stdout: stdout + (e.stdout ?? '') + (e.stderr ?? ''), ok: false };
}
}
async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
// `/api/health` matches the path probe.sh uses — keep them aligned.
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/api/health`);
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
export async function run(args: string[]): Promise<void> {
const reuse = args.includes('--reuse');
ensureShellProfilePath();
if (reuse) {
// Reuse-mode: don't touch the running gateway at all. Just verify it
// exists, read its api-host, write ONECLI_URL to .env, and move on.
const version = onecliVersion();
if (!version) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'onecli_not_found_for_reuse',
HINT: 'onecli not on PATH. Re-run setup and choose "install fresh".',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const url = getOnecliApiHost();
if (!url) {
emitStatus('ONECLI', {
INSTALLED: true,
STATUS: 'failed',
ERROR: 'onecli_api_host_not_configured',
HINT: 'Existing onecli has no api-host set. Run `onecli config set api-host <url>` or re-run setup with install-fresh.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
writeEnvOnecliUrl(url);
log.info('Reusing existing OneCLI', { url });
const healthy = await pollHealth(url, 5000);
emitStatus('ONECLI', {
INSTALLED: true,
REUSED: true,
ONECLI_URL: url,
HEALTHY: healthy,
STATUS: 'success',
LOG: 'logs/setup.log',
});
return;
}
log.info('Installing OneCLI gateway and CLI');
const res = installOnecli();
if (!res.ok) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'install_failed',
LOG: 'logs/setup.log',
});
process.exit(1);
}
if (!onecliVersion()) {
emitStatus('ONECLI', {
INSTALLED: false,
STATUS: 'failed',
ERROR: 'onecli_not_on_path_after_install',
HINT: 'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"` and retry.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
const url = extractUrlFromOutput(res.stdout);
if (!url) {
emitStatus('ONECLI', {
INSTALLED: true,
STATUS: 'failed',
ERROR: 'could_not_resolve_api_host',
HINT: 'Inspect logs/setup.log for the install output.',
LOG: 'logs/setup.log',
});
process.exit(1);
}
try {
execFileSync('onecli', ['config', 'set', 'api-host', url], {
stdio: 'ignore',
env: childEnv(),
});
} catch (err) {
log.warn('onecli config set api-host failed', { err });
}
writeEnvOnecliUrl(url);
log.info('Wrote ONECLI_URL to .env', { url });
const healthy = await pollHealth(url, 15000);
emitStatus('ONECLI', {
INSTALLED: true,
ONECLI_URL: url,
HEALTHY: healthy,
// Install succeeded regardless — a failed health poll often just means
// the endpoint is auth-gated or the gateway hasn't finished warming up.
// The next step (auth) will surface a genuinely broken gateway via
// `onecli secrets list`, so don't trigger rescue attempts from here.
STATUS: 'success',
...(healthy
? {}
: {
HEALTH_HINT:
'Health poll returned non-ok within 15s — likely auth-gated. Proceed to the auth step; it will surface a real outage.',
}),
LOG: 'logs/setup.log',
});
}