Skip to content

Commit 569e0d6

Browse files
committed
Add computer port publish commands
1 parent 895de29 commit 569e0d6

5 files changed

Lines changed: 297 additions & 0 deletions

File tree

src/celesto/computer.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from .sdk.client import Celesto
1616

1717
app = typer.Typer(help="Create, manage, and connect to sandboxed computers.")
18+
port_app = typer.Typer(help="Publish and unpublish computer ports.")
19+
app.add_typer(port_app, name="port")
1820
console = Console()
1921

2022
# Common option types
@@ -73,6 +75,77 @@ def _print_json(data: object) -> None:
7375
sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
7476

7577

78+
@port_app.command("publish")
79+
def publish_port(
80+
computer_id: Annotated[str, typer.Argument(help="Computer ID or name")],
81+
port: Annotated[int, typer.Option("--port", "-p", help="Port to publish")] = 8000,
82+
as_json: JsonOption = False,
83+
api_key: ApiKeyOption = None,
84+
):
85+
"""Publish a computer port to the internet."""
86+
with _get_client(api_key) as client:
87+
result = client.computers.publish_port(computer_id, port=port)
88+
89+
if as_json:
90+
_print_json(result)
91+
return
92+
93+
console.print(result.get("url") or "")
94+
95+
96+
@port_app.command("list")
97+
def list_ports(
98+
computer_id: Annotated[str, typer.Argument(help="Computer ID or name")],
99+
as_json: JsonOption = False,
100+
api_key: ApiKeyOption = None,
101+
):
102+
"""List published ports for a computer."""
103+
with _get_client(api_key) as client:
104+
ports = client.computers.list_published_ports(computer_id)
105+
106+
if as_json:
107+
_print_json(ports)
108+
return
109+
110+
if not ports:
111+
console.print("[dim]No published ports.[/dim]")
112+
return
113+
114+
table = Table(show_header=True, header_style="bold")
115+
table.add_column("Port", justify="right")
116+
table.add_column("Status")
117+
table.add_column("URL")
118+
table.add_column("Created")
119+
120+
for published_port in ports:
121+
table.add_row(
122+
str(published_port.get("port", "")),
123+
str(published_port.get("status", "")),
124+
str(published_port.get("url") or ""),
125+
str(published_port.get("created_at") or "")[:19],
126+
)
127+
128+
console.print(table)
129+
130+
131+
@port_app.command("unpublish")
132+
def unpublish_port(
133+
computer_id: Annotated[str, typer.Argument(help="Computer ID or name")],
134+
port: Annotated[int, typer.Option("--port", "-p", help="Port to unpublish")] = 8000,
135+
as_json: JsonOption = False,
136+
api_key: ApiKeyOption = None,
137+
):
138+
"""Unpublish a computer port."""
139+
with _get_client(api_key) as client:
140+
result = client.computers.unpublish_port(computer_id, port=port)
141+
142+
if as_json:
143+
_print_json(result)
144+
return
145+
146+
console.print(f"[dim]Port {port} is unpublished.[/dim]")
147+
148+
76149
@app.command("create")
77150
def create_computer(
78151
cpus: Annotated[

src/celesto/sdk/client.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,45 @@ def get(self, computer_id: str) -> dict[str, Any]:
892892
"""
893893
return self._request("GET", f"/computers/{computer_id}")
894894

895+
def publish_port(self, computer_id: str, port: int = 8000) -> dict[str, Any]:
896+
"""Publish a computer port to the internet.
897+
898+
Args:
899+
computer_id: Computer ID or name.
900+
port: Port to publish. The MVP supports port 8000.
901+
902+
Returns:
903+
Published port dict with id, computer_id, port, url, status, and created_at.
904+
"""
905+
return self._request(
906+
"POST",
907+
f"/computers/{computer_id}/published-ports",
908+
json_body={"port": port},
909+
)
910+
911+
def list_published_ports(self, computer_id: str) -> list[dict[str, Any]]:
912+
"""List active published ports for a computer.
913+
914+
Args:
915+
computer_id: Computer ID or name.
916+
917+
Returns:
918+
List of published port dicts.
919+
"""
920+
return self._request("GET", f"/computers/{computer_id}/published-ports")
921+
922+
def unpublish_port(self, computer_id: str, port: int = 8000) -> dict[str, Any]:
923+
"""Remove a published computer port.
924+
925+
Args:
926+
computer_id: Computer ID or name.
927+
port: Published port to remove. The MVP supports port 8000.
928+
929+
Returns:
930+
Unpublished port dict.
931+
"""
932+
return self._request("DELETE", f"/computers/{computer_id}/published-ports/{port}")
933+
895934
def exec(
896935
self,
897936
computer_id: str,

src/celesto/sdk/types.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,26 @@ class ComputerConnectionInfo(TypedDict):
123123
access_url: NotRequired[str]
124124

125125

126+
PublishedPortStatus = Literal[
127+
"publishing",
128+
"published",
129+
"unpublishing",
130+
"unpublished",
131+
"error",
132+
]
133+
134+
135+
class ComputerPublishedPortInfo(TypedDict):
136+
"""Information about a public computer port route."""
137+
138+
id: NotRequired[str | None]
139+
computer_id: str
140+
port: int
141+
url: NotRequired[str | None]
142+
status: PublishedPortStatus
143+
created_at: NotRequired[str | None]
144+
145+
126146
class ComputerInfo(TypedDict):
127147
"""Information about a computer."""
128148

@@ -136,6 +156,7 @@ class ComputerInfo(TypedDict):
136156
template_id: str
137157
template_version: NotRequired[str | None]
138158
connection: NotRequired[ComputerConnectionInfo | None]
159+
published_ports: NotRequired[List[ComputerPublishedPortInfo]]
139160
last_error: NotRequired[str | None]
140161
created_at: str
141162
stopped_at: NotRequired[str | None]
@@ -188,6 +209,8 @@ class ComputerExecResponse(TypedDict):
188209
# Computer
189210
"ComputerStatus",
190211
"ComputerConnectionInfo",
212+
"PublishedPortStatus",
213+
"ComputerPublishedPortInfo",
191214
"ComputerInfo",
192215
"SandboxTemplateInfo",
193216
"ComputerListResponse",

tests/test_computer_cli.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from typer.testing import CliRunner
6+
7+
from celesto import computer
8+
9+
10+
class _FakeComputers:
11+
def __init__(self) -> None:
12+
self.calls: list[tuple[str, str, int | None]] = []
13+
14+
def publish_port(self, computer_id: str, *, port: int = 8000) -> dict:
15+
self.calls.append(("publish", computer_id, port))
16+
return {
17+
"id": "cpp_123",
18+
"computer_id": "cmp_123",
19+
"port": port,
20+
"url": "https://p-test.celesto.ai",
21+
"status": "published",
22+
"created_at": "2026-06-07T00:00:00Z",
23+
}
24+
25+
def list_published_ports(self, computer_id: str) -> list[dict]:
26+
self.calls.append(("list", computer_id, None))
27+
return [
28+
{
29+
"id": "cpp_123",
30+
"computer_id": "cmp_123",
31+
"port": 8000,
32+
"url": "https://p-test.celesto.ai",
33+
"status": "published",
34+
"created_at": "2026-06-07T00:00:00Z",
35+
}
36+
]
37+
38+
def unpublish_port(self, computer_id: str, *, port: int = 8000) -> dict:
39+
self.calls.append(("unpublish", computer_id, port))
40+
return {
41+
"computer_id": "cmp_123",
42+
"port": port,
43+
"url": None,
44+
"status": "unpublished",
45+
"created_at": None,
46+
}
47+
48+
49+
class _FakeClient:
50+
def __init__(self) -> None:
51+
self.computers = _FakeComputers()
52+
53+
def __enter__(self) -> "_FakeClient":
54+
return self
55+
56+
def __exit__(self, *exc: object) -> None:
57+
return None
58+
59+
60+
def test_computer_port_publish_prints_url(monkeypatch):
61+
runner = CliRunner()
62+
fake_client = _FakeClient()
63+
monkeypatch.setattr(computer, "_get_client", lambda api_key=None: fake_client)
64+
65+
result = runner.invoke(computer.app, ["port", "publish", "curie"])
66+
67+
assert result.exit_code == 0
68+
assert "https://p-test.celesto.ai" in result.output
69+
assert fake_client.computers.calls == [("publish", "curie", 8000)]
70+
71+
72+
def test_computer_port_publish_json(monkeypatch):
73+
runner = CliRunner()
74+
fake_client = _FakeClient()
75+
monkeypatch.setattr(computer, "_get_client", lambda api_key=None: fake_client)
76+
77+
result = runner.invoke(computer.app, ["port", "publish", "cmp_123", "--json"])
78+
79+
assert result.exit_code == 0
80+
payload = json.loads(result.output)
81+
assert payload["url"] == "https://p-test.celesto.ai"
82+
assert fake_client.computers.calls == [("publish", "cmp_123", 8000)]
83+
84+
85+
def test_computer_port_list_json(monkeypatch):
86+
runner = CliRunner()
87+
fake_client = _FakeClient()
88+
monkeypatch.setattr(computer, "_get_client", lambda api_key=None: fake_client)
89+
90+
result = runner.invoke(computer.app, ["port", "list", "cmp_123", "--json"])
91+
92+
assert result.exit_code == 0
93+
payload = json.loads(result.output)
94+
assert payload[0]["port"] == 8000
95+
assert fake_client.computers.calls == [("list", "cmp_123", None)]
96+
97+
98+
def test_computer_port_unpublish_json(monkeypatch):
99+
runner = CliRunner()
100+
fake_client = _FakeClient()
101+
monkeypatch.setattr(computer, "_get_client", lambda api_key=None: fake_client)
102+
103+
result = runner.invoke(computer.app, ["port", "unpublish", "cmp_123", "--json"])
104+
105+
assert result.exit_code == 0
106+
payload = json.loads(result.output)
107+
assert payload["status"] == "unpublished"
108+
assert fake_client.computers.calls == [("unpublish", "cmp_123", 8000)]

tests/test_sdk.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,57 @@ def test_computers_list_templates_hits_backend_endpoint():
106106
assert templates[0]["id"] == "scratch"
107107
assert session.calls[0]["method"] == "GET"
108108
assert session.calls[0]["url"] == "https://api.example.test/v1/computers/templates"
109+
110+
111+
def test_computers_publish_port_hits_backend_endpoint():
112+
session = DummySession(
113+
payload={
114+
"id": "cpp_123",
115+
"computer_id": "cmp_123",
116+
"port": 8000,
117+
"url": "https://p-test.celesto.ai",
118+
"status": "published",
119+
"created_at": "2026-06-07T00:00:00Z",
120+
}
121+
)
122+
client = Celesto("test-key", base_url="https://api.example.test/v1")
123+
client.session = session
124+
125+
result = client.computers.publish_port("curie", port=8000)
126+
127+
assert result["url"] == "https://p-test.celesto.ai"
128+
assert session.calls[0]["method"] == "POST"
129+
assert session.calls[0]["url"] == "https://api.example.test/v1/computers/curie/published-ports"
130+
assert session.calls[0]["json"] == {"port": 8000}
131+
132+
133+
def test_computers_list_published_ports_hits_backend_endpoint():
134+
session = DummySession(payload=[])
135+
client = Celesto("test-key", base_url="https://api.example.test/v1")
136+
client.session = session
137+
138+
result = client.computers.list_published_ports("cmp_123")
139+
140+
assert result == []
141+
assert session.calls[0]["method"] == "GET"
142+
assert session.calls[0]["url"] == "https://api.example.test/v1/computers/cmp_123/published-ports"
143+
144+
145+
def test_computers_unpublish_port_hits_backend_endpoint():
146+
session = DummySession(
147+
payload={
148+
"computer_id": "cmp_123",
149+
"port": 8000,
150+
"url": None,
151+
"status": "unpublished",
152+
"created_at": None,
153+
}
154+
)
155+
client = Celesto("test-key", base_url="https://api.example.test/v1")
156+
client.session = session
157+
158+
result = client.computers.unpublish_port("cmp_123", port=8000)
159+
160+
assert result["status"] == "unpublished"
161+
assert session.calls[0]["method"] == "DELETE"
162+
assert session.calls[0]["url"] == "https://api.example.test/v1/computers/cmp_123/published-ports/8000"

0 commit comments

Comments
 (0)