Merge pull request #2035 from qwibitai/feat/setup-advanced-settings
feat(setup): advanced settings flow with remote OneCLI support
This commit is contained in:
15
nanoclaw.sh
15
nanoclaw.sh
@@ -129,10 +129,10 @@ rm -f "$PROGRESS_LOG"
|
|||||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||||
write_header
|
write_header
|
||||||
|
|
||||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing,
|
||||||
# and skip printing these again, so the flow stays visually continuous.
|
# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and
|
||||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
# skips re-printing the wordmark, keeping the flow visually continuous.
|
||||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||||
|
|
||||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||||
@@ -190,7 +190,7 @@ BOOTSTRAP_START=$(date +%s)
|
|||||||
|
|
||||||
# One-line "why" that teaches a differentiator while the user waits.
|
# One-line "why" that teaches a differentiator while the user waits.
|
||||||
printf '%s %s\n' "$(gray '│')" \
|
printf '%s %s\n' "$(gray '│')" \
|
||||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
"$(dim "Small. Runs on your machine. Yours to modify.")"
|
||||||
spinner_start "$BOOTSTRAP_LABEL"
|
spinner_start "$BOOTSTRAP_LABEL"
|
||||||
|
|
||||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||||
@@ -222,7 +222,7 @@ rm -f "$BOOTSTRAP_EXIT_FILE"
|
|||||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||||
|
|
||||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
spinner_success "Basics ready" "$BOOTSTRAP_DUR"
|
||||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||||
else
|
else
|
||||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||||
@@ -259,4 +259,5 @@ fi
|
|||||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||||
# preamble so the flow continues visually from "Basics installed" straight
|
# preamble so the flow continues visually from "Basics installed" straight
|
||||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||||
exec pnpm --silent run setup:auto
|
# `-- "$@"` forwards any flags (e.g. --onecli-api-host) to setup:auto.
|
||||||
|
exec pnpm --silent run setup:auto -- "$@"
|
||||||
|
|||||||
216
setup/auto.ts
216
setup/auto.ts
@@ -36,7 +36,15 @@ import { runWhatsAppChannel } from './channels/whatsapp.js';
|
|||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import { brightSelect } from './lib/bright-select.js';
|
import { brightSelect } from './lib/bright-select.js';
|
||||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||||
|
import {
|
||||||
|
applyToEnv,
|
||||||
|
parseFlags,
|
||||||
|
printHelp,
|
||||||
|
readFromEnv,
|
||||||
|
} from './lib/setup-config-parse.js';
|
||||||
|
import { runAdvancedScreen } from './lib/setup-config-screen.js';
|
||||||
import { runWindowedStep } from './lib/windowed-runner.js';
|
import { runWindowedStep } from './lib/windowed-runner.js';
|
||||||
|
import { pollHealth } from './onecli.js';
|
||||||
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js';
|
||||||
import { pollHealth } from './onecli.js';
|
import { pollHealth } from './onecli.js';
|
||||||
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js';
|
||||||
@@ -52,10 +60,45 @@ const RUN_START = Date.now();
|
|||||||
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
// Parse CLI flags first — `--help` short-circuits before we render anything,
|
||||||
|
// and flag values get folded into process.env so existing step code reading
|
||||||
|
// NANOCLAW_* sees them unchanged.
|
||||||
|
const flagResult = parseFlags(process.argv.slice(2));
|
||||||
|
if (flagResult.help) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (flagResult.errors.length > 0) {
|
||||||
|
for (const err of flagResult.errors) console.error(`error: ${err}`);
|
||||||
|
console.error('');
|
||||||
|
console.error('Run with --help for the full list of supported flags.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let configValues = { ...readFromEnv(), ...flagResult.values };
|
||||||
|
applyToEnv(configValues);
|
||||||
|
|
||||||
printIntro();
|
printIntro();
|
||||||
initProgressionLog();
|
initProgressionLog();
|
||||||
phEmit('auto_started');
|
phEmit('auto_started');
|
||||||
|
|
||||||
|
// Welcome menu — default path or open advanced overrides before any setup
|
||||||
|
// work begins. Default lands on standard so Enter is the happy path.
|
||||||
|
const startChoice = ensureAnswer(
|
||||||
|
await brightSelect<'default' | 'advanced'>({
|
||||||
|
message: 'How would you like to begin?',
|
||||||
|
options: [
|
||||||
|
{ value: 'default', label: 'Standard setup' },
|
||||||
|
{ value: 'advanced', label: 'Advanced', hint: 'override defaults' },
|
||||||
|
],
|
||||||
|
initialValue: 'default',
|
||||||
|
}),
|
||||||
|
) as 'default' | 'advanced';
|
||||||
|
setupLog.userInput('start_choice', startChoice);
|
||||||
|
if (startChoice === 'advanced') {
|
||||||
|
configValues = await runAdvancedScreen(configValues);
|
||||||
|
applyToEnv(configValues);
|
||||||
|
}
|
||||||
|
|
||||||
const skip = new Set(
|
const skip = new Set(
|
||||||
(process.env.NANOCLAW_SKIP ?? '')
|
(process.env.NANOCLAW_SKIP ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -123,108 +166,95 @@ async function main(): Promise<void> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
type OnecliChoice = 'reuse' | 'fresh' | 'remote';
|
const remoteHost = process.env.NANOCLAW_ONECLI_API_HOST?.trim();
|
||||||
|
|
||||||
const existing = detectExistingOnecli();
|
if (remoteHost) {
|
||||||
const onecliOptions: { value: OnecliChoice; label: string; hint?: string }[] = [
|
// Advanced-settings override: user has already named a remote vault,
|
||||||
...(existing
|
// so skip the local-vs-fresh prompt entirely. Health-check it here
|
||||||
? [
|
// rather than letting the step fail silently — a typo in the URL is a
|
||||||
{
|
// common mistake and the answer is human-fixable.
|
||||||
value: 'reuse' as OnecliChoice,
|
const s = p.spinner();
|
||||||
label: 'Use the existing instance on the same host',
|
s.start(`Checking remote OneCLI at ${remoteHost}…`);
|
||||||
hint: 'recommended — keeps other apps bound to this vault working',
|
const healthy = await pollHealth(remoteHost, 5000);
|
||||||
},
|
if (!healthy) {
|
||||||
]
|
s.stop(`Couldn't reach OneCLI at ${remoteHost}.`, 1);
|
||||||
: []),
|
await fail(
|
||||||
{
|
'onecli',
|
||||||
value: 'fresh',
|
`Couldn't reach OneCLI at ${remoteHost}.`,
|
||||||
label: 'Install a fresh instance for NanoClaw',
|
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||||
hint: existing ? 'reinstalls onecli; other apps may need to reconnect' : 'recommended',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'remote',
|
|
||||||
label: 'Connect to an OneCLI on another host',
|
|
||||||
hint: 'point to a remote URL',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const onecliChoice = ensureAnswer(
|
|
||||||
await brightSelect<OnecliChoice>({
|
|
||||||
message: existing
|
|
||||||
? `Found an existing OneCLI at ${existing.apiHost}. What would you like to do?`
|
|
||||||
: 'How would you like to set up OneCLI?',
|
|
||||||
options: onecliOptions,
|
|
||||||
}),
|
|
||||||
) as OnecliChoice;
|
|
||||||
setupLog.userInput('onecli_choice', onecliChoice);
|
|
||||||
|
|
||||||
let remoteUrl: string | undefined;
|
|
||||||
if (onecliChoice === 'remote') {
|
|
||||||
while (true) {
|
|
||||||
const answer = ensureAnswer(
|
|
||||||
await p.text({
|
|
||||||
message: 'OneCLI URL on the remote machine',
|
|
||||||
placeholder: 'http://192.168.1.10:10254',
|
|
||||||
validate: (v) => {
|
|
||||||
const t = (v ?? '').trim();
|
|
||||||
if (!t) return 'Required';
|
|
||||||
if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://';
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
remoteUrl = (answer as string).trim();
|
|
||||||
setupLog.userInput('onecli_remote_url', remoteUrl);
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Checking remote OneCLI…');
|
|
||||||
const healthy = await pollHealth(remoteUrl, 5000);
|
|
||||||
if (healthy) {
|
|
||||||
s.stop('Remote OneCLI is reachable.');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
s.stop(`Couldn't reach OneCLI at ${remoteUrl}.`, 1);
|
|
||||||
p.log.warn(wrapForGutter('Make sure OneCLI is running and accessible from this machine, then try again.', 4));
|
|
||||||
}
|
}
|
||||||
}
|
s.stop('Remote OneCLI is reachable.');
|
||||||
|
|
||||||
const stepArgs =
|
const res = await runQuietStep(
|
||||||
onecliChoice === 'reuse' ? ['--reuse'] : onecliChoice === 'remote' ? ['--remote-url', remoteUrl!] : [];
|
'onecli',
|
||||||
|
{
|
||||||
const res = await runQuietStep(
|
running: `Connecting to remote OneCLI at ${remoteHost}…`,
|
||||||
'onecli',
|
done: 'OneCLI vault ready.',
|
||||||
{
|
},
|
||||||
running:
|
['--remote-url', remoteHost],
|
||||||
onecliChoice === 'reuse'
|
);
|
||||||
? 'Hooking up to your existing OneCLI…'
|
if (!res.ok) {
|
||||||
: onecliChoice === 'remote'
|
const err = res.terminal?.fields.ERROR;
|
||||||
? `Connecting to remote OneCLI at ${remoteUrl}…`
|
|
||||||
: "Setting up OneCLI, your agent's vault…",
|
|
||||||
done: 'OneCLI vault ready.',
|
|
||||||
},
|
|
||||||
stepArgs,
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = res.terminal?.fields.ERROR;
|
|
||||||
if (onecliChoice === 'remote') {
|
|
||||||
await fail(
|
await fail(
|
||||||
'onecli',
|
'onecli',
|
||||||
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
`Couldn't connect to remote OneCLI (${err ?? 'unknown error'}).`,
|
||||||
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
'Check the URL and that OneCLI is running on the remote machine, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (err === 'onecli_not_on_path_after_install') {
|
} else {
|
||||||
|
// 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 brightSelect({
|
||||||
|
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') {
|
||||||
|
await fail(
|
||||||
|
'onecli',
|
||||||
|
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||||
|
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
await fail(
|
await fail(
|
||||||
'onecli',
|
'onecli',
|
||||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await fail(
|
|
||||||
'onecli',
|
|
||||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
|
||||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,11 +1011,11 @@ function printIntro(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always include the wordmark inside the clack intro line. When bash ran
|
// bash already printed the wordmark above us; the clack intro carries the
|
||||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
// welcome framing alone so the two don't double up. Standalone runs of
|
||||||
// above us; the small repeat is worth it to keep the brand anchored at
|
// setup:auto still see this as the first line — fine without the wordmark
|
||||||
// the visible top of the clack session once the bash output scrolls away.
|
// since the line itself signals the start of the flow.
|
||||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
p.intro("Let's get you set up.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
161
setup/lib/setup-config-parse.ts
Normal file
161
setup/lib/setup-config-parse.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Parser/reader/writer for the advanced-config registry (setup-config.ts).
|
||||||
|
*
|
||||||
|
* readFromEnv() → values found in process.env
|
||||||
|
* parseFlags() → values from argv, plus --help and any pass-through args
|
||||||
|
* applyToEnv() → write resolved values back to process.env so existing
|
||||||
|
* step code keeps reading env vars unchanged
|
||||||
|
* printHelp() → render --help from the registry
|
||||||
|
*
|
||||||
|
* Flag parsing supports:
|
||||||
|
* --key value space form
|
||||||
|
* --key=value equals form
|
||||||
|
* --key booleans only (sets true)
|
||||||
|
* --no-key booleans only (sets false)
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CONFIG,
|
||||||
|
envVarFor,
|
||||||
|
flagFor,
|
||||||
|
findByFlag,
|
||||||
|
type Entry,
|
||||||
|
} from './setup-config.js';
|
||||||
|
|
||||||
|
export type ConfigValues = Record<string, string | boolean | number>;
|
||||||
|
|
||||||
|
function coerce(e: Entry, raw: string): string | number | boolean | undefined {
|
||||||
|
switch (e.type) {
|
||||||
|
case 'boolean': {
|
||||||
|
const v = raw.toLowerCase();
|
||||||
|
if (['true', '1', 'yes'].includes(v)) return true;
|
||||||
|
if (['false', '0', 'no'].includes(v)) return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
case 'integer': {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFromEnv(env: NodeJS.ProcessEnv = process.env): ConfigValues {
|
||||||
|
const out: ConfigValues = {};
|
||||||
|
for (const e of CONFIG) {
|
||||||
|
const raw = env[envVarFor(e)];
|
||||||
|
if (raw === undefined || raw === '') continue;
|
||||||
|
const v = coerce(e, raw);
|
||||||
|
if (v !== undefined) out[e.key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlagParseResult = {
|
||||||
|
values: ConfigValues;
|
||||||
|
rest: string[];
|
||||||
|
help: boolean;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseFlags(argv: string[]): FlagParseResult {
|
||||||
|
const values: ConfigValues = {};
|
||||||
|
const rest: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
let help = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
|
||||||
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
help = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// POSIX end-of-options. pnpm passes a bare `--` through when invoked as
|
||||||
|
// `pnpm run script --` with nothing after it; treat the rest as
|
||||||
|
// pass-through positional args.
|
||||||
|
if (arg === '--') {
|
||||||
|
rest.push(...argv.slice(i + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!arg.startsWith('--')) {
|
||||||
|
rest.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eq = arg.indexOf('=');
|
||||||
|
let name = eq === -1 ? arg : arg.slice(0, eq);
|
||||||
|
const inline: string | undefined = eq === -1 ? undefined : arg.slice(eq + 1);
|
||||||
|
|
||||||
|
let negated = false;
|
||||||
|
if (name.startsWith('--no-')) {
|
||||||
|
negated = true;
|
||||||
|
name = `--${name.slice(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = findByFlag(name);
|
||||||
|
if (!entry) {
|
||||||
|
errors.push(`Unknown flag: ${arg}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'boolean') {
|
||||||
|
if (negated) values[entry.key] = false;
|
||||||
|
else if (inline !== undefined) {
|
||||||
|
const v = coerce(entry, inline);
|
||||||
|
if (v === undefined) errors.push(`Invalid boolean for ${name}: ${inline}`);
|
||||||
|
else values[entry.key] = v;
|
||||||
|
} else values[entry.key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = inline !== undefined ? inline : argv[++i];
|
||||||
|
if (raw === undefined) {
|
||||||
|
errors.push(`Missing value for ${name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = coerce(entry, raw);
|
||||||
|
if (v === undefined) {
|
||||||
|
errors.push(`Invalid ${entry.type} for ${name}: ${raw}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.type === 'string' || entry.type === 'url') {
|
||||||
|
const err = entry.validate?.(raw);
|
||||||
|
if (err) {
|
||||||
|
errors.push(`${name}: ${err}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values[entry.key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { values, rest, help, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyToEnv(
|
||||||
|
values: ConfigValues,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
for (const e of CONFIG) {
|
||||||
|
if (!(e.key in values)) continue;
|
||||||
|
const v = values[e.key];
|
||||||
|
env[envVarFor(e)] =
|
||||||
|
typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printHelp(stream: NodeJS.WritableStream = process.stdout): void {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('Usage: bash nanoclaw.sh [flags...]');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Flags:');
|
||||||
|
const width = Math.max(...CONFIG.map((e) => flagFor(e).length));
|
||||||
|
for (const e of CONFIG) {
|
||||||
|
const flag = flagFor(e).padEnd(width + 2);
|
||||||
|
lines.push(` ${flag}${e.help}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Each flag also reads from its corresponding NANOCLAW_<KEY> env var.');
|
||||||
|
lines.push('Run without flags for the default interactive flow.');
|
||||||
|
stream.write(lines.join('\n') + '\n');
|
||||||
|
}
|
||||||
127
setup/lib/setup-config-screen.ts
Normal file
127
setup/lib/setup-config-screen.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Advanced-settings screen — menu of UI-visible entries from the config
|
||||||
|
* registry. The user picks one entry, edits it, returns to the menu, and
|
||||||
|
* exits via "Done". Returns a fresh values object; the caller passes it to
|
||||||
|
* applyToEnv() so downstream step code reads them via env vars.
|
||||||
|
*
|
||||||
|
* Per-entry edit contract:
|
||||||
|
* - Blank input on text/password/integer = leave current value unchanged.
|
||||||
|
* - Enums get a synthetic "leave unchanged" first option.
|
||||||
|
* - Booleans use confirm with the current value as initialValue.
|
||||||
|
* - Secret entries mask the current value as bullets in hints/labels.
|
||||||
|
*/
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
|
||||||
|
import { brightSelect } from './bright-select.js';
|
||||||
|
import { ensureAnswer } from './runner.js';
|
||||||
|
import { CONFIG, type Entry } from './setup-config.js';
|
||||||
|
import type { ConfigValues } from './setup-config-parse.js';
|
||||||
|
|
||||||
|
const SKIP_SENTINEL = '__leave_unchanged__';
|
||||||
|
const DONE_SENTINEL = '__done__';
|
||||||
|
const MASK = '••••••••';
|
||||||
|
|
||||||
|
export async function runAdvancedScreen(
|
||||||
|
initial: ConfigValues,
|
||||||
|
): Promise<ConfigValues> {
|
||||||
|
const result: ConfigValues = { ...initial };
|
||||||
|
const visible = CONFIG.filter((e) => e.surface === 'flag+ui');
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const options = [
|
||||||
|
...visible.map((e) => ({
|
||||||
|
value: e.key,
|
||||||
|
label: e.label,
|
||||||
|
hint: hintFor(e, result),
|
||||||
|
})),
|
||||||
|
{ value: DONE_SENTINEL, label: 'Done — continue with setup' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const choice = ensureAnswer(
|
||||||
|
await brightSelect<string>({
|
||||||
|
message: 'Pick a setting to override',
|
||||||
|
options,
|
||||||
|
initialValue: DONE_SENTINEL,
|
||||||
|
}),
|
||||||
|
) as string;
|
||||||
|
|
||||||
|
if (choice === DONE_SENTINEL) return result;
|
||||||
|
const entry = visible.find((e) => e.key === choice);
|
||||||
|
if (entry) await promptOne(entry, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintFor(e: Entry, values: ConfigValues): string {
|
||||||
|
const v = values[e.key];
|
||||||
|
if (v === undefined) return 'not set';
|
||||||
|
if (e.secret) return MASK;
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptOne(e: Entry, values: ConfigValues): Promise<void> {
|
||||||
|
if (e.type === 'boolean') {
|
||||||
|
const init =
|
||||||
|
typeof values[e.key] === 'boolean'
|
||||||
|
? (values[e.key] as boolean)
|
||||||
|
: (e.default ?? false);
|
||||||
|
const ans = ensureAnswer(
|
||||||
|
await p.confirm({ message: e.label, initialValue: init }),
|
||||||
|
);
|
||||||
|
values[e.key] = ans as boolean;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.type === 'enum') {
|
||||||
|
const ans = ensureAnswer(
|
||||||
|
await brightSelect<string>({
|
||||||
|
message: e.label,
|
||||||
|
options: [
|
||||||
|
{ value: SKIP_SENTINEL, label: 'Leave unchanged' },
|
||||||
|
...e.options,
|
||||||
|
],
|
||||||
|
initialValue: SKIP_SENTINEL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (ans !== SKIP_SENTINEL) values[e.key] = ans as string;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.type === 'integer') {
|
||||||
|
const ans = ensureAnswer(
|
||||||
|
await p.text({
|
||||||
|
message: e.label,
|
||||||
|
placeholder: e.default !== undefined ? String(e.default) : undefined,
|
||||||
|
validate: (v) => {
|
||||||
|
const s = (v ?? '').trim();
|
||||||
|
if (!s) return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n)) return 'Must be a number';
|
||||||
|
if (e.min !== undefined && n < e.min) return `Must be ≥ ${e.min}`;
|
||||||
|
if (e.max !== undefined && n > e.max) return `Must be ≤ ${e.max}`;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const trimmed = ((ans as string) ?? '').trim();
|
||||||
|
if (trimmed) values[e.key] = Number(trimmed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// string | url
|
||||||
|
const validate = (v: string | undefined): string | undefined => {
|
||||||
|
const s = (v ?? '').trim();
|
||||||
|
if (!s) return undefined;
|
||||||
|
return e.validate?.(s);
|
||||||
|
};
|
||||||
|
const ans = ensureAnswer(
|
||||||
|
e.secret
|
||||||
|
? await p.password({ message: e.label, validate })
|
||||||
|
: await p.text({
|
||||||
|
message: e.label,
|
||||||
|
placeholder: e.placeholder ?? e.default,
|
||||||
|
validate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const trimmed = ((ans as string) ?? '').trim();
|
||||||
|
if (trimmed) values[e.key] = trimmed;
|
||||||
|
}
|
||||||
132
setup/lib/setup-config.ts
Normal file
132
setup/lib/setup-config.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Setup-time advanced-config registry.
|
||||||
|
*
|
||||||
|
* One source of truth for: CLI flags, env-var names, the advanced-settings
|
||||||
|
* screen, and `--help` output. The flag parser, env reader, and UI screen
|
||||||
|
* all consume this list and write resolved values back to `process.env` so
|
||||||
|
* existing step code keeps reading env vars unchanged.
|
||||||
|
*
|
||||||
|
* Default name conventions (overridable per entry):
|
||||||
|
* key 'fooBar' → envVar 'NANOCLAW_FOO_BAR' → flag '--foo-bar'
|
||||||
|
*
|
||||||
|
* Surface levels:
|
||||||
|
* 'flag' — CLI flag + env var only (debug/internal knobs)
|
||||||
|
* 'flag+ui' — also shown in the advanced-settings screen
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EntrySurface = 'flag' | 'flag+ui';
|
||||||
|
|
||||||
|
interface BaseEntry {
|
||||||
|
/** Canonical camelCase key. */
|
||||||
|
key: string;
|
||||||
|
/** Override of the auto-derived NANOCLAW_<UPPER_SNAKE> env var. */
|
||||||
|
envVar?: string;
|
||||||
|
/** Override of the auto-derived --kebab-case flag. */
|
||||||
|
flag?: string;
|
||||||
|
label: string;
|
||||||
|
help: string;
|
||||||
|
surface: EntrySurface;
|
||||||
|
/** UI section header. Entries without a group land in 'Other'. */
|
||||||
|
group?: string;
|
||||||
|
/** Mask in UI, redact in logs. */
|
||||||
|
secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StringEntry extends BaseEntry {
|
||||||
|
type: 'string' | 'url';
|
||||||
|
default?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
validate?: (v: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnumEntry extends BaseEntry {
|
||||||
|
type: 'enum';
|
||||||
|
options: { value: string; label: string; hint?: string }[];
|
||||||
|
default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoolEntry extends BaseEntry {
|
||||||
|
type: 'boolean';
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntEntry extends BaseEntry {
|
||||||
|
type: 'integer';
|
||||||
|
default?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Entry = StringEntry | EnumEntry | BoolEntry | IntEntry;
|
||||||
|
|
||||||
|
const httpUrl = (v: string): string | undefined =>
|
||||||
|
/^https?:\/\/\S+/.test(v) ? undefined : 'Must be http(s)://…';
|
||||||
|
|
||||||
|
export const CONFIG: Entry[] = [
|
||||||
|
{
|
||||||
|
key: 'onecliApiHost',
|
||||||
|
label: 'OneCLI vault URL',
|
||||||
|
help: 'Use a remote OneCLI vault instead of installing one locally.',
|
||||||
|
surface: 'flag+ui',
|
||||||
|
group: 'OneCLI',
|
||||||
|
type: 'url',
|
||||||
|
default: 'https://app.onecli.sh',
|
||||||
|
placeholder: 'https://app.onecli.sh',
|
||||||
|
validate: httpUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'onecliApiToken',
|
||||||
|
label: 'OneCLI access token',
|
||||||
|
help: 'Bearer token for the remote vault. Required if --onecli-api-host is set.',
|
||||||
|
surface: 'flag+ui',
|
||||||
|
group: 'OneCLI',
|
||||||
|
type: 'string',
|
||||||
|
secret: true,
|
||||||
|
placeholder: 'oc_…',
|
||||||
|
validate: (v) => (v.startsWith('oc_') ? undefined : 'Must start with oc_'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anthropicBaseUrl',
|
||||||
|
label: 'Anthropic API base URL',
|
||||||
|
help: 'Use a proxy or alternative endpoint instead of api.anthropic.com.',
|
||||||
|
surface: 'flag+ui',
|
||||||
|
group: 'Anthropic',
|
||||||
|
type: 'url',
|
||||||
|
placeholder: 'https://api.anthropic.com',
|
||||||
|
validate: httpUrl,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Existing env-var knobs — flag-only so they don't clutter the UI screen.
|
||||||
|
{
|
||||||
|
key: 'skip',
|
||||||
|
envVar: 'NANOCLAW_SKIP',
|
||||||
|
label: 'Skip steps',
|
||||||
|
help: 'Comma-separated step names to skip (debugging only).',
|
||||||
|
surface: 'flag',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'displayName',
|
||||||
|
envVar: 'NANOCLAW_DISPLAY_NAME',
|
||||||
|
label: 'Display name',
|
||||||
|
help: 'Skip the "what should your assistant call you?" prompt.',
|
||||||
|
surface: 'flag',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── name derivation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function envVarFor(e: Entry): string {
|
||||||
|
if (e.envVar) return e.envVar;
|
||||||
|
return `NANOCLAW_${e.key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flagFor(e: Entry): string {
|
||||||
|
if (e.flag) return e.flag;
|
||||||
|
return `--${e.key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findByFlag(flag: string): Entry | null {
|
||||||
|
return CONFIG.find((e) => flagFor(e) === flag) ?? null;
|
||||||
|
}
|
||||||
@@ -86,17 +86,22 @@ function ensureShellProfilePath(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeEnvOnecliUrl(url: string): void {
|
function writeEnvVar(name: string, value: string): void {
|
||||||
const envFile = path.join(process.cwd(), '.env');
|
const envFile = path.join(process.cwd(), '.env');
|
||||||
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
|
||||||
if (/^ONECLI_URL=/m.test(content)) {
|
const re = new RegExp(`^${name}=.*$`, 'm');
|
||||||
content = content.replace(/^ONECLI_URL=.*$/m, `ONECLI_URL=${url}`);
|
if (re.test(content)) {
|
||||||
|
content = content.replace(re, `${name}=${value}`);
|
||||||
} else {
|
} else {
|
||||||
content = content.trimEnd() + (content ? '\n' : '') + `ONECLI_URL=${url}\n`;
|
content = content.trimEnd() + (content ? '\n' : '') + `${name}=${value}\n`;
|
||||||
}
|
}
|
||||||
fs.writeFileSync(envFile, content);
|
fs.writeFileSync(envFile, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeEnvOnecliUrl(url: string): void {
|
||||||
|
writeEnvVar('ONECLI_URL', url);
|
||||||
|
}
|
||||||
|
|
||||||
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
// Last-known-good CLI release. Used only if BOTH the upstream installer
|
||||||
// and the redirect-based version probe fail. Bump deliberately when a
|
// and the redirect-based version probe fail. Bump deliberately when a
|
||||||
// new CLI release ships.
|
// new CLI release ships.
|
||||||
@@ -257,6 +262,8 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
ensureShellProfilePath();
|
ensureShellProfilePath();
|
||||||
|
|
||||||
if (remoteUrl) {
|
if (remoteUrl) {
|
||||||
|
// Remote-mode: install only the CLI, point it at the remote gateway, and
|
||||||
|
// record the URL in .env. No local gateway is started.
|
||||||
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
log.info('Installing OneCLI CLI for remote gateway', { remoteUrl });
|
||||||
const res = installOnecliCliOnly();
|
const res = installOnecliCliOnly();
|
||||||
if (!res.ok || !onecliVersion()) {
|
if (!res.ok || !onecliVersion()) {
|
||||||
@@ -279,6 +286,23 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
writeEnvOnecliUrl(remoteUrl);
|
writeEnvOnecliUrl(remoteUrl);
|
||||||
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
|
log.info('Wrote ONECLI_URL to .env', { url: remoteUrl });
|
||||||
|
const remoteToken = process.env.NANOCLAW_ONECLI_API_TOKEN?.trim();
|
||||||
|
if (remoteToken) {
|
||||||
|
// Two auth surfaces: `onecli auth login` persists the key for CLI
|
||||||
|
// calls during setup itself (e.g. detecting an existing Anthropic
|
||||||
|
// secret via `onecli secrets list`), and ONECLI_API_KEY in .env is
|
||||||
|
// read by the runtime SDK at request time. Both are needed.
|
||||||
|
try {
|
||||||
|
execFileSync('onecli', ['auth', 'login', '--api-key', remoteToken], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: childEnv(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('onecli auth login failed', { err });
|
||||||
|
}
|
||||||
|
writeEnvVar('ONECLI_API_KEY', remoteToken);
|
||||||
|
log.info('Wrote ONECLI_API_KEY to .env');
|
||||||
|
}
|
||||||
const healthy = await pollHealth(remoteUrl, 5000);
|
const healthy = await pollHealth(remoteUrl, 5000);
|
||||||
emitStatus('ONECLI', {
|
emitStatus('ONECLI', {
|
||||||
INSTALLED: true,
|
INSTALLED: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user