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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ portless alias <name> <port> # Register a static route (e.g. for Docker)
portless alias <name> <port> --force # Overwrite an existing route
portless alias --remove <name> # 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
Expand All @@ -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 <name> --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

```
Expand Down
16 changes: 16 additions & 0 deletions apps/docs/src/app/commands/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,22 @@ PORTLESS=0 pnpm dev

Runs the command directly without the proxy.

## JSON output

```bash
portless list --json
portless get <name> --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
Expand Down
19 changes: 19 additions & 0 deletions packages/portless/src/cli-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
21 changes: 21 additions & 0 deletions packages/portless/src/cli-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
227 changes: 227 additions & 0 deletions packages/portless/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}) {
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", () => {
Expand Down
Loading
Loading