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>
This commit is contained in:
gavrielc
2026-04-23 01:48:13 +03:00
parent 22c2beff3c
commit 8326b4c0be
2 changed files with 153 additions and 11 deletions
+78 -4
View File
@@ -115,10 +115,44 @@ async function main(): Promise<void> {
4,
),
);
const res = await runQuietStep('onecli', {
running: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
});
// Respect an existing OneCLI install. Re-running the installer would
// rebind the listener and knock any other app using that gateway
// offline — confirm with the user before doing that.
const existing = detectExistingOnecli();
let reuse = false;
if (existing) {
const choice = ensureAnswer(
await p.select({
message: `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`,
options: [
{
value: 'reuse',
label: 'Use the existing instance',
hint: 'recommended — keeps other apps bound to this vault working',
},
{
value: 'fresh',
label: 'Install a fresh instance for NanoClaw',
hint: 'reinstalls onecli; other apps may need to reconnect',
},
],
}),
) as 'reuse' | 'fresh';
setupLog.userInput('onecli_choice', choice);
reuse = choice === 'reuse';
}
const res = await runQuietStep(
'onecli',
{
running: reuse
? 'Hooking up to your existing OneCLI…'
: "Setting up OneCLI, your agent's vault…",
done: 'OneCLI vault ready.',
},
reuse ? ['--reuse'] : [],
);
if (!res.ok) {
const err = res.terminal?.fields.ERROR;
if (err === 'onecli_not_on_path_after_install') {
@@ -691,6 +725,46 @@ function anthropicSecretExists(): boolean {
}
}
/**
* Probe the host for a working OneCLI install so we can offer to reuse it
* instead of re-running the installer (which rebinds the listener and breaks
* any other app already using that gateway).
*/
function detectExistingOnecli(): { version: string; apiHost: string } | null {
try {
const ver = spawnSync('onecli', ['version'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (ver.status !== 0) return null;
const version = (ver.stdout ?? '').trim();
if (!version) return null;
const host = spawnSync('onecli', ['config', 'get', 'api-host'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (host.status !== 0) return null;
const raw = (host.stdout ?? '').trim();
if (!raw) return null;
// onecli 1.3+ emits JSON by default. Older versions would print raw text.
try {
const parsed = JSON.parse(raw) as { data?: unknown; value?: unknown };
const val = parsed.data ?? parsed.value;
if (typeof val === 'string' && val.trim()) {
return { version, apiHost: val.trim() };
}
} catch {
// not JSON — try to extract a URL directly
}
const m = raw.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
return m ? { version, apiHost: m[0] } : null;
} catch {
return null;
}
}
function runInheritScript(cmd: string, args: string[]): Promise<number> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: 'inherit' });
+75 -7
View File
@@ -1,13 +1,15 @@
/**
* Step: onecli — Install + configure the OneCLI gateway and CLI.
*
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
* commands. Idempotent: skips install if `onecli` already works, and safely
* re-applies PATH, api-host, and .env updates.
* 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 so /new-setup SKILL.md can forward it downstream (e.g. as
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
* (auth, service) a ready gateway.
* 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';
@@ -37,6 +39,32 @@ function onecliVersion(): string | 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;
@@ -106,9 +134,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
return false;
}
export async function run(_args: string[]): Promise<void> {
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) {