feat(cli): smart port selection with instance reuse (#226)

* feat(cli): smart port selection with instance reuse

Replace the simple 10-port retry loop with best-in-class port handling:

- Multi-host port testing (127.0.0.1, 0.0.0.0, ::1, ::) catches ports
  occupied by SSH forwarding or other interfaces invisible to localhost
- HTTP probe (/__hyperframes_config) detects existing HyperFrames
  preview servers — reuses same-project instances instead of spawning
  duplicates, skips different-project instances
- PID detection via lsof for actionable "Port N in use by PID X" logs
- Expanded scan range from 10 to 100 ports
- Added --force-new flag to bypass instance detection
- Async PID detection (execFile, no shell) and parallel host testing

Fixes the "10 ports are all in use" error that occurs when zombie
preview servers accumulate or devbox port forwarding occupies ports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add --list and --kill-all flags to preview command

- `hyperframes preview --list` scans the port range and displays all
  active HyperFrames preview servers with their project name, directory,
  and PID
- `hyperframes preview --kill-all` kills all active preview servers
- Port scanning uses parallel batched probes (20 at a time) for speed

Gives users visibility into zombie preview servers and a one-command
way to clean them up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-08 10:58:57 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fe9cd301ec
commit aea128b606
3 changed files with 451 additions and 55 deletions
+82 -55
View File
@@ -6,6 +6,9 @@ export const examples: Example[] = [
["Preview the current project", "hyperframes preview"],
["Preview a specific project directory", "hyperframes preview ./my-video"],
["Use a custom port", "hyperframes preview --port 8080"],
["Force a new server even if one is already running", "hyperframes preview --force-new"],
["List all active preview servers", "hyperframes preview --list"],
["Kill all active preview servers", "hyperframes preview --kill-all"],
];
import { existsSync, lstatSync, symlinkSync, unlinkSync, readlinkSync, mkdirSync } from "node:fs";
import { resolve, dirname, basename, join } from "node:path";
@@ -16,65 +19,69 @@ import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { lintProject } from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
/**
* Try to start a server on the given port, auto-incrementing up to maxAttempts
* times if the port is already in use. Returns the running server and actual port.
*
* Uses createAdaptorServer (no auto-listen) so we control the bind and can
* retry on EADDRINUSE without TOCTOU races.
*/
async function serveWithPortFallback(
fetch: Parameters<typeof import("@hono/node-server").serve>[0]["fetch"],
startPort: number,
maxAttempts = 10,
): Promise<{ server: import("@hono/node-server").ServerType; port: number }> {
const { createAdaptorServer } = await import("@hono/node-server");
const server = createAdaptorServer({ fetch });
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const port = startPort + attempt;
try {
await new Promise<void>((resolveListener, rejectListener) => {
const onError = (err: NodeJS.ErrnoException): void => {
server.removeListener("listening", onListening);
rejectListener(err);
};
const onListening = (): void => {
server.removeListener("error", onError);
resolveListener();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(port);
});
return { server, port };
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EADDRINUSE") {
continue; // try next port
}
throw err; // unexpected error — don't swallow it
}
}
const lastPort = startPort + maxAttempts - 1;
throw new Error(
`Ports ${startPort}${lastPort} are all in use. Use --port to specify a different port.`,
);
}
import {
findPortAndServe,
scanActiveServers,
killActiveServers,
type FindPortResult,
} from "../server/portUtils.js";
export default defineCommand({
meta: { name: "preview", description: "Start the studio for previewing compositions" },
args: {
dir: { type: "positional", description: "Project directory", required: false },
port: { type: "string", description: "Port to run the preview server on", default: "3002" },
"force-new": {
type: "boolean",
description: "Start a new server even if one is already running for this project",
default: false,
},
list: {
type: "boolean",
description: "List all active preview servers and exit",
default: false,
},
"kill-all": {
type: "boolean",
description: "Kill all active preview servers and exit",
default: false,
},
},
async run({ args }) {
const startPort = parseInt(args.port ?? "3002", 10);
// --list: scan and display active servers
if (args.list) {
const servers = await scanActiveServers(startPort);
if (servers.length === 0) {
console.log("\n No active preview servers found.\n");
return;
}
console.log(`\n ${c.bold("Active preview servers:")}\n`);
for (const s of servers) {
const pidStr = s.pid ? c.dim(` (PID ${s.pid})`) : "";
console.log(
` ${c.accent(`Port ${s.port}`)} ${s.projectName} ${c.dim(s.projectDir)}${pidStr}`,
);
}
console.log(`\n ${servers.length} server${servers.length === 1 ? "" : "s"} running.\n`);
return;
}
// --kill-all: kill all active servers
if (args["kill-all"]) {
const servers = await scanActiveServers(startPort);
if (servers.length === 0) {
console.log("\n No active preview servers to kill.\n");
return;
}
const killed = await killActiveServers(startPort);
console.log(`\n Killed ${killed} preview server${killed === 1 ? "" : "s"}.\n`);
return;
}
const rawArg = args.dir;
const dir = resolve(rawArg ?? ".");
const startPort = parseInt(args.port ?? "3002", 10);
// Compute display name: preserve symlink/CWD name when user runs "hyperframes preview ."
const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./";
@@ -102,7 +109,8 @@ export default defineCommand({
return runLocalStudioMode(dir, projectName);
}
return runEmbeddedMode(dir, startPort, projectName);
const forceNew = !!args["force-new"];
return runEmbeddedMode(dir, startPort, projectName, forceNew);
},
});
@@ -300,11 +308,15 @@ async function runLocalStudioMode(dir: string, projectName?: string): Promise<vo
/**
* Embedded mode: serve the pre-built studio SPA with a standalone Hono server.
* Works without any additional dependencies — the studio is bundled in dist/.
*
* If an existing HyperFrames server for the same project is detected,
* reuses it instead of starting a new one (unless --force-new is set).
*/
async function runEmbeddedMode(
dir: string,
startPort: number,
projectName?: string,
forceNew = false,
): Promise<void> {
const { createStudioServer } = await import("../server/studioServer.js");
@@ -315,9 +327,9 @@ async function runEmbeddedMode(
const s = clack.spinner();
s.start("Starting studio...");
let actualPort: number;
let result: FindPortResult;
try {
({ port: actualPort } = await serveWithPortFallback(app.fetch, startPort));
result = await findPortAndServe(app.fetch, startPort, dir, forceNew);
} catch (err: unknown) {
s.stop(c.error("Failed to start studio"));
console.error();
@@ -327,11 +339,26 @@ async function runEmbeddedMode(
return;
}
const url = `http://localhost:${actualPort}`;
if (result.type === "already-running") {
const url = `http://localhost:${result.port}`;
s.stop(c.success("Already running"));
console.log();
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);
console.log(` ${c.dim("Studio")} ${c.accent(url)}`);
console.log();
console.log(
` ${c.dim("Reusing existing server. Use --force-new to start a fresh instance.")}`,
);
console.log();
import("open").then((mod) => mod.default(`${url}#project/${pName}`)).catch(() => {});
return;
}
const url = `http://localhost:${result.port}`;
s.stop(c.success("Studio running"));
console.log();
if (actualPort !== startPort) {
console.log(` ${c.warn(`Port ${startPort} is in use, using ${actualPort} instead`)}`);
if (result.port !== startPort) {
console.log(` ${c.warn(`Port ${startPort} is in use, using ${result.port} instead`)}`);
console.log();
}
console.log(` ${c.dim("Project")} ${c.accent(pName)}`);