hoversource 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +75 -0
- package/dist/cert-ca.d.ts +19 -0
- package/dist/cert-ca.js +180 -0
- package/dist/cert.d.ts +15 -0
- package/dist/cert.js +76 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +770 -0
- package/dist/custom-jsx-dev-runtime-cjs.cjs +21 -0
- package/dist/custom-jsx-dev-runtime.d.ts +3 -0
- package/dist/custom-jsx-dev-runtime.js +21 -0
- package/dist/custom-jsx-runtime-cjs.cjs +76 -0
- package/dist/custom-jsx-runtime.d.ts +3 -0
- package/dist/custom-jsx-runtime.js +72 -0
- package/dist/launcher/ElectronCdpLauncher.d.ts +4 -0
- package/dist/launcher/ElectronCdpLauncher.js +63 -0
- package/dist/launcher/WebProxyLauncher.d.ts +5 -0
- package/dist/launcher/WebProxyLauncher.js +114 -0
- package/dist/launcher/types.d.ts +10 -0
- package/dist/launcher/types.js +1 -0
- package/dist/loader.d.ts +1 -0
- package/dist/loader.js +31 -0
- package/dist/patcher/ReactRuntimePatcher.d.ts +5 -0
- package/dist/patcher/ReactRuntimePatcher.js +263 -0
- package/dist/patcher/types.d.ts +4 -0
- package/dist/patcher/types.js +1 -0
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.js +45 -0
- package/dist/port.d.ts +76 -0
- package/dist/port.js +560 -0
- package/dist/proxy.d.ts +16 -0
- package/dist/proxy.js +220 -0
- package/dist/utils/patchState.d.ts +3 -0
- package/dist/utils/patchState.js +59 -0
- package/package.json +27 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startCompanionServer } from "@hoversource/companion-server";
|
|
3
|
+
import { injectOverlayScript } from "@hoversource/client-injector";
|
|
4
|
+
import { startProxy } from "./proxy.js";
|
|
5
|
+
import { exec } from "node:child_process";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import https from "node:https";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { restoreLeftoverPatches, recordPatchState, removePatchState } from "./utils/patchState.js";
|
|
13
|
+
import { findFreePort, getPidUsingPort, getProcessName, killProcess, askQuestion, resolveCompanionPort } from "./port.js";
|
|
14
|
+
// Re-export port functions for backward compatibility
|
|
15
|
+
export { isPortFree, findFreePort, getPidUsingPort, getProcessName, killProcess, askQuestion, isZombieOfProject, resolveCompanionPort, resolveDevServerPort, resolveAllPorts } from "./port.js";
|
|
16
|
+
import { WebProxyLauncher } from "./launcher/WebProxyLauncher.js";
|
|
17
|
+
import { ElectronCdpLauncher } from "./launcher/ElectronCdpLauncher.js";
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
// Helper functions to prevent Path Injection (S8707) and Command Injection (S8701)
|
|
21
|
+
export function validateSafePath(p) {
|
|
22
|
+
const pathRegex = /^[a-zA-Z0-9_\-\s./\\:]+$/;
|
|
23
|
+
if (p.includes("..") || !pathRegex.test(p)) {
|
|
24
|
+
throw new Error(`[HoverSource] Security Error: Path contains invalid characters or traversal sequence: ${p}`);
|
|
25
|
+
}
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
export function validateSafeCommand(cmd) {
|
|
29
|
+
const cmdRegex = /^[a-zA-Z0-9_\-\s./\\:'"]+$/;
|
|
30
|
+
if (/[;&|<>$\n\r]/.test(cmd) || !cmdRegex.test(cmd)) {
|
|
31
|
+
throw new Error(`[HoverSource] Security Error: Command contains invalid characters: ${cmd}`);
|
|
32
|
+
}
|
|
33
|
+
return cmd;
|
|
34
|
+
}
|
|
35
|
+
export function validateSafeUrl(urlStr) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(urlStr);
|
|
38
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
39
|
+
throw new Error(`[HoverSource] Security Error: Target URL protocol must be http or https`);
|
|
40
|
+
}
|
|
41
|
+
const hostRegex = /^[a-zA-Z0-9_\-.]+$/;
|
|
42
|
+
if (!hostRegex.test(parsed.hostname)) {
|
|
43
|
+
throw new Error(`[HoverSource] Security Error: Target URL contains invalid hostname`);
|
|
44
|
+
}
|
|
45
|
+
return urlStr;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
throw new Error(`[HoverSource] Security Error: Invalid Target URL: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function parseLongOption(arg, args) {
|
|
52
|
+
const eqIdx = arg.indexOf("=");
|
|
53
|
+
if (eqIdx === -1) {
|
|
54
|
+
args[arg.slice(2)] = true;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const key = arg.slice(2, eqIdx);
|
|
58
|
+
const value = arg.slice(eqIdx + 1);
|
|
59
|
+
args[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function parseShortOption(arg, args, argv, indexRef) {
|
|
63
|
+
const char = arg.slice(1);
|
|
64
|
+
const eqIdx = char.indexOf("=");
|
|
65
|
+
const optionChar = eqIdx === -1 ? char : char.slice(0, eqIdx);
|
|
66
|
+
const val = eqIdx === -1 ? undefined : char.slice(eqIdx + 1);
|
|
67
|
+
const optionMap = {
|
|
68
|
+
p: "port",
|
|
69
|
+
t: "target",
|
|
70
|
+
e: "exec",
|
|
71
|
+
r: "root"
|
|
72
|
+
};
|
|
73
|
+
const simpleFlags = {
|
|
74
|
+
d: "dashboard",
|
|
75
|
+
h: "help",
|
|
76
|
+
v: "vue",
|
|
77
|
+
s: "solid",
|
|
78
|
+
a: "angular"
|
|
79
|
+
};
|
|
80
|
+
if (optionChar in simpleFlags) {
|
|
81
|
+
args[simpleFlags[optionChar]] = true;
|
|
82
|
+
}
|
|
83
|
+
else if (optionChar in optionMap) {
|
|
84
|
+
const key = optionMap[optionChar];
|
|
85
|
+
if (val === undefined) {
|
|
86
|
+
const nextArg = argv[indexRef.index + 1];
|
|
87
|
+
if (nextArg !== undefined && !nextArg.startsWith("-")) {
|
|
88
|
+
indexRef.index++;
|
|
89
|
+
args[key] = nextArg;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
args[key] = val;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Helper to parse arguments
|
|
98
|
+
function getArgs() {
|
|
99
|
+
const args = {};
|
|
100
|
+
let subcommand;
|
|
101
|
+
const indexRef = { index: 2 };
|
|
102
|
+
for (; indexRef.index < process.argv.length; indexRef.index++) {
|
|
103
|
+
const arg = process.argv[indexRef.index];
|
|
104
|
+
if (arg.startsWith("--")) {
|
|
105
|
+
parseLongOption(arg, args);
|
|
106
|
+
}
|
|
107
|
+
else if (arg.startsWith("-")) {
|
|
108
|
+
parseShortOption(arg, args, process.argv, indexRef);
|
|
109
|
+
}
|
|
110
|
+
else if (!subcommand) {
|
|
111
|
+
subcommand = arg; // e.g. "start", "dev"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { args, subcommand };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* If a subcommand like "start" or "dev" is given, resolve it to an exec
|
|
118
|
+
* command by reading the project's package.json. Returns undefined if the
|
|
119
|
+
* script doesn't exist, along with whether the project appears to be Electron.
|
|
120
|
+
*/
|
|
121
|
+
function resolveSubcommand(subcommand, projectRoot) {
|
|
122
|
+
const pkgPath = validateSafePath(path.join(projectRoot, "package.json"));
|
|
123
|
+
if (!fs.existsSync(pkgPath)) {
|
|
124
|
+
console.error(`[HoverSource] No package.json found in ${projectRoot}`);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
let pkg;
|
|
128
|
+
try {
|
|
129
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.error(`[HoverSource] Failed to parse package.json:`, err);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const scripts = pkg.scripts || {};
|
|
136
|
+
if (!scripts[subcommand]) {
|
|
137
|
+
const available = Object.keys(scripts).join(", ");
|
|
138
|
+
console.error(`[HoverSource] Script "${subcommand}" not found in package.json.`);
|
|
139
|
+
if (available)
|
|
140
|
+
console.error(`[HoverSource] Available scripts: ${available}`);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const allDeps = {
|
|
144
|
+
...pkg.dependencies,
|
|
145
|
+
...pkg.devDependencies,
|
|
146
|
+
};
|
|
147
|
+
const hasElectronDep = "electron" in allDeps;
|
|
148
|
+
let isElectron = hasElectronDep;
|
|
149
|
+
if (hasElectronDep) {
|
|
150
|
+
const scriptCmd = scripts[subcommand] || "";
|
|
151
|
+
const lowerCmd = scriptCmd.toLowerCase();
|
|
152
|
+
const isWebDevServer = (lowerCmd.includes("vite") ||
|
|
153
|
+
lowerCmd.includes("next ") ||
|
|
154
|
+
lowerCmd.includes("nuxt") ||
|
|
155
|
+
lowerCmd.includes("webpack") ||
|
|
156
|
+
lowerCmd.includes("astro"));
|
|
157
|
+
const mentionsElectron = lowerCmd.includes("electron");
|
|
158
|
+
if (isWebDevServer && !mentionsElectron) {
|
|
159
|
+
isElectron = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { execCommand: `npm run ${subcommand}`, isElectron };
|
|
163
|
+
}
|
|
164
|
+
function resolveActualCmd(projectRoot, execCommand) {
|
|
165
|
+
const npmRunMatch = /^(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?([^\s]+)/.exec(execCommand);
|
|
166
|
+
if (npmRunMatch) {
|
|
167
|
+
const scriptName = npmRunMatch[1];
|
|
168
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
169
|
+
if (fs.existsSync(pkgPath)) {
|
|
170
|
+
try {
|
|
171
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
172
|
+
if (pkg.scripts?.[scriptName]) {
|
|
173
|
+
return pkg.scripts[scriptName];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return execCommand;
|
|
180
|
+
}
|
|
181
|
+
function findConfigPath(actualCmd) {
|
|
182
|
+
const configMatch = /(?:--config|-c)\s+([^\s"'\\]+)/.exec(actualCmd);
|
|
183
|
+
if (configMatch)
|
|
184
|
+
return configMatch[1];
|
|
185
|
+
const fileMatch = /([\w\-./\\]+?\.config\.[jt]s)/.exec(actualCmd);
|
|
186
|
+
return fileMatch ? fileMatch[1] : undefined;
|
|
187
|
+
}
|
|
188
|
+
function readPortFromConfig(projectRoot, configPath) {
|
|
189
|
+
const fullPath = path.resolve(projectRoot, configPath);
|
|
190
|
+
if (fs.existsSync(fullPath)) {
|
|
191
|
+
try {
|
|
192
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
193
|
+
const portMatch = /port:\s*(\d+)/.exec(content);
|
|
194
|
+
if (portMatch) {
|
|
195
|
+
return Number.parseInt(portMatch[1], 10);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch { }
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
function findPortFromExecCommand(projectRoot, execCommand) {
|
|
203
|
+
const actualCmd = resolveActualCmd(projectRoot, execCommand);
|
|
204
|
+
const configPath = findConfigPath(actualCmd);
|
|
205
|
+
if (configPath) {
|
|
206
|
+
return readPortFromConfig(projectRoot, configPath);
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
function findPortFromRootConfigs(projectRoot) {
|
|
211
|
+
const commonConfigs = ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"];
|
|
212
|
+
for (const file of commonConfigs) {
|
|
213
|
+
try {
|
|
214
|
+
const fullPath = path.join(projectRoot, file);
|
|
215
|
+
if (fs.existsSync(fullPath)) {
|
|
216
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
217
|
+
const portMatch = /port:\s*(\d+)/.exec(content);
|
|
218
|
+
if (portMatch) {
|
|
219
|
+
return Number.parseInt(portMatch[1], 10);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch { }
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
function findPortFromDependencies(projectRoot) {
|
|
228
|
+
try {
|
|
229
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
230
|
+
if (fs.existsSync(pkgPath)) {
|
|
231
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
232
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
233
|
+
if ("next" in allDeps)
|
|
234
|
+
return 3000;
|
|
235
|
+
if ("nuxt" in allDeps)
|
|
236
|
+
return 3000;
|
|
237
|
+
if ("vite" in allDeps)
|
|
238
|
+
return 5173;
|
|
239
|
+
if ("@sveltejs/kit" in allDeps)
|
|
240
|
+
return 5173;
|
|
241
|
+
if ("@angular/cli" in allDeps || "@angular/core" in allDeps)
|
|
242
|
+
return 4200;
|
|
243
|
+
if ("webpack-dev-server" in allDeps)
|
|
244
|
+
return 8080;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
export function detectDevServerPort(projectRoot, execCommand) {
|
|
251
|
+
let configPort;
|
|
252
|
+
if (execCommand) {
|
|
253
|
+
configPort = findPortFromExecCommand(projectRoot, execCommand);
|
|
254
|
+
}
|
|
255
|
+
if (!configPort) {
|
|
256
|
+
configPort = findPortFromRootConfigs(projectRoot);
|
|
257
|
+
}
|
|
258
|
+
if (!configPort) {
|
|
259
|
+
configPort = findPortFromDependencies(projectRoot);
|
|
260
|
+
}
|
|
261
|
+
return configPort ?? 3000;
|
|
262
|
+
}
|
|
263
|
+
function openBrowser(url) {
|
|
264
|
+
let startCmd = "xdg-open";
|
|
265
|
+
if (process.platform === "darwin") {
|
|
266
|
+
startCmd = "open";
|
|
267
|
+
}
|
|
268
|
+
else if (process.platform === "win32") {
|
|
269
|
+
startCmd = "start";
|
|
270
|
+
}
|
|
271
|
+
const command = process.platform === "win32" ? `start "" "${url}"` : `${startCmd} "${url}"`;
|
|
272
|
+
exec(command, (err) => {
|
|
273
|
+
if (err) {
|
|
274
|
+
console.error(`[HoverSource] Failed to automatically open dashboard in browser: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// On-disk patching functions moved to patcher/ReactRuntimePatcher.ts and utils/patchState.ts
|
|
279
|
+
function patchSingleFileForDebugPort(fullPath, projectRoot, oldPort, newPort, patchedFiles) {
|
|
280
|
+
try {
|
|
281
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
282
|
+
const searchStr = `--remote-debugging-port=${oldPort}`;
|
|
283
|
+
if (content.includes(searchStr)) {
|
|
284
|
+
const newContent = content.replaceAll(searchStr, `--remote-debugging-port=${newPort}`);
|
|
285
|
+
fs.writeFileSync(fullPath, newContent, "utf-8");
|
|
286
|
+
recordPatchState(fullPath, content);
|
|
287
|
+
patchedFiles.push({ path: fullPath, originalContent: content });
|
|
288
|
+
console.log(`[HoverSource] Temporarily patched debug port ${oldPort} -> ${newPort} in ${path.relative(projectRoot, fullPath)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
console.debug(`[HoverSource] Failed to patch file ${fullPath}:`, err);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function findAndPatchDebugPort(projectRoot, oldPort, newPort) {
|
|
296
|
+
const dirsToSearch = [
|
|
297
|
+
path.join(projectRoot, "scripts"),
|
|
298
|
+
projectRoot
|
|
299
|
+
];
|
|
300
|
+
const patchedFiles = [];
|
|
301
|
+
for (const dir of dirsToSearch) {
|
|
302
|
+
if (!fs.existsSync(dir))
|
|
303
|
+
continue;
|
|
304
|
+
const files = fs.readdirSync(dir);
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
const fullPath = path.join(dir, file);
|
|
307
|
+
const stat = fs.statSync(fullPath);
|
|
308
|
+
if (stat.isFile() && (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".ts") || file.endsWith(".json"))) {
|
|
309
|
+
patchSingleFileForDebugPort(fullPath, projectRoot, oldPort, newPort, patchedFiles);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (patchedFiles.length === 0)
|
|
314
|
+
return undefined;
|
|
315
|
+
return {
|
|
316
|
+
restore: () => {
|
|
317
|
+
for (const pf of patchedFiles) {
|
|
318
|
+
try {
|
|
319
|
+
fs.writeFileSync(pf.path, pf.originalContent, "utf-8");
|
|
320
|
+
removePatchState(pf.path);
|
|
321
|
+
console.log(`[HoverSource] Restored original port configuration in ${path.relative(projectRoot, pf.path)}`);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.debug(`[HoverSource] Failed to restore file ${pf.path}:`, err);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
async function checkDebugPortInUse(debugPort) {
|
|
331
|
+
return new Promise((resolve) => {
|
|
332
|
+
const server = net.createServer();
|
|
333
|
+
server.once("error", () => resolve(true));
|
|
334
|
+
server.once("listening", () => {
|
|
335
|
+
server.close();
|
|
336
|
+
resolve(false);
|
|
337
|
+
});
|
|
338
|
+
server.listen(debugPort, "127.0.0.1");
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function warnDebugPortInUse(debugPort, pid, procName) {
|
|
342
|
+
console.warn(`\n\x1b[33m[HoverSource] ⚠️ WARNING: Debug port ${debugPort} is already in use by another process!\x1b[0m`);
|
|
343
|
+
if (pid) {
|
|
344
|
+
console.warn(`\x1b[33m[HoverSource] Process: ${procName || "Unknown"} (PID: ${pid})\x1b[0m`);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.warn(`\x1b[33m[HoverSource] Could not identify the process holding the port.\x1b[0m`);
|
|
348
|
+
}
|
|
349
|
+
console.warn(`\x1b[33m[HoverSource] Electron will fail to bind to it, and the HoverSource overlay will not appear.\x1b[0m`);
|
|
350
|
+
}
|
|
351
|
+
async function handleTerminationOption(pid, debugPort, autoResolve) {
|
|
352
|
+
let shouldKill = autoResolve;
|
|
353
|
+
if (shouldKill) {
|
|
354
|
+
console.log(`[HoverSource] autoResolvePortConflicts is enabled. Automatically terminating process ${pid}...`);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const answer = await askQuestion(`\x1b[36m[HoverSource] Would you like to terminate this process to free port ${debugPort}? (y/N): \x1b[0m`);
|
|
358
|
+
shouldKill = answer.trim().toLowerCase() === "y";
|
|
359
|
+
}
|
|
360
|
+
if (shouldKill) {
|
|
361
|
+
console.log(`[HoverSource] Terminating process ${pid}...`);
|
|
362
|
+
const success = await killProcess(pid, debugPort);
|
|
363
|
+
if (success) {
|
|
364
|
+
console.log(`[HoverSource] Process terminated successfully. Port ${debugPort} is now free.`);
|
|
365
|
+
// Wait a brief moment for OS to release the socket
|
|
366
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
console.error(`[HoverSource] Failed to terminate process. You may need to run as administrator or close it manually.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
async function handlePatchOption(debugPort, projectRoot, autoResolve) {
|
|
376
|
+
let shouldPatch = autoResolve;
|
|
377
|
+
if (shouldPatch) {
|
|
378
|
+
console.log(`[HoverSource] autoResolvePortConflicts is enabled. Automatically patching debug port to ${debugPort + 1}...`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const portAnswer = await askQuestion(`\x1b[36m[HoverSource] Port ${debugPort} is blocked. Try changing your app's debug port to ${debugPort + 1} temporarily? (y/N): \x1b[0m`);
|
|
382
|
+
shouldPatch = portAnswer.trim().toLowerCase() === "y";
|
|
383
|
+
}
|
|
384
|
+
if (shouldPatch) {
|
|
385
|
+
const newDebugPort = await findFreePort(debugPort + 1);
|
|
386
|
+
const patchResult = findAndPatchDebugPort(projectRoot, debugPort, newDebugPort);
|
|
387
|
+
if (patchResult) {
|
|
388
|
+
return { newDebugPort, patchRestorer: patchResult.restore };
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
console.warn(`\x1b[33m[HoverSource] ⚠️ Could not locate any hardcoded references to port ${debugPort} in your scripts/ folder or root.\x1b[0m`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
async function attemptInteractiveResolve(pid, debugPort, projectRoot, autoResolve) {
|
|
397
|
+
let portFreed = false;
|
|
398
|
+
if (pid) {
|
|
399
|
+
portFreed = await handleTerminationOption(pid, debugPort, autoResolve);
|
|
400
|
+
}
|
|
401
|
+
if (!portFreed) {
|
|
402
|
+
const patch = await handlePatchOption(debugPort, projectRoot, autoResolve);
|
|
403
|
+
if (patch) {
|
|
404
|
+
return { resolvedDebugPort: patch.newDebugPort, patchRestorer: patch.patchRestorer };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { resolvedDebugPort: debugPort };
|
|
408
|
+
}
|
|
409
|
+
export async function resolveDebugPortConflicts(debugPort, projectRoot, autoResolve, args) {
|
|
410
|
+
const isDebugPortInUse = await checkDebugPortInUse(debugPort);
|
|
411
|
+
if (!isDebugPortInUse) {
|
|
412
|
+
return { resolvedDebugPort: debugPort };
|
|
413
|
+
}
|
|
414
|
+
const pid = await getPidUsingPort(debugPort);
|
|
415
|
+
const procName = pid ? await getProcessName(pid) : undefined;
|
|
416
|
+
warnDebugPortInUse(debugPort, pid, procName);
|
|
417
|
+
let currentDebugPort = debugPort;
|
|
418
|
+
let patchRestorer;
|
|
419
|
+
const isInteractive = (process.stdout.isTTY && process.stdin.isTTY) || autoResolve;
|
|
420
|
+
if (isInteractive) {
|
|
421
|
+
const result = await attemptInteractiveResolve(pid, debugPort, projectRoot, autoResolve);
|
|
422
|
+
currentDebugPort = result.resolvedDebugPort;
|
|
423
|
+
patchRestorer = result.patchRestorer;
|
|
424
|
+
}
|
|
425
|
+
if (currentDebugPort === debugPort) {
|
|
426
|
+
console.warn(`\x1b[33m[HoverSource] Proceeding with port ${currentDebugPort} anyway. Overlay connection might fail.\x1b[0m\n`);
|
|
427
|
+
console.warn(`\x1b[33m[HoverSource] To fix: close the process using port ${currentDebugPort}, or pass --debug-port=<free-port>.\x1b[0m`);
|
|
428
|
+
}
|
|
429
|
+
return { resolvedDebugPort: currentDebugPort, patchRestorer };
|
|
430
|
+
}
|
|
431
|
+
export async function runProxyMode(targetUrl, serverPort, args) {
|
|
432
|
+
let targetPort = 3000;
|
|
433
|
+
try {
|
|
434
|
+
targetPort = Number.parseInt(new URL(targetUrl).port || "3000", 10);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
console.debug("[HoverSource] Failed to parse target port:", err);
|
|
438
|
+
}
|
|
439
|
+
const requestedProxyPort = Number.parseInt(args["proxy-port"] || String(10000 + targetPort), 10);
|
|
440
|
+
const proxyPort = await findFreePort(requestedProxyPort);
|
|
441
|
+
if (proxyPort !== requestedProxyPort) {
|
|
442
|
+
console.log(`[HoverSource] Proxy port ${requestedProxyPort} in use, using ${proxyPort} instead.`);
|
|
443
|
+
}
|
|
444
|
+
const overlayScriptUrl = "/hoversource/hoversource-overlay.js";
|
|
445
|
+
const useHttps = targetUrl.toLowerCase().startsWith("https:");
|
|
446
|
+
console.log(`[HoverSource] Proxy mode: ${targetUrl} → ${useHttps ? "https" : "http"}://localhost:${proxyPort}`);
|
|
447
|
+
try {
|
|
448
|
+
await startProxy({
|
|
449
|
+
targetUrl,
|
|
450
|
+
proxyPort,
|
|
451
|
+
companionPort: serverPort,
|
|
452
|
+
overlayScriptUrl,
|
|
453
|
+
useHttps,
|
|
454
|
+
});
|
|
455
|
+
console.log(`[HoverSource] Proxy ready. Opening ${useHttps ? "https" : "http"}://localhost:${proxyPort} in your browser...`);
|
|
456
|
+
openBrowser(`${useHttps ? "https" : "http"}://localhost:${proxyPort}`);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const isAddrInUse = err?.code === "EADDRINUSE";
|
|
460
|
+
console.error(`[HoverSource] Proxy failed to start on port ${proxyPort}.`);
|
|
461
|
+
if (isAddrInUse) {
|
|
462
|
+
console.error(`[HoverSource] Port ${proxyPort} is in use. Pass --proxy-port=<port> or close the process using it.`);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
console.error(`[HoverSource] Error: ${err?.message || err}`);
|
|
466
|
+
}
|
|
467
|
+
console.error(`[HoverSource] The companion server is still running. You can open http://localhost:${serverPort}/dashboard directly.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
export async function waitForServer(port, timeoutMs = 120_000, hasExitedCheck) {
|
|
471
|
+
const start = Date.now();
|
|
472
|
+
let dots = 0;
|
|
473
|
+
while (Date.now() - start < timeoutMs) {
|
|
474
|
+
if (hasExitedCheck?.()) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const up = await new Promise((resolve) => {
|
|
478
|
+
const tryHost = (url) => {
|
|
479
|
+
const req = http.get(url, (res) => {
|
|
480
|
+
res.resume();
|
|
481
|
+
resolve(true);
|
|
482
|
+
});
|
|
483
|
+
req.setTimeout(500, () => {
|
|
484
|
+
req.destroy();
|
|
485
|
+
if (url.includes("localhost")) {
|
|
486
|
+
tryHost(`http://127.0.0.1:${port}`);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
resolve(false);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
req.on("error", () => {
|
|
493
|
+
if (url.includes("localhost")) {
|
|
494
|
+
tryHost(`http://127.0.0.1:${port}`);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
resolve(false);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
};
|
|
501
|
+
tryHost(`http://localhost:${port}`);
|
|
502
|
+
});
|
|
503
|
+
if (up)
|
|
504
|
+
return true;
|
|
505
|
+
dots++;
|
|
506
|
+
if (dots % 6 === 0) {
|
|
507
|
+
console.log(`[HoverSource] Still waiting for dev server on port ${port}...`);
|
|
508
|
+
}
|
|
509
|
+
await new Promise(r => setTimeout(r, 500));
|
|
510
|
+
}
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
// runWebAppMode has been moved to launcher/WebProxyLauncher.ts
|
|
514
|
+
export function cleanArgument(arg) {
|
|
515
|
+
const first = arg[0];
|
|
516
|
+
if ((first === '"' || first === "'") && arg.endsWith(first)) {
|
|
517
|
+
return arg.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
518
|
+
}
|
|
519
|
+
return arg;
|
|
520
|
+
}
|
|
521
|
+
export function parseCommand(cmdString) {
|
|
522
|
+
const matches = cmdString.match(/[^"'\s]+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g) || [];
|
|
523
|
+
const parts = matches.map(cleanArgument);
|
|
524
|
+
return {
|
|
525
|
+
command: parts[0] || "",
|
|
526
|
+
args: parts.slice(1),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
export function resolveWindowsCommand(command) {
|
|
530
|
+
if (process.platform === "win32") {
|
|
531
|
+
const commonCmds = ["npm", "npx", "yarn", "pnpm", "gulp", "tsc"];
|
|
532
|
+
if (commonCmds.includes(command.toLowerCase())) {
|
|
533
|
+
return `${command}.cmd`;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return command;
|
|
537
|
+
}
|
|
538
|
+
export function findScriptPath(projectRoot) {
|
|
539
|
+
const pathsToTry = [
|
|
540
|
+
path.resolve(__dirname, "../../overlay-core/dist/overlay.bundle.js"),
|
|
541
|
+
path.resolve(__dirname, "../node_modules/@hoversource/overlay-core/dist/overlay.bundle.js"),
|
|
542
|
+
path.resolve(projectRoot, "node_modules/@hoversource/overlay-core/dist/overlay.bundle.js")
|
|
543
|
+
];
|
|
544
|
+
for (const p of pathsToTry) {
|
|
545
|
+
if (fs.existsSync(p)) {
|
|
546
|
+
return p;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
console.error(`[HoverSource] Critical Error: Could not locate overlay.bundle.js.`);
|
|
550
|
+
console.error(`Please run 'npm run build' inside the HoverSource directory before launching.`);
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
export async function startCdpInjectionWatch(debugPort, scriptWithPort) {
|
|
554
|
+
console.log(`[HoverSource] Watching debug port :${debugPort} for Chromium targets...`);
|
|
555
|
+
let isInjected = false;
|
|
556
|
+
const pollAndInject = async () => {
|
|
557
|
+
try {
|
|
558
|
+
const injectedCount = await injectOverlayScript(debugPort, scriptWithPort);
|
|
559
|
+
if (injectedCount > 0 && !isInjected) {
|
|
560
|
+
console.log(`[HoverSource] Successfully connected and injected into ${injectedCount} target(s).`);
|
|
561
|
+
isInjected = true;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
if (isInjected) {
|
|
566
|
+
console.log(`[HoverSource] Lost connection or target closed. Re-watching...`);
|
|
567
|
+
isInjected = false;
|
|
568
|
+
}
|
|
569
|
+
if (process.env.DEBUG) {
|
|
570
|
+
console.debug("[HoverSource] Ignored poll injection error:", err.message);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
await pollAndInject();
|
|
575
|
+
setInterval(pollAndInject, 2500);
|
|
576
|
+
}
|
|
577
|
+
function determineIfElectron(execArg, projectRoot) {
|
|
578
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
579
|
+
if (!fs.existsSync(pkgPath))
|
|
580
|
+
return false;
|
|
581
|
+
try {
|
|
582
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
583
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
584
|
+
const hasElectronDep = "electron" in allDeps;
|
|
585
|
+
if (hasElectronDep) {
|
|
586
|
+
const lowerCmd = execArg.toLowerCase();
|
|
587
|
+
const isWebDevServer = (lowerCmd.includes("vite") ||
|
|
588
|
+
lowerCmd.includes("next ") ||
|
|
589
|
+
lowerCmd.includes("nuxt") ||
|
|
590
|
+
lowerCmd.includes("webpack") ||
|
|
591
|
+
lowerCmd.includes("astro"));
|
|
592
|
+
const mentionsElectron = lowerCmd.includes("electron");
|
|
593
|
+
if (isWebDevServer && !mentionsElectron) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return hasElectronDep;
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
export async function handleExecMode(execArg, subcommand, projectRoot, debugPort, serverPort, args) {
|
|
604
|
+
let resolved = { execCommand: execArg, isElectron: false };
|
|
605
|
+
if (subcommand) {
|
|
606
|
+
const resolvedSub = resolveSubcommand(subcommand, projectRoot);
|
|
607
|
+
if (!resolvedSub) {
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
resolved = resolvedSub;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
resolved.isElectron = determineIfElectron(execArg, projectRoot);
|
|
614
|
+
}
|
|
615
|
+
const launcher = resolved.isElectron
|
|
616
|
+
? new ElectronCdpLauncher()
|
|
617
|
+
: new WebProxyLauncher();
|
|
618
|
+
await launcher.launch({
|
|
619
|
+
execCommand: resolved.execCommand,
|
|
620
|
+
projectRoot,
|
|
621
|
+
serverPort,
|
|
622
|
+
debugPort,
|
|
623
|
+
args
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async function checkTargetUrlUp(targetUrl) {
|
|
627
|
+
const safeTarget = validateSafeUrl(targetUrl);
|
|
628
|
+
return new Promise((resolve) => {
|
|
629
|
+
try {
|
|
630
|
+
const url = new URL(safeTarget);
|
|
631
|
+
const schemesList = ["http:", "https:"];
|
|
632
|
+
if (schemesList.includes(url.protocol)) {
|
|
633
|
+
const isHttps = url.protocol === "https:";
|
|
634
|
+
const req = isHttps
|
|
635
|
+
? https.get(url, (res) => {
|
|
636
|
+
res.resume();
|
|
637
|
+
resolve(true);
|
|
638
|
+
})
|
|
639
|
+
: http.get(url, (res) => {
|
|
640
|
+
res.resume();
|
|
641
|
+
resolve(true);
|
|
642
|
+
});
|
|
643
|
+
req.setTimeout(2000, () => {
|
|
644
|
+
req.destroy();
|
|
645
|
+
resolve(false);
|
|
646
|
+
});
|
|
647
|
+
req.on("error", () => resolve(false));
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
resolve(false);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
resolve(false);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
async function handleRestartSubcommand(requestedPort, projectRoot, debugPort) {
|
|
659
|
+
console.log(`[HoverSource] Restarting companion server on port ${requestedPort}...`);
|
|
660
|
+
// Send shutdown request to the running server
|
|
661
|
+
const shutdownUrl = `http://127.0.0.1:${requestedPort}/shutdown`;
|
|
662
|
+
const req = http.get(shutdownUrl, (res) => {
|
|
663
|
+
res.resume();
|
|
664
|
+
console.log(`[HoverSource] Sent shutdown request to running instance on port ${requestedPort}.`);
|
|
665
|
+
});
|
|
666
|
+
req.on("error", () => {
|
|
667
|
+
console.log(`[HoverSource] No running instance found on port ${requestedPort}.`);
|
|
668
|
+
});
|
|
669
|
+
req.setTimeout(1000, () => {
|
|
670
|
+
req.destroy();
|
|
671
|
+
});
|
|
672
|
+
// Wait a brief moment for the old process to exit, then start a new one
|
|
673
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
674
|
+
const serverPort = await resolveCompanionPort(requestedPort);
|
|
675
|
+
await startCompanionServer({ port: serverPort, projectRoot, debugPort });
|
|
676
|
+
console.log(`[HoverSource] Companion server restarted on port ${serverPort}.`);
|
|
677
|
+
}
|
|
678
|
+
function checkHasStartScript(projectRoot) {
|
|
679
|
+
try {
|
|
680
|
+
const pkgPath = validateSafePath(path.join(projectRoot, "package.json"));
|
|
681
|
+
if (fs.existsSync(pkgPath)) {
|
|
682
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
683
|
+
return !!pkg?.scripts?.start;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch { }
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
async function handleTargetOrExec(targetArg, execArg, serverPort, projectRoot, debugPort, args, subcommand) {
|
|
690
|
+
if (targetArg) {
|
|
691
|
+
const safeTarget = validateSafeUrl(targetArg);
|
|
692
|
+
const isTargetUp = await checkTargetUrlUp(safeTarget);
|
|
693
|
+
if (!isTargetUp) {
|
|
694
|
+
console.warn(`\x1b[33m[HoverSource] ⚠️ WARNING: Target server at ${safeTarget} is not responding.\x1b[0m`);
|
|
695
|
+
console.warn(`[HoverSource] If your dev server starts slowly, HoverSource will automatically retry requests.`);
|
|
696
|
+
}
|
|
697
|
+
await runProxyMode(safeTarget, serverPort, args);
|
|
698
|
+
}
|
|
699
|
+
else if (execArg) {
|
|
700
|
+
await handleExecMode(execArg, subcommand, projectRoot, debugPort, serverPort, args);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function main() {
|
|
704
|
+
restoreLeftoverPatches();
|
|
705
|
+
const { args, subcommand } = getArgs();
|
|
706
|
+
if (args.help || args.h) {
|
|
707
|
+
showHelp();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const projectRoot = validateSafePath(path.resolve(String(args.root || args.r || ".")));
|
|
711
|
+
const requestedPort = Number.parseInt(String(args.port || args.p || 7300), 10);
|
|
712
|
+
const debugPort = Number.parseInt(String(args["debug-port"] || 9222), 10);
|
|
713
|
+
// Check if start script exists in package.json to avoid conflict
|
|
714
|
+
const hasStartScript = checkHasStartScript(projectRoot);
|
|
715
|
+
// суб-команда Start
|
|
716
|
+
// subcommand Restart
|
|
717
|
+
if (subcommand === "restart") {
|
|
718
|
+
await handleRestartSubcommand(requestedPort, projectRoot, debugPort);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (subcommand === "start" && !hasStartScript) {
|
|
722
|
+
console.log(`[HoverSource] Starting companion server...`);
|
|
723
|
+
const serverPort = await resolveCompanionPort(requestedPort);
|
|
724
|
+
await startCompanionServer({ port: serverPort, projectRoot, debugPort });
|
|
725
|
+
console.log(`[HoverSource] Companion server running on port ${serverPort}.`);
|
|
726
|
+
if (args.dashboard || args.d) {
|
|
727
|
+
openBrowser(`http://localhost:${serverPort}/dashboard`);
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const targetArg = (args.target || args.t);
|
|
732
|
+
const execArg = (args.exec || args.e || subcommand);
|
|
733
|
+
if (!targetArg && !execArg) {
|
|
734
|
+
showHelp();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
console.log(`[HoverSource] Starting...`);
|
|
738
|
+
const serverPort = await resolveCompanionPort(requestedPort);
|
|
739
|
+
await startCompanionServer({ port: serverPort, projectRoot, debugPort });
|
|
740
|
+
console.log(`[HoverSource] Companion server running on port ${serverPort}.`);
|
|
741
|
+
await handleTargetOrExec(targetArg, execArg, serverPort, projectRoot, debugPort, args, subcommand);
|
|
742
|
+
}
|
|
743
|
+
function showHelp() {
|
|
744
|
+
console.log(`
|
|
745
|
+
Usage: hs [subcommand] [options]
|
|
746
|
+
|
|
747
|
+
Subcommands:
|
|
748
|
+
start Start the companion server only.
|
|
749
|
+
restart Restart the running companion server.
|
|
750
|
+
dev, start, etc. Any package.json script name to execute with HoverSource overlay.
|
|
751
|
+
|
|
752
|
+
Options:
|
|
753
|
+
-p, --port=<port> Port for the companion server (default: 7300)
|
|
754
|
+
-t, --target=<url> Direct target dev server URL to proxy (e.g. http://localhost:3000)
|
|
755
|
+
-e, --exec=<command> Command to launch target app (e.g. "npm run dev", "electron .")
|
|
756
|
+
-r, --root=<path> Project root directory (default: current directory)
|
|
757
|
+
-d, --dashboard Automatically open the HoverSource Dashboard in your browser
|
|
758
|
+
-h, --help Show this help message
|
|
759
|
+
--debug-port=<port> CDP Remote debugging port for Electron (default: 9222)
|
|
760
|
+
--proxy-port=<port> Explicit port to bind HoverSource reverse proxy server
|
|
761
|
+
--auto-resolve Automatically resolve port conflicts (terminate process or patch debug port)
|
|
762
|
+
`);
|
|
763
|
+
}
|
|
764
|
+
try {
|
|
765
|
+
await main();
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
console.error(`[HoverSource] CLI Fatal Error:`, err);
|
|
769
|
+
process.exit(1);
|
|
770
|
+
}
|