feat: add gateway lock, shell completion, and tailscale serve (Tier 4 features 1-3)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
export interface TailscaleServeConfig {
|
||||
/** The gateway's local port (e.g. 18800). */
|
||||
localPort: number;
|
||||
/** Tailscale Serve HTTPS port (default 443). */
|
||||
servePort?: number;
|
||||
/** Custom hostname (default: machine hostname). */
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
/** Execute a command with a timeout, returning stdout. */
|
||||
function exec(cmd: string, args: string[], timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || error.message}`));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Tailscale CLI is available and the daemon is running.
|
||||
*/
|
||||
export async function isTailscaleAvailable(): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
const version = await exec('tailscale', ['version', '--short']);
|
||||
// Check if daemon is running by getting status
|
||||
await exec('tailscale', ['status', '--json']);
|
||||
return { available: true, version };
|
||||
} catch (err) {
|
||||
return { available: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MagicDNS hostname from Tailscale status.
|
||||
*/
|
||||
async function getTailscaleHostname(): Promise<string | undefined> {
|
||||
try {
|
||||
const statusJson = await exec('tailscale', ['status', '--json']);
|
||||
const status = JSON.parse(statusJson) as { Self?: { DNSName?: string } };
|
||||
const dnsName = status.Self?.DNSName;
|
||||
// DNSName ends with a trailing dot — strip it
|
||||
return dnsName?.replace(/\.$/, '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Tailscale Serve to expose the local gateway port on the tailnet.
|
||||
*
|
||||
* Runs: `tailscale serve --bg --https=<servePort> http://127.0.0.1:<localPort>`
|
||||
*/
|
||||
export async function startTailscaleServe(config: TailscaleServeConfig): Promise<string | undefined> {
|
||||
const servePort = config.servePort ?? 443;
|
||||
const target = `http://127.0.0.1:${config.localPort}`;
|
||||
|
||||
try {
|
||||
await exec('tailscale', ['serve', '--bg', `--https=${servePort}`, target]);
|
||||
const hostname = config.hostname ?? await getTailscaleHostname();
|
||||
if (hostname) {
|
||||
const url = servePort === 443 ? `https://${hostname}` : `https://${hostname}:${servePort}`;
|
||||
console.log(`Tailscale Serve: Flynn available at ${url}`);
|
||||
return url;
|
||||
}
|
||||
console.log('Tailscale Serve: started (could not determine hostname)');
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
console.error('Failed to start Tailscale Serve:', err instanceof Error ? err.message : err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Tailscale Serve for the configured port.
|
||||
*
|
||||
* Runs: `tailscale serve --https=<servePort> off`
|
||||
*/
|
||||
export async function stopTailscaleServe(config: TailscaleServeConfig): Promise<void> {
|
||||
const servePort = config.servePort ?? 443;
|
||||
|
||||
try {
|
||||
await exec('tailscale', ['serve', `--https=${servePort}`, 'off']);
|
||||
console.log('Tailscale Serve: stopped');
|
||||
} catch (err) {
|
||||
// Best effort — don't fail shutdown
|
||||
console.error('Failed to stop Tailscale Serve:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user