Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/commands/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export default function Index() {
{' '}
<Text bold>kiqr share</Text> Expose your local site at a public URL
</Text>
<Text>
{' '}
<Text bold>kiqr xdebug</Text> Toggle Xdebug step-debugging (on/off)
</Text>
<Text>
{' '}
<Text bold>kiqr wp</Text> Run a WP-CLI command
Expand Down
15 changes: 15 additions & 0 deletions src/commands/restart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -125,6 +139,7 @@ export default function Restart() {
pluginsPath,
uploadsPath,
dataDir: ref.current.runtimeDir,
xdebugEnabled,
},
ref.current.runtimeDir,
);
Expand Down
15 changes: 15 additions & 0 deletions src/commands/up.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -184,6 +198,7 @@ export default function Up() {
pluginsPath,
uploadsPath,
dataDir: ref.current.runtimeDir,
xdebugEnabled,
},
ref.current.runtimeDir,
);
Expand Down
118 changes: 118 additions & 0 deletions src/commands/xdebug.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof args>;
};

export default function Xdebug({args}: Props) {
const {exit} = useApp();
const [state] = args;
const [error, setError] = useState<string | null>(null);
const [enabled, setEnabled] = useState(false);

useEffect(() => {
let pc: ReturnType<typeof readProjectConfig>;
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<typeof readLocalConfig>;
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 (
<Box flexDirection="column" paddingTop={1}>
<Text bold color="red">
Could not toggle Xdebug
</Text>
<Text color="red">{error}</Text>
</Box>
);
}

return (
<Box flexDirection="column" paddingTop={1}>
<Text bold color={enabled ? 'green' : 'yellow'}>
Xdebug is now {enabled ? 'enabled' : 'disabled'}.
</Text>
<Text> </Text>
<Text>
Run <Text bold>kiqr restart</Text> to apply the change.
</Text>
{enabled && (
<>
<Text dimColor>
The first start after enabling rebuilds the WordPress image, so it is slower
once.
</Text>
<Text dimColor>
Configure your IDE to listen for debug connections on port{' '}
{XDEBUG_CLIENT_PORT} (VS Code: PHP Debug, "Listen for Xdebug").
</Text>
</>
)}
</Box>
);
}
1 change: 1 addition & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalConfig>;

Expand Down
65 changes: 65 additions & 0 deletions src/lib/xdebug.ts
Original file line number Diff line number Diff line change
@@ -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});
}
19 changes: 17 additions & 2 deletions src/providers/BitnamiRuntimeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {XDEBUG_DOCKERFILE} from '../lib/xdebug.js';
import type {
ComposeService,
DatabaseCredentials,
Expand Down Expand Up @@ -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<ComposeService, 'image' | 'build'> = 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,
Expand All @@ -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',
Expand Down
9 changes: 8 additions & 1 deletion src/providers/RuntimeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export interface ComposeBuild {
context: string;
dockerfile: string;
}

export interface ComposeService {
image: string;
image?: string;
build?: ComposeBuild;
environment?: Record<string, string>;
volumes?: string[];
labels?: string[];
Expand All @@ -25,6 +31,7 @@ export interface RuntimeConfig {
pluginsPath: string;
uploadsPath: string;
dataDir: string;
xdebugEnabled: boolean;
}

export interface DatabaseCredentials {
Expand Down
3 changes: 3 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions tests/lib/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading