feat: add interactive CLI token configuration with settings persistence

Implements comprehensive token management with intelligent priority resolution.
Token sources (highest to lowest priority): environment variable, settings file,
interactive prompt. Changes include:

- Updated home directory from ~/.happy to ~/.config/hapi (env var: HAPI_HOME_DIR)
- Added cliApiToken field to Settings interface for persistent storage
- Made cliApiToken mutable in Configuration class (private field with getter/setter)
- Created ui/tokenInit.ts module for token initialization with fallback prompt
- Enhanced auth command: login (interactive input), logout (clear token), status (show source)
- Updated doctor command to correctly identify token source and mask saved token in output
- Token initialization called early in startup flow to ensure availability before API use
This commit is contained in:
weishu
2025-12-16 21:54:55 +08:00
parent 0485065b87
commit c2b886896b
6 changed files with 151 additions and 29 deletions
+47 -12
View File
@@ -1,7 +1,9 @@
import chalk from 'chalk'
import os from 'node:os'
import * as readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import { configuration } from '@/configuration'
import { readSettings, clearMachineId } from '@/persistence'
import { readSettings, clearMachineId, updateSettings } from '@/persistence'
export async function handleAuthCommand(args: string[]): Promise<void> {
const subcommand = args[0]
@@ -13,24 +15,56 @@ export async function handleAuthCommand(args: string[]): Promise<void> {
if (subcommand === 'status') {
const settings = await readSettings()
const envToken = process.env.CLI_API_TOKEN
const settingsToken = settings.cliApiToken
const hasToken = Boolean(envToken || settingsToken)
const tokenSource = envToken ? 'environment' : (settingsToken ? 'settings file' : 'none')
console.log(chalk.bold('\nDirect Connect Status\n'))
console.log(chalk.gray(` HAPPY_BOT_URL: ${configuration.serverUrl}`))
console.log(chalk.gray(` CLI_API_TOKEN: ${configuration.cliApiToken ? 'set' : 'missing'}`))
console.log(chalk.gray(` CLI_API_TOKEN: ${hasToken ? 'set' : 'missing'}`))
console.log(chalk.gray(` Token Source: ${tokenSource}`))
console.log(chalk.gray(` Machine ID: ${settings.machineId ?? 'not set'}`))
console.log(chalk.gray(` Host: ${os.hostname()}`))
return
}
if (subcommand === 'login') {
console.log(chalk.yellow('No login flow in direct-connect mode.'))
console.log(chalk.gray('Set `HAPPY_BOT_URL` and `CLI_API_TOKEN` in your environment.'))
if (!process.stdin.isTTY) {
console.error(chalk.red('Cannot prompt for token in non-TTY environment.'))
console.error(chalk.gray('Set CLI_API_TOKEN environment variable instead.'))
process.exit(1)
}
const rl = readline.createInterface({ input, output })
try {
const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: '))
if (!token.trim()) {
console.error(chalk.red('Token cannot be empty'))
process.exit(1)
}
await updateSettings(current => ({
...current,
cliApiToken: token.trim()
}))
configuration._setCliApiToken(token.trim())
console.log(chalk.green('\nToken saved to ~/.config/hapi/settings.json'))
} finally {
rl.close()
}
return
}
if (subcommand === 'logout') {
await updateSettings(current => ({
...current,
cliApiToken: undefined
}))
await clearMachineId()
console.log(chalk.green('Cleared local machineId.'))
console.log(chalk.gray('Unset `CLI_API_TOKEN` in your environment to fully revoke access.'))
console.log(chalk.green('Cleared local credentials (token and machineId).'))
console.log(chalk.gray('Note: If CLI_API_TOKEN is set via environment variable, it will still be used.'))
return
}
@@ -41,15 +75,16 @@ export async function handleAuthCommand(args: string[]): Promise<void> {
function showHelp(): void {
console.log(`
${chalk.bold('hapi auth')} - Direct-connect configuration
${chalk.bold('hapi auth')} - Authentication management
${chalk.bold('Usage:')}
hapi auth status Show current configuration
hapi auth login Print configuration help
hapi auth logout Clear local machineId
hapi auth login Enter and save CLI_API_TOKEN
hapi auth logout Clear saved credentials
${chalk.bold('Required env vars:')}
HAPPY_BOT_URL=<https://your-bot-domain>
CLI_API_TOKEN=<shared secret>
${chalk.bold('Token priority (highest to lowest):')}
1. CLI_API_TOKEN environment variable
2. ~/.config/hapi/settings.json
3. Interactive prompt (on first run)
`)
}
+14 -6
View File
@@ -12,7 +12,7 @@ import packageJson from '../package.json'
class Configuration {
public readonly serverUrl: string
public readonly cliApiToken: string
private _cliApiToken: string
public readonly isDaemonProcess: boolean
// Directories and paths (from persistence)
@@ -30,19 +30,19 @@ class Configuration {
constructor() {
// Bot server configuration
this.serverUrl = process.env.HAPPY_BOT_URL || 'http://localhost:3006'
this.cliApiToken = process.env.CLI_API_TOKEN || ''
this._cliApiToken = process.env.CLI_API_TOKEN || ''
// Check if we're running as daemon based on process args
const args = process.argv.slice(2)
this.isDaemonProcess = args.length >= 2 && args[0] === 'daemon' && (args[1] === 'start-sync')
// Directory configuration - Priority: HAPPY_HOME_DIR env > default home dir
if (process.env.HAPPY_HOME_DIR) {
// Directory configuration - Priority: HAPI_HOME_DIR env > default home dir
if (process.env.HAPI_HOME_DIR) {
// Expand ~ to home directory if present
const expandedPath = process.env.HAPPY_HOME_DIR.replace(/^~/, homedir())
const expandedPath = process.env.HAPI_HOME_DIR.replace(/^~/, homedir())
this.happyHomeDir = expandedPath
} else {
this.happyHomeDir = join(homedir(), '.happy')
this.happyHomeDir = join(homedir(), '.config', 'hapi')
}
this.logsDir = join(this.happyHomeDir, 'logs')
@@ -64,6 +64,14 @@ class Configuration {
mkdirSync(this.logsDir, { recursive: true })
}
}
get cliApiToken(): string {
return this._cliApiToken
}
_setCliApiToken(token: string): void {
this._cliApiToken = token
}
}
export const configuration: Configuration = new Configuration()
+9 -5
View File
@@ -26,6 +26,7 @@ import { handleConnectCommand } from './commands/connect'
import { spawnHappyCLI } from './utils/spawnHappyCLI'
import { claudeCliPath } from './claude/claudeLocal'
import { execFileSync } from 'node:child_process'
import { initializeToken } from './ui/tokenInit'
(async () => {
@@ -87,7 +88,8 @@ import { execFileSync } from 'node:child_process'
startedBy = args[++i] as 'daemon' | 'terminal';
}
}
await initializeToken();
await authAndSetupMachineIfNeeded();
await runCodex({ startedBy });
// Do not force exit here; allow instrumentation to show lingering handles
@@ -179,6 +181,7 @@ import { execFileSync } from 'node:child_process'
}
process.exit(0);
} else if (daemonSubcommand === 'start-sync') {
await initializeToken();
await startDaemon()
process.exit(0)
} else if (daemonSubcommand === 'stop') {
@@ -283,7 +286,7 @@ ${chalk.bold('hapi')} - Claude Code On the Go
${chalk.bold('Usage:')}
hapi [options] Start Claude with Telegram control (direct-connect)
hapi auth Show direct-connect configuration
hapi auth Manage authentication
hapi codex Start Codex mode
hapi connect (not available in direct-connect mode)
hapi notify (not available in direct-connect mode)
@@ -292,9 +295,9 @@ ${chalk.bold('Usage:')}
hapi doctor System diagnostics & troubleshooting
${chalk.bold('Examples:')}
HAPPY_BOT_URL=http://localhost:3006 CLI_API_TOKEN=... hapi
Start session (direct-connect)
hapi --yolo Start with bypassing permissions
hapi Start session (will prompt for token if not set)
hapi auth login Configure CLI_API_TOKEN interactively
hapi --yolo Start with bypassing permissions
hapi sugar for --dangerously-skip-permissions
hapi auth status Show direct-connect status
hapi doctor Run diagnostics
@@ -327,6 +330,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')}
}
// Normal flow - auth and machine setup
await initializeToken();
await authAndSetupMachineIfNeeded();
// Always auto-start daemon for simplicity
+1
View File
@@ -19,6 +19,7 @@ interface Settings {
machineId?: string
machineIdConfirmedByServer?: boolean
daemonAutoStartWhenRunningHappy?: boolean
cliApiToken?: string
}
const defaultSettings: Settings = {
+15 -6
View File
@@ -23,7 +23,7 @@ import packageJson from '../../package.json'
export function getEnvironmentInfo(): Record<string, any> {
return {
PWD: process.env.PWD,
HAPPY_HOME_DIR: process.env.HAPPY_HOME_DIR,
HAPI_HOME_DIR: process.env.HAPI_HOME_DIR,
HAPPY_BOT_URL: process.env.HAPPY_BOT_URL,
HAPPY_PROJECT_ROOT: process.env.HAPPY_PROJECT_ROOT,
CLI_API_TOKEN_SET: Boolean(process.env.CLI_API_TOKEN),
@@ -111,7 +111,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise<void>
// Environment
console.log(chalk.bold('\n🌍 Environment Variables'));
const env = getEnvironmentInfo();
console.log(`HAPPY_HOME_DIR: ${env.HAPPY_HOME_DIR ? chalk.green(env.HAPPY_HOME_DIR) : chalk.gray('not set')}`);
console.log(`HAPI_HOME_DIR: ${env.HAPI_HOME_DIR ? chalk.green(env.HAPI_HOME_DIR) : chalk.gray('not set')}`);
console.log(`HAPPY_BOT_URL: ${env.HAPPY_BOT_URL ? chalk.green(env.HAPPY_BOT_URL) : chalk.gray('not set')}`);
console.log(`CLI_API_TOKEN: ${env.CLI_API_TOKEN_SET ? chalk.green('set') : chalk.gray('not set')}`);
console.log(`DANGEROUSLY_LOG_TO_SERVER: ${env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING ? chalk.yellow('ENABLED') : chalk.gray('not set')}`);
@@ -119,21 +119,30 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise<void>
console.log(`NODE_ENV: ${env.NODE_ENV ? chalk.green(env.NODE_ENV) : chalk.gray('not set')}`);
// Settings
let settings;
try {
const settings = await readSettings();
settings = await readSettings();
console.log(chalk.bold('\n📄 Settings (settings.json):'));
console.log(chalk.gray(JSON.stringify(settings, null, 2)));
// Hide cliApiToken in output for security
const displaySettings = { ...settings, cliApiToken: settings.cliApiToken ? '***' : undefined };
console.log(chalk.gray(JSON.stringify(displaySettings, null, 2)));
} catch (error) {
console.log(chalk.bold('\n📄 Settings:'));
console.log(chalk.red('❌ Failed to read settings'));
settings = {};
}
// Authentication status (direct-connect)
console.log(chalk.bold('\n🔐 Direct Connect Auth'));
if (configuration.cliApiToken) {
console.log(chalk.green('✓ CLI_API_TOKEN is set'));
const envToken = process.env.CLI_API_TOKEN;
const settingsToken = settings.cliApiToken;
const hasToken = Boolean(envToken || settingsToken);
const tokenSource = envToken ? 'environment variable' : (settingsToken ? 'settings file' : 'none');
if (hasToken) {
console.log(chalk.green(`✓ CLI_API_TOKEN is set (from ${tokenSource})`));
} else {
console.log(chalk.red('❌ CLI_API_TOKEN is not set'));
console.log(chalk.gray(' Run `hapi auth login` to configure or set CLI_API_TOKEN env var'));
}
// Legacy credentials (unused in direct-connect mode)
+65
View File
@@ -0,0 +1,65 @@
/**
* Token initialization module
*
* Handles CLI_API_TOKEN initialization with priority:
* 1. Environment variable (highest - allows temporary override)
* 2. Settings file (~/.config/hapi/settings.json)
* 3. Interactive prompt (only when both above are missing)
*/
import * as readline from 'node:readline/promises'
import { stdin as input, stdout as output } from 'node:process'
import chalk from 'chalk'
import { configuration } from '@/configuration'
import { readSettings, updateSettings } from '@/persistence'
/**
* Initialize CLI API token
* Must be called before any API operations
*/
export async function initializeToken(): Promise<void> {
// 1. Environment variable has highest priority (allows temporary override)
if (configuration.cliApiToken) {
return
}
// 2. Read from settings file
const settings = await readSettings()
if (settings.cliApiToken) {
configuration._setCliApiToken(settings.cliApiToken)
return
}
// 3. Non-TTY environment cannot prompt, fail with clear error
if (!process.stdin.isTTY) {
throw new Error('CLI_API_TOKEN is required. Set it via environment variable or run `hapi auth login`.')
}
// 4. Interactive prompt
const token = await promptForToken()
// 5. Save and update configuration
await updateSettings(current => ({
...current,
cliApiToken: token
}))
configuration._setCliApiToken(token)
}
async function promptForToken(): Promise<string> {
const rl = readline.createInterface({ input, output })
console.log(chalk.yellow('\nNo CLI_API_TOKEN found.'))
console.log(chalk.gray('You can set it via environment variable or enter it now.\n'))
try {
const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: '))
if (!token.trim()) {
throw new Error('Token cannot be empty')
}
console.log(chalk.green('\nToken saved to ~/.config/hapi/settings.json'))
return token.trim()
} finally {
rl.close()
}
}