diff --git a/docs/wizard.md b/docs/wizard.md index dfaec1d89..0ef0de786 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -83,6 +83,7 @@ It does **not** install or change anything on the remote host. 9) **Finish** - Summary + next steps, including iOS/Android/macOS apps for extra features. + - If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser. ## Remote mode diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 032400673..3cee67535 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -38,7 +38,9 @@ import { import { applyWizardMetadata, DEFAULT_WORKSPACE, + detectBrowserOpenSupport, ensureWorkspaceAndSessions, + formatControlUiSshHint, guardCancel, openUrl, printWizardHeader, @@ -630,21 +632,43 @@ export async function runConfigureWizard( "Control UI", ); - const wantsOpen = guardCancel( - await confirm({ - message: "Open Control UI now?", - initialValue: false, - }), - runtime, - ); - if (wantsOpen) { - const bind = nextConfig.gateway?.bind ?? "loopback"; - const links = resolveControlUiLinks({ - bind, - port: gatewayPort, - basePath: nextConfig.gateway?.controlUi?.basePath, - }); - await openUrl(links.httpUrl); + const browserSupport = await detectBrowserOpenSupport(); + if (!browserSupport.ok) { + note( + formatControlUiSshHint({ + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + token: gatewayToken, + }), + "Open Control UI", + ); + } else { + const wantsOpen = guardCancel( + await confirm({ + message: "Open Control UI now?", + initialValue: false, + }), + runtime, + ); + if (wantsOpen) { + const bind = nextConfig.gateway?.bind ?? "loopback"; + const links = resolveControlUiLinks({ + bind, + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + }); + const opened = await openUrl(links.httpUrl); + if (!opened) { + note( + formatControlUiSshHint({ + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + token: gatewayToken, + }), + "Open Control UI", + ); + } + } } outro("Configure complete."); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index bafa59b9b..8491723e7 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -83,18 +83,126 @@ export function applyWizardMetadata( }; } -export async function openUrl(url: string): Promise { +type BrowserOpenSupport = { + ok: boolean; + reason?: string; + command?: string; +}; + +let wslCached: boolean | null = null; + +async function isWSL(): Promise { + if (wslCached !== null) return wslCached; + if (process.platform !== "linux") { + wslCached = false; + return wslCached; + } + if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + wslCached = true; + return wslCached; + } + try { + const release = (await fs.readFile("/proc/version", "utf8")).toLowerCase(); + wslCached = release.includes("microsoft") || release.includes("wsl"); + } catch { + wslCached = false; + } + return wslCached; +} + +type BrowserOpenCommand = { + argv: string[] | null; + reason?: string; + command?: string; +}; + +async function resolveBrowserOpenCommand(): Promise { const platform = process.platform; - const command = - platform === "darwin" - ? ["open", url] - : platform === "win32" - ? ["cmd", "/c", "start", "", url] - : ["xdg-open", url]; + const hasDisplay = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + const isSsh = + Boolean(process.env.SSH_CLIENT) || + Boolean(process.env.SSH_TTY) || + Boolean(process.env.SSH_CONNECTION); + + if (isSsh && !hasDisplay && platform !== "win32") { + return { argv: null, reason: "ssh-no-display" }; + } + + if (platform === "win32") { + return { argv: ["cmd", "/c", "start", ""], command: "cmd" }; + } + + if (platform === "darwin") { + const hasOpen = await detectBinary("open"); + return hasOpen + ? { argv: ["open"], command: "open" } + : { argv: null, reason: "missing-open" }; + } + + if (platform === "linux") { + const wsl = await isWSL(); + if (!hasDisplay && !wsl) { + return { argv: null, reason: "no-display" }; + } + if (wsl) { + const hasWslview = await detectBinary("wslview"); + if (hasWslview) return { argv: ["wslview"], command: "wslview" }; + if (!hasDisplay) return { argv: null, reason: "wsl-no-wslview" }; + } + const hasXdgOpen = await detectBinary("xdg-open"); + return hasXdgOpen + ? { argv: ["xdg-open"], command: "xdg-open" } + : { argv: null, reason: "missing-xdg-open" }; + } + + return { argv: null, reason: "unsupported-platform" }; +} + +export async function detectBrowserOpenSupport(): Promise { + const resolved = await resolveBrowserOpenCommand(); + if (!resolved.argv) return { ok: false, reason: resolved.reason }; + return { ok: true, command: resolved.command }; +} + +export function formatControlUiSshHint(params: { + port: number; + basePath?: string; + token?: string; +}): string { + const basePath = normalizeControlUiBasePath(params.basePath); + const uiPath = basePath ? `${basePath}/` : "/"; + const localUrl = `http://localhost:${params.port}${uiPath}`; + const tokenParam = params.token ? `?token=${encodeURIComponent(params.token)}` : ""; + const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined; + const sshTarget = resolveSshTargetHint(); + return [ + "No GUI detected. Open from your computer:", + `ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`, + "Then open:", + localUrl, + authedUrl, + ] + .filter(Boolean) + .join("\n"); +} + +function resolveSshTargetHint(): string { + const user = process.env.USER || process.env.LOGNAME || "user"; + const conn = process.env.SSH_CONNECTION?.trim().split(/\s+/); + const host = conn?.[2] ?? ""; + return `${user}@${host}`; +} + +export async function openUrl(url: string): Promise { + const resolved = await resolveBrowserOpenCommand(); + if (!resolved.argv) return false; + const command = [...resolved.argv, url]; try { await runCommandWithTimeout(command, { timeoutMs: 5_000 }); + return true; } catch { // ignore; we still print the URL for manual open + return false; } } diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 9bd4f6d6e..728474511 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -14,7 +14,9 @@ import { import { applyWizardMetadata, DEFAULT_WORKSPACE, + detectBrowserOpenSupport, ensureWorkspaceAndSessions, + formatControlUiSshHint, handleReset, openUrl, printWizardHeader, @@ -522,21 +524,43 @@ export async function runOnboardingWizard( "Control UI", ); - const wantsOpen = await prompter.confirm({ - message: "Open Control UI now?", - initialValue: true, - }); - if (wantsOpen) { - const links = resolveControlUiLinks({ - bind, - port, - basePath: baseConfig.gateway?.controlUi?.basePath, + const browserSupport = await detectBrowserOpenSupport(); + if (!browserSupport.ok) { + await prompter.note( + formatControlUiSshHint({ + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + token: authMode === "token" ? gatewayToken : undefined, + }), + "Open Control UI", + ); + } else { + const wantsOpen = await prompter.confirm({ + message: "Open Control UI now?", + initialValue: true, }); - const tokenParam = - authMode === "token" && gatewayToken - ? `?token=${encodeURIComponent(gatewayToken)}` - : ""; - await openUrl(`${links.httpUrl}${tokenParam}`); + if (wantsOpen) { + const links = resolveControlUiLinks({ + bind, + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + }); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + const opened = await openUrl(`${links.httpUrl}${tokenParam}`); + if (!opened) { + await prompter.note( + formatControlUiSshHint({ + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + token: authMode === "token" ? gatewayToken : undefined, + }), + "Open Control UI", + ); + } + } } await prompter.outro("Onboarding complete.");