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();
+ });
});