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:
@@ -115,10 +115,44 @@ async function main(): Promise<void> {
|
|||||||
4,
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const res = await runQuietStep('onecli', {
|
|
||||||
running: "Setting up OneCLI, your agent's vault…",
|
// 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.',
|
done: 'OneCLI vault ready.',
|
||||||
});
|
},
|
||||||
|
reuse ? ['--reuse'] : [],
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = res.terminal?.fields.ERROR;
|
const err = res.terminal?.fields.ERROR;
|
||||||
if (err === 'onecli_not_on_path_after_install') {
|
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> {
|
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Step: onecli — Install + configure the OneCLI gateway and CLI.
|
* Step: onecli — Install + configure the OneCLI gateway and CLI.
|
||||||
*
|
*
|
||||||
* Aggregates what the old /setup + /init-onecli skills ran as loose shell
|
* Two modes:
|
||||||
* commands. Idempotent: skips install if `onecli` already works, and safely
|
* (default) run the OneCLI installer, configure api-host, write .env.
|
||||||
* re-applies PATH, api-host, and .env updates.
|
* --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
|
* Emits ONECLI_URL and polls /health so downstream steps (auth, service)
|
||||||
* ${ONECLI_URL} in status messages). Polls /health to give downstream steps
|
* get a ready gateway.
|
||||||
* (auth, service) a ready gateway.
|
|
||||||
*/
|
*/
|
||||||
import { execFileSync, execSync } from 'child_process';
|
import { execFileSync, execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
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 {
|
function extractUrlFromOutput(output: string): string | null {
|
||||||
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
const match = output.match(/https?:\/\/[\w.\-]+(?::\d+)?/);
|
||||||
return match ? match[0] : null;
|
return match ? match[0] : null;
|
||||||
@@ -106,9 +134,49 @@ async function pollHealth(url: string, timeoutMs: number): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(_args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
|
const reuse = args.includes('--reuse');
|
||||||
ensureShellProfilePath();
|
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');
|
log.info('Installing OneCLI gateway and CLI');
|
||||||
const res = installOnecli();
|
const res = installOnecli();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user