feat(remote): real QR codes, live tailscale URL, TUI status & shortcut
* Replace placeholder /remote/qr SVG (URL drawn as text) with real QR rendered via the qrcode package; add format=terminal returning ASCII QR for the TUI. * Resolve the public tailscale funnel URL from captured CLI output instead of constructing http://<hostname>:<port> from a configured hostname label — that label was never used by `tailscale funnel` and produced a non-public URL in the auth/QR link. * Drop hostname requirement from engine + UI; only target port matters. * Tighten tailscale parseReadiness to require a URL on the matched line so the tunnel manager doesn't lock in `running` before the URL line. * TUI: poll remote status, show ● tunnel indicator + URL in MainHeader, bind Ctrl+Q to a global QR overlay (terminal ASCII), and switch the in-Settings K shortcut to render the same ASCII QR. * Auto-poll remote status in the dashboard while in `starting`/`stopping` so the UI flips to running without reopening the modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -479,7 +479,6 @@ describe("Settings view", () => {
|
||||
await waitForFrameContains(lastFrame, "Short-lived expires:", 6000);
|
||||
|
||||
stdin.write("K");
|
||||
await waitForFrameContains(lastFrame, "QR text payload:", 6000);
|
||||
await waitForFrameContains(lastFrame, "ASCII-QR-PAYLOAD", 6000);
|
||||
unmount();
|
||||
});
|
||||
@@ -541,7 +540,7 @@ describe("Settings view", () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders SVG QR fallback instruction", async () => {
|
||||
it("renders ASCII QR payload when terminal format is returned", async () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
controller.setInteractiveData(makeInteractiveData({
|
||||
@@ -549,8 +548,8 @@ describe("Settings view", () => {
|
||||
getQrPayload: async () => ({
|
||||
url: "https://remote.example.com?token=svg",
|
||||
expiresAt: new Date().toISOString(),
|
||||
format: "image/svg",
|
||||
data: "<svg/>",
|
||||
format: "terminal",
|
||||
data: "▀▀▀ASCII-QR▀▀▀",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -561,7 +560,7 @@ describe("Settings view", () => {
|
||||
stdin.write("\u001B[C");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
stdin.write("K");
|
||||
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
|
||||
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -959,6 +959,20 @@ function MainHeader({ state }: { state: DashboardState }) {
|
||||
);
|
||||
})}
|
||||
<Box flexGrow={1} />
|
||||
{state.remoteStatus?.state === "running" && (
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text wrap="truncate-end" color="green" bold>● tunnel</Text>
|
||||
{state.remoteStatus.url && cols >= 100 && (
|
||||
<Text wrap="truncate-end" color="green"> {state.remoteStatus.url}</Text>
|
||||
)}
|
||||
{cols >= 80 && <Text wrap="truncate-end" dimColor> [^Q] QR</Text>}
|
||||
</Box>
|
||||
)}
|
||||
{state.remoteStatus?.state === "starting" && (
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text wrap="truncate-end" color="yellow">● tunnel starting…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{showHelpHint && <Box flexShrink={0}><Text wrap="truncate-end" dimColor>[?] help [q] quit</Text></Box>}
|
||||
</Box>
|
||||
);
|
||||
@@ -2308,16 +2322,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
|
||||
async function handleFetchRemoteQr(tokenType: "persistent" | "short-lived", ttlMs?: number) {
|
||||
if (!data?.remote) return;
|
||||
const result = await data.remote.getQrPayload(tokenType, ttlMs);
|
||||
const result = await data.remote.getQrPayload(tokenType, ttlMs, "terminal");
|
||||
setRemoteUrl(result.url);
|
||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : tokenType);
|
||||
if (result.format === "text") {
|
||||
setRemoteQrDisplay(result.data ?? result.url);
|
||||
setRemoteQrFallback(null);
|
||||
return;
|
||||
}
|
||||
setRemoteQrDisplay(null);
|
||||
setRemoteQrFallback("QR SVG returned by server. Open the authenticated URL on your phone/browser to continue.");
|
||||
setRemoteQrDisplay(result.data ?? result.url);
|
||||
setRemoteQrFallback(null);
|
||||
}
|
||||
|
||||
useInput((input, key) => {
|
||||
@@ -2628,7 +2637,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
|
||||
<Text dimColor wrap="truncate-end">Short-lived expires: {new Date(shortLivedExpiresAt).toLocaleString()}</Text>
|
||||
)}
|
||||
{remoteQrDisplay && (
|
||||
<Text wrap="truncate-end">QR text payload: {remoteQrDisplay}</Text>
|
||||
<Box flexDirection="column">
|
||||
{remoteQrDisplay.split("\n").map((line, idx) => (
|
||||
<Text key={idx}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{remoteQrFallback && (
|
||||
<Text color="yellow" wrap="truncate-end">{remoteQrFallback}</Text>
|
||||
@@ -3881,19 +3894,63 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
useCallback(() => controller.getSnapshot(), [controller]),
|
||||
);
|
||||
|
||||
// Global QR overlay state — populated when the user hits Ctrl+Q on a
|
||||
// running tunnel. `loading` covers the network request; `error` surfaces
|
||||
// the message inline so the overlay never sits blank.
|
||||
const [qrOverlay, setQrOverlay] = useState<
|
||||
| { state: "loading" }
|
||||
| { state: "ready"; url: string; ascii: string; tokenType: string; expiresAt: string | null }
|
||||
| { state: "error"; message: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
// Global key handling
|
||||
useInput((input, key) => {
|
||||
// Quit — route through SIGINT so the dashboard's shutdown handler runs
|
||||
// (stops dev-server child process groups, engines, mesh, etc.). Calling
|
||||
// process.exit(0) directly here orphans node/vitest children spawned by
|
||||
// user-project dev servers.
|
||||
if (input === "q" || input === "Q" || (key.ctrl && input === "c")) {
|
||||
if (((input === "q" || input === "Q") && !key.ctrl) || (key.ctrl && input === "c")) {
|
||||
void controller.stop();
|
||||
exit();
|
||||
process.kill(process.pid, "SIGINT");
|
||||
return;
|
||||
}
|
||||
|
||||
// QR overlay open/close — Ctrl+Q toggles, Esc closes when open.
|
||||
if (qrOverlay && key.escape) {
|
||||
setQrOverlay(null);
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === "q") {
|
||||
if (qrOverlay) {
|
||||
setQrOverlay(null);
|
||||
return;
|
||||
}
|
||||
const remote = state.interactiveData?.remote;
|
||||
if (!remote) return;
|
||||
if (state.remoteStatus?.state !== "running") {
|
||||
setQrOverlay({ state: "error", message: "No remote tunnel is running. Start one in Settings (g)." });
|
||||
return;
|
||||
}
|
||||
setQrOverlay({ state: "loading" });
|
||||
void remote
|
||||
.getQrPayload("persistent", undefined, "terminal")
|
||||
.then((payload) => {
|
||||
setQrOverlay({
|
||||
state: "ready",
|
||||
url: payload.url,
|
||||
ascii: payload.data ?? "",
|
||||
tokenType: "persistent",
|
||||
expiresAt: payload.expiresAt,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setQrOverlay({ state: "error", message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "interactive" && state.interactiveInputLocked) {
|
||||
return;
|
||||
}
|
||||
@@ -4151,6 +4208,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
<HelpOverlay />
|
||||
</Box>
|
||||
)}
|
||||
{qrOverlay && (
|
||||
<Box position="absolute" marginTop={2} marginLeft={2} flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text bold color="cyanBright">Remote Access — Scan to connect</Text>
|
||||
{qrOverlay.state === "loading" && <Text dimColor>Generating QR…</Text>}
|
||||
{qrOverlay.state === "error" && <Text color="red">{qrOverlay.message}</Text>}
|
||||
{qrOverlay.state === "ready" && (
|
||||
<>
|
||||
{qrOverlay.ascii.split("\n").map((line, idx) => (
|
||||
<Text key={idx}>{line}</Text>
|
||||
))}
|
||||
<Text wrap="truncate-end">{qrOverlay.url}</Text>
|
||||
<Text dimColor>
|
||||
{qrOverlay.tokenType}
|
||||
{qrOverlay.expiresAt ? ` · expires ${new Date(qrOverlay.expiresAt).toLocaleString()}` : ""}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text dimColor>[Esc] close</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
DashboardState,
|
||||
InteractiveData,
|
||||
InteractiveView,
|
||||
RemoteStatus,
|
||||
UpdateStatus,
|
||||
} from "./state.js";
|
||||
import { SECTION_ORDER } from "./state.js";
|
||||
@@ -129,6 +130,11 @@ export class DashboardTUI {
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private lastCpuSampleAt = 0;
|
||||
|
||||
// Polled remote tunnel status; null until first successful fetch (or when
|
||||
// no remote API is wired up).
|
||||
private remoteStatus: RemoteStatus | null = null;
|
||||
private remoteStatusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.logBuffer = new LogRingBuffer();
|
||||
}
|
||||
@@ -165,6 +171,7 @@ export class DashboardTUI {
|
||||
vitestKillThreshold: this.vitestKillThreshold,
|
||||
updateStatus: this.updateStatus,
|
||||
clipboardFlash: this.clipboardFlash,
|
||||
remoteStatus: this.remoteStatus,
|
||||
};
|
||||
return this.cachedSnapshot;
|
||||
}
|
||||
@@ -365,6 +372,25 @@ export class DashboardTUI {
|
||||
setInteractiveData(data: InteractiveData): void {
|
||||
this.interactiveData = data;
|
||||
this.notify();
|
||||
this.startRemoteStatusPolling();
|
||||
}
|
||||
|
||||
private startRemoteStatusPolling(): void {
|
||||
if (this.remoteStatusTimer) return;
|
||||
const tick = async () => {
|
||||
const remote = this.interactiveData?.remote;
|
||||
if (!remote) return;
|
||||
try {
|
||||
const status = await remote.getStatus();
|
||||
const changed = JSON.stringify(this.remoteStatus) !== JSON.stringify(status);
|
||||
this.remoteStatus = status;
|
||||
if (changed) this.notify();
|
||||
} catch {
|
||||
// network/auth errors are non-fatal — leave the prior value alone
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
this.remoteStatusTimer = setInterval(() => { void tick(); }, 3000);
|
||||
}
|
||||
|
||||
setInteractiveView(view: InteractiveView): void {
|
||||
@@ -719,6 +745,11 @@ export class DashboardTUI {
|
||||
this.systemStatsTimer = null;
|
||||
}
|
||||
|
||||
if (this.remoteStatusTimer) {
|
||||
clearInterval(this.remoteStatusTimer);
|
||||
this.remoteStatusTimer = null;
|
||||
}
|
||||
|
||||
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
|
||||
process.stdout.off("resize", this.resizeListener);
|
||||
this.resizeListener = null;
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface RemoteTokenResult {
|
||||
export interface RemoteQrPayload {
|
||||
url: string;
|
||||
expiresAt: string | null;
|
||||
format: "text" | "image/svg";
|
||||
format: "text" | "image/svg" | "terminal";
|
||||
data?: string;
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ export interface InteractiveData {
|
||||
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
|
||||
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
|
||||
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
||||
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
|
||||
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<RemoteQrPayload>;
|
||||
};
|
||||
git: {
|
||||
getStatus: (projectPath: string) => Promise<GitStatus>;
|
||||
@@ -354,6 +354,10 @@ export interface DashboardState {
|
||||
// monotonic timestamp so the view can render "Copied!" briefly before the
|
||||
// controller clears it via setTimeout.
|
||||
clipboardFlash: { ok: boolean; at: number } | null;
|
||||
// Latest remote tunnel status, polled by the controller while
|
||||
// `interactiveData.remote` is available. Used to surface tunnel state
|
||||
// (state/url) globally in the TUI header.
|
||||
remoteStatus: RemoteStatus | null;
|
||||
}
|
||||
|
||||
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
|
||||
@@ -382,5 +386,6 @@ export function createInitialState(): DashboardState {
|
||||
vitestKillThreshold: 0.9,
|
||||
updateStatus: null,
|
||||
clipboardFlash: null,
|
||||
remoteStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2205,9 +2205,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
|
||||
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => {
|
||||
const params = new URLSearchParams({ tokenType });
|
||||
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
|
||||
if (format) params.set("format", format);
|
||||
const response = await fetch(`${baseUrl}/api/remote/qr?${params.toString()}`, { headers: buildAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
@@ -677,6 +677,19 @@ export function SettingsModal({
|
||||
});
|
||||
}, [activeSection, loadRemoteData]);
|
||||
|
||||
// Poll remote status while the tunnel is starting so the UI flips to
|
||||
// "running" without the user closing/reopening the modal. Stops polling
|
||||
// once it reaches a terminal state.
|
||||
useEffect(() => {
|
||||
if (activeSection !== "remote") return;
|
||||
const state = remoteStatus?.state;
|
||||
if (state !== "starting" && state !== "stopping") return;
|
||||
const interval = setInterval(() => {
|
||||
fetchRemoteStatus(projectId).then(setRemoteStatus).catch(() => {});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSection, projectId, remoteStatus?.state]);
|
||||
|
||||
// When the tunnel is running, fetch a persistent-token authenticated URL +
|
||||
// QR so the user can share/scan it without digging into Advanced Settings.
|
||||
useEffect(() => {
|
||||
@@ -4025,8 +4038,7 @@ export function SettingsModal({
|
||||
<div className="form-group remote-provider-settings">
|
||||
{activeProvider === "tailscale" ? (
|
||||
<>
|
||||
<label htmlFor="remoteTailscaleHostname">Hostname label</label>
|
||||
<input id="remoteTailscaleHostname" type="text" placeholder="tailnet label" value={String(remoteForm.remoteTailscaleHostname || (typeof window !== "undefined" ? window.location.hostname : ""))} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleHostname: e.target.value } as SettingsFormState))} />
|
||||
<small>Tailscale Funnel exposes the configured port on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname configuration is needed.</small>
|
||||
<label htmlFor="remoteTailscaleTargetPort">Target port</label>
|
||||
<input id="remoteTailscaleTargetPort" type="number" min={1} max={65535} value={Number(remoteForm.remoteTailscaleTargetPort ?? 4040)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleTargetPort: Number(e.target.value || 4040) } as SettingsFormState))} />
|
||||
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
|
||||
@@ -4087,7 +4099,7 @@ export function SettingsModal({
|
||||
const savePayload: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale",
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname || (typeof window !== "undefined" ? window.location.hostname : "")),
|
||||
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
|
||||
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare",
|
||||
|
||||
@@ -1614,17 +1614,15 @@ describe("SettingsModal", () => {
|
||||
await openRemoteSection();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Tailscale"));
|
||||
expect(screen.getByLabelText("Hostname label")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(screen.getByLabelText("Hostname label"));
|
||||
await userEvent.type(screen.getByLabelText("Hostname label"), "tail-new.ts.net");
|
||||
fireEvent.change(screen.getByLabelText("Target port"), { target: { value: "4242" } });
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
|
||||
|
||||
if (!screen.queryByLabelText("Tunnel name")) {
|
||||
const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement;
|
||||
@@ -1857,12 +1855,12 @@ describe("SettingsModal", () => {
|
||||
await openRemoteSection();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Tailscale"));
|
||||
expect(screen.getByLabelText("Hostname label")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Cloudflare"));
|
||||
expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets quick tunnel false when opening Cloudflare advanced details", async () => {
|
||||
|
||||
@@ -96,9 +96,27 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Prefer the actual public funnel URL captured from `tailscale funnel`
|
||||
// output (https://<machine>.<tailnet>.ts.net/) — that's what a remote
|
||||
// device must hit. The configured hostname label is only useful as a
|
||||
// fallback before the tunnel reports its URL.
|
||||
const liveTunnel = tunnelUrl?.trim();
|
||||
if (liveTunnel) {
|
||||
try {
|
||||
const parsed = new URL(liveTunnel);
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through to hostname-based fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = remoteAccess.providers.tailscale.hostname?.trim();
|
||||
if (!hostname) {
|
||||
throw new ApiError(409, "Tailscale hostname is not configured", { code: "REMOTE_URL_NOT_CONFIGURED" });
|
||||
throw new ApiError(409, "Tailscale tunnel URL not yet available — start the tunnel first", {
|
||||
code: "REMOTE_URL_NOT_READY",
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = new URL(`http://${hostname}`);
|
||||
@@ -605,7 +623,8 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore, engine } = await getProjectContext(req);
|
||||
const tokenType = req.query.tokenType === "short-lived" ? "short-lived" : "persistent";
|
||||
const format = req.query.format === "image/svg" ? "image/svg" : "text";
|
||||
const formatQuery = req.query.format;
|
||||
const format = formatQuery === "image/svg" ? "image/svg" : formatQuery === "terminal" ? "terminal" : "text";
|
||||
const payload = await buildRemoteLoginUrlForTokenType(scopedStore, tokenType, getCurrentTunnelUrl(engine ?? options?.engine));
|
||||
if (format === "image/svg") {
|
||||
const svg = await QRCode.toString(payload.loginUrl, {
|
||||
@@ -617,6 +636,11 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: svg });
|
||||
return;
|
||||
}
|
||||
if (format === "terminal") {
|
||||
const ascii = await QRCode.toString(payload.loginUrl, { type: "terminal", small: true, errorCorrectionLevel: "M" });
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: ascii });
|
||||
return;
|
||||
}
|
||||
res.json({ url: payload.loginUrl, tokenType: payload.tokenType, expiresAt: payload.expiresAt, format, data: payload.loginUrl });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
|
||||
@@ -811,8 +811,8 @@ export class ProjectEngine {
|
||||
if (!tailscale.enabled) {
|
||||
return { provider, reason: "provider_not_enabled", message: "Tailscale provider is disabled" };
|
||||
}
|
||||
if (!tailscale.hostname?.trim() || !Number.isFinite(tailscale.targetPort) || tailscale.targetPort <= 0) {
|
||||
return { provider, reason: "provider_not_configured", message: "Tailscale hostname and target port must be configured" };
|
||||
if (!Number.isFinite(tailscale.targetPort) || tailscale.targetPort <= 0) {
|
||||
return { provider, reason: "provider_not_configured", message: "Tailscale target port must be configured" };
|
||||
}
|
||||
|
||||
const executable = await this.checkExecutableAvailable("tailscale");
|
||||
|
||||
Reference in New Issue
Block a user