wayscloud-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wayscloud_cli-0.1.0/PKG-INFO +39 -0
- wayscloud_cli-0.1.0/README.md +23 -0
- wayscloud_cli-0.1.0/pyproject.toml +28 -0
- wayscloud_cli-0.1.0/setup.cfg +11 -0
- wayscloud_cli-0.1.0/wayscloud_cli/__init__.py +2 -0
- wayscloud_cli-0.1.0/wayscloud_cli/__main__.py +61 -0
- wayscloud_cli-0.1.0/wayscloud_cli/client.py +94 -0
- wayscloud_cli-0.1.0/wayscloud_cli/commands/__init__.py +0 -0
- wayscloud_cli-0.1.0/wayscloud_cli/commands/login.py +88 -0
- wayscloud_cli-0.1.0/wayscloud_cli/commands/shell.py +138 -0
- wayscloud_cli-0.1.0/wayscloud_cli/commands/vps.py +194 -0
- wayscloud_cli-0.1.0/wayscloud_cli/config.py +77 -0
- wayscloud_cli-0.1.0/wayscloud_cli/output.py +104 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/PKG-INFO +39 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/SOURCES.txt +18 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/dependency_links.txt +1 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/entry_points.txt +2 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/requires.txt +4 -0
- wayscloud_cli-0.1.0/wayscloud_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wayscloud-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WAYSCloud CLI — command-line interface for WAYSCloud platform
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://wayscloud.net
|
|
7
|
+
Project-URL: Documentation, https://docs.wayscloud.net/cli
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.25
|
|
13
|
+
Requires-Dist: typer>=0.9
|
|
14
|
+
Requires-Dist: rich>=13.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
|
|
17
|
+
# WAYSCloud CLI
|
|
18
|
+
|
|
19
|
+
Command-line interface for WAYSCloud platform services.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install wayscloud-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cloud login --token <pat-from-portal>
|
|
31
|
+
cloud whoami
|
|
32
|
+
cloud vps list
|
|
33
|
+
cloud vps plans
|
|
34
|
+
cloud shell connect
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
See [docs/en/23-cli.md](https://docs.wayscloud.net/cli) for full documentation.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# WAYSCloud CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for WAYSCloud platform services.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install wayscloud-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cloud login --token <pat-from-portal>
|
|
15
|
+
cloud whoami
|
|
16
|
+
cloud vps list
|
|
17
|
+
cloud vps plans
|
|
18
|
+
cloud shell connect
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Documentation
|
|
22
|
+
|
|
23
|
+
See [docs/en/23-cli.md](https://docs.wayscloud.net/cli) for full documentation.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wayscloud-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "WAYSCloud CLI — command-line interface for WAYSCloud platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "Proprietary"}
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx>=0.25",
|
|
18
|
+
"typer>=0.9",
|
|
19
|
+
"rich>=13.0",
|
|
20
|
+
"websockets>=12.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://wayscloud.net"
|
|
25
|
+
Documentation = "https://docs.wayscloud.net/cli"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
cloud = "wayscloud_cli.__main__:main"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WAYSCloud CLI entry point.
|
|
3
|
+
|
|
4
|
+
Binary name: cloud (C21)
|
|
5
|
+
Package name: wayscloud-cli (C21)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .output import set_json_mode, set_no_color
|
|
12
|
+
from .commands.login import app as login_app
|
|
13
|
+
from .commands.vps import app as vps_app
|
|
14
|
+
from .commands.shell import app as shell_app
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="cloud",
|
|
18
|
+
help="WAYSCloud CLI",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Global options callback
|
|
25
|
+
@app.callback(invoke_without_command=True)
|
|
26
|
+
def global_options(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON (C11)"),
|
|
29
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable colors"),
|
|
30
|
+
version: bool = typer.Option(False, "--version", help="Show version"),
|
|
31
|
+
):
|
|
32
|
+
"""WAYSCloud CLI"""
|
|
33
|
+
if version:
|
|
34
|
+
print(f"WAYSCloud CLI {__version__}")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
set_json_mode(json_output)
|
|
38
|
+
set_no_color(no_color)
|
|
39
|
+
|
|
40
|
+
if ctx.invoked_subcommand is None:
|
|
41
|
+
print(ctx.get_help())
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Register command groups
|
|
46
|
+
app.add_typer(login_app, name="auth", help="Login, logout, whoami")
|
|
47
|
+
app.add_typer(vps_app, name="vps", help="Virtual Private Servers")
|
|
48
|
+
app.add_typer(shell_app, name="shell", help="Interactive CloudShell")
|
|
49
|
+
|
|
50
|
+
# Top-level shortcuts for login/logout/whoami
|
|
51
|
+
app.command("login")(login_app.registered_commands[0].callback)
|
|
52
|
+
app.command("logout")(login_app.registered_commands[1].callback)
|
|
53
|
+
app.command("whoami")(login_app.registered_commands[2].callback)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
app()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for WAYSCloud API (C28, C29).
|
|
3
|
+
|
|
4
|
+
All requests use Authorization: Bearer <token> (C28).
|
|
5
|
+
Error responses follow {error, code, message} format (C29).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import API_BASE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_client(token: str, timeout: float = 15.0) -> httpx.Client:
|
|
17
|
+
"""Create HTTP client with auth header (C28)."""
|
|
18
|
+
return httpx.Client(
|
|
19
|
+
base_url=API_BASE,
|
|
20
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
21
|
+
timeout=timeout,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api_get(token: str, path: str, params: Optional[dict] = None) -> dict:
|
|
26
|
+
"""GET request to API. Returns parsed JSON or exits with error."""
|
|
27
|
+
with get_client(token) as client:
|
|
28
|
+
resp = client.get(path, params=params)
|
|
29
|
+
return _handle_response(resp)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def api_post(token: str, path: str, json_body: Optional[dict] = None) -> dict:
|
|
33
|
+
"""POST request to API."""
|
|
34
|
+
with get_client(token) as client:
|
|
35
|
+
resp = client.post(path, json=json_body)
|
|
36
|
+
return _handle_response(resp)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def api_delete(token: str, path: str) -> dict:
|
|
40
|
+
"""DELETE request to API."""
|
|
41
|
+
with get_client(token) as client:
|
|
42
|
+
resp = client.delete(path)
|
|
43
|
+
return _handle_response(resp)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _handle_response(resp: httpx.Response) -> dict:
|
|
47
|
+
"""Handle API response. Returns data or raises SystemExit with error (C13, C29)."""
|
|
48
|
+
if resp.status_code == 200 or resp.status_code == 201:
|
|
49
|
+
try:
|
|
50
|
+
return resp.json()
|
|
51
|
+
except Exception:
|
|
52
|
+
return {"status": "ok"}
|
|
53
|
+
|
|
54
|
+
if resp.status_code == 204:
|
|
55
|
+
return {"status": "ok"}
|
|
56
|
+
|
|
57
|
+
# Error handling (C29)
|
|
58
|
+
try:
|
|
59
|
+
body = resp.json()
|
|
60
|
+
detail = body.get("detail", "Unknown error")
|
|
61
|
+
if isinstance(detail, dict):
|
|
62
|
+
msg = detail.get("error", str(detail))
|
|
63
|
+
elif isinstance(detail, list):
|
|
64
|
+
msg = "; ".join(d.get("msg", str(d)) for d in detail)
|
|
65
|
+
else:
|
|
66
|
+
msg = str(detail)
|
|
67
|
+
except Exception:
|
|
68
|
+
msg = resp.text[:200] if resp.text else "Unknown error"
|
|
69
|
+
|
|
70
|
+
# Map HTTP status to exit code (C13)
|
|
71
|
+
if resp.status_code in (401, 403):
|
|
72
|
+
_error(msg, exit_code=2)
|
|
73
|
+
elif resp.status_code == 404:
|
|
74
|
+
_error(msg, exit_code=1)
|
|
75
|
+
elif resp.status_code == 422:
|
|
76
|
+
_error(msg, exit_code=1)
|
|
77
|
+
else:
|
|
78
|
+
_error(msg, exit_code=3)
|
|
79
|
+
|
|
80
|
+
return {} # unreachable
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _error(msg: str, exit_code: int = 1) -> None:
|
|
84
|
+
"""Print error to stderr and exit (C13)."""
|
|
85
|
+
import json as json_mod
|
|
86
|
+
from .output import is_json_mode
|
|
87
|
+
|
|
88
|
+
if is_json_mode():
|
|
89
|
+
err = {"error": "api_error", "code": exit_code, "message": msg}
|
|
90
|
+
print(json_mod.dumps(err), file=sys.stderr)
|
|
91
|
+
else:
|
|
92
|
+
print(f"Error: {msg}", file=sys.stderr)
|
|
93
|
+
|
|
94
|
+
sys.exit(exit_code)
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloud login / logout / whoami commands (C1, C2, C3).
|
|
3
|
+
|
|
4
|
+
login validates token against API before saving (C1).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..config import resolve_token, save_token, delete_token, API_BASE
|
|
10
|
+
from ..output import print_object, print_error, is_json_mode, print_json
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Authentication")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def login(
|
|
17
|
+
token: str = typer.Option(..., "--token", help="PAT token from portal"),
|
|
18
|
+
):
|
|
19
|
+
"""Login with a CLI token from the WAYSCloud portal.
|
|
20
|
+
|
|
21
|
+
Token is validated against API before saving (C1).
|
|
22
|
+
Saved to ~/.wayscloud/credentials with chmod 600 (C24).
|
|
23
|
+
"""
|
|
24
|
+
# Validate format
|
|
25
|
+
if not token.startswith("wayscloud_pat_"):
|
|
26
|
+
print_error("invalid_token", "Invalid token format. Expected: wayscloud_pat_...", exit_code=2)
|
|
27
|
+
|
|
28
|
+
# Validate against API before saving (C1)
|
|
29
|
+
import httpx
|
|
30
|
+
try:
|
|
31
|
+
resp = httpx.get(
|
|
32
|
+
f"{API_BASE}/api/v1/dashboard/account/profile",
|
|
33
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
except httpx.ConnectError:
|
|
37
|
+
print_error("network_error", f"Cannot reach {API_BASE}", exit_code=3)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print_error("network_error", str(e), exit_code=3)
|
|
40
|
+
|
|
41
|
+
if resp.status_code == 401:
|
|
42
|
+
print_error("invalid_token", "Token is invalid or expired", exit_code=2)
|
|
43
|
+
if resp.status_code == 403:
|
|
44
|
+
print_error("forbidden", "Token does not have required permissions", exit_code=2)
|
|
45
|
+
if resp.status_code != 200:
|
|
46
|
+
print_error("api_error", f"API returned {resp.status_code}", exit_code=3)
|
|
47
|
+
|
|
48
|
+
# Token is valid — save it (C2)
|
|
49
|
+
save_token(token)
|
|
50
|
+
|
|
51
|
+
if is_json_mode():
|
|
52
|
+
print_json({"status": "ok", "message": "Login successful"})
|
|
53
|
+
else:
|
|
54
|
+
profile = resp.json()
|
|
55
|
+
print("WAYSCloud CLI v0.1.0")
|
|
56
|
+
print(f"Logged in as: {profile.get('email', 'unknown')}")
|
|
57
|
+
print(f"Customer: {profile.get('customer_id', 'unknown')}")
|
|
58
|
+
print(f"Token saved to ~/.wayscloud/credentials")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def logout():
|
|
63
|
+
"""Remove saved credentials."""
|
|
64
|
+
if delete_token():
|
|
65
|
+
print("Logged out. Token removed.")
|
|
66
|
+
else:
|
|
67
|
+
print("No saved credentials found.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def whoami(
|
|
72
|
+
token: str = typer.Option(None, "--token", help="Override token"),
|
|
73
|
+
):
|
|
74
|
+
"""Show current authenticated identity."""
|
|
75
|
+
resolved = resolve_token(token)
|
|
76
|
+
if not resolved:
|
|
77
|
+
print_error("not_authenticated", "Not logged in. Run: cloud login --token <pat>", exit_code=2)
|
|
78
|
+
|
|
79
|
+
from ..client import api_get
|
|
80
|
+
profile = api_get(resolved, "/api/v1/dashboard/account/profile")
|
|
81
|
+
|
|
82
|
+
fields = [
|
|
83
|
+
("customer_id", "Customer ID"),
|
|
84
|
+
("email", "Email"),
|
|
85
|
+
("name", "Name"),
|
|
86
|
+
("customer_type", "Type"),
|
|
87
|
+
]
|
|
88
|
+
print_object(profile, fields)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloud shell — interactive CloudShell via WebSocket (C14, C25, C30).
|
|
3
|
+
|
|
4
|
+
Connects to wss://shell.wayscloud.services/ws with Authorization header.
|
|
5
|
+
PAT must have shell:connect scope (C6).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from ..config import resolve_token, SHELL_WS
|
|
16
|
+
from ..output import print_error
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Interactive shell")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("connect")
|
|
22
|
+
def connect(
|
|
23
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
24
|
+
):
|
|
25
|
+
"""Connect to interactive CloudShell.
|
|
26
|
+
|
|
27
|
+
Requires shell:connect scope on your CLI token.
|
|
28
|
+
"""
|
|
29
|
+
resolved = resolve_token(token)
|
|
30
|
+
if not resolved:
|
|
31
|
+
print_error("not_authenticated", "Not logged in. Run: cloud login --token <pat>", exit_code=2)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
asyncio.run(_shell_session(resolved))
|
|
35
|
+
except KeyboardInterrupt:
|
|
36
|
+
print("\nSession ended.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _shell_session(token: str) -> None:
|
|
40
|
+
"""Run interactive WebSocket shell session."""
|
|
41
|
+
import websockets
|
|
42
|
+
|
|
43
|
+
# Connect with Authorization header (C14, C30 — never query string)
|
|
44
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
async with websockets.connect(
|
|
48
|
+
SHELL_WS,
|
|
49
|
+
additional_headers=headers,
|
|
50
|
+
open_timeout=10,
|
|
51
|
+
close_timeout=5,
|
|
52
|
+
ping_interval=30,
|
|
53
|
+
) as ws:
|
|
54
|
+
print("WAYSCloud Shell v0.1.0")
|
|
55
|
+
print("Type 'cloud help' for commands, 'exit' to quit.\n")
|
|
56
|
+
|
|
57
|
+
# Start reader task
|
|
58
|
+
reader_task = asyncio.create_task(_read_messages(ws))
|
|
59
|
+
|
|
60
|
+
# Input loop
|
|
61
|
+
try:
|
|
62
|
+
await _input_loop(ws)
|
|
63
|
+
finally:
|
|
64
|
+
reader_task.cancel()
|
|
65
|
+
try:
|
|
66
|
+
await reader_task
|
|
67
|
+
except asyncio.CancelledError:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
except Exception as e:
|
|
71
|
+
err = str(e)
|
|
72
|
+
# Map connection errors to exit codes (C13, C16)
|
|
73
|
+
if "403" in err:
|
|
74
|
+
print("Error: Authentication failed or missing shell:connect scope.", file=sys.stderr)
|
|
75
|
+
sys.exit(2)
|
|
76
|
+
elif "Connection refused" in err or "unreachable" in err.lower():
|
|
77
|
+
print(f"Error: Cannot connect to shell service.", file=sys.stderr)
|
|
78
|
+
sys.exit(3)
|
|
79
|
+
else:
|
|
80
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
81
|
+
sys.exit(3)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _read_messages(ws) -> None:
|
|
85
|
+
"""Read and display messages from WebSocket."""
|
|
86
|
+
try:
|
|
87
|
+
async for raw in ws:
|
|
88
|
+
try:
|
|
89
|
+
msg = json.loads(raw)
|
|
90
|
+
msg_type = msg.get("type", "")
|
|
91
|
+
data = msg.get("data", "")
|
|
92
|
+
|
|
93
|
+
if msg_type == "output":
|
|
94
|
+
print(data, end="", flush=True)
|
|
95
|
+
elif msg_type == "prompt":
|
|
96
|
+
print(data, end="", flush=True)
|
|
97
|
+
elif msg_type == "error":
|
|
98
|
+
print(f"\nError: {data}", file=sys.stderr)
|
|
99
|
+
elif msg_type == "history":
|
|
100
|
+
pass # Could populate readline history
|
|
101
|
+
elif msg_type == "ratelimit":
|
|
102
|
+
print(f"\nRate limited. Try again later.", file=sys.stderr)
|
|
103
|
+
elif msg_type == "close":
|
|
104
|
+
print(f"\n{data}")
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
except json.JSONDecodeError:
|
|
108
|
+
print(raw, end="", flush=True)
|
|
109
|
+
|
|
110
|
+
except asyncio.CancelledError:
|
|
111
|
+
raise
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _input_loop(ws) -> None:
|
|
117
|
+
"""Read user input and send to WebSocket."""
|
|
118
|
+
loop = asyncio.get_event_loop()
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
123
|
+
if not line:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
command = line.strip()
|
|
127
|
+
if command.lower() in ("exit", "quit", "q"):
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
await ws.send(json.dumps({"type": "input", "data": command}))
|
|
131
|
+
|
|
132
|
+
except (EOFError, KeyboardInterrupt):
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
await ws.close()
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloud vps commands — first v1 resource commands.
|
|
3
|
+
|
|
4
|
+
All calls go directly to API with PAT auth (not through CloudShell).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..config import resolve_token
|
|
11
|
+
from ..client import api_get, api_post, api_delete
|
|
12
|
+
from ..output import print_table, print_object, print_error, print_json, is_json_mode
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Virtual Private Servers")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require_token(token: Optional[str]) -> str:
|
|
18
|
+
resolved = resolve_token(token)
|
|
19
|
+
if not resolved:
|
|
20
|
+
print_error("not_authenticated", "Not logged in. Run: cloud login --token <pat>", exit_code=2)
|
|
21
|
+
return resolved
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("list")
|
|
25
|
+
def list_vps(
|
|
26
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
27
|
+
status: Optional[str] = typer.Option(None, help="Filter by status"),
|
|
28
|
+
region: Optional[str] = typer.Option(None, help="Filter by region"),
|
|
29
|
+
):
|
|
30
|
+
"""List your VPS instances."""
|
|
31
|
+
t = _require_token(token)
|
|
32
|
+
params = {}
|
|
33
|
+
if status:
|
|
34
|
+
params["status"] = status
|
|
35
|
+
if region:
|
|
36
|
+
params["region"] = region
|
|
37
|
+
|
|
38
|
+
data = api_get(t, "/api/v1/dashboard/vps/", params=params)
|
|
39
|
+
|
|
40
|
+
instances = data.get("instances", data) if isinstance(data, dict) else data
|
|
41
|
+
|
|
42
|
+
if is_json_mode():
|
|
43
|
+
print_json(instances if isinstance(instances, list) else [])
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not instances:
|
|
47
|
+
print("No VPS instances found.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
rows = []
|
|
51
|
+
for v in (instances if isinstance(instances, list) else []):
|
|
52
|
+
rows.append({
|
|
53
|
+
"id": v.get("id", "")[:12],
|
|
54
|
+
"hostname": v.get("hostname", v.get("display_name", "")),
|
|
55
|
+
"status": v.get("status", ""),
|
|
56
|
+
"ip": v.get("ipv4_address", v.get("ip_address", "")),
|
|
57
|
+
"plan": v.get("plan_code", ""),
|
|
58
|
+
"region": v.get("region", ""),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
print_table(rows, [
|
|
62
|
+
("id", "ID", 14),
|
|
63
|
+
("hostname", "Hostname", 24),
|
|
64
|
+
("status", "Status", 12),
|
|
65
|
+
("ip", "IP", 16),
|
|
66
|
+
("plan", "Plan", 16),
|
|
67
|
+
("region", "Region", 8),
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("info")
|
|
72
|
+
def info_vps(
|
|
73
|
+
vps_id: str = typer.Argument(..., help="VPS ID"),
|
|
74
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
75
|
+
):
|
|
76
|
+
"""Show VPS details."""
|
|
77
|
+
t = _require_token(token)
|
|
78
|
+
data = api_get(t, f"/api/v1/dashboard/vps/{vps_id}")
|
|
79
|
+
|
|
80
|
+
print_object(data, [
|
|
81
|
+
("id", "ID"),
|
|
82
|
+
("hostname", "Hostname"),
|
|
83
|
+
("status", "Status"),
|
|
84
|
+
("power_state", "Power"),
|
|
85
|
+
("ipv4_address", "IPv4"),
|
|
86
|
+
("os_template", "OS"),
|
|
87
|
+
("plan_code", "Plan"),
|
|
88
|
+
("region", "Region"),
|
|
89
|
+
("created_at", "Created"),
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("plans")
|
|
94
|
+
def list_plans(
|
|
95
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
96
|
+
region: Optional[str] = typer.Option(None, help="Filter by region"),
|
|
97
|
+
):
|
|
98
|
+
"""List available VPS plans."""
|
|
99
|
+
t = _require_token(token)
|
|
100
|
+
params = {}
|
|
101
|
+
if region:
|
|
102
|
+
params["region"] = region
|
|
103
|
+
|
|
104
|
+
data = api_get(t, "/api/v1/dashboard/vps/plans", params=params)
|
|
105
|
+
|
|
106
|
+
plans = data if isinstance(data, list) else []
|
|
107
|
+
|
|
108
|
+
if is_json_mode():
|
|
109
|
+
print_json(plans)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
rows = []
|
|
113
|
+
for p in plans:
|
|
114
|
+
rows.append({
|
|
115
|
+
"code": p.get("plan_code", ""),
|
|
116
|
+
"vcpu": p.get("vcpu", ""),
|
|
117
|
+
"ram": f"{p.get('ram_mb', 0) // 1024}GB",
|
|
118
|
+
"disk": f"{p.get('disk_gb', 0)}GB",
|
|
119
|
+
"price": f"{p.get('monthly_price', '')} {p.get('currency', '')}",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
print_table(rows, [
|
|
123
|
+
("code", "Plan", 24),
|
|
124
|
+
("vcpu", "vCPU", 6),
|
|
125
|
+
("ram", "RAM", 8),
|
|
126
|
+
("disk", "Disk", 8),
|
|
127
|
+
("price", "Price/mo", 14),
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("status")
|
|
132
|
+
def vps_status(
|
|
133
|
+
vps_id: str = typer.Argument(..., help="VPS ID"),
|
|
134
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
135
|
+
):
|
|
136
|
+
"""Get real-time VPS status."""
|
|
137
|
+
t = _require_token(token)
|
|
138
|
+
data = api_get(t, f"/api/v1/dashboard/vps/{vps_id}/status")
|
|
139
|
+
print_object(data, [
|
|
140
|
+
("status", "Status"),
|
|
141
|
+
("power_state", "Power"),
|
|
142
|
+
("uptime", "Uptime"),
|
|
143
|
+
("cpu_usage", "CPU"),
|
|
144
|
+
("memory_usage", "Memory"),
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command("start")
|
|
149
|
+
def start_vps(
|
|
150
|
+
vps_id: str = typer.Argument(..., help="VPS ID"),
|
|
151
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
152
|
+
):
|
|
153
|
+
"""Start a VPS."""
|
|
154
|
+
t = _require_token(token)
|
|
155
|
+
data = api_post(t, f"/api/v1/dashboard/vps/{vps_id}/start")
|
|
156
|
+
if is_json_mode():
|
|
157
|
+
print_json(data)
|
|
158
|
+
else:
|
|
159
|
+
print(f"VPS {vps_id}: {data.get('message', 'starting')}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("stop")
|
|
163
|
+
def stop_vps(
|
|
164
|
+
vps_id: str = typer.Argument(..., help="VPS ID"),
|
|
165
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
166
|
+
):
|
|
167
|
+
"""Stop a VPS."""
|
|
168
|
+
t = _require_token(token)
|
|
169
|
+
data = api_post(t, f"/api/v1/dashboard/vps/{vps_id}/stop")
|
|
170
|
+
if is_json_mode():
|
|
171
|
+
print_json(data)
|
|
172
|
+
else:
|
|
173
|
+
print(f"VPS {vps_id}: {data.get('message', 'stopping')}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command("delete")
|
|
177
|
+
def delete_vps(
|
|
178
|
+
vps_id: str = typer.Argument(..., help="VPS ID"),
|
|
179
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
180
|
+
confirm: bool = typer.Option(False, "--confirm", help="Skip confirmation"),
|
|
181
|
+
):
|
|
182
|
+
"""Delete a VPS (permanent)."""
|
|
183
|
+
t = _require_token(token)
|
|
184
|
+
|
|
185
|
+
if not confirm:
|
|
186
|
+
print(f"This will permanently delete VPS {vps_id} and all its data.")
|
|
187
|
+
print("Use --confirm to proceed.")
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
|
|
190
|
+
data = api_delete(t, f"/api/v1/dashboard/vps/{vps_id}")
|
|
191
|
+
if is_json_mode():
|
|
192
|
+
print_json(data)
|
|
193
|
+
else:
|
|
194
|
+
print(f"VPS {vps_id}: deleted")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration and token resolution (C2, C3, C22, C24).
|
|
3
|
+
|
|
4
|
+
Token priority:
|
|
5
|
+
1. --token flag
|
|
6
|
+
2. WAYSCLOUD_TOKEN env var
|
|
7
|
+
3. ~/.wayscloud/credentials file
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path.home() / ".wayscloud"
|
|
17
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials"
|
|
18
|
+
ENV_VAR = "WAYSCLOUD_TOKEN"
|
|
19
|
+
|
|
20
|
+
# API base URL (C25 — public endpoint)
|
|
21
|
+
API_BASE = os.environ.get("WAYSCLOUD_API_URL", "https://api.wayscloud.services")
|
|
22
|
+
SHELL_WS = "wss://shell.wayscloud.services/ws"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_token(explicit_token: Optional[str] = None) -> Optional[str]:
|
|
26
|
+
"""Resolve token using priority order (C3):
|
|
27
|
+
1. Explicit --token flag
|
|
28
|
+
2. WAYSCLOUD_TOKEN env var
|
|
29
|
+
3. ~/.wayscloud/credentials file
|
|
30
|
+
"""
|
|
31
|
+
# 1. Explicit flag
|
|
32
|
+
if explicit_token:
|
|
33
|
+
return explicit_token
|
|
34
|
+
|
|
35
|
+
# 2. Environment variable
|
|
36
|
+
env_token = os.environ.get(ENV_VAR)
|
|
37
|
+
if env_token:
|
|
38
|
+
return env_token
|
|
39
|
+
|
|
40
|
+
# 3. Credentials file
|
|
41
|
+
if CREDENTIALS_FILE.exists():
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(CREDENTIALS_FILE.read_text())
|
|
44
|
+
return data.get("token")
|
|
45
|
+
except (json.JSONDecodeError, OSError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_token(token: str) -> None:
|
|
52
|
+
"""Save token to credentials file (C2, C24)."""
|
|
53
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
data = {
|
|
56
|
+
"version": 1,
|
|
57
|
+
"token": token,
|
|
58
|
+
"created_at": _now_iso(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
CREDENTIALS_FILE.write_text(json.dumps(data, indent=2))
|
|
62
|
+
|
|
63
|
+
# chmod 600 (C24)
|
|
64
|
+
CREDENTIALS_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def delete_token() -> bool:
|
|
68
|
+
"""Delete saved credentials. Returns True if file existed."""
|
|
69
|
+
if CREDENTIALS_FILE.exists():
|
|
70
|
+
CREDENTIALS_FILE.unlink()
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _now_iso() -> str:
|
|
76
|
+
from datetime import datetime, timezone
|
|
77
|
+
return datetime.now(timezone.utc).isoformat()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Output formatting (C11, C12, C29).
|
|
3
|
+
|
|
4
|
+
--json is alias for --format json (C11).
|
|
5
|
+
list → array, info → object, error → {error, code, message} (C12).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, List, Optional
|
|
11
|
+
|
|
12
|
+
# Global state set by CLI flags
|
|
13
|
+
_json_mode = False
|
|
14
|
+
_no_color = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_json_mode(enabled: bool) -> None:
|
|
18
|
+
global _json_mode
|
|
19
|
+
_json_mode = enabled
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_no_color(enabled: bool) -> None:
|
|
23
|
+
global _no_color
|
|
24
|
+
_no_color = enabled
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_json_mode() -> bool:
|
|
28
|
+
return _json_mode
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_json(data: Any) -> None:
|
|
32
|
+
"""Print data as JSON to stdout (C12)."""
|
|
33
|
+
print(json.dumps(data, indent=2, default=str))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_table(rows: List[dict], columns: List[tuple]) -> None:
|
|
37
|
+
"""Print data as formatted table.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
rows: List of dicts
|
|
41
|
+
columns: List of (key, header, width) tuples
|
|
42
|
+
"""
|
|
43
|
+
if _json_mode:
|
|
44
|
+
print_json(rows)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if not rows:
|
|
48
|
+
print("No results.")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from rich.console import Console
|
|
53
|
+
from rich.table import Table
|
|
54
|
+
|
|
55
|
+
console = Console(no_color=_no_color)
|
|
56
|
+
table = Table(show_header=True, header_style="bold" if not _no_color else None)
|
|
57
|
+
|
|
58
|
+
for key, header, _ in columns:
|
|
59
|
+
table.add_column(header)
|
|
60
|
+
|
|
61
|
+
for row in rows:
|
|
62
|
+
table.add_row(*[str(row.get(key, "")) for key, _, _ in columns])
|
|
63
|
+
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
except ImportError:
|
|
67
|
+
# Fallback without rich
|
|
68
|
+
header = " ".join(h.ljust(w) for _, h, w in columns)
|
|
69
|
+
print(header)
|
|
70
|
+
print("-" * len(header))
|
|
71
|
+
for row in rows:
|
|
72
|
+
line = " ".join(str(row.get(k, "")).ljust(w) for k, _, w in columns)
|
|
73
|
+
print(line)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def print_object(data: dict, fields: Optional[List[tuple]] = None) -> None:
|
|
77
|
+
"""Print single object as key-value pairs.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
data: Dict to display
|
|
81
|
+
fields: Optional list of (key, label) tuples to control display order
|
|
82
|
+
"""
|
|
83
|
+
if _json_mode:
|
|
84
|
+
print_json(data)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if fields:
|
|
88
|
+
for key, label in fields:
|
|
89
|
+
val = data.get(key, "")
|
|
90
|
+
print(f" {label}: {val}")
|
|
91
|
+
else:
|
|
92
|
+
for key, val in data.items():
|
|
93
|
+
print(f" {key}: {val}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_error(code: str, message: str, exit_code: int = 1) -> None:
|
|
97
|
+
"""Print error in consistent format (C29) and exit."""
|
|
98
|
+
if _json_mode:
|
|
99
|
+
err = {"error": code, "code": exit_code, "message": message}
|
|
100
|
+
print(json.dumps(err), file=sys.stderr)
|
|
101
|
+
else:
|
|
102
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
103
|
+
|
|
104
|
+
sys.exit(exit_code)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wayscloud-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WAYSCloud CLI — command-line interface for WAYSCloud platform
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://wayscloud.net
|
|
7
|
+
Project-URL: Documentation, https://docs.wayscloud.net/cli
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.25
|
|
13
|
+
Requires-Dist: typer>=0.9
|
|
14
|
+
Requires-Dist: rich>=13.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
|
|
17
|
+
# WAYSCloud CLI
|
|
18
|
+
|
|
19
|
+
Command-line interface for WAYSCloud platform services.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install wayscloud-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cloud login --token <pat-from-portal>
|
|
31
|
+
cloud whoami
|
|
32
|
+
cloud vps list
|
|
33
|
+
cloud vps plans
|
|
34
|
+
cloud shell connect
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
See [docs/en/23-cli.md](https://docs.wayscloud.net/cli) for full documentation.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.cfg
|
|
4
|
+
wayscloud_cli/__init__.py
|
|
5
|
+
wayscloud_cli/__main__.py
|
|
6
|
+
wayscloud_cli/client.py
|
|
7
|
+
wayscloud_cli/config.py
|
|
8
|
+
wayscloud_cli/output.py
|
|
9
|
+
wayscloud_cli.egg-info/PKG-INFO
|
|
10
|
+
wayscloud_cli.egg-info/SOURCES.txt
|
|
11
|
+
wayscloud_cli.egg-info/dependency_links.txt
|
|
12
|
+
wayscloud_cli.egg-info/entry_points.txt
|
|
13
|
+
wayscloud_cli.egg-info/requires.txt
|
|
14
|
+
wayscloud_cli.egg-info/top_level.txt
|
|
15
|
+
wayscloud_cli/commands/__init__.py
|
|
16
|
+
wayscloud_cli/commands/login.py
|
|
17
|
+
wayscloud_cli/commands/shell.py
|
|
18
|
+
wayscloud_cli/commands/vps.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wayscloud_cli
|