fix(browser): harden CDP readiness
This commit is contained in:
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
import { ensurePortAvailable } from "../infra/ports.js";
|
import { ensurePortAvailable } from "../infra/ports.js";
|
||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
@@ -306,20 +307,86 @@ export async function isChromeReachable(
|
|||||||
cdpPort: number,
|
cdpPort: number,
|
||||||
timeoutMs = 500,
|
timeoutMs = 500,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
||||||
|
return Boolean(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChromeVersion = {
|
||||||
|
webSocketDebuggerUrl?: string;
|
||||||
|
Browser?: string;
|
||||||
|
"User-Agent"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchChromeVersion(
|
||||||
|
cdpPort: number,
|
||||||
|
timeoutMs = 500,
|
||||||
|
): Promise<ChromeVersion | null> {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
|
const res = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
return res.ok;
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as ChromeVersion;
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChromeWebSocketUrl(
|
||||||
|
cdpPort: number,
|
||||||
|
timeoutMs = 500,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const version = await fetchChromeVersion(cdpPort, timeoutMs);
|
||||||
|
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||||
|
return wsUrl ? wsUrl : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canOpenWebSocket(
|
||||||
|
wsUrl: string,
|
||||||
|
timeoutMs = 800,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ws.terminate();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve(false);
|
||||||
|
}, Math.max(50, timeoutMs + 25));
|
||||||
|
ws.once("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
ws.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isChromeCdpReady(
|
||||||
|
cdpPort: number,
|
||||||
|
timeoutMs = 500,
|
||||||
|
handshakeTimeoutMs = 800,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const wsUrl = await getChromeWebSocketUrl(cdpPort, timeoutMs);
|
||||||
|
if (!wsUrl) return false;
|
||||||
|
return await canOpenWebSocket(wsUrl, handshakeTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
export async function launchClawdChrome(
|
export async function launchClawdChrome(
|
||||||
resolved: ResolvedBrowserConfig,
|
resolved: ResolvedBrowserConfig,
|
||||||
): Promise<RunningChrome> {
|
): Promise<RunningChrome> {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type BrowserStatus = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
controlUrl: string;
|
controlUrl: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
cdpReady?: boolean;
|
||||||
|
cdpHttp?: boolean;
|
||||||
pid: number | null;
|
pid: number | null;
|
||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
chosenBrowser: string | null;
|
chosenBrowser: string | null;
|
||||||
@@ -15,6 +17,13 @@ export type BrowserStatus = {
|
|||||||
attachOnly: boolean;
|
attachOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BrowserResetProfileResult = {
|
||||||
|
ok: true;
|
||||||
|
moved: boolean;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserTab = {
|
export type BrowserTab = {
|
||||||
targetId: string;
|
targetId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -75,6 +84,18 @@ export async function browserStop(baseUrl: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function browserResetProfile(
|
||||||
|
baseUrl: string,
|
||||||
|
): Promise<BrowserResetProfileResult> {
|
||||||
|
return await fetchBrowserJson<BrowserResetProfileResult>(
|
||||||
|
`${baseUrl}/reset-profile`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
|
||||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||||
`${baseUrl}/tabs`,
|
`${baseUrl}/tabs`,
|
||||||
|
|||||||
@@ -100,20 +100,33 @@ async function connectBrowser(endpoint: string): Promise<ConnectedBrowser> {
|
|||||||
if (cached?.endpoint === endpoint) return cached;
|
if (cached?.endpoint === endpoint) return cached;
|
||||||
if (connecting) return await connecting;
|
if (connecting) return await connecting;
|
||||||
|
|
||||||
connecting = chromium
|
const connectWithRetry = async (): Promise<ConnectedBrowser> => {
|
||||||
.connectOverCDP(endpoint, { timeout: 5000 })
|
let lastErr: unknown;
|
||||||
.then((browser) => {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
const connected: ConnectedBrowser = { browser, endpoint };
|
try {
|
||||||
cached = connected;
|
const timeout = 5000 + attempt * 2000;
|
||||||
observeBrowser(browser);
|
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
||||||
browser.on("disconnected", () => {
|
const connected: ConnectedBrowser = { browser, endpoint };
|
||||||
if (cached?.browser === browser) cached = null;
|
cached = connected;
|
||||||
});
|
observeBrowser(browser);
|
||||||
return connected;
|
browser.on("disconnected", () => {
|
||||||
})
|
if (cached?.browser === browser) cached = null;
|
||||||
.finally(() => {
|
});
|
||||||
connecting = null;
|
return connected;
|
||||||
});
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
const delay = 250 + attempt * 250;
|
||||||
|
await new Promise((r) => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error
|
||||||
|
? lastErr
|
||||||
|
: new Error(String(lastErr ?? "CDP connect failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
connecting = connectWithRetry().finally(() => {
|
||||||
|
connecting = null;
|
||||||
|
});
|
||||||
|
|
||||||
return await connecting;
|
return await connecting;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ export function registerBrowserBasicRoutes(
|
|||||||
return jsonError(res, 503, "browser server not started");
|
return jsonError(res, 503, "browser server not started");
|
||||||
}
|
}
|
||||||
|
|
||||||
const reachable = await ctx.isReachable(300);
|
const [cdpHttp, cdpReady] = await Promise.all([
|
||||||
|
ctx.isHttpReachable(300),
|
||||||
|
ctx.isReachable(600),
|
||||||
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
enabled: current.resolved.enabled,
|
enabled: current.resolved.enabled,
|
||||||
controlUrl: current.resolved.controlUrl,
|
controlUrl: current.resolved.controlUrl,
|
||||||
running: reachable,
|
running: cdpReady,
|
||||||
|
cdpReady,
|
||||||
|
cdpHttp,
|
||||||
pid: current.running?.pid ?? null,
|
pid: current.running?.pid ?? null,
|
||||||
cdpPort: current.cdpPort,
|
cdpPort: current.cdpPort,
|
||||||
chosenBrowser: current.running?.exe.kind ?? null,
|
chosenBrowser: current.running?.exe.kind ?? null,
|
||||||
@@ -47,4 +52,13 @@ export function registerBrowserBasicRoutes(
|
|||||||
jsonError(res, 500, String(err));
|
jsonError(res, 500, String(err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/reset-profile", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await ctx.resetProfile();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
jsonError(res, 500, String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
import { createTargetViaCdp } from "./cdp.js";
|
import { createTargetViaCdp } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
|
isChromeCdpReady,
|
||||||
isChromeReachable,
|
isChromeReachable,
|
||||||
launchClawdChrome,
|
launchClawdChrome,
|
||||||
type RunningChrome,
|
type RunningChrome,
|
||||||
|
resolveClawdUserDataDir,
|
||||||
stopClawdChrome,
|
stopClawdChrome,
|
||||||
} from "./chrome.js";
|
} from "./chrome.js";
|
||||||
import type { ResolvedBrowserConfig } from "./config.js";
|
import type { ResolvedBrowserConfig } from "./config.js";
|
||||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
|
|
||||||
export type BrowserTab = {
|
export type BrowserTab = {
|
||||||
targetId: string;
|
targetId: string;
|
||||||
@@ -30,12 +36,18 @@ export type BrowserRouteContext = {
|
|||||||
state: () => BrowserServerState;
|
state: () => BrowserServerState;
|
||||||
ensureBrowserAvailable: () => Promise<void>;
|
ensureBrowserAvailable: () => Promise<void>;
|
||||||
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
|
||||||
|
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||||
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
isReachable: (timeoutMs?: number) => Promise<boolean>;
|
||||||
listTabs: () => Promise<BrowserTab[]>;
|
listTabs: () => Promise<BrowserTab[]>;
|
||||||
openTab: (url: string) => Promise<BrowserTab>;
|
openTab: (url: string) => Promise<BrowserTab>;
|
||||||
focusTab: (targetId: string) => Promise<void>;
|
focusTab: (targetId: string) => Promise<void>;
|
||||||
closeTab: (targetId: string) => Promise<void>;
|
closeTab: (targetId: string) => Promise<void>;
|
||||||
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
|
||||||
|
resetProfile: () => Promise<{
|
||||||
|
moved: boolean;
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
}>;
|
||||||
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
mapTabError: (err: unknown) => { status: number; message: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,27 +169,64 @@ export function createBrowserRouteContext(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isReachable = async (timeoutMs = 300) => {
|
const isReachable = async (timeoutMs = 300) => {
|
||||||
|
const current = state();
|
||||||
|
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||||
|
return await isChromeCdpReady(current.cdpPort, timeoutMs, wsTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHttpReachable = async (timeoutMs = 300) => {
|
||||||
const current = state();
|
const current = state();
|
||||||
return await isChromeReachable(current.cdpPort, timeoutMs);
|
return await isChromeReachable(current.cdpPort, timeoutMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const attachRunning = (running: RunningChrome) => {
|
||||||
|
opts.setRunning(running);
|
||||||
|
running.proc.on("exit", () => {
|
||||||
|
const live = opts.getState();
|
||||||
|
if (live?.running?.pid === running.pid) {
|
||||||
|
opts.setRunning(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||||
const current = state();
|
const current = state();
|
||||||
|
const httpReachable = await isHttpReachable();
|
||||||
|
if (!httpReachable) {
|
||||||
|
if (current.resolved.attachOnly) {
|
||||||
|
throw new Error(
|
||||||
|
"Browser attachOnly is enabled and no browser is running.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const launched = await launchClawdChrome(current.resolved);
|
||||||
|
attachRunning(launched);
|
||||||
|
}
|
||||||
|
|
||||||
if (await isReachable()) return;
|
if (await isReachable()) return;
|
||||||
|
|
||||||
if (current.resolved.attachOnly) {
|
if (current.resolved.attachOnly) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Browser attachOnly is enabled and no browser is running.",
|
"Browser attachOnly is enabled and CDP websocket is not reachable.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const launched = await launchClawdChrome(current.resolved);
|
if (!current.running) {
|
||||||
opts.setRunning(launched);
|
throw new Error(
|
||||||
launched.proc.on("exit", () => {
|
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
|
||||||
const live = opts.getState();
|
);
|
||||||
if (live?.running?.pid === launched.pid) {
|
}
|
||||||
opts.setRunning(null);
|
|
||||||
}
|
await stopClawdChrome(current.running);
|
||||||
});
|
opts.setRunning(null);
|
||||||
|
|
||||||
|
const relaunched = await launchClawdChrome(current.resolved);
|
||||||
|
attachRunning(relaunched);
|
||||||
|
|
||||||
|
if (!(await isReachable(600))) {
|
||||||
|
throw new Error(
|
||||||
|
"Chrome CDP websocket is not reachable after restart.",
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
|
||||||
@@ -244,6 +293,36 @@ export function createBrowserRouteContext(
|
|||||||
return { stopped: true };
|
return { stopped: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetProfile = async () => {
|
||||||
|
const current = state();
|
||||||
|
const userDataDir = resolveClawdUserDataDir();
|
||||||
|
|
||||||
|
const httpReachable = await isHttpReachable(300);
|
||||||
|
if (httpReachable && !current.running) {
|
||||||
|
throw new Error(
|
||||||
|
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.running) {
|
||||||
|
await stopRunningBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import("./pw-ai.js");
|
||||||
|
await mod.closePlaywrightBrowserConnection();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDataDir)) {
|
||||||
|
return { moved: false, from: userDataDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = await movePathToTrash(userDataDir);
|
||||||
|
return { moved: true, from: userDataDir, to: moved };
|
||||||
|
};
|
||||||
|
|
||||||
const mapTabError = (err: unknown) => {
|
const mapTabError = (err: unknown) => {
|
||||||
const msg = String(err);
|
const msg = String(err);
|
||||||
if (msg.includes("ambiguous target id prefix")) {
|
if (msg.includes("ambiguous target id prefix")) {
|
||||||
@@ -259,12 +338,31 @@ export function createBrowserRouteContext(
|
|||||||
state,
|
state,
|
||||||
ensureBrowserAvailable,
|
ensureBrowserAvailable,
|
||||||
ensureTabAvailable,
|
ensureTabAvailable,
|
||||||
|
isHttpReachable,
|
||||||
isReachable,
|
isReachable,
|
||||||
listTabs,
|
listTabs,
|
||||||
openTab,
|
openTab,
|
||||||
focusTab,
|
focusTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
stopRunningBrowser,
|
stopRunningBrowser,
|
||||||
|
resetProfile,
|
||||||
mapTabError,
|
mapTabError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function movePathToTrash(targetPath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
|
||||||
|
return targetPath;
|
||||||
|
} catch {
|
||||||
|
const trashDir = path.join(os.homedir(), ".Trash");
|
||||||
|
fs.mkdirSync(trashDir, { recursive: true });
|
||||||
|
const base = path.basename(targetPath);
|
||||||
|
let dest = path.join(trashDir, `${base}-${Date.now()}`);
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
|
||||||
|
}
|
||||||
|
fs.renameSync(targetPath, dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ vi.mock("../config/config.js", () => ({
|
|||||||
|
|
||||||
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
|
||||||
vi.mock("./chrome.js", () => ({
|
vi.mock("./chrome.js", () => ({
|
||||||
|
isChromeCdpReady: vi.fn(async () => reachable),
|
||||||
isChromeReachable: vi.fn(async () => reachable),
|
isChromeReachable: vi.fn(async () => reachable),
|
||||||
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
|
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
|
||||||
launchCalls.push({ port: resolved.cdpPort });
|
launchCalls.push({ port: resolved.cdpPort });
|
||||||
@@ -90,6 +91,7 @@ vi.mock("./chrome.js", () => ({
|
|||||||
proc,
|
proc,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
|
||||||
stopClawdChrome: vi.fn(async () => {
|
stopClawdChrome: vi.fn(async () => {
|
||||||
reachable = false;
|
reachable = false;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
browserCloseTab,
|
browserCloseTab,
|
||||||
browserFocusTab,
|
browserFocusTab,
|
||||||
browserOpenTab,
|
browserOpenTab,
|
||||||
|
browserResetProfile,
|
||||||
browserStart,
|
browserStart,
|
||||||
browserStatus,
|
browserStatus,
|
||||||
browserStop,
|
browserStop,
|
||||||
@@ -87,6 +88,32 @@ export function registerBrowserManageCommands(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
browser
|
||||||
|
.command("reset-profile")
|
||||||
|
.description("Reset clawd browser profile (moves it to Trash)")
|
||||||
|
.action(async (_opts, cmd) => {
|
||||||
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
|
try {
|
||||||
|
const result = await browserResetProfile(baseUrl);
|
||||||
|
if (parent?.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.moved) {
|
||||||
|
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dest = result.to ?? result.from;
|
||||||
|
defaultRuntime.log(
|
||||||
|
info(`🦞 clawd browser profile moved to Trash (${dest})`),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(String(err)));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
browser
|
browser
|
||||||
.command("tabs")
|
.command("tabs")
|
||||||
.description("List open tabs")
|
.description("List open tabs")
|
||||||
|
|||||||
Reference in New Issue
Block a user