275 lines
7.9 KiB
JavaScript
Executable File
275 lines
7.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir, rm, cp } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import net from "node:net";
|
|
import puppeteer from "puppeteer-core";
|
|
|
|
import {
|
|
DEFAULT_PORT,
|
|
createLogger,
|
|
delay,
|
|
expandPath,
|
|
fail,
|
|
findExistingPath,
|
|
parseArgs,
|
|
printJSON,
|
|
resolveBrowserConnection,
|
|
waitFor,
|
|
normalizeNumber,
|
|
pathExists,
|
|
} from "./config.js";
|
|
|
|
const args = parseArgs(process.argv.slice(2), {
|
|
boolean: ["json", "quiet"],
|
|
string: ["profile", "profile-path", "chrome-path", "chromePath", "host", "user-data-dir", "ws"],
|
|
number: ["port", "timeout"],
|
|
alias: {
|
|
j: "json",
|
|
q: "quiet",
|
|
},
|
|
defaults: {
|
|
port: DEFAULT_PORT,
|
|
timeout: 15000,
|
|
},
|
|
});
|
|
|
|
const jsonOutput = Boolean(args.json);
|
|
const logger = createLogger({ quiet: Boolean(args.quiet), json: jsonOutput });
|
|
|
|
const port = normalizeNumber(args.port, DEFAULT_PORT);
|
|
const timeout = normalizeNumber(args.timeout, 15000);
|
|
const host = args.host ?? "127.0.0.1";
|
|
|
|
const userDataDir = expandPath(
|
|
args["user-data-dir"] ?? join(homedir(), ".cache", "browser-tools", `profile-${port}`),
|
|
);
|
|
|
|
await ensurePortAvailable({ port, host });
|
|
|
|
const chromeExecutable = await resolveChromeExecutable(args);
|
|
if (!chromeExecutable) {
|
|
fail(
|
|
"Unable to find Chrome/Chromium executable. Provide --chrome-path or set CHROME_PATH/PUPPETEER_EXECUTABLE_PATH.",
|
|
{ json: jsonOutput },
|
|
);
|
|
}
|
|
|
|
let profileSource = await resolveProfileSource(args);
|
|
let copiedProfile = false;
|
|
|
|
try {
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
|
|
if (profileSource) {
|
|
logger.info(`⏳ Copying profile from ${profileSource}...`);
|
|
await cp(profileSource, userDataDir, { recursive: true });
|
|
copiedProfile = true;
|
|
} else {
|
|
await mkdir(userDataDir, { recursive: true });
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to prepare user data dir: ${error.message}`);
|
|
fail("Failed to prepare profile directory", { json: jsonOutput });
|
|
}
|
|
|
|
const chromeArgs = buildChromeArguments({ port, userDataDir });
|
|
|
|
logger.info(`🚀 Launching Chrome at ${chromeExecutable} on port ${port}...`);
|
|
|
|
const chromeProcess = spawn(chromeExecutable, chromeArgs, {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
chromeProcess.unref();
|
|
|
|
logger.info("⏳ Waiting for DevTools endpoint...");
|
|
|
|
const connectionOptions = resolveBrowserConnection({ port, host, ws: args.ws });
|
|
|
|
const connected = await waitFor(async () => {
|
|
try {
|
|
const browser = await puppeteer.connect({
|
|
...connectionOptions,
|
|
timeout: 2000,
|
|
});
|
|
await browser.disconnect();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, { timeout, interval: 500 });
|
|
|
|
if (!connected) {
|
|
fail(
|
|
`Failed to connect to Chrome on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
|
|
{ json: jsonOutput },
|
|
);
|
|
}
|
|
|
|
logger.info(`✅ Chrome listening on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`);
|
|
|
|
const result = {
|
|
ok: true,
|
|
port,
|
|
userDataDir,
|
|
chromePath: chromeExecutable,
|
|
profile: copiedProfile ? profileSource : null,
|
|
};
|
|
|
|
if (jsonOutput) {
|
|
printJSON(result);
|
|
} else {
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
logger.info(
|
|
`✓ Chrome started on ${connectionOptions.browserWSEndpoint ?? connectionOptions.browserURL}`,
|
|
);
|
|
}
|
|
|
|
async function ensurePortAvailable({ port: portToCheck, host: hostToCheck }) {
|
|
await new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", (error) => {
|
|
if (error.code === "EADDRINUSE") {
|
|
reject(
|
|
new Error(
|
|
`Port ${portToCheck} is already in use. If Chrome is already running, reconnect with other tools or close it first.`,
|
|
),
|
|
);
|
|
} else {
|
|
reject(error);
|
|
}
|
|
});
|
|
server.once("listening", () => {
|
|
server.close(() => resolve());
|
|
});
|
|
server.listen(portToCheck, hostToCheck);
|
|
}).catch((error) => {
|
|
fail(error.message, { json: jsonOutput });
|
|
});
|
|
|
|
// small delay to allow OS to release the port after closing the test server
|
|
await delay(50);
|
|
}
|
|
|
|
function buildChromeArguments({ port: portValue, userDataDir: dir }) {
|
|
return [
|
|
`--remote-debugging-port=${portValue}`,
|
|
`--user-data-dir=${dir}`,
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--disable-background-networking",
|
|
"--disable-sync",
|
|
"--metrics-recording-only",
|
|
"--enable-automation",
|
|
];
|
|
}
|
|
|
|
async function resolveChromeExecutable(parsed) {
|
|
const providedPath = parsed["chrome-path"] ?? parsed.chromePath ?? process.env.CHROME_PATH;
|
|
const puppeteerPath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
|
|
const platformCandidates = getDefaultChromeCandidates();
|
|
|
|
return findExistingPath([
|
|
providedPath && expandPath(providedPath),
|
|
puppeteerPath && expandPath(puppeteerPath),
|
|
...platformCandidates,
|
|
]);
|
|
}
|
|
|
|
async function resolveProfileSource(parsed) {
|
|
const raw = parsed.profile ?? parsed["profile-path"];
|
|
|
|
if (raw === undefined || raw === false) return null;
|
|
|
|
const normalized = typeof raw === "string" ? raw.trim() : "";
|
|
|
|
if (normalized && normalized.toLowerCase() !== "default") {
|
|
const expanded = expandPath(normalized);
|
|
if (await pathExists(expanded)) return expanded;
|
|
logger.warn(`⚠️ Profile path not found: ${expanded}`);
|
|
return null;
|
|
}
|
|
|
|
const defaultPath = await resolveDefaultProfileRoot();
|
|
if (!defaultPath) {
|
|
logger.warn("⚠️ No default Chrome profile detected; starting with a fresh profile.");
|
|
return null;
|
|
}
|
|
return defaultPath;
|
|
}
|
|
|
|
async function resolveDefaultProfileRoot() {
|
|
const candidates = getDefaultProfileCandidates();
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue;
|
|
const expanded = expandPath(candidate);
|
|
if (await pathExists(expanded)) {
|
|
return expanded;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getDefaultChromeCandidates() {
|
|
const platform = process.platform;
|
|
const home = homedir();
|
|
|
|
if (platform === "darwin") {
|
|
return [
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
];
|
|
}
|
|
|
|
if (platform === "linux") {
|
|
return [
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium-browser",
|
|
"/usr/bin/chromium",
|
|
join(home, "snap", "chromium", "current", "usr", "lib", "chromium-browser", "chromium-browser"),
|
|
];
|
|
}
|
|
|
|
if (platform === "win32") {
|
|
const localAppData = process.env.LOCALAPPDATA;
|
|
const programFiles = process.env["PROGRAMFILES(X86)"] ?? process.env.PROGRAMFILES;
|
|
return [
|
|
localAppData && join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
|
programFiles && join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function getDefaultProfileCandidates() {
|
|
const platform = process.platform;
|
|
if (platform === "darwin") {
|
|
return [
|
|
"~/Library/Application Support/Google/Chrome",
|
|
"~/Library/Application Support/Chromium",
|
|
];
|
|
}
|
|
|
|
if (platform === "linux") {
|
|
return [
|
|
"~/.config/google-chrome",
|
|
"~/.config/chromium",
|
|
];
|
|
}
|
|
|
|
if (platform === "win32") {
|
|
const localAppData = process.env.LOCALAPPDATA;
|
|
if (!localAppData) return [];
|
|
return [join(localAppData, "Google", "Chrome", "User Data")];
|
|
}
|
|
|
|
return [];
|
|
} |