mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+47
-12
@@ -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)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Settings {
|
||||
machineId?: string
|
||||
machineIdConfirmedByServer?: boolean
|
||||
daemonAutoStartWhenRunningHappy?: boolean
|
||||
cliApiToken?: string
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
|
||||
+15
-6
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user