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/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 []; } } 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.