diff --git a/apps/cli/lib/debug-log.ts b/apps/cli/lib/debug-log.ts new file mode 100644 index 0000000000..95a94113bf --- /dev/null +++ b/apps/cli/lib/debug-log.ts @@ -0,0 +1,62 @@ +import { appendFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +export interface DebugLogOptions { + defaultFilename: string; + enabledEnvVar: string; + logFileEnvVar?: string; + scope?: string; +} + +export interface DebugLogEntry { + event: string; + payload?: unknown; + scope?: string; + timestamp: string; +} + +export interface DebugLogger { + enabled: boolean; + log: ( event: string, payload?: unknown ) => void; + path: string; +} + +function isEnabled( value: string | undefined ): boolean { + if ( ! value ) { + return false; + } + + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true'; +} + +export function createDebugLogger( options: DebugLogOptions ): DebugLogger { + const enabled = isEnabled( process.env[ options.enabledEnvVar ] ); + const path = + process.env[ options.logFileEnvVar ?? `${ options.enabledEnvVar }_FILE` ] ?? + join( tmpdir(), options.defaultFilename ); + + return { + enabled, + path, + log: ( event, payload ) => { + if ( ! enabled ) { + return; + } + + const entry: DebugLogEntry = { + timestamp: new Date().toISOString(), + scope: options.scope, + event, + payload, + }; + + try { + appendFileSync( path, JSON.stringify( entry ) + '\n' ); + } catch { + return; + } + }, + }; +} diff --git a/apps/cli/lib/tests/debug-log.test.ts b/apps/cli/lib/tests/debug-log.test.ts new file mode 100644 index 0000000000..b9e76308ee --- /dev/null +++ b/apps/cli/lib/tests/debug-log.test.ts @@ -0,0 +1,121 @@ +import { appendFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock( 'fs', () => { + const mockedModule = { + appendFileSync: vi.fn(), + }; + return { + ...mockedModule, + default: mockedModule, + }; +} ); + +vi.mock( 'os', () => { + const mockedModule = { + tmpdir: vi.fn(), + }; + return { + ...mockedModule, + default: mockedModule, + }; +} ); + +describe( 'createDebugLogger', () => { + const originalEnv = { ...process.env }; + + beforeEach( () => { + vi.resetModules(); + vi.clearAllMocks(); + process.env = { ...originalEnv }; + vi.mocked( tmpdir ).mockReturnValue( '/tmp/studio-tests' ); + vi.useFakeTimers(); + vi.setSystemTime( new Date( '2026-03-12T10:00:00.000Z' ) ); + } ); + + afterEach( () => { + process.env = { ...originalEnv }; + vi.useRealTimers(); + } ); + + it( 'uses the default tmpdir path and skips writes when disabled', async () => { + delete process.env.STUDIO_AI_DEBUG; + + const { createDebugLogger } = await import( '../debug-log' ); + const logger = createDebugLogger( { + enabledEnvVar: 'STUDIO_AI_DEBUG', + defaultFilename: 'studio-ai-debug.log', + scope: 'todo-rendering', + } ); + + logger.log( 'todo_tool_use_received', { input: 'test' } ); + + expect( logger.enabled ).toBe( false ); + expect( logger.path ).toBe( join( '/tmp/studio-tests', 'studio-ai-debug.log' ) ); + expect( appendFileSync ).not.toHaveBeenCalled(); + } ); + + it( 'writes JSONL entries when enabled', async () => { + process.env.STUDIO_AI_DEBUG = ' TRUE '; + + const { createDebugLogger } = await import( '../debug-log' ); + const logger = createDebugLogger( { + enabledEnvVar: 'STUDIO_AI_DEBUG', + defaultFilename: 'studio-ai-debug.log', + scope: 'todo-rendering', + } ); + + logger.log( 'todo_tool_use_received', { input: 'test' } ); + + expect( logger.enabled ).toBe( true ); + expect( appendFileSync ).toHaveBeenCalledWith( + join( '/tmp/studio-tests', 'studio-ai-debug.log' ), + JSON.stringify( { + timestamp: '2026-03-12T10:00:00.000Z', + scope: 'todo-rendering', + event: 'todo_tool_use_received', + payload: { input: 'test' }, + } ) + '\n' + ); + } ); + + it( 'uses the derived file override env var by default', async () => { + process.env.STUDIO_AI_DEBUG = '1'; + process.env.STUDIO_AI_DEBUG_FILE = '/tmp/custom-debug.log'; + + const { createDebugLogger } = await import( '../debug-log' ); + const logger = createDebugLogger( { + enabledEnvVar: 'STUDIO_AI_DEBUG', + defaultFilename: 'studio-ai-debug.log', + } ); + + logger.log( 'todo_tool_use_received' ); + + expect( logger.path ).toBe( '/tmp/custom-debug.log' ); + expect( appendFileSync ).toHaveBeenCalledWith( + '/tmp/custom-debug.log', + JSON.stringify( { + timestamp: '2026-03-12T10:00:00.000Z', + scope: undefined, + event: 'todo_tool_use_received', + payload: undefined, + } ) + '\n' + ); + } ); + + it( 'uses an explicit log file env var override when provided', async () => { + process.env.STUDIO_AI_DEBUG = '1'; + process.env.STUDIO_AI_LOG_PATH = '/tmp/explicit-debug.log'; + + const { createDebugLogger } = await import( '../debug-log' ); + const logger = createDebugLogger( { + enabledEnvVar: 'STUDIO_AI_DEBUG', + defaultFilename: 'studio-ai-debug.log', + logFileEnvVar: 'STUDIO_AI_LOG_PATH', + } ); + + expect( logger.path ).toBe( '/tmp/explicit-debug.log' ); + } ); +} ); diff --git a/apps/studio/src/stores/provider-constants-slice.ts b/apps/studio/src/stores/provider-constants-slice.ts index 01c473ec16..f8487dcee2 100644 --- a/apps/studio/src/stores/provider-constants-slice.ts +++ b/apps/studio/src/stores/provider-constants-slice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION,