From e2b0bdc2c173d97f7fcf19a243e7ea716da051e6 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Tue, 15 Sep 2026 11:58:51 -0500 Subject: [PATCH 1/2] fix(routes): warn when the routes file cannot be read loadRoutes() treated a routes.json it could not read (for example EACCES or EISDIR) as empty without calling onWarning, while loadRoutesRaw() already reported it. Report it the same way, so callers can tell an unreadable registry apart from an empty one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BSDFAb4giiESfUnAHgHG3S --- packages/portless/src/routes.test.ts | 11 +++++++++++ packages/portless/src/routes.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/portless/src/routes.test.ts b/packages/portless/src/routes.test.ts index 6049520c..44f511fb 100644 --- a/packages/portless/src/routes.test.ts +++ b/packages/portless/src/routes.test.ts @@ -68,6 +68,17 @@ describe("RouteStore", () => { expect(warnings[0]).toContain("expected array"); }); + it("calls onWarning when routes file cannot be read", () => { + const warnings: string[] = []; + const warnStore = new RouteStore(tmpDir, { + onWarning: (msg) => warnings.push(msg), + }); + fs.mkdirSync(warnStore.getRoutesPath()); + expect(warnStore.loadRoutes()).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Could not read routes file"); + }); + it("filters out entries with invalid schema", () => { store.ensureDir(); const routes = [ diff --git a/packages/portless/src/routes.ts b/packages/portless/src/routes.ts index 412c131b..80792c43 100644 --- a/packages/portless/src/routes.ts +++ b/packages/portless/src/routes.ts @@ -209,7 +209,9 @@ export class RouteStore { } } return alive; - } catch { + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.onWarning?.(`Could not read routes file: ${message}`); return []; } } From be21b4c68031c94065c6e09d763dd5709845a73c Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Tue, 15 Sep 2026 11:58:54 -0500 Subject: [PATCH 2/2] feat(cli): add --json output to list, get, doctor, and service status --json is a global flag with one contract: stdout carries only the JSON document, warnings and errors go to stderr, a command that fails exits non-zero, and keys are camelCase like routes.json and portless.json. Commands without JSON output reject the flag, and a --json after the child command still reaches the child. list --json returns the routes the proxy serves (live processes and aliases) with their public URLs, without persisting the stale-route cleanup. It exits 1 when routes.json cannot be read or parsed, instead of printing an empty list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BSDFAb4giiESfUnAHgHG3S --- README.md | 30 ++++ apps/docs/src/app/commands/page.mdx | 16 ++ packages/portless/src/cli-utils.test.ts | 19 ++ packages/portless/src/cli-utils.ts | 21 +++ packages/portless/src/cli.test.ts | 227 ++++++++++++++++++++++++ packages/portless/src/cli.ts | 118 +++++++++--- packages/portless/src/service.test.ts | 53 ++++++ packages/portless/src/service.ts | 28 ++- skills/portless/SKILL.md | 11 ++ 9 files changed, 499 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 48e0d452..f5aeb05d 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,7 @@ portless alias # Register a static route (e.g. for Docker) portless alias --force # Overwrite an existing route portless alias --remove # Remove a static route portless list # Show active routes +portless list --json # Print active routes as JSON (for scripts) portless doctor # Check proxy, routes, DNS, and CA trust portless trust # Add local CA to system trust store portless clean # Remove state, CA trust entry, and hosts block @@ -520,9 +521,38 @@ portless service install # Start HTTPS proxy when the OS starts portless service install --lan # Start service in LAN mode portless service install --wildcard # Persist wildcard routing in the service portless service status # Show service and proxy status +portless service status --json # Service and proxy status as JSON portless service uninstall # Remove the startup service ``` +### JSON output + +`list`, `get`, `doctor`, and `service status` accept `--json` for scripts and agents. Other commands reject the flag. + +- stdout carries only the JSON document, without colors. Warnings and errors go to stderr, and a command that fails exits non-zero. +- Keys are camelCase, like `routes.json` and `portless.json`. Optional fields are omitted when unset. + +```bash +portless list --json +# [ +# { +# "hostname": "myapp.localhost", +# "pathPrefix": "/api", +# "port": 4123, +# "pid": 51234, +# "alias": false, +# "url": "https://myapp.localhost/api" +# } +# ] +``` + +| Command | Output | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `portless list --json` | Array of the routes the proxy serves (live processes and aliases): `hostname`, `pathPrefix`, `port` (the app's port), `pid` (`0` for an alias), `alias`, `url`, and `tailscaleUrl` or `ngrokUrl` when shared. A routes file that cannot be read or parsed exits 1 instead of printing `[]`. | +| `portless get --json` | `name`, `hostname`, `pathPrefix`, `url`, `proxyPort`, `tls`. Like `get`, it builds the URL without checking whether the service is running. | +| `portless doctor --json` | `version`, `node`, `platform`, `arch`, `stateDir`, `proxyPort`, `tls`, `tlds`, `lanMode`, `findings` (each with `status`, `message`, `hint`), `failures`, `warnings`. Exits 1 when a check fails, like `doctor`. | +| `portless service status --json` | `installed`, `managerState`, `proxyPort`, `proxyRunning`, `tls`, `tlds`, `lanMode`, `lanIp`, `wildcard`, `stateDir`, `serviceEntry`. | + ### Options ``` diff --git a/apps/docs/src/app/commands/page.mdx b/apps/docs/src/app/commands/page.mdx index d05960b5..ee35f047 100644 --- a/apps/docs/src/app/commands/page.mdx +++ b/apps/docs/src/app/commands/page.mdx @@ -285,6 +285,22 @@ PORTLESS=0 pnpm dev Runs the command directly without the proxy. +## JSON output + +```bash +portless list --json +portless get --json +portless doctor --json +portless service status --json +``` + +These commands print a JSON document for scripts and agents. stdout carries only the JSON, without colors. Warnings and errors go to stderr, and a command that fails exits non-zero. Other commands reject `--json`. Keys are camelCase, like `routes.json` and `portless.json`, and optional fields are omitted when unset. + +- `list`: an array of the routes the proxy serves (live processes and aliases) with `hostname`, `pathPrefix`, `port` (the app's port), `pid` (`0` for an alias), `alias`, `url`, and `tailscaleUrl` or `ngrokUrl` when shared. If the routes file cannot be read or parsed, it exits 1 instead of printing `[]`. +- `get`: `name`, `hostname`, `pathPrefix`, `url`, `proxyPort`, `tls`. Like `get`, it builds the URL without checking whether the service is running. +- `doctor`: `version`, `node`, `platform`, `arch`, `stateDir`, `proxyPort`, `tls`, `tlds`, `lanMode`, `findings` (each with `status`, `message`, `hint`), `failures`, `warnings`. Exits 1 when a check fails, like `doctor`. +- `service status`: `installed`, `managerState`, `proxyPort`, `proxyRunning`, `tls`, `tlds`, `lanMode`, `lanIp`, `wildcard`, `stateDir`, `serviceEntry`. + ## Info ```bash diff --git a/packages/portless/src/cli-utils.test.ts b/packages/portless/src/cli-utils.test.ts index 8980bf1d..b43763ee 100644 --- a/packages/portless/src/cli-utils.test.ts +++ b/packages/portless/src/cli-utils.test.ts @@ -44,7 +44,26 @@ import { writeTldFile, writeTldsFile, writeTlsMarker, + supportsJsonOutput, } from "./cli-utils.js"; + +describe("supportsJsonOutput", () => { + it("accepts the commands that print JSON", () => { + expect(supportsJsonOutput(["list"])).toBe(true); + expect(supportsJsonOutput(["get", "backend"])).toBe(true); + expect(supportsJsonOutput(["doctor"])).toBe(true); + expect(supportsJsonOutput(["service", "status"])).toBe(true); + }); + + it("rejects other commands and app runs", () => { + expect(supportsJsonOutput(["service", "install"])).toBe(false); + expect(supportsJsonOutput(["alias", "db", "5432"])).toBe(false); + expect(supportsJsonOutput(["run", "next", "dev"])).toBe(false); + expect(supportsJsonOutput(["myapp", "next", "dev"])).toBe(false); + expect(supportsJsonOutput([])).toBe(false); + }); +}); + describe("proxy listener interface", () => { it("uses only IPv4 and IPv6 loopback outside LAN mode", () => { expect(getProxyBindTargets(false)).toEqual([ diff --git a/packages/portless/src/cli-utils.ts b/packages/portless/src/cli-utils.ts index cb462a46..d0c57f56 100644 --- a/packages/portless/src/cli-utils.ts +++ b/packages/portless/src/cli-utils.ts @@ -264,6 +264,27 @@ export function killTree( } } +// --------------------------------------------------------------------------- +// JSON output +// --------------------------------------------------------------------------- + +/** Commands that accept the global --json flag. */ +export const JSON_OUTPUT_COMMANDS = ["list", "get", "doctor", "service status"] as const; + +/** Whether the command in `args` (global flags already stripped) supports --json. */ +export function supportsJsonOutput(args: readonly string[]): boolean { + const command = args[0] === "service" ? `service ${args[1] ?? ""}` : (args[0] ?? ""); + return (JSON_OUTPUT_COMMANDS as readonly string[]).includes(command); +} + +/** + * Print a command's --json output. Only the JSON goes to stdout, so callers + * send warnings and errors to stderr. + */ +export function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + // --------------------------------------------------------------------------- // Port configuration // --------------------------------------------------------------------------- diff --git a/packages/portless/src/cli.test.ts b/packages/portless/src/cli.test.ts index c0f8aa09..86d9ffd5 100644 --- a/packages/portless/src/cli.test.ts +++ b/packages/portless/src/cli.test.ts @@ -276,6 +276,233 @@ describe("CLI", () => { const { status } = run(["list"]); expect(status).toBe(0); }); + + describe("with a state directory", () => { + let stateDir: string; + let routesPath: string; + let proxyPort: number; + + beforeEach(async () => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "portless-list-")); + routesPath = path.join(stateDir, "routes.json"); + proxyPort = await getFreePort(); + fs.writeFileSync(path.join(stateDir, "proxy.port"), proxyPort.toString()); + fs.writeFileSync(path.join(stateDir, "proxy.tls"), "1"); + }); + + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + function writeRoutes(routes: unknown): void { + fs.writeFileSync(routesPath, JSON.stringify(routes)); + } + + function list(args: string[] = []) { + return run(["list", ...args], { env: { PORTLESS_STATE_DIR: stateDir } }); + } + + it("prints live routes and aliases as JSON with --json", () => { + writeRoutes([ + { hostname: "myapp.localhost", port: 4001, pid: process.pid }, + { hostname: "myapp.localhost", port: 4002, pid: process.pid, pathPrefix: "/api" }, + { hostname: "stale.localhost", port: 4003, pid: 999999 }, + { hostname: "db.localhost", port: 5432, pid: 0 }, + ]); + + const { status, stdout } = list(["--json"]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual([ + { + hostname: "myapp.localhost", + port: 4001, + pid: process.pid, + alias: false, + url: `https://myapp.localhost:${proxyPort}`, + }, + { + hostname: "myapp.localhost", + pathPrefix: "/api", + port: 4002, + pid: process.pid, + alias: false, + url: `https://myapp.localhost:${proxyPort}/api`, + }, + { + hostname: "db.localhost", + port: 5432, + pid: 0, + alias: true, + url: `https://db.localhost:${proxyPort}`, + }, + ]); + }); + + it("includes tunnel URLs with the path prefix in --json output", () => { + writeRoutes([ + { + hostname: "myapp.localhost", + port: 4001, + pid: process.pid, + pathPrefix: "/api", + tailscaleUrl: "https://devbox.tail1234.ts.net", + ngrokUrl: "https://myapp.ngrok.app", + }, + ]); + + const { status, stdout } = list(["--json"]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)[0]).toMatchObject({ + tailscaleUrl: "https://devbox.tail1234.ts.net/api", + ngrokUrl: "https://myapp.ngrok.app/api", + }); + }); + + it("does not write stale routes back with --json", () => { + writeRoutes([{ hostname: "stale.localhost", port: 4003, pid: 999999 }]); + const before = fs.readFileSync(routesPath, "utf-8"); + + const { status, stdout } = list(["--json"]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + expect(fs.readFileSync(routesPath, "utf-8")).toBe(before); + }); + + it("prints an empty array with --json when no routes are registered", () => { + const { status, stdout } = list(["--json"]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + }); + + it("fails with --json when the routes file is corrupted", () => { + fs.writeFileSync(routesPath, "not json"); + + const { status, stdout, stderr } = list(["--json"]); + + expect(status).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("invalid JSON"); + }); + + it("fails with --json when the routes file cannot be read", () => { + fs.mkdirSync(routesPath); + + const { status, stdout, stderr } = list(["--json"]); + + expect(status).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("Could not read routes file"); + }); + + it("keeps the human-readable output without --json", () => { + writeRoutes([ + { hostname: "myapp.localhost", port: 4002, pid: process.pid, pathPrefix: "/api" }, + { hostname: "db.localhost", port: 5432, pid: 0 }, + ]); + + const { status, stdout } = list(); + + expect(status).toBe(0); + expect(stdout).toContain("Active routes:"); + expect(stdout).toContain(`https://myapp.localhost:${proxyPort}/api -> localhost:4002`); + expect(stdout).toContain(`https://db.localhost:${proxyPort} -> localhost:5432 (alias)`); + }); + }); + }); + + describe("--json", () => { + let stateDir: string; + let proxyPort: number; + + beforeEach(async () => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "portless-json-")); + proxyPort = await getFreePort(); + fs.writeFileSync(path.join(stateDir, "proxy.port"), proxyPort.toString()); + }); + + afterEach(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + }); + + function runInState(args: string[], env: Record = {}) { + return run(args, { env: { PORTLESS_STATE_DIR: stateDir, ...env } }); + } + + it("is accepted before the command", () => { + // PORTLESS=0 keeps a regression harmless: if the flag were not stripped, + // "--json" would be taken as an app name and portless would start a proxy. + const { status, stdout } = runInState(["--json", "list"], { PORTLESS: "0" }); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual([]); + }); + + it("prints the URL that get builds", () => { + fs.writeFileSync(path.join(stateDir, "proxy.tls"), "1"); + + const { status, stdout } = runInState(["get", "backend", "--no-worktree", "--json"]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + name: "backend", + hostname: "backend.localhost", + url: `https://backend.localhost:${proxyPort}`, + proxyPort, + tls: true, + }); + }); + + it("includes the path prefix in get output", () => { + const { status, stdout } = runInState([ + "get", + "backend", + "--no-worktree", + "--path", + "/api", + "--json", + ]); + + expect(status).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + pathPrefix: "/api", + url: `http://backend.localhost:${proxyPort}/api`, + }); + }); + + it("prints doctor findings with the summary counts", () => { + const { status, stdout } = runInState(["doctor", "--json"]); + + expect(status).toBe(0); + const report = JSON.parse(stdout); + expect(report).toMatchObject({ stateDir, proxyPort, tls: false, failures: 0 }); + expect(report.findings).toContainEqual({ + status: "warn", + message: `Proxy is not running on port ${proxyPort}.`, + hint: expect.stringContaining("portless proxy start"), + }); + }); + + it("is rejected by commands without JSON output", () => { + const { status, stdout, stderr } = runInState(["alias", "db", "5432", "--json"], { + PORTLESS_SYNC_HOSTS: "0", + }); + + expect(status).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("--json is only supported by"); + expect(fs.existsSync(path.join(stateDir, "routes.json"))).toBe(false); + }); + + it("is passed through when it follows the child command", () => { + const { status, args } = captureBypassedExpo(["run", "expo", "start", "--json"]); + + expect(status).toBe(0); + expect(args).toEqual(["start", "--json"]); + }); }); describe("doctor", () => { diff --git a/packages/portless/src/cli.ts b/packages/portless/src/cli.ts index b2258c33..a83e5925 100644 --- a/packages/portless/src/cli.ts +++ b/packages/portless/src/cli.ts @@ -93,6 +93,9 @@ import { writeTldFile, writeTldsFile, writeTlsMarker, + JSON_OUTPUT_COMMANDS, + printJson, + supportsJsonOutput, } from "./cli-utils.js"; import { attemptCATrustRemovalForCleanup, @@ -1096,6 +1099,36 @@ function listRoutes(store: RouteStore, proxyPort: number, tls: boolean): void { console.log(); } +/** + * `portless list --json`: the routes `portless list` shows, with their public + * URLs. A routes file that cannot be read or parsed is an error (exit 1), so + * callers can tell it apart from having no routes. + */ +function listRoutesJson(dir: string, proxyPort: number, tls: boolean): void { + let loadError: string | undefined; + const store = new RouteStore(dir, { + onWarning: (msg) => { + loadError = msg; + }, + }); + const routes = store.loadRoutes().map((route) => ({ + hostname: route.hostname, + pathPrefix: route.pathPrefix, + port: route.port, + pid: route.pid, + alias: route.pid === 0, + url: formatUrl(route.hostname, proxyPort, tls, route.pathPrefix), + tailscaleUrl: route.tailscaleUrl ? `${route.tailscaleUrl}${route.pathPrefix ?? ""}` : undefined, + ngrokUrl: route.ngrokUrl ? `${route.ngrokUrl}${route.pathPrefix ?? ""}` : undefined, + })); + if (loadError) { + console.error(colors.red(`Error: ${loadError}`)); + process.exitCode = 1; + return; + } + printJson(routes); +} + type EnsureProxyResult = | { started: true; state: Awaited> } | { started: false }; @@ -2084,6 +2117,7 @@ ${colors.bold("Options:")} --ngrok Share the app publicly via ngrok --force Kill the existing process and take over its route --name Use as the app name (bypasses subcommand dispatch) + --json Print JSON (list, get, doctor, service status) -- Stop flag parsing; everything after is passed to the child ${colors.bold("Environment variables:")} @@ -2388,15 +2422,19 @@ ${colors.bold("Options:")} ); } -async function handleList(): Promise { +async function handleList(options: { json: boolean }): Promise { const { dir, port, tls } = await discoverState(); + if (options.json) { + listRoutesJson(dir, port, tls); + return; + } const store = new RouteStore(dir, { onWarning: (msg) => console.warn(colors.yellow(msg)), }); listRoutes(store, port, tls); } -async function handleGet(args: string[]): Promise { +async function handleGet(args: string[], options: { json: boolean }): Promise { if (args[1] === "--help" || args[1] === "-h") { console.log(` ${colors.bold("portless get")} - Print the URL for a service. @@ -2413,6 +2451,7 @@ together: ${colors.bold("Options:")} --no-worktree Skip worktree prefix detection --path Include a path prefix in the URL + --json Print the URL and its parts as JSON --help, -h Show this help ${colors.bold("Examples:")} @@ -2439,7 +2478,7 @@ ${colors.bold("Examples:")} pathPrefix = parsePathPrefixOrExit(args[i]); } else if (args[i].startsWith("-")) { console.error(colors.red(`Error: Unknown flag "${args[i]}".`)); - console.error(colors.blue("Known flags: --no-worktree, --path, --help")); + console.error(colors.blue("Known flags: --no-worktree, --path, --json, --help")); process.exit(1); } else { positional.push(args[i]); @@ -2462,6 +2501,10 @@ ${colors.bold("Examples:")} const { port, tls, tlds } = await discoverState(); const hostname = buildHostnames(effectiveName, tlds)[0]!; const url = formatUrl(hostname, port, tls, pathPrefix); + if (options.json) { + printJson({ name, hostname, pathPrefix, url, proxyPort: port, tls }); + return; + } // Print bare URL to stdout so it works in $(portless get ) process.stdout.write(url + "\n"); } @@ -2751,7 +2794,7 @@ function doctorProxyStartHint(proxyPort: number, tls: boolean): string { return `Run: portless proxy start${portArgs}${tlsArgs}`; } -async function handleDoctor(args: string[]): Promise { +async function handleDoctor(args: string[], options: { json: boolean }): Promise { if (args[1] === "--help" || args[1] === "-h") { console.log(` ${colors.bold("portless doctor")} - Check local portless health and print suggested fixes. @@ -2764,6 +2807,7 @@ trust, hostname resolution, and LAN mode prerequisites. It does not start, stop, clean, prune, trust, or modify portless state. ${colors.bold("Options:")} + --json Print the report as JSON --help, -h Show this help `); process.exit(0); @@ -2817,16 +2861,18 @@ ${colors.bold("Options:")} const proxyUsesCustomCert = proxyTls && readCustomCertMarker(state.dir); const stateExists = fs.existsSync(state.dir); - console.log(colors.blue.bold("\nportless doctor\n")); - console.log(`Version: ${__VERSION__}`); - console.log(`Node.js: ${process.versions.node}`); - console.log(`Platform: ${process.platform} ${process.arch}`); - console.log(`State dir: ${state.dir}`); - console.log(`Proxy target: ${formatUrl("127.0.0.1", proxyPort, proxyTls)}`); - console.log( - `Mode: ${proxyTls ? "HTTPS" : "HTTP"}, ${formatTldList(state.tlds)}${state.lanMode ? ", LAN" : ""}` - ); - console.log(""); + if (!options.json) { + console.log(colors.blue.bold("\nportless doctor\n")); + console.log(`Version: ${__VERSION__}`); + console.log(`Node.js: ${process.versions.node}`); + console.log(`Platform: ${process.platform} ${process.arch}`); + console.log(`State dir: ${state.dir}`); + console.log(`Proxy target: ${formatUrl("127.0.0.1", proxyPort, proxyTls)}`); + console.log( + `Mode: ${proxyTls ? "HTTPS" : "HTTP"}, ${formatTldList(state.tlds)}${state.lanMode ? ", LAN" : ""}` + ); + console.log(""); + } const nodeMajor = parseInt(process.versions.node.split(".")[0], 10); if (nodeMajor >= 24) { @@ -3059,12 +3105,33 @@ ${colors.bold("Options:")} } } + const failures = findings.filter((finding) => finding.status === "fail").length; + const warnings = findings.filter((finding) => finding.status === "warn").length; + + if (options.json) { + printJson({ + version: __VERSION__, + node: process.versions.node, + platform: process.platform, + arch: process.arch, + stateDir: state.dir, + proxyPort, + tls: proxyTls, + tlds: state.tlds, + lanMode: state.lanMode, + findings, + failures, + warnings, + }); + // exitCode rather than exit() so a piped stdout is flushed first. + if (failures > 0) process.exitCode = 1; + return; + } + for (const finding of findings) { printDoctorFinding(finding); } - const failures = findings.filter((finding) => finding.status === "fail").length; - const warnings = findings.filter((finding) => finding.status === "warn").length; console.log(""); if (failures > 0) { console.log( @@ -4453,7 +4520,7 @@ async function main() { process.exit(1); } - const globalBooleanFlags = new Set(["--lan", "--tailscale", "--funnel", "--ngrok"]); + const globalBooleanFlags = new Set(["--lan", "--tailscale", "--funnel", "--ngrok", "--json"]); const globalValueFlags = new Set(["--ip", INTERNAL_LAN_IP_FLAG, "--script"]); const childlessCommands = new Set([ "--help", @@ -4582,6 +4649,15 @@ async function main() { } const globalScript = typeof scriptResult === "string" ? scriptResult : undefined; + // --json: machine-readable output, only for the commands that print JSON. + const jsonOutput = stripGlobalFlag("--json", false) === true; + if (jsonOutput && !supportsJsonOutput(args)) { + console.error( + colors.red(`Error: --json is only supported by ${JSON_OUTPUT_COMMANDS.join(", ")}.`) + ); + process.exit(1); + } + // --name flag: treat the next arg as an explicit app name, bypassing // subcommand dispatch. Useful when the app name collides with a reserved // subcommand (run, alias, hosts, list, doctor, trust, clean, prune, proxy, service). @@ -4677,15 +4753,15 @@ async function main() { return; } if (args[0] === "list") { - await handleList(); + await handleList({ json: jsonOutput }); return; } if (args[0] === "doctor") { - await handleDoctor(args); + await handleDoctor(args, { json: jsonOutput }); return; } if (args[0] === "get") { - await handleGet(args); + await handleGet(args, { json: jsonOutput }); return; } if (args[0] === "alias") { @@ -4701,7 +4777,7 @@ async function main() { return; } if (args[0] === "service") { - await handleService(args, { entryScript: getEntryScript() }); + await handleService(args, { entryScript: getEntryScript(), json: jsonOutput }); return; } } diff --git a/packages/portless/src/service.test.ts b/packages/portless/src/service.test.ts index 34d796a3..389bc156 100644 --- a/packages/portless/src/service.test.ts +++ b/packages/portless/src/service.test.ts @@ -918,4 +918,57 @@ describe("handleService", () => { expect(output).toContain("Wildcard: yes"); expect(output).toContain("State directory: /srv/portless"); }); + + it("prints service status as JSON", async () => { + setPlatform("linux"); + setGetuid(0); + const installedSpec = buildServiceSpec({ + platform: "linux", + nodePath: process.execPath, + entryScript: "/fake/cli.js", + userHome: "/home/alice", + installConfig: { + stateDir: "/srv/portless", + proxyPort: 8443, + useHttps: false, + lanMode: true, + lanIp: "192.168.1.42", + lanIpExplicit: true, + useWildcard: true, + }, + }); + if (installedSpec.platform !== "linux") throw new Error("Expected Linux service spec"); + + vi.mocked(existsSync).mockImplementation((file) => file === installedSpec.unitPath); + vi.mocked(readFileSync).mockImplementation((file) => + file === installedSpec.unitPath ? installedSpec.unit : "" + ); + vi.mocked(isProxyRunning).mockResolvedValueOnce(false); + const runner = vi.fn((command: string, args: string[]) => ({ + status: command === "systemctl" && args[0] === "is-enabled" ? 0 : 1, + stdout: "", + stderr: "", + })); + + await handleService(["service", "status"], { + entryScript: "/fake/cli.js", + runner, + json: true, + }); + + const output = logSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n"); + expect(JSON.parse(output)).toEqual({ + installed: true, + managerState: "installed", + proxyPort: 8443, + proxyRunning: false, + tls: false, + tlds: ["local"], + lanMode: true, + lanIp: "192.168.1.42", + wildcard: true, + stateDir: "/srv/portless", + serviceEntry: installedSpec.unitPath, + }); + }); }); diff --git a/packages/portless/src/service.ts b/packages/portless/src/service.ts index fbdda6d1..fff57804 100644 --- a/packages/portless/src/service.ts +++ b/packages/portless/src/service.ts @@ -11,6 +11,7 @@ import { getProtocolPort, isProxyRunning, parseTldList, + printJson, } from "./cli-utils.js"; import { isMdnsSupported } from "./mdns.js"; import { fixOwnership, resolveUserHome } from "./utils.js"; @@ -1175,9 +1176,29 @@ async function getServiceStatus( }; } -async function printServiceStatus(entryScript: string, runner: CommandRunner): Promise { +async function printServiceStatus( + entryScript: string, + runner: CommandRunner, + json: boolean +): Promise { const status = await getServiceStatus(entryScript, runner); const config = status.config; + if (json) { + printJson({ + installed: status.installed, + managerState: status.managerState, + proxyPort: config.proxyPort, + proxyRunning: status.proxyRunning, + tls: config.useHttps, + tlds: config.lanMode ? ["local"] : config.tlds, + lanMode: config.lanMode, + lanIp: config.lanIpExplicit && config.lanIp ? config.lanIp : undefined, + wildcard: config.useWildcard, + stateDir: config.stateDir, + serviceEntry: status.details, + }); + return; + } console.log(colors.bold("portless service")); console.log(` Manager state: ${status.managerState}`); console.log(` Installed: ${status.installed ? "yes" : "no"}`); @@ -1207,6 +1228,7 @@ ${colors.bold("Usage:")} ${colors.cyan("portless service install -p 8443")} Use a custom proxy port ${colors.cyan("portless service uninstall")} Stop and remove the startup service ${colors.cyan("portless service status")} Show service and proxy status + ${colors.cyan("portless service status --json")} Print service and proxy status as JSON ${colors.bold("Install options:")} -p, --port Port for the proxy service @@ -1230,7 +1252,7 @@ ${colors.bold("Notes:")} export async function handleService( args: string[], - options: { entryScript: string; runner?: CommandRunner } + options: { entryScript: string; runner?: CommandRunner; json?: boolean } ): Promise { const action = args[1]; const runner = options.runner || defaultRunner; @@ -1250,7 +1272,7 @@ export async function handleService( return; } if (action === "status") { - await printServiceStatus(options.entryScript, runner); + await printServiceStatus(options.entryScript, runner, options.json ?? false); return; } diff --git a/skills/portless/SKILL.md b/skills/portless/SKILL.md index 382fd2dc..ece05a7c 100644 --- a/skills/portless/SKILL.md +++ b/skills/portless/SKILL.md @@ -307,6 +307,10 @@ The chosen service configuration is written into launchd, systemd, or Task Sched | `portless get ` | Print URL for a service (for cross-service wiring) | | `portless get --no-worktree` | Print URL without worktree prefix | | `portless list` | Show active routes | +| `portless list --json` | Print active routes as JSON (see JSON output below) | +| `portless get --json` | Print the service URL and its parts as JSON | +| `portless doctor --json` | Print the health report as JSON | +| `portless service status --json` | Print service and proxy status as JSON | | `portless doctor` | Check proxy, routes, DNS, CA trust, and LAN prerequisites | | `portless trust` | Add local CA to system trust store (for HTTPS) | | `portless clean` | Remove state, CA trust entry, and /etc/hosts block | @@ -346,6 +350,13 @@ The chosen service configuration is written into launchd, systemd, or Task Sched **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `doctor`, `trust`, `clean`, `prune`, `proxy`, and `service` are subcommands and cannot be used as app names directly. Use `portless run ` to infer the name, or `portless --name ` to force any name including reserved ones. +**JSON output:** `list`, `get`, `doctor`, and `service status` accept `--json`. Other commands reject it. stdout carries only the JSON, without colors. Warnings and errors go to stderr, and a command that fails exits non-zero. Keys are camelCase and optional fields are omitted when unset. + +- `portless list --json`: array of the routes the proxy serves (live processes and aliases) with `hostname`, `pathPrefix`, `port`, `pid` (`0` for an alias), `alias`, `url`, and `tailscaleUrl` or `ngrokUrl` when shared. An unreadable or corrupted routes file exits 1 instead of printing `[]`. +- `portless get --json`: `name`, `hostname`, `pathPrefix`, `url`, `proxyPort`, `tls`. It does not check whether the service is running. Use `list --json` for that. +- `portless doctor --json`: `version`, `node`, `platform`, `arch`, `stateDir`, `proxyPort`, `tls`, `tlds`, `lanMode`, `findings` (`status`, `message`, `hint`), `failures`, `warnings`. Exits 1 when a check fails. +- `portless service status --json`: `installed`, `managerState`, `proxyPort`, `proxyRunning`, `tls`, `tlds`, `lanMode`, `lanIp`, `wildcard`, `stateDir`, `serviceEntry`. + ## portless.json Optional config file. Portless looks for it in the current directory.