Merge remote-tracking branch 'origin/main' into setup-root-warning-v2
# Conflicts: # setup/auto.ts
This commit is contained in:
164
setup/auto.ts
164
setup/auto.ts
@@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js';
|
||||
import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
@@ -126,51 +126,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect existing .env and offer to reuse it so the user doesn't have to
|
||||
// paste credentials again on a re-run.
|
||||
const existingEnv = detectExistingEnv();
|
||||
if (existingEnv) {
|
||||
// On sg re-exec, auto-reuse — the user already decided in the first run.
|
||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||
let reuseChoice: 'reuse' | 'fresh' = 'reuse';
|
||||
|
||||
if (!isReexec) {
|
||||
const lines = Object.values(existingEnv.groups).map(
|
||||
(g) => ` ${k.green('✓')} ${g.label}`,
|
||||
);
|
||||
note(lines.join('\n'), 'Found existing configuration');
|
||||
|
||||
reuseChoice = ensureAnswer(
|
||||
await brightSelect({
|
||||
message: 'Use this existing environment?',
|
||||
options: [
|
||||
{ value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' },
|
||||
{ value: 'fresh', label: 'No, start fresh' },
|
||||
],
|
||||
initialValue: 'reuse',
|
||||
}),
|
||||
) as 'reuse' | 'fresh';
|
||||
setupLog.userInput('existing_env_choice', reuseChoice);
|
||||
}
|
||||
|
||||
if (reuseChoice === 'reuse') {
|
||||
for (const [key, value] of Object.entries(existingEnv.raw)) {
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
if (existingEnv.groups.onecli) skip.add('onecli');
|
||||
if (detectRegisteredGroups(process.cwd())) {
|
||||
skip.add('cli-agent');
|
||||
skip.add('first-chat');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4));
|
||||
p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)));
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const res = await runWindowedStep('container', {
|
||||
@@ -205,9 +168,11 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -331,9 +296,11 @@ async function main(): Promise<void> {
|
||||
await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.');
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker.");
|
||||
p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker."));
|
||||
p.log.message(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
brandBody(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -348,6 +315,11 @@ async function main(): Promise<void> {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
|
||||
skip.add('cli-agent');
|
||||
skip.add('first-chat');
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent')) {
|
||||
await resolveDisplayName();
|
||||
const res = await runQuietStep(
|
||||
@@ -367,16 +339,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
brandBody(
|
||||
dimWrap(
|
||||
"Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
const next = ensureAnswer(
|
||||
await p.select({
|
||||
await brightSelect<'continue' | 'chat'>({
|
||||
message: 'What next?',
|
||||
options: [
|
||||
{
|
||||
@@ -437,9 +411,11 @@ async function main(): Promise<void> {
|
||||
await runIMessageChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).',
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -579,18 +555,16 @@ async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
s.start(fitToWidth(label, ' (99m 59s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
const suffix = ` (${fmtDuration(Date.now() - start)})`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`);
|
||||
} else {
|
||||
@@ -679,7 +653,7 @@ function sendChatMessage(message: string): Promise<void> {
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
p.log.success(brandBody('Your Claude account is already connected.'));
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
@@ -727,7 +701,7 @@ async function runAuthStep(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step('Opening the Claude sign-in flow…');
|
||||
p.log.step(brandBody('Opening the Claude sign-in flow…'));
|
||||
console.log(k.dim(' (a browser will open for sign-in; this part is interactive)'));
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
@@ -746,7 +720,7 @@ async function runSubscriptionAuth(): Promise<void> {
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
p.log.success(brandBody('Claude account connected.'));
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
@@ -970,9 +944,11 @@ async function runTimezoneStep(): Promise<void> {
|
||||
tz = await resolveTimezoneViaClaude(raw);
|
||||
} else {
|
||||
p.log.warn(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
brandBody(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1015,7 +991,7 @@ async function runTimezoneStep(): Promise<void> {
|
||||
async function askDisplayName(fallback: string): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant call you?',
|
||||
message: `What should your assistant call ${accentGreen('you')}?`,
|
||||
placeholder: fallback,
|
||||
defaultValue: fallback,
|
||||
}),
|
||||
@@ -1061,56 +1037,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
interface ExistingEnvGroup {
|
||||
label: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
const ENV_KEY_GROUPS: Record<string, { label: string; keys: string[] }> = {
|
||||
onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] },
|
||||
telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] },
|
||||
discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] },
|
||||
slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] },
|
||||
signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] },
|
||||
teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] },
|
||||
whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] },
|
||||
imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] },
|
||||
};
|
||||
|
||||
function detectExistingEnv(): { groups: Record<string, ExistingEnvGroup>; raw: Record<string, string> } | null {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
if (!fs.existsSync(envPath)) return null;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw: Record<string, string> = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 1) continue;
|
||||
raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
||||
}
|
||||
|
||||
if (Object.keys(raw).length === 0) return null;
|
||||
|
||||
const groups: Record<string, ExistingEnvGroup> = {};
|
||||
for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) {
|
||||
const found = def.keys.filter((key) => raw[key] !== undefined);
|
||||
if (found.length > 0) {
|
||||
groups[id] = { label: def.label, keys: found };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(groups).length === 0) return null;
|
||||
return { groups, raw };
|
||||
}
|
||||
|
||||
function anthropicSecretExists(): boolean {
|
||||
try {
|
||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||
@@ -1187,7 +1113,7 @@ function maybeReexecUnderSg(): void {
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.'));
|
||||
const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(',');
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
|
||||
Reference in New Issue
Block a user