feat: add verbosity control to vt command (#356)

This commit is contained in:
Peter Steinberger 2025-07-15 22:43:38 +02:00 committed by GitHub
parent de2f5bcf59
commit 32d92e306a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 967 additions and 52 deletions

150
README.md
View file

@ -75,20 +75,21 @@ When you run `vt` from the npm package, it:
```bash
# Run any command in the browser
vt pnpm run dev
vt npm test
vt python script.py
# Monitor AI agents (with automatic activity tracking)
# Monitor AI agents with automatic activity tracking
vt claude --dangerously-skip-permissions
vt --title-mode dynamic claude # See real-time Claude status
# Control terminal titles
vt --title-mode static npm run dev # Shows path and command
vt --title-mode dynamic python app.py # Shows path, command, and activity
vt --title-mode filter vim # Blocks vim from changing title
# Shell aliases work automatically!
vt claude-danger # Your custom aliases are resolved
# Use your shell aliases
vt gs # Your 'git status' alias works!
vt claude-danger # Custom aliases are resolved
# Open an interactive shell
vt --shell
vt --shell # or vt -i
# For more examples and options, see "The vt Forwarding Command" section below
```
### Git Repository Scanning on First Session
@ -349,6 +350,12 @@ vt --title-mode static npm run dev # Shows path and command
vt --title-mode dynamic python app.py # Shows path, command, and activity
vt --title-mode filter vim # Blocks vim from changing title
# Control output verbosity
vt -q npm test # Quiet mode - no console output
vt -v npm run dev # Verbose mode - show more information
vt -vv cargo build # Extra verbose - all except debug
vt -vvv python app.py # Debug mode - show everything
# Shell aliases work automatically!
vt claude-danger # Your custom alias for claude --dangerously-skip-permissions
@ -356,6 +363,106 @@ vt claude-danger # Your custom alias for claude --dangerously-skip-permissions
vt title "My Project - Testing"
```
### The `vt` Forwarding Command
The `vt` command is VibeTunnel's terminal forwarding wrapper that allows you to run any command while making its output visible in the browser. Under the hood, `vt` is a convenient shortcut for `vibetunnel fwd` - it's a bash script that calls the full command with proper path resolution and additional features like shell alias support. The `vt` wrapper acts as a transparent proxy between your terminal and the command, forwarding all input and output through VibeTunnel's infrastructure.
#### Command Syntax
```bash
vt [options] <command> [args...]
```
#### Options
**Terminal Title Control:**
- `--title-mode <mode>` - Control how terminal titles are managed:
- `none` - No title management, apps control their own titles (default)
- `filter` - Block all title changes from applications
- `static` - Show working directory and command in title
- `dynamic` - Show directory, command, and live activity status (auto-enabled for Claude)
**Verbosity Control:**
- `-q, --quiet` - Quiet mode, no console output (logs to file only)
- `-v, --verbose` - Verbose mode, show errors, warnings, and info messages
- `-vv` - Extra verbose, show all messages except debug
- `-vvv` - Debug mode, show all messages including debug
**Other Options:**
- `--shell, -i` - Launch your current shell interactively
- `--no-shell-wrap, -S` - Execute command directly without shell interpretation
- `--log-file <path>` - Override default log file location (defaults to `~/.vibetunnel/log.txt`)
- `--help, -h` - Show help message with all options
#### Verbosity Levels
VibeTunnel uses a hierarchical logging system where each level includes all messages from more severe levels:
| Level | Flag | Environment Variable | Shows |
|-------|------|---------------------|-------|
| SILENT | `-q` | `VIBETUNNEL_LOG_LEVEL=silent` | No console output (file logging only) |
| ERROR | (default) | `VIBETUNNEL_LOG_LEVEL=error` | Errors only |
| WARN | - | `VIBETUNNEL_LOG_LEVEL=warn` | Errors and warnings |
| INFO | `-v` | `VIBETUNNEL_LOG_LEVEL=info` | Errors, warnings, and informational messages |
| VERBOSE | `-vv` | `VIBETUNNEL_LOG_LEVEL=verbose` | All messages except debug |
| DEBUG | `-vvv` | `VIBETUNNEL_LOG_LEVEL=debug` | Everything including debug traces |
**Note:** All logs are always written to `~/.vibetunnel/log.txt` regardless of verbosity settings. The verbosity only controls terminal output.
#### Examples
```bash
# Basic command forwarding
vt ls -la # List files with VibeTunnel monitoring
vt npm run dev # Run development server
vt python script.py # Execute Python script
# With verbosity control
vt -q npm test # Run tests silently
vt -v npm install # See detailed installation progress
vt -vvv python debug.py # Full debug output
vt --log-file debug.log npm run dev # Write logs to custom file
# Terminal title management
vt --title-mode static npm run dev # Fixed title showing command
vt --title-mode dynamic claude # Live activity updates
vt --title-mode filter vim # Prevent vim from changing title
# Shell handling
vt --shell # Open interactive shell
vt -S /usr/bin/python # Run python directly without shell
```
#### How It Works
1. **Command Resolution**: The `vt` wrapper first checks if your command is an alias, shell function, or binary
2. **Session Creation**: It creates a new VibeTunnel session with a unique ID
3. **PTY Allocation**: A pseudo-terminal is allocated to preserve terminal features (colors, cursor control, etc.)
4. **I/O Forwarding**: All input/output is forwarded between your terminal and the browser in real-time
5. **Process Management**: The wrapper monitors the process and handles signals, exit codes, and cleanup
#### Environment Variables
- `VIBETUNNEL_LOG_LEVEL` - Set default verbosity level (silent, error, warn, info, verbose, debug)
- `VIBETUNNEL_TITLE_MODE` - Set default title mode (none, filter, static, dynamic)
- `VIBETUNNEL_DEBUG` - Legacy debug flag, equivalent to `VIBETUNNEL_LOG_LEVEL=debug`
- `VIBETUNNEL_CLAUDE_DYNAMIC_TITLE` - Force dynamic title mode for Claude commands
#### Special Features
**Automatic Claude Detection**: When running Claude AI, `vt` automatically enables dynamic title mode to show real-time activity status (thinking, writing, idle).
**Shell Alias Support**: Your shell aliases and functions work transparently through `vt`:
```bash
alias gs='git status'
vt gs # Works as expected
```
**Session Title Updates**: Inside a VibeTunnel session, use `vt title` to update the session name:
```bash
vt title "Building Production Release"
```
### Mac App Interoperability
The npm package is designed to work seamlessly alongside the Mac app:
@ -622,6 +729,31 @@ VIBETUNNEL_DEBUG=1 vt your-command
Debug logs are written to `~/.vibetunnel/log.txt`.
### Verbosity Control
Control the amount of output from VibeTunnel commands:
```bash
# Command-line flags
vt -q npm test # Quiet mode - no console output
vt npm test # Default - errors only
vt -v npm run dev # Verbose - errors, warnings, and info
vt -vv cargo build # Extra verbose - all except debug
vt -vvv python script.py # Debug mode - everything
# Environment variable
export VIBETUNNEL_LOG_LEVEL=error # Default
export VIBETUNNEL_LOG_LEVEL=warn # Show errors and warnings
export VIBETUNNEL_LOG_LEVEL=info # Show errors, warnings, and info
export VIBETUNNEL_LOG_LEVEL=verbose # All except debug
export VIBETUNNEL_LOG_LEVEL=debug # Everything
# Or use inline
VIBETUNNEL_LOG_LEVEL=silent vt npm test
```
**Note**: All logs are always written to `~/.vibetunnel/log.txt` regardless of verbosity level. The verbosity settings only control what's displayed in the terminal.
## Documentation
- [Keyboard Shortcuts](docs/keyboard-shortcuts.md) - Complete keyboard shortcut reference

View file

@ -141,6 +141,9 @@ USAGE:
vt title <new title> # Inside a VibeTunnel session only
vt --help
QUICK VERBOSITY:
-q (quiet), -v (verbose), -vv (extra), -vvv (debug)
DESCRIPTION:
This wrapper script allows VibeTunnel to see the output of commands by
forwarding TTY data through the vibetunnel utility. When you run commands
@ -160,12 +163,18 @@ EXAMPLES:
vt -i # Launch current shell (short form)
vt -S ls -la # List files without shell alias resolution
vt title "My Project" # Update session title (inside session only)
vt -q npm test # Run with minimal output (errors only)
vt -vv npm run dev # Run with verbose output
OPTIONS:
--shell, -i Launch current shell (equivalent to vt $SHELL)
--no-shell-wrap, -S Execute command directly without shell wrapper
--title-mode <mode> Terminal title mode (none, filter, static, dynamic)
Default: none (dynamic for claude)
--quiet, -q Quiet mode - only show errors
--verbose, -v Verbose mode - show more information
-vv Extra verbose - show all except debug
-vvv Debug mode - show all messages
--help, -h Show this help message and exit
TITLE MODES:
@ -174,6 +183,20 @@ TITLE MODES:
static Show working directory and command in title
dynamic Show directory, command, and live activity status (default for web UI)
VERBOSITY:
By default, only errors are shown. Use verbosity flags to control output:
-q/--quiet Suppress all output except critical errors
-v/--verbose Show errors, warnings, and informational messages
-vv Show everything except debug messages
-vvv Show all messages including debug
You can also set VIBETUNNEL_LOG_LEVEL environment variable:
export VIBETUNNEL_LOG_LEVEL=error # Default
export VIBETUNNEL_LOG_LEVEL=warn # Show errors and warnings
export VIBETUNNEL_LOG_LEVEL=info # Show errors, warnings, and info
export VIBETUNNEL_LOG_LEVEL=verbose # All except debug
export VIBETUNNEL_LOG_LEVEL=debug # Everything
NOTE:
This script automatically detects and uses the best available VibeTunnel installation:
- Mac app bundle (preferred on macOS)
@ -236,15 +259,15 @@ resolve_command() {
case "$shell_name" in
zsh)
# For zsh, we need interactive mode to get aliases
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -i -c "$(printf '%q ' "$cmd" "$@")"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -i -c "$(printf '%q ' "$cmd" "$@")"
;;
bash)
# For bash, expand aliases in non-interactive mode
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "shopt -s expand_aliases; source ~/.bashrc 2>/dev/null || source ~/.bash_profile 2>/dev/null || true; $(printf '%q ' "$cmd" "$@")"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "shopt -s expand_aliases; source ~/.bashrc 2>/dev/null || source ~/.bash_profile 2>/dev/null || true; $(printf '%q ' "$cmd" "$@")"
;;
*)
# Generic shell handling
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "$(printf '%q ' "$cmd" "$@")"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$user_shell" -c "$(printf '%q ' "$cmd" "$@")"
;;
esac
}
@ -262,11 +285,27 @@ if [[ "$1" == "title" ]]; then
exit 1
fi
# Handle verbosity flags
VERBOSITY_ARGS=""
if [[ "$1" == "--quiet" || "$1" == "-q" ]]; then
VERBOSITY_ARGS="--verbosity silent"
shift
elif [[ "$1" == "--verbose" || "$1" == "-v" ]]; then
VERBOSITY_ARGS="--verbosity info"
shift
elif [[ "$1" == "-vv" ]]; then
VERBOSITY_ARGS="--verbosity verbose"
shift
elif [[ "$1" == "-vvv" ]]; then
VERBOSITY_ARGS="--verbosity debug"
shift
fi
# Handle --shell or -i option (launch current shell)
if [[ "$1" == "--shell" || "$1" == "-i" ]]; then
shift
# Execute current shell through vibetunnel
exec "$0" "${SHELL:-/bin/bash}" "$@"
exec "$0" ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} "${SHELL:-/bin/bash}" "$@"
fi
# Handle --no-shell-wrap or -S option
@ -287,12 +326,12 @@ fi
if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
if [[ "$NO_SHELL_WRAP" == "true" ]]; then
# Execute directly without shell wrapper
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
else
# Check if the first argument is a real binary
if which "$1" >/dev/null 2>&1; then
# It's a real binary, execute directly
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
else
# Not a real binary, try alias resolution
resolve_command "$@"
@ -300,5 +339,5 @@ if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
fi
else
# Run with fwd command (original behavior for options)
exec "$VIBETUNNEL_BIN" fwd ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
exec "$VIBETUNNEL_BIN" fwd ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} ${TITLE_MODE_ARGS:+"$TITLE_MODE_ARGS"} "$@"
fi

View file

@ -8,13 +8,18 @@ suppressXtermErrors();
import { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.js';
import { closeLogger, createLogger, initLogger } from './server/utils/logger.js';
import { closeLogger, createLogger, initLogger, VerbosityLevel } from './server/utils/logger.js';
import { parseVerbosityFromEnv } from './server/utils/verbosity-parser.js';
import { VERSION } from './server/version.js';
// Initialize logger before anything else
// Check VIBETUNNEL_DEBUG environment variable for debug mode
// Parse verbosity from environment variables
const verbosityLevel = parseVerbosityFromEnv();
// Check for legacy debug mode (for backward compatibility with initLogger)
const debugMode = process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true';
initLogger(debugMode);
initLogger(debugMode, verbosityLevel);
const logger = createLogger('cli');
// Source maps are only included if built with --sourcemap flag
@ -61,7 +66,10 @@ if (!module.parent && (require.main === module || require.main === undefined)) {
process.exit(1);
});
} else {
logger.log('Starting VibeTunnel server...');
// Show startup message at INFO level or when debug is enabled
if (verbosityLevel !== undefined && verbosityLevel >= VerbosityLevel.INFO) {
logger.log('Starting VibeTunnel server...');
}
startVibeTunnelServer();
}
}

View file

@ -21,9 +21,17 @@ import { SessionManager } from './pty/session-manager.js';
import { VibeTunnelSocketClient } from './pty/socket-client.js';
import { ActivityDetector } from './utils/activity-detector.js';
import { checkAndPatchClaude } from './utils/claude-patcher.js';
import { closeLogger, createLogger } from './utils/logger.js';
import {
closeLogger,
createLogger,
parseVerbosityLevel,
setLogFilePath,
setVerbosityLevel,
VerbosityLevel,
} from './utils/logger.js';
import { generateSessionName } from './utils/session-naming.js';
import { generateTitleSequence } from './utils/terminal-title.js';
import { parseVerbosityFromEnv } from './utils/verbosity-parser.js';
import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js';
const logger = createLogger('fwd');
@ -33,7 +41,7 @@ function showUsage() {
console.log('');
console.log('Usage:');
console.log(
' pnpm exec tsx src/fwd.ts [--session-id <id>] [--title-mode <mode>] <command> [args...]'
' pnpm exec tsx src/fwd.ts [--session-id <id>] [--title-mode <mode>] [--verbosity <level>] <command> [args...]'
);
console.log('');
console.log('Options:');
@ -41,6 +49,12 @@ function showUsage() {
console.log(' --title-mode <mode> Terminal title mode: none, filter, static, dynamic');
console.log(' (defaults to none for most commands, dynamic for claude)');
console.log(' --update-title <title> Update session title and exit (requires --session-id)');
console.log(
' --verbosity <level> Set logging verbosity: silent, error, warn, info, verbose, debug'
);
console.log(' (defaults to error)');
console.log(' --log-file <path> Override default log file location');
console.log(' (defaults to ~/.vibetunnel/log.txt)');
console.log('');
console.log('Title Modes:');
console.log(' none - No title management (default)');
@ -48,9 +62,23 @@ function showUsage() {
console.log(' static - Show working directory and command');
console.log(' dynamic - Show directory, command, and activity (auto-selected for claude)');
console.log('');
console.log('Verbosity Levels:');
console.log(` ${chalk.gray('silent')} - No output except critical errors`);
console.log(` ${chalk.red('error')} - Only errors ${chalk.gray('(default)')}`);
console.log(` ${chalk.yellow('warn')} - Errors and warnings`);
console.log(` ${chalk.green('info')} - Errors, warnings, and informational messages`);
console.log(` ${chalk.blue('verbose')} - All messages except debug`);
console.log(` ${chalk.magenta('debug')} - All messages including debug`);
console.log('');
console.log(
`Quick verbosity: ${chalk.cyan('-q (quiet), -v (verbose), -vv (extra), -vvv (debug)')}`
);
console.log('');
console.log('Environment Variables:');
console.log(' VIBETUNNEL_TITLE_MODE=<mode> Set default title mode');
console.log(' VIBETUNNEL_CLAUDE_DYNAMIC_TITLE=1 Force dynamic title for Claude');
console.log(' VIBETUNNEL_LOG_LEVEL=<level> Set default verbosity level');
console.log(' VIBETUNNEL_DEBUG=1 Enable debug mode (legacy)');
console.log('');
console.log('Examples:');
console.log(' pnpm exec tsx src/fwd.ts claude --resume');
@ -58,16 +86,19 @@ function showUsage() {
console.log(' pnpm exec tsx src/fwd.ts --title-mode filter vim');
console.log(' pnpm exec tsx src/fwd.ts --session-id abc123 claude');
console.log(' pnpm exec tsx src/fwd.ts --update-title "New Title" --session-id abc123');
console.log(' pnpm exec tsx src/fwd.ts --verbosity silent npm test');
console.log('');
console.log('The command will be spawned in the current working directory');
console.log('and managed through the VibeTunnel PTY infrastructure.');
}
export async function startVibeTunnelForward(args: string[]) {
// Log startup with version (logger already initialized in cli.ts)
if (process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true') {
// Parse verbosity from environment variables
let verbosityLevel = parseVerbosityFromEnv();
// Set debug mode on logger for backward compatibility
if (verbosityLevel === VerbosityLevel.DEBUG) {
logger.setDebugMode(true);
logger.warn('Debug mode enabled');
}
// Parse command line arguments
@ -84,6 +115,7 @@ export async function startVibeTunnelForward(args: string[]) {
let sessionId: string | undefined;
let titleMode: TitleMode = TitleMode.NONE;
let updateTitle: string | undefined;
let logFilePath: string | undefined;
let remainingArgs = args;
// Check environment variables for title mode
@ -123,6 +155,20 @@ export async function startVibeTunnelForward(args: string[]) {
process.exit(1);
}
remainingArgs = remainingArgs.slice(2);
} else if (remainingArgs[0] === '--verbosity' && remainingArgs.length > 1) {
const parsedLevel = parseVerbosityLevel(remainingArgs[1]);
if (parsedLevel !== undefined) {
verbosityLevel = parsedLevel;
} else {
logger.error(`Invalid verbosity level: ${remainingArgs[1]}`);
logger.error('Valid levels: silent, error, warn, info, verbose, debug');
closeLogger();
process.exit(1);
}
remainingArgs = remainingArgs.slice(2);
} else if (remainingArgs[0] === '--log-file' && remainingArgs.length > 1) {
logFilePath = remainingArgs[1];
remainingArgs = remainingArgs.slice(2);
} else {
// Not a flag, must be the start of the command
break;
@ -135,6 +181,20 @@ export async function startVibeTunnelForward(args: string[]) {
remainingArgs = remainingArgs.slice(1);
}
// Apply log file path if set
if (logFilePath !== undefined) {
setLogFilePath(logFilePath);
logger.debug(`Log file path set to: ${logFilePath}`);
}
// Apply verbosity level if set
if (verbosityLevel !== undefined) {
setVerbosityLevel(verbosityLevel);
if (verbosityLevel >= VerbosityLevel.INFO) {
logger.log(`Verbosity level set to: ${VerbosityLevel[verbosityLevel].toLowerCase()}`);
}
}
// Handle special case: --update-title mode
if (updateTitle !== undefined) {
if (!sessionId) {

View file

@ -1029,7 +1029,7 @@ export async function createApp(): Promise<AppInstance> {
// Start mDNS advertisement if enabled
if (config.enableMDNS) {
mdnsService.startAdvertising(actualPort).catch((err) => {
logger.error('Failed to start mDNS advertisement:', err);
logger.warn('Failed to start mDNS advertisement:', err);
});
} else {
logger.debug('mDNS advertisement disabled');

View file

@ -52,11 +52,11 @@ export class MDNSService {
});
this.service.on('error', (...args: unknown[]) => {
log.error('mDNS service error:', args[0]);
log.warn('mDNS service error:', args[0]);
});
}
} catch (error) {
log.error('Failed to start mDNS advertisement:', error);
log.warn('Failed to start mDNS advertisement:', error);
throw error;
}
}
@ -92,7 +92,7 @@ export class MDNSService {
this.isAdvertising = false;
log.log('Stopped mDNS advertisement');
} catch (error) {
log.error('Error stopping mDNS advertisement:', error);
log.warn('Error stopping mDNS advertisement:', error);
}
}

View file

@ -5,11 +5,78 @@ import * as path from 'path';
// Log file path
const LOG_DIR = path.join(os.homedir(), '.vibetunnel');
const LOG_FILE = path.join(LOG_DIR, 'log.txt');
let LOG_FILE = path.join(LOG_DIR, 'log.txt');
// Debug mode flag
/**
* Set custom log file path
*/
export function setLogFilePath(filePath: string): void {
// Close existing file handle if open
if (logFileHandle) {
logFileHandle.end();
logFileHandle = null;
}
LOG_FILE = filePath;
// Ensure directory exists
const dir = path.dirname(LOG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Re-open log file at new location
try {
logFileHandle = fs.createWriteStream(LOG_FILE, { flags: 'a' });
} catch (error) {
console.error('Failed to open log file at new location:', error);
}
}
// Verbosity levels
export enum VerbosityLevel {
SILENT = 0, // No console output (logs to file only)
ERROR = 1, // Errors only (default)
WARN = 2, // Errors and warnings
INFO = 3, // Errors, warnings, and info
VERBOSE = 4, // All except debug
DEBUG = 5, // Everything
}
/**
* Type-safe mapping of string names to verbosity levels
*/
export const VERBOSITY_MAP: Record<string, VerbosityLevel> = {
silent: VerbosityLevel.SILENT,
error: VerbosityLevel.ERROR,
warn: VerbosityLevel.WARN,
info: VerbosityLevel.INFO,
verbose: VerbosityLevel.VERBOSE,
debug: VerbosityLevel.DEBUG,
} as const;
// Current verbosity level
let verbosityLevel: VerbosityLevel = VerbosityLevel.ERROR;
// Debug mode flag (kept for backward compatibility)
// biome-ignore lint/correctness/noUnusedVariables: Used for backward compatibility
let debugMode = false;
/**
* Type guard to check if a string is a valid VerbosityLevel key
*/
export function isVerbosityLevel(value: string): value is keyof typeof VERBOSITY_MAP {
return value.toLowerCase() in VERBOSITY_MAP;
}
/**
* Parse a string to VerbosityLevel, returns undefined if invalid
*/
export function parseVerbosityLevel(value: string): VerbosityLevel | undefined {
const normalized = value.toLowerCase();
return VERBOSITY_MAP[normalized];
}
// File handle for log file
let logFileHandle: fs.WriteStream | null = null;
@ -20,9 +87,17 @@ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
/**
* Initialize the logger - creates log directory and file
*/
export function initLogger(debug: boolean = false): void {
export function initLogger(debug: boolean = false, verbosity?: VerbosityLevel): void {
debugMode = debug;
// Set verbosity level
if (verbosity !== undefined) {
verbosityLevel = verbosity;
} else if (debug) {
// If debug mode is enabled, set verbosity to DEBUG
verbosityLevel = VerbosityLevel.DEBUG;
}
// If already initialized, just update debug mode and return
if (logFileHandle) {
return;
@ -39,9 +114,9 @@ export function initLogger(debug: boolean = false): void {
if (fs.existsSync(LOG_FILE)) {
fs.unlinkSync(LOG_FILE);
}
} catch (unlinkError) {
} catch {
// Ignore unlink errors - file might not exist or be locked
console.debug('Could not delete old log file:', unlinkError);
// Don't log here as logger isn't fully initialized yet
}
// Create new log file write stream
@ -132,16 +207,72 @@ function writeToFile(message: string): void {
*/
export function setDebugMode(enabled: boolean): void {
debugMode = enabled;
// If enabling debug mode, also set verbosity to DEBUG
if (enabled) {
verbosityLevel = VerbosityLevel.DEBUG;
}
}
/**
* Set verbosity level
*/
export function setVerbosityLevel(level: VerbosityLevel): void {
verbosityLevel = level;
// Update debug mode flag for backward compatibility
debugMode = level >= VerbosityLevel.DEBUG;
}
/**
* Get current verbosity level
*/
export function getVerbosityLevel(): VerbosityLevel {
return verbosityLevel;
}
/**
* Check if debug logging is enabled
*/
export function isDebugEnabled(): boolean {
return verbosityLevel >= VerbosityLevel.DEBUG;
}
/**
* Check if verbose logging is enabled
*/
export function isVerbose(): boolean {
return verbosityLevel >= VerbosityLevel.VERBOSE;
}
/**
* Check if a log level should be output based on current verbosity
*/
function shouldLog(level: string): boolean {
switch (level) {
case 'ERROR':
return verbosityLevel >= VerbosityLevel.ERROR;
case 'WARN':
return verbosityLevel >= VerbosityLevel.WARN;
case 'LOG':
return verbosityLevel >= VerbosityLevel.INFO;
case 'DEBUG':
return verbosityLevel >= VerbosityLevel.DEBUG;
default:
return true;
}
}
/**
* Log from a specific module (used by client-side API)
*/
export function logFromModule(level: string, module: string, args: unknown[]): void {
if (level === 'DEBUG' && !debugMode) return;
const { console: consoleMsg, file: fileMsg } = formatMessage(level, module, args);
// Always write to file
writeToFile(fileMsg);
// Check if we should output to console based on verbosity
if (!shouldLog(level)) return;
// Log to console
switch (level) {
case 'ERROR':
@ -153,9 +284,6 @@ export function logFromModule(level: string, module: string, args: unknown[]): v
default:
console.log(consoleMsg);
}
// Log to file
writeToFile(fileMsg);
}
/**
@ -167,10 +295,22 @@ export function createLogger(moduleName: string) {
const prefixedModuleName = moduleName.startsWith('[') ? moduleName : `[SRV] ${moduleName}`;
return {
/**
* @deprecated Use info() instead for clarity
*/
log: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args);
console.log(consoleMsg);
writeToFile(fileMsg);
writeToFile(fileMsg); // Always write to file
if (shouldLog('LOG')) {
console.log(consoleMsg);
}
},
info: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args);
writeToFile(fileMsg); // Always write to file
if (shouldLog('LOG')) {
console.log(consoleMsg);
}
},
warn: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage(
@ -178,8 +318,10 @@ export function createLogger(moduleName: string) {
prefixedModuleName,
args
);
console.warn(consoleMsg);
writeToFile(fileMsg);
writeToFile(fileMsg); // Always write to file
if (shouldLog('WARN')) {
console.warn(consoleMsg);
}
},
error: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage(
@ -187,20 +329,23 @@ export function createLogger(moduleName: string) {
prefixedModuleName,
args
);
console.error(consoleMsg);
writeToFile(fileMsg);
writeToFile(fileMsg); // Always write to file
if (shouldLog('ERROR')) {
console.error(consoleMsg);
}
},
debug: (...args: unknown[]) => {
if (debugMode) {
const { console: consoleMsg, file: fileMsg } = formatMessage(
'DEBUG',
prefixedModuleName,
args
);
const { console: consoleMsg, file: fileMsg } = formatMessage(
'DEBUG',
prefixedModuleName,
args
);
writeToFile(fileMsg); // Always write to file
if (shouldLog('DEBUG')) {
console.log(consoleMsg);
writeToFile(fileMsg);
}
},
setDebugMode: (enabled: boolean) => setDebugMode(enabled),
setVerbosity: (level: VerbosityLevel) => setVerbosityLevel(level),
};
}

View file

@ -0,0 +1,26 @@
import { parseVerbosityLevel, VerbosityLevel } from './logger.js';
/**
* Parse verbosity level from environment variables
* Checks VIBETUNNEL_LOG_LEVEL first, then falls back to VIBETUNNEL_DEBUG for backward compatibility
* @returns The parsed verbosity level or undefined if not set
*/
export function parseVerbosityFromEnv(): VerbosityLevel | undefined {
// Check VIBETUNNEL_LOG_LEVEL first
if (process.env.VIBETUNNEL_LOG_LEVEL) {
const parsed = parseVerbosityLevel(process.env.VIBETUNNEL_LOG_LEVEL);
if (parsed !== undefined) {
return parsed;
}
// Warn about invalid value
console.warn(`Invalid VIBETUNNEL_LOG_LEVEL: ${process.env.VIBETUNNEL_LOG_LEVEL}`);
console.warn('Valid levels: silent, error, warn, info, verbose, debug');
}
// Check legacy VIBETUNNEL_DEBUG for backward compatibility
if (process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true') {
return VerbosityLevel.DEBUG;
}
return undefined;
}

View file

@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VerbosityLevel } from '../../server/utils/logger';
import { parseVerbosityFromEnv } from '../../server/utils/verbosity-parser';
describe('CLI Verbosity Environment Variables', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset modules to ensure fresh imports
vi.resetModules();
// Clone the original env
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
});
async function getVerbosityFromEnv(): Promise<VerbosityLevel | undefined> {
// Use the shared parser
return parseVerbosityFromEnv();
}
describe('VIBETUNNEL_LOG_LEVEL', () => {
it('should parse silent level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'silent';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.SILENT);
});
it('should parse error level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'error';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.ERROR);
});
it('should parse warn level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'warn';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.WARN);
});
it('should parse info level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'info';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.INFO);
});
it('should parse verbose level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'verbose';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.VERBOSE);
});
it('should parse debug level', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'debug';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.DEBUG);
});
it('should be case-insensitive', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'DEBUG';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.DEBUG);
process.env.VIBETUNNEL_LOG_LEVEL = 'WaRn';
const level2 = await getVerbosityFromEnv();
expect(level2).toBe(VerbosityLevel.WARN);
});
it('should return undefined for invalid values', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'invalid';
const level = await getVerbosityFromEnv();
expect(level).toBeUndefined();
});
});
describe('VIBETUNNEL_DEBUG (backward compatibility)', () => {
it('should enable debug mode with value 1', async () => {
process.env.VIBETUNNEL_DEBUG = '1';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.DEBUG);
});
it('should enable debug mode with value true', async () => {
process.env.VIBETUNNEL_DEBUG = 'true';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.DEBUG);
});
it('should not enable debug mode with other values', async () => {
process.env.VIBETUNNEL_DEBUG = '0';
const level = await getVerbosityFromEnv();
expect(level).toBeUndefined();
process.env.VIBETUNNEL_DEBUG = 'false';
const level2 = await getVerbosityFromEnv();
expect(level2).toBeUndefined();
});
it('should NOT override valid VIBETUNNEL_LOG_LEVEL when set', async () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'warn';
process.env.VIBETUNNEL_DEBUG = '1';
const level = await getVerbosityFromEnv();
expect(level).toBe(VerbosityLevel.WARN); // VIBETUNNEL_LOG_LEVEL takes precedence
});
});
});

View file

@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createLogger,
getVerbosityLevel,
initLogger,
isDebugEnabled,
isVerbose,
isVerbosityLevel,
parseVerbosityLevel,
setVerbosityLevel,
VERBOSITY_MAP,
VerbosityLevel,
} from '../../server/utils/logger';
describe('Logger Verbosity Control', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Reset to default verbosity
setVerbosityLevel(VerbosityLevel.ERROR);
// Spy on console methods
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Verbosity Level Management', () => {
it('should default to ERROR level', () => {
expect(getVerbosityLevel()).toBe(VerbosityLevel.ERROR);
});
it('should set and get verbosity level', () => {
setVerbosityLevel(VerbosityLevel.INFO);
expect(getVerbosityLevel()).toBe(VerbosityLevel.INFO);
setVerbosityLevel(VerbosityLevel.DEBUG);
expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG);
});
it('should initialize with custom verbosity', () => {
initLogger(false, VerbosityLevel.WARN);
expect(getVerbosityLevel()).toBe(VerbosityLevel.WARN);
});
it('should set DEBUG verbosity when debug mode is enabled', () => {
initLogger(true);
expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG);
});
});
describe('Console Output Control', () => {
const logger = createLogger('test-module');
it('should only show errors at ERROR level', () => {
setVerbosityLevel(VerbosityLevel.ERROR);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('error message'));
});
it('should show errors and warnings at WARN level', () => {
setVerbosityLevel(VerbosityLevel.WARN);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
it('should show info, warnings, and errors at INFO level', () => {
setVerbosityLevel(VerbosityLevel.INFO);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
it('should show all messages at DEBUG level', () => {
setVerbosityLevel(VerbosityLevel.DEBUG);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
expect(consoleLogSpy).toHaveBeenCalledTimes(2); // info + debug
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
it('should show nothing at SILENT level except critical errors', () => {
setVerbosityLevel(VerbosityLevel.SILENT);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
// At SILENT level, even regular errors are suppressed
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should show all except debug at VERBOSE level', () => {
setVerbosityLevel(VerbosityLevel.VERBOSE);
logger.log('info message');
logger.warn('warning message');
logger.error('error message');
logger.debug('debug message');
expect(consoleLogSpy).toHaveBeenCalledTimes(1); // only info
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
});
});
describe('Backward Compatibility', () => {
it('should support setDebugMode for backward compatibility', () => {
const logger = createLogger('test-module');
// Enable debug mode
logger.setDebugMode(true);
expect(getVerbosityLevel()).toBe(VerbosityLevel.DEBUG);
// Debug messages should now appear
logger.debug('debug message');
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('debug message'));
});
});
describe('Logger Instance Methods', () => {
it('should support per-logger verbosity control', () => {
const logger = createLogger('test-module');
// Set verbosity through logger instance
logger.setVerbosity(VerbosityLevel.WARN);
expect(getVerbosityLevel()).toBe(VerbosityLevel.WARN);
logger.log('info message');
logger.warn('warning message');
expect(consoleLogSpy).not.toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
});
});
describe('Logger Methods', () => {
const logger = createLogger('test-module');
it('should have info() method that works the same as log()', () => {
setVerbosityLevel(VerbosityLevel.INFO);
logger.info('info message via info()');
logger.log('info message via log()');
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('info message via info()')
);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('info message via log()'));
});
it('info() method should respect verbosity levels', () => {
setVerbosityLevel(VerbosityLevel.ERROR);
logger.info('hidden info message');
expect(consoleLogSpy).not.toHaveBeenCalled();
setVerbosityLevel(VerbosityLevel.INFO);
logger.info('visible info message');
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
});
});
describe('Type Guards and Parsing', () => {
it('should correctly identify valid verbosity level strings', () => {
expect(isVerbosityLevel('SILENT')).toBe(true);
expect(isVerbosityLevel('silent')).toBe(true);
expect(isVerbosityLevel('ERROR')).toBe(true);
expect(isVerbosityLevel('error')).toBe(true);
expect(isVerbosityLevel('WARN')).toBe(true);
expect(isVerbosityLevel('warn')).toBe(true);
expect(isVerbosityLevel('INFO')).toBe(true);
expect(isVerbosityLevel('info')).toBe(true);
expect(isVerbosityLevel('VERBOSE')).toBe(true);
expect(isVerbosityLevel('verbose')).toBe(true);
expect(isVerbosityLevel('DEBUG')).toBe(true);
expect(isVerbosityLevel('debug')).toBe(true);
});
it('should reject invalid verbosity level strings', () => {
expect(isVerbosityLevel('invalid')).toBe(false);
expect(isVerbosityLevel('trace')).toBe(false);
expect(isVerbosityLevel('log')).toBe(false);
expect(isVerbosityLevel('')).toBe(false);
expect(isVerbosityLevel('123')).toBe(false);
});
it('should parse valid verbosity levels correctly', () => {
expect(parseVerbosityLevel('silent')).toBe(VerbosityLevel.SILENT);
expect(parseVerbosityLevel('SILENT')).toBe(VerbosityLevel.SILENT);
expect(parseVerbosityLevel('error')).toBe(VerbosityLevel.ERROR);
expect(parseVerbosityLevel('ERROR')).toBe(VerbosityLevel.ERROR);
expect(parseVerbosityLevel('warn')).toBe(VerbosityLevel.WARN);
expect(parseVerbosityLevel('WARN')).toBe(VerbosityLevel.WARN);
expect(parseVerbosityLevel('info')).toBe(VerbosityLevel.INFO);
expect(parseVerbosityLevel('INFO')).toBe(VerbosityLevel.INFO);
expect(parseVerbosityLevel('verbose')).toBe(VerbosityLevel.VERBOSE);
expect(parseVerbosityLevel('VERBOSE')).toBe(VerbosityLevel.VERBOSE);
expect(parseVerbosityLevel('debug')).toBe(VerbosityLevel.DEBUG);
expect(parseVerbosityLevel('DEBUG')).toBe(VerbosityLevel.DEBUG);
});
it('should return undefined for invalid verbosity levels', () => {
expect(parseVerbosityLevel('invalid')).toBeUndefined();
expect(parseVerbosityLevel('trace')).toBeUndefined();
expect(parseVerbosityLevel('')).toBeUndefined();
});
it('should have correct VERBOSITY_MAP structure', () => {
expect(VERBOSITY_MAP).toEqual({
silent: VerbosityLevel.SILENT,
error: VerbosityLevel.ERROR,
warn: VerbosityLevel.WARN,
info: VerbosityLevel.INFO,
verbose: VerbosityLevel.VERBOSE,
debug: VerbosityLevel.DEBUG,
});
});
it('should parse using VERBOSITY_MAP', () => {
Object.entries(VERBOSITY_MAP).forEach(([key, value]) => {
expect(parseVerbosityLevel(key)).toBe(value);
expect(parseVerbosityLevel(key.toUpperCase())).toBe(value);
});
});
});
describe('Helper Functions', () => {
it('isDebugEnabled should return true only for DEBUG level', () => {
setVerbosityLevel(VerbosityLevel.SILENT);
expect(isDebugEnabled()).toBe(false);
setVerbosityLevel(VerbosityLevel.ERROR);
expect(isDebugEnabled()).toBe(false);
setVerbosityLevel(VerbosityLevel.WARN);
expect(isDebugEnabled()).toBe(false);
setVerbosityLevel(VerbosityLevel.INFO);
expect(isDebugEnabled()).toBe(false);
setVerbosityLevel(VerbosityLevel.VERBOSE);
expect(isDebugEnabled()).toBe(false);
setVerbosityLevel(VerbosityLevel.DEBUG);
expect(isDebugEnabled()).toBe(true);
});
it('isVerbose should return true for VERBOSE and DEBUG levels', () => {
setVerbosityLevel(VerbosityLevel.SILENT);
expect(isVerbose()).toBe(false);
setVerbosityLevel(VerbosityLevel.ERROR);
expect(isVerbose()).toBe(false);
setVerbosityLevel(VerbosityLevel.WARN);
expect(isVerbose()).toBe(false);
setVerbosityLevel(VerbosityLevel.INFO);
expect(isVerbose()).toBe(false);
setVerbosityLevel(VerbosityLevel.VERBOSE);
expect(isVerbose()).toBe(true);
setVerbosityLevel(VerbosityLevel.DEBUG);
expect(isVerbose()).toBe(true);
});
});
});

View file

@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { VerbosityLevel } from '../../server/utils/logger';
import { parseVerbosityFromEnv } from '../../server/utils/verbosity-parser';
describe('Verbosity Parser', () => {
const originalEnv = process.env;
beforeEach(() => {
// Reset environment variables before each test
process.env = { ...originalEnv };
delete process.env.VIBETUNNEL_LOG_LEVEL;
delete process.env.VIBETUNNEL_DEBUG;
});
afterEach(() => {
process.env = originalEnv;
});
describe('parseVerbosityFromEnv', () => {
it('should return undefined when no environment variables are set', () => {
expect(parseVerbosityFromEnv()).toBeUndefined();
});
it('should parse VIBETUNNEL_LOG_LEVEL correctly', () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'info';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.INFO);
process.env.VIBETUNNEL_LOG_LEVEL = 'DEBUG';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG);
process.env.VIBETUNNEL_LOG_LEVEL = 'silent';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.SILENT);
});
it('should return undefined for invalid VIBETUNNEL_LOG_LEVEL', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
process.env.VIBETUNNEL_LOG_LEVEL = 'invalid';
expect(parseVerbosityFromEnv()).toBeUndefined();
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid VIBETUNNEL_LOG_LEVEL: invalid');
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Valid levels: silent, error, warn, info, verbose, debug'
);
consoleWarnSpy.mockRestore();
});
it('should handle VIBETUNNEL_DEBUG=1', () => {
process.env.VIBETUNNEL_DEBUG = '1';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG);
});
it('should handle VIBETUNNEL_DEBUG=true', () => {
process.env.VIBETUNNEL_DEBUG = 'true';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG);
});
it('should ignore VIBETUNNEL_DEBUG when set to other values', () => {
process.env.VIBETUNNEL_DEBUG = '0';
expect(parseVerbosityFromEnv()).toBeUndefined();
process.env.VIBETUNNEL_DEBUG = 'false';
expect(parseVerbosityFromEnv()).toBeUndefined();
process.env.VIBETUNNEL_DEBUG = 'yes';
expect(parseVerbosityFromEnv()).toBeUndefined();
});
it('should prioritize VIBETUNNEL_LOG_LEVEL over VIBETUNNEL_DEBUG', () => {
process.env.VIBETUNNEL_LOG_LEVEL = 'warn';
process.env.VIBETUNNEL_DEBUG = '1';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.WARN);
});
it('should return DEBUG when VIBETUNNEL_LOG_LEVEL is invalid but VIBETUNNEL_DEBUG is set', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
process.env.VIBETUNNEL_LOG_LEVEL = 'invalid';
process.env.VIBETUNNEL_DEBUG = '1';
expect(parseVerbosityFromEnv()).toBe(VerbosityLevel.DEBUG);
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith('Invalid VIBETUNNEL_LOG_LEVEL: invalid');
consoleWarnSpy.mockRestore();
});
});
});

View file

@ -163,6 +163,8 @@ export async function startTestServer(config: ServerConfig = {}): Promise<Server
VIBETUNNEL_CONTROL_DIR: controlDir,
NODE_ENV: 'production',
FORCE_COLOR: '0',
// Ensure INFO verbosity for tests to see server startup messages
VIBETUNNEL_LOG_LEVEL: env.VIBETUNNEL_LOG_LEVEL || env.VIBETUNNEL_DEBUG ? undefined : 'info',
...env,
};