Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "copilot-lens",
"version": "1.2.2",
"description": "A local dashboard to visualize and analyze your GitHub Copilot CLI sessions",
"engines": {
"node": ">=20"
},
"main": "dist/server.js",
"bin": {
"copilot-lens": "dist/cli.js"
Expand Down
37 changes: 37 additions & 0 deletions src/__tests__/cli-version-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { meetsNodeRequirement } from "../cli";

describe("Node.js version check", () => {
it("rejects Node 16", () => {
expect(meetsNodeRequirement("16.20.2")).toBe(false);
});

it("rejects Node 14", () => {
expect(meetsNodeRequirement("14.21.3")).toBe(false);
});

it("rejects Node 12", () => {
expect(meetsNodeRequirement("12.22.12")).toBe(false);
});

it("rejects Node 18", () => {
expect(meetsNodeRequirement("18.0.0")).toBe(false);
});

it("rejects Node 18 (LTS point release)", () => {
expect(meetsNodeRequirement("18.19.1")).toBe(false);
});

it("accepts Node 20", () => {
expect(meetsNodeRequirement("20.11.0")).toBe(true);
});

it("accepts Node 22", () => {
expect(meetsNodeRequirement("22.4.1")).toBe(true);
});

it("uses the same parsing logic as cli.ts", () => {
// Verify the actual current Node version passes
expect(meetsNodeRequirement(process.versions.node)).toBe(true);
});
});
102 changes: 59 additions & 43 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
#!/usr/bin/env node

process.on("uncaughtException", (err) => {
console.error("Uncaught error:", err.message);
});
process.on("unhandledRejection", (err: any) => {
console.error("Unhandled rejection:", err?.message || err);
});
export function meetsNodeRequirement(versionString: string): boolean {
const [major] = versionString.split('.').map(Number);
return major >= 20;
}

const args = process.argv.slice(2);
// Node.js version gate — must run before any modern syntax/APIs.
if (!meetsNodeRequirement(process.versions.node)) {
console.error(
`Error: copilot-lens requires Node.js 20 or later. You are running v${process.versions.node}.`
);
process.exit(1);
}

if (args[0] === "tokens") {
const { runTokensTUI } = require("./cli-tokens");
runTokensTUI(args.slice(1));
} else if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
process.stdout.write(`
// Avoid running the CLI side-effects when imported by Vitest
if (process.env.VITEST !== "true") {
process.on("uncaughtException", (err) => {
console.error("Uncaught error:", err.message);
});
process.on("unhandledRejection", (err: any) => {
console.error("Unhandled rejection:", err?.message || err);
});

const args = process.argv.slice(2);

if (args[0] === "tokens") {
const { runTokensTUI } = require("./cli-tokens");
runTokensTUI(args.slice(1));
} else if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
process.stdout.write(`
Usage: copilot-lens [command] [options]

Commands:
Expand All @@ -27,42 +42,43 @@ if (args[0] === "tokens") {

Run "copilot-lens tokens --help" for tokens command options.
`);
} else {
const { createApp } = require("./server");
} else {
const { createApp } = require("./server");

function getArg(name: string, fallback: string): string {
const idx = args.indexOf(name);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
}
function getArg(name: string, fallback: string): string {
const idx = args.indexOf(name);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
}

const port = parseInt(getArg("--port", "3000"), 10);
const host = getArg("--host", "localhost");
const shouldOpen = args.includes("--open");
const port = parseInt(getArg("--port", "3000"), 10);
const host = getArg("--host", "localhost");
const shouldOpen = args.includes("--open");

const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
if (!LOOPBACK_HOSTS.has(host)) {
console.warn(
`\n ⚠️ Binding to ${host} exposes your AI session data to the network with no authentication.\n`
);
}
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
if (!LOOPBACK_HOSTS.has(host)) {
console.warn(
`\n ⚠️ Binding to ${host} exposes your AI session data to the network with no authentication.\n`
);
}

const app = createApp({ host });
const app = createApp({ host });

app.listen(port, host, async () => {
const url = `http://${host}:${port}`;
console.log(`\n 👓 Copilot Lens is running at ${url}\n`);
app.listen(port, host, async () => {
const url = `http://${host}:${port}`;
console.log(`\n 👓 Copilot Lens is running at ${url}\n`);

if (shouldOpen) {
// Use execFile with an argument array (no shell) so the URL/host cannot
// be interpreted as shell syntax.
const { execFile } = await import("child_process");
if (process.platform === "win32") {
execFile("cmd", ["/c", "start", "", url]);
} else if (process.platform === "darwin") {
execFile("open", [url]);
} else {
execFile("xdg-open", [url]);
if (shouldOpen) {
// Use execFile with an argument array (no shell) so the URL/host cannot
// be interpreted as shell syntax.
const { execFile } = await import("child_process");
if (process.platform === "win32") {
execFile("cmd", ["/c", "start", "", url]);
} else if (process.platform === "darwin") {
execFile("open", [url]);
} else {
execFile("xdg-open", [url]);
}
}
}
});
});
}
}
Loading