mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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:
+5
-83
@@ -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'], {
|
||||||
|
|||||||
@@ -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?`,
|
||||||
|
|||||||
@@ -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?`,
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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?`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user