Skip to content
Merged
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 @@ -41,6 +41,10 @@ export default function Index() {
{' '}
<Text bold>kiqr open</Text> Open site, admin, or phpMyAdmin in browser
</Text>
<Text>
{' '}
<Text bold>kiqr share</Text> Expose your local site at a public URL
</Text>
<Text>
{' '}
<Text bold>kiqr wp</Text> Run a WP-CLI command
Expand Down
131 changes: 131 additions & 0 deletions src/commands/share.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [starting, setStarting] = useState(true);
const [publicUrl, setPublicUrl] = useState<string | null>(null);
const childRef = useRef<ChildProcess | null>(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<typeof readProjectConfig>;
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 (
<Box flexDirection="column" paddingTop={1}>
<Text bold color="red">
Cannot share your site
</Text>
<Text color="red">{error}</Text>
</Box>
);
}

if (publicUrl) {
return (
<Box flexDirection="column" paddingTop={1}>
<Text bold color="green">
Your site is live!
</Text>
<Text> </Text>
<Text>🌐 Share this URL:</Text>
<Text bold color="cyan">
{publicUrl}
</Text>
<Text> </Text>
<Text dimColor>
Anyone with this link can reach your local site. Press Ctrl+C to stop sharing.
</Text>
</Box>
);
}

return (
<Box flexDirection="column" paddingTop={1}>
<Text dimColor>
{starting ? 'Opening a Cloudflare tunnel...' : 'Connecting...'}
</Text>
</Box>
);
}
85 changes: 85 additions & 0 deletions src/lib/share.ts
Original file line number Diff line number Diff line change
@@ -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(<project hostname>)`). A tunnel
* pointed naively at Traefik would forward `Host: <random>.trycloudflare.com`,
* which matches no project router and falls through to the splash page. So we
* run cloudflared with `--http-host-header <project hostname>` 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://<sub>.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/';
}
}
21 changes: 19 additions & 2 deletions src/providers/BitnamiRuntimeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);",
};
}

Expand Down
86 changes: 86 additions & 0 deletions tests/lib/share.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
25 changes: 25 additions & 0 deletions tests/providers/BitnamiRuntimeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading