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.
@@ -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,11 @@
1
+ [metadata]
2
+ name = wayscloud-cli
3
+ version = 0.1.0
4
+
5
+ [options]
6
+ packages = find:
7
+
8
+ [egg_info]
9
+ tag_build =
10
+ tag_date = 0
11
+
@@ -0,0 +1,2 @@
1
+ """WAYSCloud CLI — cloud command."""
2
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ cloud = wayscloud_cli.__main__:main
@@ -0,0 +1,4 @@
1
+ httpx>=0.25
2
+ typer>=0.9
3
+ rich>=13.0
4
+ websockets>=12.0
@@ -0,0 +1 @@
1
+ wayscloud_cli