From aa390b3fd0466af30e5cc19113bb9e52944d3684 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 29 Apr 2026 10:20:54 +0000 Subject: [PATCH] detect existing .env and credentials on setup re-run When re-running setup on a machine that already has a .env with channel tokens or OneCLI config, detect them early and offer to reuse instead of prompting the user to paste everything again. - Add detectExistingEnv() to parse .env and group known keys - Add detectExistingDisplayName() to read display name from v2.db - Defer display name prompt until actually needed (cli-agent or channel) - Skip cli-agent and first-chat when groups are already wired - Add token reuse checks to Telegram, Discord, Slack, Teams, iMessage Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 98 ++++++++++++++++++++++++++++++++++++-- setup/channels/discord.ts | 12 +++++ setup/channels/imessage.ts | 13 +++++ setup/channels/slack.ts | 24 ++++++++++ setup/channels/teams.ts | 22 +++++++++ setup/channels/telegram.ts | 12 +++++ setup/environment.ts | 18 +++++++ 7 files changed, 195 insertions(+), 4 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8a0..01d7f3a31 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -46,6 +46,7 @@ import { } from './lib/setup-config-parse.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; @@ -121,6 +122,39 @@ async function main(): Promise { } } + // 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) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + p.note(lines.join('\n'), 'Found existing configuration'); + + const 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( @@ -295,14 +329,17 @@ async function main(): Promise { } let displayName: string | undefined; - const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); - if (needsDisplayName) { - const fallback = process.env.USER?.trim() || 'Operator'; + async function resolveDisplayName(): Promise { + if (displayName) return displayName; const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); - displayName = preset || (await askDisplayName(fallback)); + const existing = detectExistingDisplayName(process.cwd()); + const fallback = process.env.USER?.trim() || 'Operator'; + displayName = preset || existing || (await askDisplayName(fallback)); + return displayName; } if (!skip.has('cli-agent')) { + await resolveDisplayName(); const res = await runQuietStep( 'cli-agent', { @@ -371,6 +408,9 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip') { + await resolveDisplayName(); + } if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { @@ -1010,6 +1050,56 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +interface ExistingEnvGroup { + label: string; + keys: string[]; +} + +const ENV_KEY_GROUPS: Record = { + 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; raw: Record } | 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 = {}; + 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 = {}; + 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'], { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 366868692..dd17bc2bc 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -239,6 +239,18 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { + const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('discord_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129fa8..89d2efeef 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -222,6 +222,19 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { + const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); + const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Photon credentials (${existingUrl}). Use them?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('imessage_remote_creds', 'reused-existing'); + return { serverUrl: existingUrl, apiKey: existingKey }; + } + } + p.note( [ "Photon is a separate service that owns an iMessage account and", diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff5628..cfbd9881c 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -151,6 +151,18 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { + const existing = process.env.SLACK_BOT_TOKEN?.trim(); + if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_bot_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', @@ -172,6 +184,18 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { + const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: 'Found an existing Slack signing secret. Use it?', + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_signing_secret', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878de..91a91d902 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -59,6 +59,28 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; + const existingAppId = process.env.TEAMS_APP_ID?.trim(); + const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + if (existingAppId && existingPassword) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, + initialValue: true, + })); + if (reuse) { + collected.appId = existingAppId; + collected.appPassword = existingPassword; + collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + if (collected.appType === 'SingleTenant') { + collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + } + setupLog.userInput('teams_credentials', 'reused-existing'); + await installAdapter(collected); + completed.push('Adapter installed and service restarted (reused existing credentials).'); + await finishWithHandoff(collected, completed); + return; + } + } + printIntro(); await confirmPrereqs({ collected, completed }); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcff1..4659bd6b9 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -132,6 +132,18 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { + const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('telegram_token', 'reused-existing'); + return existing; + } + } + p.note( [ "Your assistant talks to you through a Telegram bot you create.", diff --git a/setup/environment.ts b/setup/environment.ts index 6986396d7..c351023ab 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,24 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectExistingDisplayName(projectRoot: string): string | null { + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return null; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`) + .get() as { display_name: string } | undefined; + return row?.display_name?.trim() || null; + } catch { + return null; + } finally { + db?.close(); + } +} + export function detectRegisteredGroups(projectRoot: string): boolean { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { return true;