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 = 'INF 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',