diff --git a/src/commands/index.tsx b/src/commands/index.tsx
index f9a5566..17cb0fb 100644
--- a/src/commands/index.tsx
+++ b/src/commands/index.tsx
@@ -41,6 +41,10 @@ export default function Index() {
{' '}
kiqr open Open site, admin, or phpMyAdmin in browser
+
+ {' '}
+ kiqr share Expose your local site at a public URL
+
{' '}
kiqr wp Run a WP-CLI command
diff --git a/src/commands/share.tsx b/src/commands/share.tsx
new file mode 100644
index 0000000..d818487
--- /dev/null
+++ b/src/commands/share.tsx
@@ -0,0 +1,131 @@
+import {type ChildProcess, spawn} from 'node:child_process';
+import {Box, Text, useApp} from 'ink';
+import {useEffect, useRef, useState} from 'react';
+import {readProjectConfig} from '../lib/config.js';
+import {isContainerRunning} from '../lib/docker.js';
+import {buildProjectHostname} from '../lib/hostname.js';
+import {
+ buildCloudflaredArgs,
+ cloudflaredInstallHint,
+ isCloudflaredInstalled,
+ parseTunnelUrl,
+ TRAEFIK_BASE_URL,
+} from '../lib/share.js';
+import {containerNameFor} from '../lib/status.js';
+
+export const description =
+ 'Expose your local site at a public URL via a Cloudflare tunnel';
+
+export default function Share() {
+ const {exit} = useApp();
+ const [error, setError] = useState(null);
+ const [starting, setStarting] = useState(true);
+ const [publicUrl, setPublicUrl] = useState(null);
+ const childRef = useRef(null);
+ // Tracked via a ref because the `exit` handler closes over the effect's
+ // initial render and would otherwise never see the URL once it's found.
+ const foundUrlRef = useRef(false);
+
+ useEffect(() => {
+ let pc: ReturnType;
+ try {
+ pc = readProjectConfig();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ return;
+ }
+ if (!pc) {
+ setError('This project is not initialized. Run "kiqr init" then "kiqr up" first.');
+ return;
+ }
+
+ const wordpressContainer = containerNameFor(pc.project_id, 'wordpress');
+ if (!isContainerRunning(wordpressContainer)) {
+ setError('Your site is not running. Run "kiqr up" first.');
+ return;
+ }
+
+ if (!isCloudflaredInstalled()) {
+ setError(
+ `cloudflared is required to share your site but was not found on your PATH.\n\n${cloudflaredInstallHint()}`,
+ );
+ return;
+ }
+
+ const hostHeader = buildProjectHostname(pc.name);
+ const args = buildCloudflaredArgs(TRAEFIK_BASE_URL, hostHeader);
+ const child = spawn('cloudflared', args, {stdio: ['ignore', 'pipe', 'pipe']});
+ childRef.current = child;
+
+ const handleData = (chunk: Buffer) => {
+ const text = chunk.toString();
+ for (const line of text.split('\n')) {
+ const url = parseTunnelUrl(line);
+ if (url) {
+ foundUrlRef.current = true;
+ setPublicUrl(url);
+ setStarting(false);
+ }
+ }
+ };
+
+ child.stdout?.on('data', handleData);
+ child.stderr?.on('data', handleData);
+
+ child.on('error', (err) => {
+ setError(`Failed to start cloudflared: ${err.message}`);
+ });
+
+ child.on('exit', (code) => {
+ childRef.current = null;
+ if (code && code !== 0 && !foundUrlRef.current) {
+ setError(`cloudflared exited with code ${code}.`);
+ return;
+ }
+ exit();
+ });
+
+ return () => {
+ childRef.current?.kill('SIGINT');
+ childRef.current = null;
+ };
+ }, []);
+
+ if (error) {
+ return (
+
+
+ Cannot share your site
+
+ {error}
+
+ );
+ }
+
+ if (publicUrl) {
+ return (
+
+
+ Your site is live!
+
+
+ 🌐 Share this URL:
+
+ {publicUrl}
+
+
+
+ Anyone with this link can reach your local site. Press Ctrl+C to stop sharing.
+
+
+ );
+ }
+
+ return (
+
+
+ {starting ? 'Opening a Cloudflare tunnel...' : 'Connecting...'}
+
+
+ );
+}
diff --git a/src/lib/share.ts b/src/lib/share.ts
new file mode 100644
index 0000000..d8cdee3
--- /dev/null
+++ b/src/lib/share.ts
@@ -0,0 +1,85 @@
+import {execSync} from 'node:child_process';
+import {AGENT_PORT} from './agent.js';
+
+/**
+ * `kiqr share` exposes the developer's running local WordPress site at a public
+ * URL through a Cloudflare "quick tunnel" -- an instant, account-less tunnel
+ * provided by the external `cloudflared` binary.
+ *
+ * The crux is the kiqr agent's Traefik reverse proxy on {@link AGENT_PORT}: it
+ * routes purely by the `Host` header (`Host()`). A tunnel
+ * pointed naively at Traefik would forward `Host: .trycloudflare.com`,
+ * which matches no project router and falls through to the splash page. So we
+ * run cloudflared with `--http-host-header ` to make Traefik
+ * route correctly, while WordPress reads the real public host from the
+ * `X-Forwarded-Host` header cloudflared still forwards (see
+ * BitnamiRuntimeProvider's WORDPRESS_CONFIG_EXTRA).
+ */
+
+/** Base URL of the local kiqr agent (Traefik) that fronts every project. */
+export const TRAEFIK_BASE_URL = `http://localhost:${AGENT_PORT}`;
+
+export type Exec = (command: string) => void;
+
+const defaultExec: Exec = (command) => {
+ execSync(command, {stdio: 'pipe'});
+};
+
+/**
+ * Whether the `cloudflared` binary is available on PATH. The check is injected
+ * so it can be exercised in tests without a real binary.
+ */
+export function isCloudflaredInstalled(exec: Exec = defaultExec): boolean {
+ try {
+ exec('cloudflared --version');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Build the cloudflared argument vector for a quick tunnel that points at the
+ * local Traefik proxy but rewrites the upstream `Host` header so Traefik routes
+ * the request to the right project.
+ */
+export function buildCloudflaredArgs(localUrl: string, hostHeader: string): string[] {
+ return [
+ 'tunnel',
+ '--no-autoupdate',
+ '--url',
+ localUrl,
+ '--http-host-header',
+ hostHeader,
+ ];
+}
+
+const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
+
+/**
+ * Extract the public `https://.trycloudflare.com` URL from a single line
+ * of cloudflared output. cloudflared prints the URL inside an ASCII box with
+ * surrounding text and may include ANSI color codes, so we scan for the URL
+ * anywhere in the line. Returns null when the line contains no such URL.
+ */
+export function parseTunnelUrl(line: string): string | null {
+ const match = line.match(TUNNEL_URL_PATTERN);
+ return match ? match[0] : null;
+}
+
+/**
+ * Short, per-platform guidance for installing cloudflared. Defaults to the
+ * current process platform but is parameterized for testing.
+ */
+export function cloudflaredInstallHint(
+ platform: NodeJS.Platform = process.platform,
+): string {
+ switch (platform) {
+ case 'darwin':
+ return 'Install it with Homebrew:\n brew install cloudflared';
+ case 'win32':
+ return 'Install it with winget:\n winget install --id Cloudflare.cloudflared\nor download it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
+ default:
+ return 'Install it from your package manager or download it from\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/';
+ }
+}
diff --git a/src/providers/BitnamiRuntimeProvider.ts b/src/providers/BitnamiRuntimeProvider.ts
index 8f2d79f..9253d68 100644
--- a/src/providers/BitnamiRuntimeProvider.ts
+++ b/src/providers/BitnamiRuntimeProvider.ts
@@ -27,10 +27,27 @@ export class BitnamiRuntimeProvider implements RuntimeProvider {
WORDPRESS_DB_NAME: credentials.dbName,
WORDPRESS_DB_USER: credentials.dbUser,
WORDPRESS_DB_PASSWORD: credentials.dbPassword,
+ // WordPress generates absolute URLs (links, asset/script tags, redirects)
+ // from WP_HOME / WP_SITEURL. We derive them dynamically from the incoming
+ // request so the site works under both its local hostname and a public
+ // Cloudflare tunnel (`kiqr share`) without reconfiguring anything.
+ //
+ // When the request arrives through a tunnel, cloudflared forwards the
+ // original public hostname as `X-Forwarded-Host` and the scheme as
+ // `X-Forwarded-Proto`, while the actual `Host` header is rewritten to the
+ // local Traefik route. So prefer the forwarded values when present, and
+ // fall back to `HTTP_HOST` over plain http for normal local requests --
+ // which keeps local behavior identical to before.
+ //
+ // This string is embedded verbatim into the docker-compose YAML, where
+ // Compose treats `$` as an interpolation sigil. Every PHP `$` is written
+ // as `$$` so Compose unescapes it back to a single `$` for PHP.
WORDPRESS_CONFIG_EXTRA:
"define('KIQR_DEVELOPMENT', true); " +
- "define('WP_HOME', 'http://' . $$_SERVER['HTTP_HOST']); " +
- "define('WP_SITEURL', 'http://' . $$_SERVER['HTTP_HOST']);",
+ "$$host = !empty($$_SERVER['HTTP_X_FORWARDED_HOST']) ? $$_SERVER['HTTP_X_FORWARDED_HOST'] : $$_SERVER['HTTP_HOST']; " +
+ "$$proto = (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') ? 'https' : 'http'; " +
+ "define('WP_HOME', $$proto . '://' . $$host); " +
+ "define('WP_SITEURL', $$proto . '://' . $$host);",
};
}
diff --git a/tests/lib/share.test.ts b/tests/lib/share.test.ts
new file mode 100644
index 0000000..f01898a
--- /dev/null
+++ b/tests/lib/share.test.ts
@@ -0,0 +1,86 @@
+import {describe, expect, it} from 'vitest';
+import {AGENT_PORT} from '../../src/lib/agent.js';
+import {
+ buildCloudflaredArgs,
+ cloudflaredInstallHint,
+ isCloudflaredInstalled,
+ parseTunnelUrl,
+ TRAEFIK_BASE_URL,
+} from '../../src/lib/share.js';
+
+describe('TRAEFIK_BASE_URL', () => {
+ it('points at the local agent port', () => {
+ expect(TRAEFIK_BASE_URL).toBe(`http://localhost:${AGENT_PORT}`);
+ });
+});
+
+describe('buildCloudflaredArgs', () => {
+ it('builds a quick-tunnel arg vector with the forced host header', () => {
+ const args = buildCloudflaredArgs('http://localhost:5477', 'my-theme.host.lvh.me');
+ expect(args).toEqual([
+ 'tunnel',
+ '--no-autoupdate',
+ '--url',
+ 'http://localhost:5477',
+ '--http-host-header',
+ 'my-theme.host.lvh.me',
+ ]);
+ });
+});
+
+describe('parseTunnelUrl', () => {
+ it('extracts the URL from a clean line', () => {
+ const url = parseTunnelUrl('https://random-words-1234.trycloudflare.com');
+ expect(url).toBe('https://random-words-1234.trycloudflare.com');
+ });
+
+ it('extracts the URL from a line with box-drawing and surrounding text', () => {
+ const line = '2024-01-01 | INF | | https://big-blue-cat-42.trycloudflare.com | ';
+ expect(parseTunnelUrl(line)).toBe('https://big-blue-cat-42.trycloudflare.com');
+ });
+
+ it('extracts the URL from a line with ANSI color codes', () => {
+ const line = '[32mINF[0m https://shy-fox-99.trycloudflare.com';
+ expect(parseTunnelUrl(line)).toBe('https://shy-fox-99.trycloudflare.com');
+ });
+
+ it('returns null for a line with no tunnel URL', () => {
+ expect(parseTunnelUrl('Requesting new quick Tunnel on trycloudflare.com...')).toBe(
+ null,
+ );
+ expect(parseTunnelUrl('')).toBe(null);
+ });
+
+ it('does not match a plain http (non-https) trycloudflare URL', () => {
+ expect(parseTunnelUrl('http://insecure.trycloudflare.com')).toBe(null);
+ });
+});
+
+describe('isCloudflaredInstalled', () => {
+ it('returns true when the version command succeeds', () => {
+ const exec = () => {};
+ expect(isCloudflaredInstalled(exec)).toBe(true);
+ });
+
+ it('returns false when the version command throws', () => {
+ const exec = () => {
+ throw new Error('command not found: cloudflared');
+ };
+ expect(isCloudflaredInstalled(exec)).toBe(false);
+ });
+});
+
+describe('cloudflaredInstallHint', () => {
+ it('suggests Homebrew on macOS', () => {
+ expect(cloudflaredInstallHint('darwin')).toContain('brew install cloudflared');
+ });
+
+ it('suggests winget on Windows', () => {
+ const hint = cloudflaredInstallHint('win32');
+ expect(hint.toLowerCase()).toContain('winget');
+ });
+
+ it('points to the download page on Linux/other platforms', () => {
+ expect(cloudflaredInstallHint('linux')).toContain('developers.cloudflare.com');
+ });
+});
diff --git a/tests/providers/BitnamiRuntimeProvider.test.ts b/tests/providers/BitnamiRuntimeProvider.test.ts
index 8a4be64..3775243 100644
--- a/tests/providers/BitnamiRuntimeProvider.test.ts
+++ b/tests/providers/BitnamiRuntimeProvider.test.ts
@@ -34,6 +34,31 @@ describe('BitnamiRuntimeProvider', () => {
expect(env['WORDPRESS_DB_NAME']).toBe('wordpress');
});
+ it('derives dynamic URLs from X-Forwarded-Host with an HTTP_HOST fallback', () => {
+ const env = provider.getEnvironmentVariables({
+ dbName: 'wordpress',
+ dbUser: 'wordpress',
+ dbPassword: 'wordpress_password',
+ });
+ const extra = env['WORDPRESS_CONFIG_EXTRA']!;
+
+ // Prefers the public host forwarded by the tunnel, then falls back to the
+ // local Host header for normal requests (unchanged local behavior).
+ expect(extra).toContain("$$_SERVER['HTTP_X_FORWARDED_HOST']");
+ expect(extra).toContain("$$_SERVER['HTTP_HOST']");
+ // Honors the forwarded scheme so tunneled (https) requests generate https
+ // URLs, defaulting to http locally.
+ expect(extra).toContain("$$_SERVER['HTTP_X_FORWARDED_PROTO']");
+ expect(extra).toContain("=== 'https'");
+ // Still defines both URL constants and keeps the kiqr dev flag.
+ expect(extra).toContain("define('WP_HOME'");
+ expect(extra).toContain("define('WP_SITEURL'");
+ expect(extra).toContain("define('KIQR_DEVELOPMENT', true)");
+ // PHP `$` must stay escaped as `$$` so docker-compose unescapes it; there
+ // must be no lone `$` (a single, unescaped sigil) anywhere in the string.
+ expect(extra).not.toMatch(/(^|[^$])\$([^$]|$)/);
+ });
+
it('returns compose services with correct version', () => {
const services = provider.generateComposeServices({
projectSlug: 'my-theme',