Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .claude/packages/about-system-info/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ ISP and installed tools as one compact emoji line. Designed to be dropped into
`.github/workflows/about-system-desktop.yml`, with the CLI compiled inside it
so nothing needs installing first.

- **`about-system web`** serves a React + shadcn/ui dashboard (`web/`, its own
Vite config) plus `GET /api/info` from `src/web-server.ts`. The web build runs
*after* the library build because the library build empties `dist/`. React,
Tailwind and the shadcn deps are devDependencies — they are bundled into
`dist/web`, so the published CLI gains no runtime deps.

## Layout

`src/about-system-cli.ts` (bin) · `src/index.ts` (library) ·
`src/info/` (per-block collectors) · `src/cache/` · `src/bench/` ·
`src/system-info-api.ts` · `src/types/`
`src/system-info-api.ts` · `src/web-server.ts` · `src/types/` ·
`web/` (dashboard: `src/App.tsx`, shadcn components in `src/components/ui`)

```bash
cd packages/about-system-info
Expand Down
16 changes: 16 additions & 0 deletions packages/about-system-info/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ about-system --json
about-system --help
```

### Web Dashboard

`about-system web` starts a small HTTP server with a React + [shadcn/ui](https://ui.shadcn.com) dashboard: usage bars for memory and disk, every info block grouped into cards, a filter box, light/dark mode, and auto-refresh every 10 seconds.

```bash
about-system web # http://127.0.0.1:3777
about-system web --port 8080 # or PORT=8080 about-system web
about-system web --host 0.0.0.0 --port 8080 # reachable from other machines
```

It listens on localhost by default. Pass `--host 0.0.0.0` to reach it on a remote server, but note that anyone who can reach the port sees the public IP, open ports, and running processes — prefer an SSH tunnel (`ssh -L 3777:localhost:3777 server`) or put it behind auth.

The page reads `GET /api/info`, which returns the same object as `about-system --json`.

To work on the dashboard itself (`web/`), run `about-system web` in one terminal and `bun run dev:web` in another — the Vite dev server proxies `/api` to port 3777.

### Installation as Shell Greeting

```bash
Expand Down
22 changes: 20 additions & 2 deletions packages/about-system-info/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "about-system",
"version": "0.9.32",
"version": "0.10.0",
"description": "A Node.js script to display key system information with emojis. Cross-platform support for Windows, macOS, and Linux with customizable output and caching.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -26,11 +26,14 @@
}
},
"scripts": {
"build": "vite build",
"build": "vite build && vite build --config web/vite.config.ts",
"build:web": "vite build --config web/vite.config.ts",
"build:tsc": "tsc",
"dev": "vite build --watch",
"dev:web": "vite --config web/vite.config.ts",
"ship": "npm run build && npx standard-version --release-as patch; rm CHANGELOG.md; npm publish",
"start": "npm run build && node dist/about-system-cli.js",
"web": "npm run build && node dist/about-system-cli.js web",
"install-greeting": "npm run build && node dist/about-system-cli.js --install",
"show-settings": "npm run build && node dist/about-system-cli.js --settings-show",
"reset-settings": "npm run build && node dist/about-system-cli.js --settings-reset",
Expand All @@ -39,6 +42,7 @@
"app:dev": "cd native && npm run dev",
"app:build": "cd native && npm run build:desktop",
"test": "vitest run",
"typecheck:web": "tsc -p web/tsconfig.json",
"test:ci": "vitest run --reporter=junit --outputFile=./junit.xml --coverage --coverage.reporter=lcov",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
Expand All @@ -57,6 +61,7 @@
"greeting",
"emoji",
"system-monitoring",
"dashboard",
"desktop-app",
"tauri"
],
Expand Down Expand Up @@ -89,8 +94,21 @@
"ora": "^9.0.0"
},
"devDependencies": {
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^25.0.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^5",
"@vitest/coverage-v8": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.48.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
Expand Down
36 changes: 36 additions & 0 deletions packages/about-system-info/src/about-system-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { getSystemInfo } from "./system-info-api";
import type { SystemInfo, SystemInfoOptions } from "./systeminfo-types";
import { startWebServer, DEFAULT_WEB_PORT, DEFAULT_WEB_HOST } from "./web-server";

const __filename = fileURLToPath(import.meta.url);

Expand Down Expand Up @@ -380,6 +381,33 @@ function installShellGreeting(): void {
}
}

/** Reads `--flag value` or `--flag=value` from args. */
function flagValue(args: string[], flag: string): string | undefined {
for (let i = 0; i < args.length; i++) {
if (args[i] === flag) return args[i + 1];
if (args[i].startsWith(flag + "=")) return args[i].slice(flag.length + 1);
}
return undefined;
}

async function runWebServer(args: string[]): Promise<void> {
const rawPort = flagValue(args, "--port") ?? process.env.PORT;
const port = rawPort === undefined ? DEFAULT_WEB_PORT : Number(rawPort);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`Invalid port: ${rawPort}`);
}
const host = flagValue(args, "--host") ?? DEFAULT_WEB_HOST;

const { url } = await startWebServer({ port, host });
console.log(`About System web UI running at ${url}`);
if (host === DEFAULT_WEB_HOST) {
console.log("Listening on localhost only. Use --host 0.0.0.0 to expose it on the network.");
} else {
console.log("Warning: system details (public IP, ports, processes) are visible to anyone who can reach this port.");
}
console.log("Press Ctrl+C to stop.");
}

function parseCLIMode(args: string[]): string[] | null {
for (const arg of args) {
if (!arg.startsWith("--") && arg.includes(",")) {
Expand All @@ -406,6 +434,7 @@ System Info Script - TypeScript Version
Usage:
about-system [options]
about-system <part1,part2,...> # CLI mode: show specific parts only
about-system web [--port N] [--host H] # Serve the web dashboard

Options:
--help, -h Show this help message
Expand All @@ -429,6 +458,8 @@ Examples:
about-system --set emojis.cpu "🚀 "
about-system --set labels.cpu "Processor"
about-system --json
about-system web # http://127.0.0.1:${DEFAULT_WEB_PORT}
about-system web --port 8080 --host 0.0.0.0

Settings file: ${SETTINGS_FILE}
Cache file: ${CACHE_FILE}
Expand Down Expand Up @@ -470,6 +501,11 @@ async function main(): Promise<void> {
return;
}

if (args[0] === "web" || args.includes("--web")) {
await runWebServer(args);
return;
}

if (args.includes("--json")) {
const info = await getSystemInfo();
console.log(JSON.stringify(info, null, 2));
Expand Down
69 changes: 69 additions & 0 deletions packages/about-system-info/src/web-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import type http from "http";

vi.mock("./system-info-api", () => ({
getSystemInfo: vi.fn(async () => ({ user: "tester", hostname: "box", ram_used: "8/16GB" })),
}));

import { resolveStaticFile, startWebServer } from "./web-server";

let webRoot: string;
let server: http.Server;
let baseUrl: string;

beforeAll(async () => {
webRoot = fs.mkdtempSync(path.join(os.tmpdir(), "about-system-web-"));
fs.mkdirSync(path.join(webRoot, "assets"));
fs.writeFileSync(path.join(webRoot, "index.html"), "<!doctype html><div id=root></div>");
fs.writeFileSync(path.join(webRoot, "assets", "app.js"), "console.log(1)");

({ server, url: baseUrl } = await startWebServer({ port: 0, webRoot }));
});

afterAll(() => {
server?.close();
fs.rmSync(webRoot, { recursive: true, force: true });
});

describe("resolveStaticFile", () => {
it("serves files inside the web root", () => {
expect(resolveStaticFile(webRoot, "/assets/app.js")).toBe(path.join(webRoot, "assets", "app.js"));
});

it("falls back to index.html for unknown routes", () => {
expect(resolveStaticFile(webRoot, "/some/route")).toBe(path.join(webRoot, "index.html"));
});

it("never escapes the web root", () => {
for (const attempt of ["/../../etc/passwd", "/%2e%2e/%2e%2e/etc/passwd", "/..%2f..%2fetc/passwd"]) {
const file = resolveStaticFile(webRoot, attempt);
expect(file === null || file.startsWith(webRoot)).toBe(true);
}
});
});

describe("web server", () => {
it("returns system info as JSON from /api/info", async () => {
const res = await fetch(`${baseUrl}/api/info`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
expect(await res.json()).toEqual({ user: "tester", hostname: "box", ram_used: "8/16GB" });
});

it("serves the dashboard with the right content types", async () => {
const index = await fetch(`${baseUrl}/`);
expect(index.headers.get("content-type")).toContain("text/html");
expect(await index.text()).toContain('id=root');

const js = await fetch(`${baseUrl}/assets/app.js`);
expect(js.headers.get("content-type")).toContain("text/javascript");
});

it("rejects non-GET methods", async () => {
const res = await fetch(`${baseUrl}/api/info`, { method: "POST" });
expect(res.status).toBe(405);
});
});
120 changes: 120 additions & 0 deletions packages/about-system-info/src/web-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @fileoverview `about-system web` — serves the React dashboard and a JSON API.
*
* The dashboard is prebuilt into dist/web by `vite build --config web/vite.config.ts`
* and served as static files; the only dynamic route is GET /api/info, which
* returns the same object as `about-system --json`.
*/

import http from "http";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { getSystemInfo } from "./system-info-api";

export interface WebServerOptions {
port?: number;
host?: string;
/** Directory holding the built dashboard. Defaults to dist/web next to this file. */
webRoot?: string;
}

export const DEFAULT_WEB_PORT = 3777;
export const DEFAULT_WEB_HOST = "127.0.0.1";

const MIME_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
};

function defaultWebRoot(): string {
return path.join(path.dirname(fileURLToPath(import.meta.url)), "web");
}

/**
* Resolves a request path to a file inside webRoot, or null if it escapes the
* root or does not exist. Unknown paths fall back to index.html (SPA routing).
*/
export function resolveStaticFile(webRoot: string, urlPath: string): string | null {
let decoded: string;
try {
decoded = decodeURIComponent(urlPath.split("?")[0]);
} catch {
return null;
}
const root = path.resolve(webRoot);
const candidate = path.resolve(root, "." + path.posix.normalize("/" + decoded));
if (candidate !== root && !candidate.startsWith(root + path.sep)) return null;

if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
const index = path.join(root, "index.html");
return fs.existsSync(index) ? index : null;
}

function send(res: http.ServerResponse, status: number, type: string, body: string | Buffer): void {
res.writeHead(status, {
"Content-Type": type,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
res.end(body);
}

export function createWebServer(options: WebServerOptions = {}): http.Server {
const webRoot = options.webRoot ?? defaultWebRoot();

return http.createServer(async (req, res) => {
const url = req.url ?? "/";

if (req.method !== "GET" && req.method !== "HEAD") {
send(res, 405, "text/plain; charset=utf-8", "Method Not Allowed");
return;
}

if (url === "/api/info" || url.startsWith("/api/info?")) {
try {
const info = await getSystemInfo();
send(res, 200, MIME_TYPES[".json"], JSON.stringify(info));
} catch (error) {
send(res, 500, MIME_TYPES[".json"], JSON.stringify({ error: (error as Error).message }));
}
return;
}

const file = resolveStaticFile(webRoot, url);
if (!file) {
send(
res,
404,
"text/plain; charset=utf-8",
"Dashboard not found. Build it with: npm run build:web"
);
return;
}
const type = MIME_TYPES[path.extname(file)] ?? "application/octet-stream";
send(res, 200, type, fs.readFileSync(file));
});
}

/** Starts the dashboard server and resolves with the URL it is listening on. */
export function startWebServer(options: WebServerOptions = {}): Promise<{ server: http.Server; url: string }> {
const port = options.port ?? DEFAULT_WEB_PORT;
const host = options.host ?? DEFAULT_WEB_HOST;
const server = createWebServer(options);

return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
const address = server.address();
const actualPort = typeof address === "object" && address ? address.port : port;
const shownHost = host === "0.0.0.0" || host === "::" ? "localhost" : host;
resolve({ server, url: `http://${shownHost}:${actualPort}` });
});
});
}
2 changes: 1 addition & 1 deletion packages/about-system-info/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default defineConfig({
fileName: (format, entryName) => `${entryName}.js`,
},
rollupOptions: {
external: ['os', 'fs', 'path', 'child_process', 'https', 'url'],
external: ['os', 'fs', 'path', 'child_process', 'http', 'https', 'url'],
output: {
preserveModules: false,
exports: 'named',
Expand Down
20 changes: 20 additions & 0 deletions packages/about-system-info/web/components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
Loading
Loading