refactor(setup): check env vars per-step instead of upfront all-or-nothing

Remove the grouped detectExistingEnv() block that asked "reuse all or
start fresh" at the top of setup. Each channel step now reads credentials
directly from .env on disk via readEnvKey() and offers to reuse them
individually at the point of use.

- Add readEnvKey() helper in setup/environment.ts
- Remove ENV_KEY_GROUPS, ExistingEnvGroup, detectExistingEnv from auto.ts
- Move detectRegisteredGroups skip to right before cli-agent step
- Switch all channel files (telegram, discord, slack, teams, imessage)
  from process.env to readEnvKey()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gabi
2026-04-30 12:36:25 +00:00
parent bb1b41800c
commit 1db98ee614
7 changed files with 44 additions and 93 deletions
+5 -83
View File
@@ -122,39 +122,6 @@ 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) {
const lines = Object.values(existingEnv.groups).map(
(g) => ` ${k.green('✓')} ${g.label}`,
);
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')) { if (!skip.has('container')) {
p.log.message(brandBody(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( p.log.message(
@@ -344,6 +311,11 @@ async function main(): Promise<void> {
return displayName; return displayName;
} }
if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) {
skip.add('cli-agent');
skip.add('first-chat');
}
if (!skip.has('cli-agent')) { if (!skip.has('cli-agent')) {
await resolveDisplayName(); await resolveDisplayName();
const res = await runQuietStep( const res = await runQuietStep(
@@ -1063,56 +1035,6 @@ async function askChannelChoice(): Promise<ChannelChoice> {
// ─── interactive / env helpers ───────────────────────────────────────── // ─── 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 { function anthropicSecretExists(): boolean {
try { try {
const res = spawnSync('onecli', ['secrets', 'list'], { const res = spawnSync('onecli', ['secrets', 'list'], {
+2 -1
View File
@@ -32,6 +32,7 @@ import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, brandBody, note } from '../lib/theme.js'; import { accentGreen, brandBody, note } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano'; const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10'; const DISCORD_API = 'https://discord.com/api/v10';
@@ -240,7 +241,7 @@ async function walkThroughServerCreation(): Promise<void> {
} }
async function collectDiscordToken(): Promise<string> { async function collectDiscordToken(): Promise<string> {
const existing = process.env.DISCORD_BOT_TOKEN?.trim(); const existing = readEnvKey('DISCORD_BOT_TOKEN');
if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`,
+3 -2
View File
@@ -37,6 +37,7 @@ import { brightSelect } from '../lib/bright-select.js';
import { askOperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano'; const DEFAULT_AGENT_NAME = 'Nano';
@@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise<void> {
} }
async function collectRemoteCreds(): Promise<RemoteCreds> { async function collectRemoteCreds(): Promise<RemoteCreds> {
const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); const existingUrl = readEnvKey('IMESSAGE_SERVER_URL');
const existingKey = process.env.IMESSAGE_API_KEY?.trim(); const existingKey = readEnvKey('IMESSAGE_API_KEY');
if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: `Found existing Photon credentials (${existingUrl}). Use them?`, message: `Found existing Photon credentials (${existingUrl}). Use them?`,
+3 -2
View File
@@ -29,6 +29,7 @@ import { confirmThenOpen } from '../lib/browser.js';
import { askOperatorRole } from '../lib/role-prompt.js'; import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const SLACK_API = 'https://slack.com/api'; const SLACK_API = 'https://slack.com/api';
const SLACK_APPS_URL = 'https://api.slack.com/apps'; const SLACK_APPS_URL = 'https://api.slack.com/apps';
@@ -151,7 +152,7 @@ async function walkThroughAppCreation(): Promise<void> {
} }
async function collectBotToken(): Promise<string> { async function collectBotToken(): Promise<string> {
const existing = process.env.SLACK_BOT_TOKEN?.trim(); const existing = readEnvKey('SLACK_BOT_TOKEN');
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
@@ -185,7 +186,7 @@ async function collectBotToken(): Promise<string> {
} }
async function collectSigningSecret(): Promise<string> { async function collectSigningSecret(): Promise<string> {
const existing = process.env.SLACK_SIGNING_SECRET?.trim(); const existing = readEnvKey('SLACK_SIGNING_SECRET');
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: 'Found an existing Slack signing secret. Use it?', message: 'Found an existing Slack signing secret. Use it?',
+5 -4
View File
@@ -42,6 +42,7 @@ import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
import { note } from '../lib/theme.js'; import { note } from '../lib/theme.js';
import * as setupLog from '../logs.js'; import * as setupLog from '../logs.js';
import { readEnvKey } from '../environment.js';
const CHANNEL = 'teams'; const CHANNEL = 'teams';
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
@@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
const collected: Collected = {}; const collected: Collected = {};
const completed: string[] = []; const completed: string[] = [];
const existingAppId = process.env.TEAMS_APP_ID?.trim(); const existingAppId = readEnvKey('TEAMS_APP_ID');
const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); const existingPassword = readEnvKey('TEAMS_APP_PASSWORD');
if (existingAppId && existingPassword) { if (existingAppId && existingPassword) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`,
@@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise<void> {
if (reuse) { if (reuse) {
collected.appId = existingAppId; collected.appId = existingAppId;
collected.appPassword = existingPassword; collected.appPassword = existingPassword;
collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant';
if (collected.appType === 'SingleTenant') { if (collected.appType === 'SingleTenant') {
collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined;
} }
setupLog.userInput('teams_credentials', 'reused-existing'); setupLog.userInput('teams_credentials', 'reused-existing');
await installAdapter(collected); await installAdapter(collected);
+2 -1
View File
@@ -34,6 +34,7 @@ import {
writeStepEntry, writeStepEntry,
} from '../lib/runner.js'; } from '../lib/runner.js';
import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js';
import { readEnvKey } from '../environment.js';
const DEFAULT_AGENT_NAME = 'Nano'; const DEFAULT_AGENT_NAME = 'Nano';
@@ -132,7 +133,7 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
} }
async function collectTelegramToken(): Promise<string> { async function collectTelegramToken(): Promise<string> {
const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); const existing = readEnvKey('TELEGRAM_BOT_TOKEN');
if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) {
const reuse = ensureAnswer(await p.confirm({ const reuse = ensureAnswer(await p.confirm({
message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`,
+24
View File
@@ -11,6 +11,30 @@ import { log } from '../src/log.js';
import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js';
import { emitStatus } from './status.js'; import { emitStatus } from './status.js';
/**
* Read a single key from `.env` on disk (not process.env).
* Returns the trimmed value or null if the key isn't set / file doesn't exist.
*/
export function readEnvKey(key: string, projectRoot?: string): string | null {
const envPath = path.join(projectRoot ?? process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envPath, 'utf-8');
} catch {
return null;
}
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 1) continue;
if (trimmed.slice(0, eq) === key) {
return trimmed.slice(eq + 1).trim() || null;
}
}
return null;
}
export function detectExistingDisplayName(projectRoot: string): string | null { export function detectExistingDisplayName(projectRoot: string): string | null {
const dbPath = path.join(projectRoot, 'data', 'v2.db'); const dbPath = path.join(projectRoot, 'data', 'v2.db');
if (!fs.existsSync(dbPath)) return null; if (!fs.existsSync(dbPath)) return null;