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 ```bash
# Run any command in the browser # Run any command in the browser
vt pnpm run dev 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 claude --dangerously-skip-permissions
vt --title-mode dynamic claude # See real-time Claude status
# Control terminal titles # Use your shell aliases
vt --title-mode static npm run dev # Shows path and command vt gs # Your 'git status' alias works!
vt --title-mode dynamic python app.py # Shows path, command, and activity vt claude-danger # Custom aliases are resolved
vt --title-mode filter vim # Blocks vim from changing title
# Shell aliases work automatically!
vt claude-danger # Your custom aliases are resolved
# Open an interactive shell # 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 ### 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 dynamic python app.py # Shows path, command, and activity
vt --title-mode filter vim # Blocks vim from changing title 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! # Shell aliases work automatically!
vt claude-danger # Your custom alias for claude --dangerously-skip-permissions 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" 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 ### Mac App Interoperability
The npm package is designed to work seamlessly alongside the Mac app: 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`. 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 ## Documentation
- [Keyboard Shortcuts](docs/keyboard-shortcuts.md) - Complete keyboard shortcut reference - [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 title <new title> # Inside a VibeTunnel session only
vt --help vt --help
QUICK VERBOSITY:
-q (quiet), -v (verbose), -vv (extra), -vvv (debug)
DESCRIPTION: DESCRIPTION:
This wrapper script allows VibeTunnel to see the output of commands by This wrapper script allows VibeTunnel to see the output of commands by
forwarding TTY data through the vibetunnel utility. When you run commands forwarding TTY data through the vibetunnel utility. When you run commands
@ -160,12 +163,18 @@ EXAMPLES:
vt -i # Launch current shell (short form) vt -i # Launch current shell (short form)
vt -S ls -la # List files without shell alias resolution vt -S ls -la # List files without shell alias resolution
vt title "My Project" # Update session title (inside session only) 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: OPTIONS:
--shell, -i Launch current shell (equivalent to vt $SHELL) --shell, -i Launch current shell (equivalent to vt $SHELL)
--no-shell-wrap, -S Execute command directly without shell wrapper --no-shell-wrap, -S Execute command directly without shell wrapper
--title-mode <mode> Terminal title mode (none, filter, static, dynamic) --title-mode <mode> Terminal title mode (none, filter, static, dynamic)
Default: none (dynamic for claude) 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 --help, -h Show this help message and exit
TITLE MODES: TITLE MODES:
@ -174,6 +183,20 @@ TITLE MODES:
static Show working directory and command in title static Show working directory and command in title
dynamic Show directory, command, and live activity status (default for web UI) 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: NOTE:
This script automatically detects and uses the best available VibeTunnel installation: This script automatically detects and uses the best available VibeTunnel installation:
- Mac app bundle (preferred on macOS) - Mac app bundle (preferred on macOS)
@ -236,15 +259,15 @@ resolve_command() {
case "$shell_name" in case "$shell_name" in
zsh) zsh)
# For zsh, we need interactive mode to get aliases # 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) bash)
# For bash, expand aliases in non-interactive mode # 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 # 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 esac
} }
@ -262,11 +285,27 @@ if [[ "$1" == "title" ]]; then
exit 1 exit 1
fi 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) # Handle --shell or -i option (launch current shell)
if [[ "$1" == "--shell" || "$1" == "-i" ]]; then if [[ "$1" == "--shell" || "$1" == "-i" ]]; then
shift shift
# Execute current shell through vibetunnel # Execute current shell through vibetunnel
exec "$0" "${SHELL:-/bin/bash}" "$@" exec "$0" ${VERBOSITY_ARGS:+$VERBOSITY_ARGS} "${SHELL:-/bin/bash}" "$@"
fi fi
# Handle --no-shell-wrap or -S option # Handle --no-shell-wrap or -S option
@ -287,12 +326,12 @@ fi
if [ $# -gt 0 ] && [[ "$1" != -* ]]; then if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
if [[ "$NO_SHELL_WRAP" == "true" ]]; then if [[ "$NO_SHELL_WRAP" == "true" ]]; then
# Execute directly without shell wrapper # 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 else
# Check if the first argument is a real binary # Check if the first argument is a real binary
if which "$1" >/dev/null 2>&1; then if which "$1" >/dev/null 2>&1; then
# It's a real binary, execute directly # 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 else
# Not a real binary, try alias resolution # Not a real binary, try alias resolution
resolve_command "$@" resolve_command "$@"
@ -300,5 +339,5 @@ if [ $# -gt 0 ] && [[ "$1" != -* ]]; then
fi fi
else else
# Run with fwd command (original behavior for options) # 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 fi

View file

@ -8,13 +8,18 @@ suppressXtermErrors();
import { startVibeTunnelForward } from './server/fwd.js'; import { startVibeTunnelForward } from './server/fwd.js';
import { startVibeTunnelServer } from './server/server.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'; import { VERSION } from './server/version.js';
// Initialize logger before anything else // 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'; const debugMode = process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true';
initLogger(debugMode);
initLogger(debugMode, verbosityLevel);
const logger = createLogger('cli'); const logger = createLogger('cli');
// Source maps are only included if built with --sourcemap flag // 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); process.exit(1);
}); });
} else { } 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(); startVibeTunnelServer();
} }
} }

View file

@ -21,9 +21,17 @@ import { SessionManager } from './pty/session-manager.js';
import { VibeTunnelSocketClient } from './pty/socket-client.js'; import { VibeTunnelSocketClient } from './pty/socket-client.js';
import { ActivityDetector } from './utils/activity-detector.js'; import { ActivityDetector } from './utils/activity-detector.js';
import { checkAndPatchClaude } from './utils/claude-patcher.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 { generateSessionName } from './utils/session-naming.js';
import { generateTitleSequence } from './utils/terminal-title.js'; import { generateTitleSequence } from './utils/terminal-title.js';
import { parseVerbosityFromEnv } from './utils/verbosity-parser.js';
import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js'; import { BUILD_DATE, GIT_COMMIT, VERSION } from './version.js';
const logger = createLogger('fwd'); const logger = createLogger('fwd');
@ -33,7 +41,7 @@ function showUsage() {
console.log(''); console.log('');
console.log('Usage:'); console.log('Usage:');
console.log( 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('');
console.log('Options:'); console.log('Options:');
@ -41,6 +49,12 @@ function showUsage() {
console.log(' --title-mode <mode> Terminal title mode: none, filter, static, dynamic'); 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(' (defaults to none for most commands, dynamic for claude)');
console.log(' --update-title <title> Update session title and exit (requires --session-id)'); 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('');
console.log('Title Modes:'); console.log('Title Modes:');
console.log(' none - No title management (default)'); console.log(' none - No title management (default)');
@ -48,9 +62,23 @@ function showUsage() {
console.log(' static - Show working directory and command'); console.log(' static - Show working directory and command');
console.log(' dynamic - Show directory, command, and activity (auto-selected for claude)'); console.log(' dynamic - Show directory, command, and activity (auto-selected for claude)');
console.log(''); 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('Environment Variables:');
console.log(' VIBETUNNEL_TITLE_MODE=<mode> Set default title mode'); 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_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('');
console.log('Examples:'); console.log('Examples:');
console.log(' pnpm exec tsx src/fwd.ts claude --resume'); 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 --title-mode filter vim');
console.log(' pnpm exec tsx src/fwd.ts --session-id abc123 claude'); 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 --update-title "New Title" --session-id abc123');
console.log(' pnpm exec tsx src/fwd.ts --verbosity silent npm test');
console.log(''); console.log('');
console.log('The command will be spawned in the current working directory'); console.log('The command will be spawned in the current working directory');
console.log('and managed through the VibeTunnel PTY infrastructure.'); console.log('and managed through the VibeTunnel PTY infrastructure.');
} }
export async function startVibeTunnelForward(args: string[]) { export async function startVibeTunnelForward(args: string[]) {
// Log startup with version (logger already initialized in cli.ts) // Parse verbosity from environment variables
if (process.env.VIBETUNNEL_DEBUG === '1' || process.env.VIBETUNNEL_DEBUG === 'true') { let verbosityLevel = parseVerbosityFromEnv();
// Set debug mode on logger for backward compatibility
if (verbosityLevel === VerbosityLevel.DEBUG) {
logger.setDebugMode(true); logger.setDebugMode(true);
logger.warn('Debug mode enabled');
} }
// Parse command line arguments // Parse command line arguments
@ -84,6 +115,7 @@ export async function startVibeTunnelForward(args: string[]) {
let sessionId: string | undefined; let sessionId: string | undefined;
let titleMode: TitleMode = TitleMode.NONE; let titleMode: TitleMode = TitleMode.NONE;
let updateTitle: string | undefined; let updateTitle: string | undefined;
let logFilePath: string | undefined;
let remainingArgs = args; let remainingArgs = args;
// Check environment variables for title mode // Check environment variables for title mode
@ -123,6 +155,20 @@ export async function startVibeTunnelForward(args: string[]) {
process.exit(1); process.exit(1);
} }
remainingArgs = remainingArgs.slice(2); 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 { } else {
// Not a flag, must be the start of the command // Not a flag, must be the start of the command
break; break;
@ -135,6 +181,20 @@ export async function startVibeTunnelForward(args: string[]) {
remainingArgs = remainingArgs.slice(1); 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 // Handle special case: --update-title mode
if (updateTitle !== undefined) { if (updateTitle !== undefined) {
if (!sessionId) { if (!sessionId) {

View file

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

View file

@ -52,11 +52,11 @@ export class MDNSService {
}); });
this.service.on('error', (...args: unknown[]) => { this.service.on('error', (...args: unknown[]) => {
log.error('mDNS service error:', args[0]); log.warn('mDNS service error:', args[0]);
}); });
} }
} catch (error) { } catch (error) {
log.error('Failed to start mDNS advertisement:', error); log.warn('Failed to start mDNS advertisement:', error);
throw error; throw error;
} }
} }
@ -92,7 +92,7 @@ export class MDNSService {
this.isAdvertising = false; this.isAdvertising = false;
log.log('Stopped mDNS advertisement'); log.log('Stopped mDNS advertisement');
} catch (error) { } 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 // Log file path
const LOG_DIR = path.join(os.homedir(), '.vibetunnel'); 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; 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 // File handle for log file
let logFileHandle: fs.WriteStream | null = null; 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 * 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; 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 already initialized, just update debug mode and return
if (logFileHandle) { if (logFileHandle) {
return; return;
@ -39,9 +114,9 @@ export function initLogger(debug: boolean = false): void {
if (fs.existsSync(LOG_FILE)) { if (fs.existsSync(LOG_FILE)) {
fs.unlinkSync(LOG_FILE); fs.unlinkSync(LOG_FILE);
} }
} catch (unlinkError) { } catch {
// Ignore unlink errors - file might not exist or be locked // 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 // Create new log file write stream
@ -132,16 +207,72 @@ function writeToFile(message: string): void {
*/ */
export function setDebugMode(enabled: boolean): void { export function setDebugMode(enabled: boolean): void {
debugMode = enabled; 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) * Log from a specific module (used by client-side API)
*/ */
export function logFromModule(level: string, module: string, args: unknown[]): void { export function logFromModule(level: string, module: string, args: unknown[]): void {
if (level === 'DEBUG' && !debugMode) return;
const { console: consoleMsg, file: fileMsg } = formatMessage(level, module, args); 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 // Log to console
switch (level) { switch (level) {
case 'ERROR': case 'ERROR':
@ -153,9 +284,6 @@ export function logFromModule(level: string, module: string, args: unknown[]): v
default: default:
console.log(consoleMsg); console.log(consoleMsg);
} }
// Log to file
writeToFile(fileMsg);
} }
/** /**
@ -167,10 +295,22 @@ export function createLogger(moduleName: string) {
const prefixedModuleName = moduleName.startsWith('[') ? moduleName : `[SRV] ${moduleName}`; const prefixedModuleName = moduleName.startsWith('[') ? moduleName : `[SRV] ${moduleName}`;
return { return {
/**
* @deprecated Use info() instead for clarity
*/
log: (...args: unknown[]) => { log: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args); const { console: consoleMsg, file: fileMsg } = formatMessage('LOG', prefixedModuleName, args);
console.log(consoleMsg); writeToFile(fileMsg); // Always write to file
writeToFile(fileMsg); 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[]) => { warn: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage( const { console: consoleMsg, file: fileMsg } = formatMessage(
@ -178,8 +318,10 @@ export function createLogger(moduleName: string) {
prefixedModuleName, prefixedModuleName,
args args
); );
console.warn(consoleMsg); writeToFile(fileMsg); // Always write to file
writeToFile(fileMsg); if (shouldLog('WARN')) {
console.warn(consoleMsg);
}
}, },
error: (...args: unknown[]) => { error: (...args: unknown[]) => {
const { console: consoleMsg, file: fileMsg } = formatMessage( const { console: consoleMsg, file: fileMsg } = formatMessage(
@ -187,20 +329,23 @@ export function createLogger(moduleName: string) {
prefixedModuleName, prefixedModuleName,
args args
); );
console.error(consoleMsg); writeToFile(fileMsg); // Always write to file
writeToFile(fileMsg); if (shouldLog('ERROR')) {
console.error(consoleMsg);
}
}, },
debug: (...args: unknown[]) => { debug: (...args: unknown[]) => {
if (debugMode) { const { console: consoleMsg, file: fileMsg } = formatMessage(
const { console: consoleMsg, file: fileMsg } = formatMessage( 'DEBUG',
'DEBUG', prefixedModuleName,
prefixedModuleName, args
args );
); writeToFile(fileMsg); // Always write to file
if (shouldLog('DEBUG')) {
console.log(consoleMsg); console.log(consoleMsg);
writeToFile(fileMsg);
} }
}, },
setDebugMode: (enabled: boolean) => setDebugMode(enabled), 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, VIBETUNNEL_CONTROL_DIR: controlDir,
NODE_ENV: 'production', NODE_ENV: 'production',
FORCE_COLOR: '0', 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, ...env,
}; };