diff --git a/src/commands/index.tsx b/src/commands/index.tsx index 17cb0fb..0f7b599 100644 --- a/src/commands/index.tsx +++ b/src/commands/index.tsx @@ -45,6 +45,10 @@ export default function Index() { {' '} kiqr share Expose your local site at a public URL + + {' '} + kiqr xdebug Toggle Xdebug step-debugging (on/off) + {' '} kiqr wp Run a WP-CLI command diff --git a/src/commands/restart.tsx b/src/commands/restart.tsx index ae0f57c..5acdd43 100644 --- a/src/commands/restart.tsx +++ b/src/commands/restart.tsx @@ -21,7 +21,9 @@ import { getProjectRuntimeDir, getProjectUploadsDir, } from '../lib/paths.js'; +import {createRuntimeProvider} from '../lib/runtime.js'; import {detectTheme} from '../lib/theme.js'; +import {removeXdebugAssets, writeXdebugAssets} from '../lib/xdebug.js'; import type {LocalConfig, ProjectConfig} from '../types/config.js'; export const description = 'Restart the WordPress development environment'; @@ -110,6 +112,18 @@ export default function Restart() { setSiteUrl(`http://${hostname}:5477`); setPmaUrl(`http://${phpMyAdminHostname}:5477`); + const xdebugEnabled = lc.xdebug === true; + if (xdebugEnabled) { + const provider = createRuntimeProvider(lc.runtime); + const baseImage = provider.getWordPressImage( + pc.wordpress.version, + pc.wordpress.php_version, + ); + writeXdebugAssets(ref.current.runtimeDir, baseImage); + } else { + removeXdebugAssets(ref.current.runtimeDir); + } + writeProjectCompose( { projectSlug: pc.name, @@ -125,6 +139,7 @@ export default function Restart() { pluginsPath, uploadsPath, dataDir: ref.current.runtimeDir, + xdebugEnabled, }, ref.current.runtimeDir, ); diff --git a/src/commands/up.tsx b/src/commands/up.tsx index e488d62..c343185 100644 --- a/src/commands/up.tsx +++ b/src/commands/up.tsx @@ -29,7 +29,9 @@ import { getProjectRuntimeDir, getProjectUploadsDir, } from '../lib/paths.js'; +import {createRuntimeProvider} from '../lib/runtime.js'; import {detectTheme} from '../lib/theme.js'; +import {removeXdebugAssets, writeXdebugAssets} from '../lib/xdebug.js'; import type {LocalConfig, ProjectConfig} from '../types/config.js'; export const description = 'Start the WordPress development environment'; @@ -169,6 +171,18 @@ export default function Up() { setSiteUrl(`http://${hostname}:5477`); setPmaUrl(`http://${phpMyAdminHostname}:5477`); + const xdebugEnabled = lc.xdebug === true; + if (xdebugEnabled) { + const provider = createRuntimeProvider(lc.runtime); + const baseImage = provider.getWordPressImage( + pc.wordpress.version, + pc.wordpress.php_version, + ); + writeXdebugAssets(ref.current.runtimeDir, baseImage); + } else { + removeXdebugAssets(ref.current.runtimeDir); + } + writeProjectCompose( { projectSlug: pc.name, @@ -184,6 +198,7 @@ export default function Up() { pluginsPath, uploadsPath, dataDir: ref.current.runtimeDir, + xdebugEnabled, }, ref.current.runtimeDir, ); diff --git a/src/commands/xdebug.tsx b/src/commands/xdebug.tsx new file mode 100644 index 0000000..7689444 --- /dev/null +++ b/src/commands/xdebug.tsx @@ -0,0 +1,118 @@ +import {Box, Text, useApp} from 'ink'; +import {argument} from 'pastel'; +import {useEffect, useState} from 'react'; +import zod from 'zod'; +import {readLocalConfig, readProjectConfig, writeLocalConfig} from '../lib/config.js'; +import {getProjectRuntimeDir} from '../lib/paths.js'; +import {createRuntimeProvider} from '../lib/runtime.js'; +import { + removeXdebugAssets, + writeXdebugAssets, + XDEBUG_CLIENT_PORT, +} from '../lib/xdebug.js'; + +export const description = 'Toggle Xdebug step-debugging (on/off)'; + +export const args = zod.tuple([ + zod.enum(['on', 'off']).describe( + argument({ + name: 'state', + description: 'Whether to enable or disable Xdebug step-debugging', + }), + ), +]); + +type Props = { + args: zod.infer; +}; + +export default function Xdebug({args}: Props) { + const {exit} = useApp(); + const [state] = args; + const [error, setError] = useState(null); + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + let pc: ReturnType; + try { + pc = readProjectConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + exit(new Error()); + return; + } + if (!pc) { + setError('This project is not initialized. Run "kiqr init" first.'); + exit(new Error()); + return; + } + + const runtimeDir = getProjectRuntimeDir(pc.project_id); + let lc: ReturnType; + try { + lc = readLocalConfig(runtimeDir); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + exit(new Error()); + return; + } + if (!lc) { + setError('Local configuration not found. Run "kiqr init" first.'); + exit(new Error()); + return; + } + + const turnOn = state === 'on'; + lc.xdebug = turnOn; + writeLocalConfig(lc, runtimeDir); + + if (turnOn) { + const provider = createRuntimeProvider(lc.runtime); + const baseImage = provider.getWordPressImage( + pc.wordpress.version, + pc.wordpress.php_version, + ); + writeXdebugAssets(runtimeDir, baseImage); + } else { + removeXdebugAssets(runtimeDir); + } + + setEnabled(turnOn); + exit(); + }, []); + + if (error) { + return ( + + + Could not toggle Xdebug + + {error} + + ); + } + + return ( + + + Xdebug is now {enabled ? 'enabled' : 'disabled'}. + + + + Run kiqr restart to apply the change. + + {enabled && ( + <> + + The first start after enabling rebuilds the WordPress image, so it is slower + once. + + + Configure your IDE to listen for debug connections on port{' '} + {XDEBUG_CLIENT_PORT} (VS Code: PHP Debug, "Listen for Xdebug"). + + + )} + + ); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 407819a..c6f734b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -29,6 +29,7 @@ const localConfigSchema = z.object({ db_password: z.string().min(1, 'db_password is required'), login_secret: z.string().min(1, 'login_secret is required'), wordpress_version: versionString.optional(), + xdebug: z.boolean().optional(), created_at: z.string().min(1, 'created_at is required'), }) satisfies z.ZodType; diff --git a/src/lib/xdebug.ts b/src/lib/xdebug.ts new file mode 100644 index 0000000..a997f33 --- /dev/null +++ b/src/lib/xdebug.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const XDEBUG_DOCKERFILE = 'xdebug.Dockerfile'; +export const XDEBUG_INI = 'xdebug.ini'; + +// Xdebug's default DBGp port. IDEs (e.g. VS Code) listen here for incoming +// debug connections from the running PHP process. +export const XDEBUG_CLIENT_PORT = 9003; + +/** + * Build the PHP ini that configures Xdebug for step-debugging. + * + * `host.docker.internal` resolves to the host machine from inside the + * container (wired up via an `extra_hosts` entry on the wordpress service), + * so the debugger connects out to the IDE listening on the host. + */ +export function xdebugIni(): string { + return [ + 'zend_extension=xdebug', + 'xdebug.mode=debug', + 'xdebug.start_with_request=yes', + 'xdebug.client_host=host.docker.internal', + `xdebug.client_port=${XDEBUG_CLIENT_PORT}`, + 'xdebug.discover_client_host=true', + '', + ].join('\n'); +} + +/** + * Build a minimal Dockerfile that layers Xdebug onto the official `wordpress` + * image. The base image ships with the PECL/`docker-php-ext-*` helpers, so we + * install the extension and drop the ini into the PHP conf.d directory. The + * `zz-` prefix keeps it ordered last among conf.d files. + */ +export function buildXdebugDockerfile(baseImage: string): string { + return [ + `FROM ${baseImage}`, + 'RUN pecl install xdebug && docker-php-ext-enable xdebug', + `COPY ${XDEBUG_INI} /usr/local/etc/php/conf.d/zz-xdebug.ini`, + '', + ].join('\n'); +} + +/** + * Write the Dockerfile + ini into the runtime dir so the wordpress service can + * build from them. Returns the absolute path of the generated Dockerfile. + */ +export function writeXdebugAssets(runtimeDir: string, baseImage: string): string { + fs.mkdirSync(runtimeDir, {recursive: true}); + const dockerfilePath = path.join(runtimeDir, XDEBUG_DOCKERFILE); + const iniPath = path.join(runtimeDir, XDEBUG_INI); + fs.writeFileSync(dockerfilePath, buildXdebugDockerfile(baseImage), 'utf-8'); + fs.writeFileSync(iniPath, xdebugIni(), 'utf-8'); + return dockerfilePath; +} + +/** + * Remove the generated Xdebug assets from the runtime dir. Safe to call when + * they do not exist. + */ +export function removeXdebugAssets(runtimeDir: string): void { + fs.rmSync(path.join(runtimeDir, XDEBUG_DOCKERFILE), {force: true}); + fs.rmSync(path.join(runtimeDir, XDEBUG_INI), {force: true}); +} diff --git a/src/providers/BitnamiRuntimeProvider.ts b/src/providers/BitnamiRuntimeProvider.ts index 9253d68..c67e59f 100644 --- a/src/providers/BitnamiRuntimeProvider.ts +++ b/src/providers/BitnamiRuntimeProvider.ts @@ -1,3 +1,4 @@ +import {XDEBUG_DOCKERFILE} from '../lib/xdebug.js'; import type { ComposeService, DatabaseCredentials, @@ -58,9 +59,23 @@ export class BitnamiRuntimeProvider implements RuntimeProvider { dbPassword: config.dbPassword, }; + const baseImage = this.getWordPressImage(config.wordpressVersion, config.phpVersion); + + // When Xdebug is enabled we build a tiny image that layers the extension on + // top of the official `wordpress` image (which ships without Xdebug). The + // Dockerfile + ini are generated into the runtime dir by `writeXdebugAssets` + // before `kiqr up`/`restart` runs. `host.docker.internal` lets the debugger + // reach the IDE listening on the host machine. + const wordpressSource: Pick = config.xdebugEnabled + ? {build: {context: config.dataDir, dockerfile: XDEBUG_DOCKERFILE}} + : {image: baseImage}; + const wordpressExtraHosts = config.xdebugEnabled + ? [`${config.hostname}:host-gateway`, 'host.docker.internal:host-gateway'] + : [`${config.hostname}:host-gateway`]; + return { wordpress: { - image: this.getWordPressImage(config.wordpressVersion, config.phpVersion), + ...wordpressSource, environment: { ...this.getEnvironmentVariables(credentials), KIQR_LOGIN_SECRET: config.loginSecret, @@ -80,7 +95,7 @@ export class BitnamiRuntimeProvider implements RuntimeProvider { `traefik.http.routers.${config.projectSlug}-wp.entrypoints=web`, `traefik.http.services.${config.projectSlug}-wp.loadbalancer.server.port=80`, ], - extra_hosts: [`${config.hostname}:host-gateway`], + extra_hosts: wordpressExtraHosts, networks: [KIQR_NETWORK, 'default'], depends_on: ['mariadb'], restart: 'unless-stopped', diff --git a/src/providers/RuntimeProvider.ts b/src/providers/RuntimeProvider.ts index f961e58..e38f550 100644 --- a/src/providers/RuntimeProvider.ts +++ b/src/providers/RuntimeProvider.ts @@ -1,5 +1,11 @@ +export interface ComposeBuild { + context: string; + dockerfile: string; +} + export interface ComposeService { - image: string; + image?: string; + build?: ComposeBuild; environment?: Record; volumes?: string[]; labels?: string[]; @@ -25,6 +31,7 @@ export interface RuntimeConfig { pluginsPath: string; uploadsPath: string; dataDir: string; + xdebugEnabled: boolean; } export interface DatabaseCredentials { diff --git a/src/types/config.ts b/src/types/config.ts index 34c4b73..a4b77dc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -26,5 +26,8 @@ export interface LocalConfig { db_password: string; login_secret: string; wordpress_version?: string; + // Whether Xdebug step-debugging is enabled. Machine-local because debugging + // is a per-developer choice. Absent means off (backward compatible). + xdebug?: boolean; created_at: string; } diff --git a/tests/lib/compose.test.ts b/tests/lib/compose.test.ts index 181c8f1..81f5f4c 100644 --- a/tests/lib/compose.test.ts +++ b/tests/lib/compose.test.ts @@ -18,6 +18,7 @@ describe('generateProjectCompose', () => { pluginsPath: '/tmp/plugins', uploadsPath: '/tmp/uploads', dataDir: '/tmp/kiqr/projects/uuid', + xdebugEnabled: false, }; it('generates valid YAML with all three services', () => { diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index ec6d209..3125f0d 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -109,4 +109,47 @@ describe('local config', () => { expect(() => readLocalConfig(tmpDir)).toThrow(/config\.yaml is invalid/); expect(() => readLocalConfig(tmpDir)).toThrow(/db_password/); }); + + it('treats xdebug as optional (backward compatible) and defaults to absent', () => { + fs.writeFileSync( + path.join(tmpDir, 'config.yaml'), + 'project_id: test-uuid\n' + + 'runtime: bitnami\n' + + 'db_password: testpassword123456789012\n' + + 'login_secret: testsecret1234567890\n' + + 'created_at: 2026-05-29T00:00:00Z\n', + 'utf-8', + ); + const loaded = readLocalConfig(tmpDir); + expect(loaded).not.toBeNull(); + expect(loaded?.xdebug).toBeUndefined(); + }); + + it('round-trips the xdebug flag when set', () => { + const config: LocalConfig = { + project_id: 'test-uuid', + runtime: 'bitnami', + db_password: 'testpassword123456789012', + login_secret: 'testsecret1234567890', + xdebug: true, + created_at: '2026-05-29T00:00:00Z', + }; + writeLocalConfig(config, tmpDir); + expect(readLocalConfig(tmpDir)?.xdebug).toBe(true); + }); + + it('rejects a non-boolean xdebug value', () => { + fs.writeFileSync( + path.join(tmpDir, 'config.yaml'), + 'project_id: test-uuid\n' + + 'runtime: bitnami\n' + + 'db_password: testpassword123456789012\n' + + 'login_secret: testsecret1234567890\n' + + 'xdebug: maybe\n' + + 'created_at: 2026-05-29T00:00:00Z\n', + 'utf-8', + ); + expect(() => readLocalConfig(tmpDir)).toThrow(/config\.yaml is invalid/); + expect(() => readLocalConfig(tmpDir)).toThrow(/xdebug/); + }); }); diff --git a/tests/lib/xdebug.test.ts b/tests/lib/xdebug.test.ts new file mode 100644 index 0000000..256a898 --- /dev/null +++ b/tests/lib/xdebug.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import { + buildXdebugDockerfile, + removeXdebugAssets, + writeXdebugAssets, + XDEBUG_CLIENT_PORT, + XDEBUG_DOCKERFILE, + XDEBUG_INI, + xdebugIni, +} from '../../src/lib/xdebug.js'; + +describe('xdebugIni', () => { + it('enables debug mode with start_with_request', () => { + const ini = xdebugIni(); + expect(ini).toContain('zend_extension=xdebug'); + expect(ini).toContain('xdebug.mode=debug'); + expect(ini).toContain('xdebug.start_with_request=yes'); + }); + + it('points the client at the host on the standard DBGp port', () => { + const ini = xdebugIni(); + expect(ini).toContain('xdebug.client_host=host.docker.internal'); + expect(ini).toContain(`xdebug.client_port=${XDEBUG_CLIENT_PORT}`); + expect(XDEBUG_CLIENT_PORT).toBe(9003); + expect(ini).toContain('xdebug.discover_client_host=true'); + }); +}); + +describe('buildXdebugDockerfile', () => { + it('layers Xdebug onto the given base image', () => { + const dockerfile = buildXdebugDockerfile('wordpress:php8.3'); + expect(dockerfile).toContain('FROM wordpress:php8.3'); + expect(dockerfile).toContain('pecl install xdebug'); + expect(dockerfile).toContain('docker-php-ext-enable xdebug'); + }); + + it('copies the ini into the PHP conf.d directory', () => { + const dockerfile = buildXdebugDockerfile('wordpress:php8.4'); + expect(dockerfile).toContain( + `COPY ${XDEBUG_INI} /usr/local/etc/php/conf.d/zz-xdebug.ini`, + ); + }); + + it('uses whatever base image it is given', () => { + expect(buildXdebugDockerfile('wordpress:6.7-php8.2')).toContain( + 'FROM wordpress:6.7-php8.2', + ); + }); +}); + +describe('writeXdebugAssets / removeXdebugAssets', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiqr-xdebug-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('writes both the Dockerfile and ini into the runtime dir', () => { + const dockerfilePath = writeXdebugAssets(tmpDir, 'wordpress:php8.3'); + + expect(dockerfilePath).toBe(path.join(tmpDir, XDEBUG_DOCKERFILE)); + expect(fs.existsSync(path.join(tmpDir, XDEBUG_DOCKERFILE))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, XDEBUG_INI))).toBe(true); + + const dockerfile = fs.readFileSync(path.join(tmpDir, XDEBUG_DOCKERFILE), 'utf-8'); + const ini = fs.readFileSync(path.join(tmpDir, XDEBUG_INI), 'utf-8'); + expect(dockerfile).toBe(buildXdebugDockerfile('wordpress:php8.3')); + expect(ini).toBe(xdebugIni()); + }); + + it('creates the runtime dir if it does not exist', () => { + const nested = path.join(tmpDir, 'nested', 'runtime'); + writeXdebugAssets(nested, 'wordpress:php8.3'); + expect(fs.existsSync(path.join(nested, XDEBUG_DOCKERFILE))).toBe(true); + }); + + it('removes the generated assets', () => { + writeXdebugAssets(tmpDir, 'wordpress:php8.3'); + removeXdebugAssets(tmpDir); + expect(fs.existsSync(path.join(tmpDir, XDEBUG_DOCKERFILE))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, XDEBUG_INI))).toBe(false); + }); + + it('is a no-op when removing assets that do not exist', () => { + expect(() => removeXdebugAssets(tmpDir)).not.toThrow(); + }); +}); diff --git a/tests/providers/BitnamiRuntimeProvider.test.ts b/tests/providers/BitnamiRuntimeProvider.test.ts index 3775243..089e04d 100644 --- a/tests/providers/BitnamiRuntimeProvider.test.ts +++ b/tests/providers/BitnamiRuntimeProvider.test.ts @@ -74,6 +74,7 @@ describe('BitnamiRuntimeProvider', () => { pluginsPath: '/tmp/plugins', uploadsPath: '/tmp/uploads', dataDir: '/tmp/kiqr/projects/uuid', + xdebugEnabled: false, }); expect(services['wordpress']).toBeDefined(); expect(services['mariadb']).toBeDefined(); @@ -98,8 +99,63 @@ describe('BitnamiRuntimeProvider', () => { pluginsPath: '/tmp/plugins', uploadsPath: '/tmp/uploads', dataDir: '/tmp/kiqr/projects/uuid', + xdebugEnabled: false, }); expect(services['wordpress']!.image).toBe('wordpress:php8.4'); expect(services['wpcli']!.image).toBe('wordpress:cli-php8.4'); }); + + const baseConfig = { + projectSlug: 'my-theme', + themePath: '/home/user/my-theme', + themeSlug: 'my-theme', + hostname: 'my-theme.test.lvh.me', + phpMyAdminHostname: 'phpmyadmin.my-theme.test.lvh.me', + wordpressVersion: 'latest', + phpVersion: '8.3', + dbPassword: 'test_password', + loginSecret: 'secret123', + muPluginPath: '/tmp/mu-plugin.php', + pluginsPath: '/tmp/plugins', + uploadsPath: '/tmp/uploads', + dataDir: '/tmp/kiqr/projects/uuid', + }; + + it('uses an image (not a build) and only the hostname extra_host when xdebug is off', () => { + const services = provider.generateComposeServices({ + ...baseConfig, + xdebugEnabled: false, + }); + const wp = services['wordpress']!; + expect(wp.image).toBe('wordpress:php8.3'); + expect(wp.build).toBeUndefined(); + expect(wp.extra_hosts).toEqual(['my-theme.test.lvh.me:host-gateway']); + expect(wp.extra_hosts).not.toContain('host.docker.internal:host-gateway'); + }); + + it('builds from the generated Dockerfile and adds host.docker.internal when xdebug is on', () => { + const services = provider.generateComposeServices({ + ...baseConfig, + xdebugEnabled: true, + }); + const wp = services['wordpress']!; + expect(wp.image).toBeUndefined(); + expect(wp.build).toEqual({ + context: '/tmp/kiqr/projects/uuid', + dockerfile: 'xdebug.Dockerfile', + }); + expect(wp.extra_hosts).toContain('my-theme.test.lvh.me:host-gateway'); + expect(wp.extra_hosts).toContain('host.docker.internal:host-gateway'); + }); + + it('leaves the other services unchanged when xdebug is on', () => { + const services = provider.generateComposeServices({ + ...baseConfig, + xdebugEnabled: true, + }); + expect(services['wpcli']!.image).toBe('wordpress:cli-php8.3'); + expect(services['mariadb']!.image).toBe('mariadb:11.4'); + expect(services['phpmyadmin']!.image).toBe('phpmyadmin:5.2'); + expect(services['wpcli']!.build).toBeUndefined(); + }); });