From 1910daad4cb7bef965d028ac90a25b2ca3e79c5b Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Thu, 12 Mar 2026 09:56:42 +0100 Subject: [PATCH 1/3] Add a reusable CLI debug logger --- apps/cli/lib/debug-log.ts | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/cli/lib/debug-log.ts 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; + } + }, + }; +} From f1a4e902532d6d701ed7c918dd1046e890589315 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Thu, 12 Mar 2026 10:34:02 +0100 Subject: [PATCH 2/3] Add tests for CLI debug logger --- apps/cli/lib/tests/debug-log.test.ts | 120 ++++++++++++++++++ .../src/stores/provider-constants-slice.ts | 2 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 apps/cli/lib/tests/debug-log.test.ts 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..3c4274fdab --- /dev/null +++ b/apps/cli/lib/tests/debug-log.test.ts @@ -0,0 +1,120 @@ +import { appendFileSync } from 'fs'; +import { tmpdir } from 'os'; +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( '/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( + '/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, From 69d0f06b166b428ecffa16f025a881a2a85ee5a5 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Thu, 12 Mar 2026 10:47:03 +0100 Subject: [PATCH 3/3] Make debug log test path portable --- apps/cli/lib/tests/debug-log.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/tests/debug-log.test.ts b/apps/cli/lib/tests/debug-log.test.ts index 3c4274fdab..b9e76308ee 100644 --- a/apps/cli/lib/tests/debug-log.test.ts +++ b/apps/cli/lib/tests/debug-log.test.ts @@ -1,5 +1,6 @@ import { appendFileSync } from 'fs'; import { tmpdir } from 'os'; +import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock( 'fs', () => { @@ -52,7 +53,7 @@ describe( 'createDebugLogger', () => { logger.log( 'todo_tool_use_received', { input: 'test' } ); expect( logger.enabled ).toBe( false ); - expect( logger.path ).toBe( '/tmp/studio-tests/studio-ai-debug.log' ); + expect( logger.path ).toBe( join( '/tmp/studio-tests', 'studio-ai-debug.log' ) ); expect( appendFileSync ).not.toHaveBeenCalled(); } ); @@ -70,7 +71,7 @@ describe( 'createDebugLogger', () => { expect( logger.enabled ).toBe( true ); expect( appendFileSync ).toHaveBeenCalledWith( - '/tmp/studio-tests/studio-ai-debug.log', + join( '/tmp/studio-tests', 'studio-ai-debug.log' ), JSON.stringify( { timestamp: '2026-03-12T10:00:00.000Z', scope: 'todo-rendering',